inbox.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strings"
  7
  8	"github.com/charmbracelet/bubbles/key"
  9	"github.com/charmbracelet/bubbles/list"
 10	tea "github.com/charmbracelet/bubbletea"
 11	"github.com/charmbracelet/lipgloss"
 12	"github.com/floatpane/matcha/config"
 13	"github.com/floatpane/matcha/fetcher"
 14)
 15
 16var (
 17	paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
 18	inboxHelpStyle  = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
 19	tabStyle        = lipgloss.NewStyle().Padding(0, 2)
 20	activeTabStyle  = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
 21	tabBarStyle     = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
 22)
 23
 24type item struct {
 25	title, desc   string
 26	originalIndex int
 27	uid           uint32
 28	accountID     string
 29	accountEmail  string
 30}
 31
 32func (i item) Title() string       { return i.title }
 33func (i item) Description() string { return i.desc }
 34func (i item) FilterValue() string { return i.title + " " + i.desc }
 35
 36type itemDelegate struct{}
 37
 38func (d itemDelegate) Height() int                               { return 1 }
 39func (d itemDelegate) Spacing() int                              { return 0 }
 40func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
 41func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 42	i, ok := listItem.(item)
 43	if !ok {
 44		return
 45	}
 46
 47	str := fmt.Sprintf("%d. %s", index+1, i.title)
 48
 49	// For "ALL" view, show account indicator
 50	if i.accountEmail != "" {
 51		str = fmt.Sprintf("%d. [%s] %s", index+1, truncateEmail(i.accountEmail), i.title)
 52	}
 53
 54	fn := itemStyle.Render
 55	if index == m.Index() {
 56		fn = func(s ...string) string {
 57			return selectedItemStyle.Render("> " + s[0])
 58		}
 59	}
 60
 61	fmt.Fprint(w, fn(str))
 62}
 63
 64// truncateEmail shortens an email for display
 65func truncateEmail(email string) string {
 66	parts := strings.Split(email, "@")
 67	if len(parts) >= 1 && len(parts[0]) > 8 {
 68		return parts[0][:8] + "..."
 69	}
 70	if len(parts) >= 1 {
 71		return parts[0]
 72	}
 73	return email
 74}
 75
 76// AccountTab represents a tab for an account
 77type AccountTab struct {
 78	ID    string
 79	Label string
 80	Email string
 81}
 82
 83type Inbox struct {
 84	list             list.Model
 85	isFetching       bool
 86	isRefreshing     bool
 87	emailsCount      int
 88	accounts         []config.Account
 89	emailsByAccount  map[string][]fetcher.Email
 90	allEmails        []fetcher.Email
 91	tabs             []AccountTab
 92	activeTabIndex   int
 93	width            int
 94	height           int
 95	currentAccountID string // Empty means "ALL"
 96	emailCountByAcct map[string]int
 97	mailbox          MailboxKind
 98}
 99
100func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
101	return NewInboxWithMailbox(emails, accounts, MailboxInbox)
102}
103
104func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
105	return NewInboxWithMailbox(emails, accounts, MailboxSent)
106}
107
108func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
109	// Build tabs: "ALL" + one per account
110	tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}}
111	for _, acc := range accounts {
112		label := acc.Email
113		tabs = append(tabs, AccountTab{ID: acc.ID, Label: label, Email: acc.Email})
114	}
115
116	// Group emails by account
117	emailsByAccount := make(map[string][]fetcher.Email)
118	for _, email := range emails {
119		emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
120	}
121
122	// Track email counts per account
123	emailCountByAcct := make(map[string]int)
124	for accID, accEmails := range emailsByAccount {
125		emailCountByAcct[accID] = len(accEmails)
126	}
127
128	inbox := &Inbox{
129		accounts:         accounts,
130		emailsByAccount:  emailsByAccount,
131		allEmails:        emails,
132		tabs:             tabs,
133		activeTabIndex:   0,
134		currentAccountID: "",
135		emailCountByAcct: emailCountByAcct,
136		mailbox:          mailbox,
137	}
138
139	inbox.updateList()
140	return inbox
141}
142
143// NewInboxSingleAccount creates an inbox for a single account (legacy support)
144func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
145	return NewInbox(emails, nil)
146}
147
148func (m *Inbox) updateList() {
149	var displayEmails []fetcher.Email
150	var showAccountLabel bool
151
152	if m.currentAccountID == "" {
153		// "ALL" view - show all emails sorted by date
154		displayEmails = m.allEmails
155		showAccountLabel = len(m.accounts) > 1
156	} else {
157		// Specific account view
158		displayEmails = m.emailsByAccount[m.currentAccountID]
159		showAccountLabel = false
160	}
161
162	m.emailsCount = len(displayEmails)
163
164	items := make([]list.Item, len(displayEmails))
165	for i, email := range displayEmails {
166		accountEmail := ""
167		if showAccountLabel {
168			// Find the account email for display
169			for _, acc := range m.accounts {
170				if acc.ID == email.AccountID {
171					accountEmail = acc.Email
172					break
173				}
174			}
175		}
176
177		items[i] = item{
178			title:         email.Subject,
179			desc:          email.From,
180			originalIndex: i,
181			uid:           email.UID,
182			accountID:     email.AccountID,
183			accountEmail:  accountEmail,
184		}
185	}
186
187	l := list.New(items, itemDelegate{}, 20, 14)
188	l.Title = m.getTitle()
189	l.SetShowStatusBar(true)
190	l.SetFilteringEnabled(true)
191	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
192	l.Styles.PaginationStyle = paginationStyle
193	l.Styles.HelpStyle = inboxHelpStyle
194	l.SetStatusBarItemName("email", "emails")
195	l.AdditionalShortHelpKeys = func() []key.Binding {
196		bindings := []key.Binding{
197			key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
198			key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "archive")),
199			key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
200		}
201		if len(m.tabs) > 1 {
202			bindings = append(bindings,
203				key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
204				key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
205			)
206		}
207		return bindings
208	}
209
210	l.KeyMap.Quit.SetEnabled(false)
211
212	if m.width > 0 {
213		l.SetWidth(m.width)
214	}
215
216	m.list = l
217}
218
219func (m *Inbox) getTitle() string {
220	var title string
221	if m.currentAccountID == "" {
222		title = m.getBaseTitle() + " - All Accounts"
223	} else {
224		title = m.getBaseTitle()
225		for _, acc := range m.accounts {
226			if acc.ID == m.currentAccountID {
227				if acc.Name != "" {
228					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
229				} else {
230					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Email)
231				}
232				break
233			}
234		}
235	}
236	if m.isRefreshing {
237		title += " (refreshing...)"
238	}
239	if m.isFetching {
240		title += " (loading more...)"
241	}
242	return title
243}
244
245func (m *Inbox) getBaseTitle() string {
246	switch m.mailbox {
247	case MailboxSent:
248		return "Sent"
249	default:
250		return "Inbox"
251	}
252}
253
254func (m *Inbox) Init() tea.Cmd {
255	return nil
256}
257
258func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
259	var cmds []tea.Cmd
260
261	switch msg := msg.(type) {
262	case tea.KeyMsg:
263		if m.list.FilterState() == list.Filtering {
264			break
265		}
266		switch keypress := msg.String(); keypress {
267		case "left", "h":
268			if len(m.tabs) > 1 {
269				m.activeTabIndex--
270				if m.activeTabIndex < 0 {
271					m.activeTabIndex = len(m.tabs) - 1
272				}
273				m.currentAccountID = m.tabs[m.activeTabIndex].ID
274				m.updateList()
275				return m, nil
276			}
277		case "right", "l":
278			if len(m.tabs) > 1 {
279				m.activeTabIndex++
280				if m.activeTabIndex >= len(m.tabs) {
281					m.activeTabIndex = 0
282				}
283				m.currentAccountID = m.tabs[m.activeTabIndex].ID
284				m.updateList()
285				return m, nil
286			}
287		case "d":
288			selectedItem, ok := m.list.SelectedItem().(item)
289			if ok {
290				return m, func() tea.Msg {
291					return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
292				}
293			}
294		case "a":
295			selectedItem, ok := m.list.SelectedItem().(item)
296			if ok {
297				return m, func() tea.Msg {
298					return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
299				}
300			}
301		case "r":
302			return m, func() tea.Msg {
303				return RequestRefreshMsg{Mailbox: m.mailbox}
304			}
305		case "enter":
306			selectedItem, ok := m.list.SelectedItem().(item)
307			if ok {
308				idx := selectedItem.originalIndex
309				uid := selectedItem.uid
310				accountID := selectedItem.accountID
311				return m, func() tea.Msg {
312					return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
313				}
314			}
315		}
316	case tea.WindowSizeMsg:
317		m.width = msg.Width
318		m.height = msg.Height
319		m.list.SetWidth(msg.Width)
320		return m, nil
321
322	case FetchingMoreEmailsMsg:
323		m.isFetching = true
324		m.list.Title = m.getTitle()
325		return m, nil
326
327	case EmailsAppendedMsg:
328		if msg.Mailbox != m.mailbox {
329			return m, nil
330		}
331		m.isFetching = false
332		m.list.Title = m.getTitle()
333
334		// Add emails to the appropriate account
335		for _, email := range msg.Emails {
336			m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
337			m.allEmails = append(m.allEmails, email)
338		}
339		m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
340
341		m.updateList()
342		return m, nil
343
344	case RefreshingEmailsMsg:
345		if msg.Mailbox != m.mailbox {
346			return m, nil
347		}
348		m.isRefreshing = true
349		m.list.Title = m.getTitle()
350		return m, nil
351
352	case EmailsRefreshedMsg:
353		if msg.Mailbox != m.mailbox {
354			return m, nil
355		}
356		m.isRefreshing = false
357
358		// Replace emails with fresh data
359		m.emailsByAccount = msg.EmailsByAccount
360
361		// Flatten all emails
362		var allEmails []fetcher.Email
363		for _, emails := range msg.EmailsByAccount {
364			allEmails = append(allEmails, emails...)
365		}
366
367		// Sort by date (newest first)
368		for i := 0; i < len(allEmails); i++ {
369			for j := i + 1; j < len(allEmails); j++ {
370				if allEmails[j].Date.After(allEmails[i].Date) {
371					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
372				}
373			}
374		}
375
376		m.allEmails = allEmails
377
378		// Update email counts
379		m.emailCountByAcct = make(map[string]int)
380		for accID, accEmails := range m.emailsByAccount {
381			m.emailCountByAcct[accID] = len(accEmails)
382		}
383
384		m.updateList()
385		return m, nil
386	}
387
388	var cmd tea.Cmd
389	m.list, cmd = m.list.Update(msg)
390	cmds = append(cmds, cmd)
391
392	if m.shouldFetchMore() {
393		cmds = append(cmds, m.fetchMoreCmds()...)
394	}
395	return m, tea.Batch(cmds...)
396}
397
398func (m *Inbox) shouldFetchMore() bool {
399	if m.isFetching {
400		return false
401	}
402	if len(m.list.Items()) == 0 {
403		return false
404	}
405	if m.list.FilterState() == list.Filtering {
406		return false
407	}
408	return m.list.Index() >= len(m.list.Items())-1
409}
410
411func (m *Inbox) fetchMoreCmds() []tea.Cmd {
412	var cmds []tea.Cmd
413	if m.currentAccountID == "" {
414		if len(m.accounts) == 0 {
415			return nil
416		}
417		for _, acc := range m.accounts {
418			accountID := acc.ID
419			offset := uint32(len(m.emailsByAccount[accountID]))
420			cmds = append(cmds, func(id string, off uint32) tea.Cmd {
421				return func() tea.Msg {
422					return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox}
423				}
424			}(accountID, offset))
425		}
426		return cmds
427	}
428
429	offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
430	cmds = append(cmds, func(id string, off uint32) tea.Cmd {
431		return func() tea.Msg {
432			return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox}
433		}
434	}(m.currentAccountID, offset))
435	return cmds
436}
437
438func (m *Inbox) View() string {
439	var b strings.Builder
440
441	// Render tabs if there are multiple accounts
442	if len(m.tabs) > 1 {
443		var tabViews []string
444		for i, tab := range m.tabs {
445			label := tab.Label
446			if tab.ID == "" {
447				label = "ALL"
448			}
449
450			if i == m.activeTabIndex {
451				tabViews = append(tabViews, activeTabStyle.Render(label))
452			} else {
453				tabViews = append(tabViews, tabStyle.Render(label))
454			}
455		}
456		tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
457		b.WriteString(tabBar)
458		b.WriteString("\n")
459	}
460
461	b.WriteString(m.list.View())
462	return b.String()
463}
464
465// GetCurrentAccountID returns the currently selected account ID
466func (m *Inbox) GetCurrentAccountID() string {
467	return m.currentAccountID
468}
469
470// GetEmailAtIndex returns the email at the given index for the current view
471func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
472	var displayEmails []fetcher.Email
473	if m.currentAccountID == "" {
474		displayEmails = m.allEmails
475	} else {
476		displayEmails = m.emailsByAccount[m.currentAccountID]
477	}
478
479	if index >= 0 && index < len(displayEmails) {
480		return &displayEmails[index]
481	}
482	return nil
483}
484
485func (m *Inbox) GetMailbox() MailboxKind {
486	return m.mailbox
487}
488
489// RemoveEmail removes an email by UID and account ID
490func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
491	// Remove from account-specific list
492	if emails, ok := m.emailsByAccount[accountID]; ok {
493		var filtered []fetcher.Email
494		for _, e := range emails {
495			if e.UID != uid {
496				filtered = append(filtered, e)
497			}
498		}
499		m.emailsByAccount[accountID] = filtered
500	}
501
502	// Remove from all emails list
503	var filteredAll []fetcher.Email
504	for _, e := range m.allEmails {
505		if !(e.UID == uid && e.AccountID == accountID) {
506			filteredAll = append(filteredAll, e)
507		}
508	}
509	m.allEmails = filteredAll
510
511	m.updateList()
512}
513
514// SetEmails updates all emails (used after fetch)
515func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
516	m.accounts = accounts
517	m.allEmails = emails
518
519	// Rebuild tabs
520	tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}}
521	for _, acc := range accounts {
522		label := acc.Email
523		tabs = append(tabs, AccountTab{ID: acc.ID, Label: label, Email: acc.Email})
524	}
525	m.tabs = tabs
526
527	// Re-group emails by account
528	m.emailsByAccount = make(map[string][]fetcher.Email)
529	for _, email := range emails {
530		m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
531	}
532
533	// Update email counts
534	m.emailCountByAcct = make(map[string]int)
535	for accID, accEmails := range m.emailsByAccount {
536		m.emailCountByAcct[accID] = len(accEmails)
537	}
538
539	m.updateList()
540}