inbox.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strings"
  7
  8	"charm.land/bubbles/v2/key"
  9	"charm.land/bubbles/v2/list"
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/lipgloss/v2"
 12	"github.com/floatpane/matcha/config"
 13	"github.com/floatpane/matcha/fetcher"
 14)
 15
 16var (
 17	// In bubbles v2, list.DefaultStyles() takes a boolean for hasDarkBackground
 18	paginationStyle = list.DefaultStyles(true).PaginationStyle.PaddingLeft(4)
 19	inboxHelpStyle  = list.DefaultStyles(true).HelpStyle.PaddingLeft(4).PaddingBottom(1)
 20	tabStyle        = lipgloss.NewStyle().Padding(0, 2)
 21	activeTabStyle  = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
 22	tabBarStyle     = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
 23)
 24
 25type item struct {
 26	title, desc   string
 27	originalIndex int
 28	uid           uint32
 29	accountID     string
 30	accountEmail  string
 31}
 32
 33func (i item) Title() string       { return i.title }
 34func (i item) Description() string { return i.desc }
 35func (i item) FilterValue() string { return i.title + " " + i.desc }
 36
 37type itemDelegate struct{}
 38
 39func (d itemDelegate) Height() int                               { return 1 }
 40func (d itemDelegate) Spacing() int                              { return 0 }
 41func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
 42func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 43	i, ok := listItem.(item)
 44	if !ok {
 45		return
 46	}
 47
 48	str := fmt.Sprintf("%d. %s", index+1, i.title)
 49
 50	// For "ALL" view, show account indicator
 51	if i.accountEmail != "" {
 52		str = fmt.Sprintf("%d. [%s] %s", index+1, truncateEmail(i.accountEmail), i.title)
 53	}
 54
 55	fn := itemStyle.Render
 56	if index == m.Index() {
 57		fn = func(s ...string) string {
 58			return selectedItemStyle.Render("> " + s[0])
 59		}
 60	}
 61
 62	fmt.Fprint(w, fn(str))
 63}
 64
 65// truncateEmail shortens an email for display
 66func truncateEmail(email string) string {
 67	parts := strings.Split(email, "@")
 68	if len(parts) >= 1 && len(parts[0]) > 8 {
 69		return parts[0][:8] + "..."
 70	}
 71	if len(parts) >= 1 {
 72		return parts[0]
 73	}
 74	return email
 75}
 76
 77// AccountTab represents a tab for an account
 78type AccountTab struct {
 79	ID    string
 80	Label string
 81	Email string
 82}
 83
 84type Inbox struct {
 85	list             list.Model
 86	isFetching       bool
 87	isRefreshing     bool
 88	emailsCount      int
 89	accounts         []config.Account
 90	emailsByAccount  map[string][]fetcher.Email
 91	allEmails        []fetcher.Email
 92	tabs             []AccountTab
 93	activeTabIndex   int
 94	width            int
 95	height           int
 96	currentAccountID string // Empty means "ALL"
 97	emailCountByAcct  map[string]int
 98	mailbox             MailboxKind
 99	folderName          string // Custom folder name override for title
100	noMoreByAccount     map[string]bool // Per-account: true when pagination returns 0 results
101	extraShortHelpKeys  []key.Binding
102}
103
104func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
105	return NewInboxWithMailbox(emails, accounts, MailboxInbox)
106}
107
108func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
109	return NewInboxWithMailbox(emails, accounts, MailboxSent)
110}
111
112func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
113	return NewInboxWithMailbox(emails, accounts, MailboxTrash)
114}
115
116func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
117	return NewInboxWithMailbox(emails, accounts, MailboxArchive)
118}
119
120func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
121	// Build tabs: empty for single account, "ALL" + accounts for multiple
122	var tabs []AccountTab
123	if len(accounts) <= 1 {
124		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
125	} else {
126		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
127		for _, acc := range accounts {
128			// Use FetchEmail for display, fall back to Email if not set
129			displayEmail := acc.FetchEmail
130			if displayEmail == "" {
131				displayEmail = acc.Email
132			}
133			tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
134		}
135	}
136
137	// Group emails by account
138	emailsByAccount := make(map[string][]fetcher.Email)
139	for _, email := range emails {
140		emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
141	}
142
143	// Track email counts per account
144	emailCountByAcct := make(map[string]int)
145	for accID, accEmails := range emailsByAccount {
146		emailCountByAcct[accID] = len(accEmails)
147	}
148
149	inbox := &Inbox{
150		accounts:         accounts,
151		emailsByAccount:  emailsByAccount,
152		allEmails:        emails,
153		tabs:             tabs,
154		activeTabIndex:   0,
155		currentAccountID: "",
156		emailCountByAcct: emailCountByAcct,
157		mailbox:          mailbox,
158	}
159
160	inbox.updateList()
161	return inbox
162}
163
164// NewInboxSingleAccount creates an inbox for a single account (legacy support)
165func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
166	return NewInbox(emails, nil)
167}
168
169func (m *Inbox) updateList() {
170	// Capture current index to restore later
171	currentIndex := m.list.Index()
172
173	var displayEmails []fetcher.Email
174	var showAccountLabel bool
175
176	if m.currentAccountID == "" {
177		// "ALL" view - show all emails sorted by date
178		displayEmails = m.allEmails
179		showAccountLabel = !(len(m.accounts) <= 1)
180	} else {
181		// Specific account view
182		displayEmails = m.emailsByAccount[m.currentAccountID]
183		showAccountLabel = false
184	}
185
186	m.emailsCount = len(displayEmails)
187
188	items := make([]list.Item, len(displayEmails))
189	for i, email := range displayEmails {
190		accountEmail := ""
191		if showAccountLabel {
192			// Find the account email for display
193			for _, acc := range m.accounts {
194				if acc.ID == email.AccountID {
195					accountEmail = acc.FetchEmail
196					break
197				}
198			}
199		}
200
201		items[i] = item{
202			title:         email.Subject,
203			desc:          email.From,
204			originalIndex: i,
205			uid:           email.UID,
206			accountID:     email.AccountID,
207			accountEmail:  accountEmail,
208		}
209	}
210
211	l := list.New(items, itemDelegate{}, 20, 14)
212	l.Title = m.getTitle()
213	l.SetShowStatusBar(true)
214	l.SetFilteringEnabled(true)
215	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
216	l.Styles.PaginationStyle = paginationStyle
217	l.Styles.HelpStyle = inboxHelpStyle
218	l.SetStatusBarItemName("email", "emails")
219	l.AdditionalShortHelpKeys = func() []key.Binding {
220		bindings := []key.Binding{
221			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
222			key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
223			key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
224		}
225		if len(m.tabs) > 1 {
226			bindings = append(bindings,
227				key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
228				key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
229			)
230		}
231		bindings = append(bindings, m.extraShortHelpKeys...)
232		return bindings
233	}
234
235	l.KeyMap.Quit.SetEnabled(false)
236
237	// Disable default help to render it manually at the bottom
238	l.SetShowHelp(false)
239
240	if m.width > 0 {
241		l.SetWidth(m.width)
242	}
243	if m.height > 0 {
244		l.SetHeight(m.height / 2)
245	}
246
247	// Restore index
248	// If index is out of bounds (e.g. list shrank), clamp it.
249	if currentIndex >= len(items) {
250		currentIndex = len(items) - 1
251	}
252	if currentIndex < 0 {
253		currentIndex = 0
254	}
255	l.Select(currentIndex)
256
257	m.list = l
258}
259
260func (m *Inbox) getTitle() string {
261	var title string
262	if m.currentAccountID == "" {
263		title = m.getBaseTitle() + " - All Accounts"
264	} else {
265		title = m.getBaseTitle()
266		for _, acc := range m.accounts {
267			if acc.ID == m.currentAccountID {
268				if acc.Name != "" {
269					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
270				} else {
271					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
272				}
273				break
274			}
275		}
276	}
277	if m.isRefreshing {
278		title += " (refreshing...)"
279	}
280	if m.isFetching {
281		title += " (loading more...)"
282	}
283	return title
284}
285
286func (m *Inbox) getBaseTitle() string {
287	if m.folderName != "" {
288		return m.folderName
289	}
290	switch m.mailbox {
291	case MailboxSent:
292		return "Sent"
293	case MailboxTrash:
294		return "Trash"
295	case MailboxArchive:
296		return "Archive"
297	default:
298		return "Inbox"
299	}
300}
301
302func (m *Inbox) Init() tea.Cmd {
303	return nil
304}
305
306func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
307	var cmds []tea.Cmd
308
309	switch msg := msg.(type) {
310	case tea.KeyPressMsg:
311		if m.list.FilterState() == list.Filtering {
312			break
313		}
314		switch keypress := msg.String(); keypress {
315		case "left", "h":
316			if len(m.tabs) > 1 {
317				m.activeTabIndex--
318				if m.activeTabIndex < 0 {
319					m.activeTabIndex = len(m.tabs) - 1
320				}
321				m.currentAccountID = m.tabs[m.activeTabIndex].ID
322				m.updateList()
323				return m, nil
324			}
325		case "right", "l":
326			if len(m.tabs) > 1 {
327				m.activeTabIndex++
328				if m.activeTabIndex >= len(m.tabs) {
329					m.activeTabIndex = 0
330				}
331				m.currentAccountID = m.tabs[m.activeTabIndex].ID
332				m.updateList()
333				return m, nil
334			}
335		case "d":
336			selectedItem, ok := m.list.SelectedItem().(item)
337			if ok {
338				return m, func() tea.Msg {
339					return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
340				}
341			}
342		case "a":
343			selectedItem, ok := m.list.SelectedItem().(item)
344			if ok {
345				return m, func() tea.Msg {
346					return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
347				}
348			}
349		case "r":
350			// Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
351			counts := make(map[string]int)
352			for k, v := range m.emailCountByAcct {
353				counts[k] = v
354			}
355			return m, func() tea.Msg {
356				return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
357			}
358		case "enter":
359			selectedItem, ok := m.list.SelectedItem().(item)
360			if ok {
361				idx := selectedItem.originalIndex
362				uid := selectedItem.uid
363				accountID := selectedItem.accountID
364				return m, func() tea.Msg {
365					return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
366				}
367			}
368		}
369	case tea.WindowSizeMsg:
370		m.width = msg.Width
371		m.height = msg.Height
372		m.list.SetWidth(msg.Width)
373		m.list.SetHeight(msg.Height / 2)
374		if m.shouldFetchMore() {
375			return m, tea.Batch(m.fetchMoreCmds()...)
376		}
377		return m, nil
378
379	case FetchingMoreEmailsMsg:
380		m.isFetching = true
381		m.list.Title = m.getTitle()
382		return m, nil
383
384	case EmailsAppendedMsg:
385		if msg.Mailbox != m.mailbox {
386			return m, nil
387		}
388		m.isFetching = false
389		m.list.Title = m.getTitle()
390
391		if len(msg.Emails) == 0 {
392			if m.noMoreByAccount == nil {
393				m.noMoreByAccount = make(map[string]bool)
394			}
395			m.noMoreByAccount[msg.AccountID] = true
396			return m, nil
397		}
398
399		// Add emails to the appropriate account
400		for _, email := range msg.Emails {
401			m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
402			m.allEmails = append(m.allEmails, email)
403		}
404		m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
405
406		m.updateList()
407		return m, nil
408
409	case RefreshingEmailsMsg:
410		if msg.Mailbox != m.mailbox {
411			return m, nil
412		}
413		m.isRefreshing = true
414		m.list.Title = m.getTitle()
415		return m, nil
416
417	case EmailsRefreshedMsg:
418		if msg.Mailbox != m.mailbox {
419			return m, nil
420		}
421		// Only clear the refreshing indicator. The actual email data is
422		// merged by the main model (preserving paginated emails) and
423		// pushed to us via SetEmails, so we must not overwrite it here.
424		m.isRefreshing = false
425		m.list.Title = m.getTitle()
426		return m, nil
427	}
428
429	var cmd tea.Cmd
430	m.list, cmd = m.list.Update(msg)
431	cmds = append(cmds, cmd)
432
433	if m.shouldFetchMore() {
434		cmds = append(cmds, m.fetchMoreCmds()...)
435	}
436	return m, tea.Batch(cmds...)
437}
438
439func (m *Inbox) shouldFetchMore() bool {
440	if m.isFetching {
441		return false
442	}
443	if m.allAccountsExhausted() {
444		return false
445	}
446	if len(m.list.Items()) == 0 {
447		return false
448	}
449	if m.list.FilterState() == list.Filtering {
450		return false
451	}
452	// Fetch if we've reached the bottom OR if we don't have enough items to fill the view
453	return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
454}
455
456// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
457func (m *Inbox) allAccountsExhausted() bool {
458	if len(m.noMoreByAccount) == 0 {
459		return false
460	}
461	if m.currentAccountID != "" {
462		return m.noMoreByAccount[m.currentAccountID]
463	}
464	// "ALL" view: all accounts must be exhausted
465	for _, acc := range m.accounts {
466		if !m.noMoreByAccount[acc.ID] {
467			return false
468		}
469	}
470	return len(m.accounts) > 0
471}
472
473func (m *Inbox) fetchMoreCmds() []tea.Cmd {
474	var cmds []tea.Cmd
475	limit := uint32(m.list.Height())
476	if limit < 20 {
477		limit = 20
478	}
479
480	if m.currentAccountID == "" {
481		if len(m.accounts) == 0 {
482			return nil
483		}
484		for _, acc := range m.accounts {
485			accountID := acc.ID
486			if m.noMoreByAccount[accountID] {
487				continue
488			}
489			offset := uint32(len(m.emailsByAccount[accountID]))
490			cmds = append(cmds, func(id string, off uint32) tea.Cmd {
491				return func() tea.Msg {
492					return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
493				}
494			}(accountID, offset))
495		}
496		return cmds
497	}
498
499	if m.noMoreByAccount[m.currentAccountID] {
500		return nil
501	}
502	offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
503	cmds = append(cmds, func(id string, off uint32) tea.Cmd {
504		return func() tea.Msg {
505			return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
506		}
507	}(m.currentAccountID, offset))
508	return cmds
509}
510
511func (m *Inbox) View() tea.View {
512	var b strings.Builder
513
514	// Render tabs if there are multiple accounts
515	if len(m.tabs) > 1 {
516		var tabViews []string
517		for i, tab := range m.tabs {
518			label := tab.Label
519			if tab.ID == "" {
520				label = "ALL"
521			}
522
523			if i == m.activeTabIndex {
524				tabViews = append(tabViews, activeTabStyle.Render(label))
525			} else {
526				tabViews = append(tabViews, tabStyle.Render(label))
527			}
528		}
529		tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
530		b.WriteString(tabBar)
531		b.WriteString("\n")
532	}
533
534	b.WriteString(m.list.View())
535
536	// Ensure we don't start gap calculation on the same line as the list
537	if !strings.HasSuffix(b.String(), "\n") {
538		b.WriteString("\n")
539	}
540
541	helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
542
543	if m.height > 0 {
544		usedHeight := lipgloss.Height(b.String())
545		helpHeight := lipgloss.Height(helpView)
546
547		gap := m.height - usedHeight - helpHeight
548		if gap > 0 {
549			b.WriteString(strings.Repeat("\n", gap))
550		}
551	} else {
552		b.WriteString("\n")
553	}
554
555	b.WriteString(helpView)
556
557	return tea.NewView(b.String())
558}
559
560// GetCurrentAccountID returns the currently selected account ID
561func (m *Inbox) GetCurrentAccountID() string {
562	return m.currentAccountID
563}
564
565// GetEmailAtIndex returns the email at the given index for the current view
566func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
567	var displayEmails []fetcher.Email
568	if m.currentAccountID == "" {
569		displayEmails = m.allEmails
570	} else {
571		displayEmails = m.emailsByAccount[m.currentAccountID]
572	}
573
574	if index >= 0 && index < len(displayEmails) {
575		return &displayEmails[index]
576	}
577	return nil
578}
579
580func (m *Inbox) GetMailbox() MailboxKind {
581	return m.mailbox
582}
583
584// RemoveEmail removes an email by UID and account ID
585func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
586	// Remove from account-specific list
587	if emails, ok := m.emailsByAccount[accountID]; ok {
588		var filtered []fetcher.Email
589		for _, e := range emails {
590			if e.UID != uid {
591				filtered = append(filtered, e)
592			}
593		}
594		m.emailsByAccount[accountID] = filtered
595	}
596
597	// Remove from all emails list
598	var filteredAll []fetcher.Email
599	for _, e := range m.allEmails {
600		if !(e.UID == uid && e.AccountID == accountID) {
601			filteredAll = append(filteredAll, e)
602		}
603	}
604	m.allEmails = filteredAll
605
606	m.updateList()
607}
608
609// SetSize sets the width and height of the inbox, then updates the list.
610func (m *Inbox) SetSize(width, height int) {
611	m.width = width
612	m.height = height
613	m.list.SetWidth(width)
614	m.list.SetHeight(height / 2)
615}
616
617// SetFolderName sets a custom folder name for the inbox title.
618func (m *Inbox) SetFolderName(name string) {
619	m.folderName = name
620	m.list.Title = m.getTitle()
621}
622
623// SetEmails updates all emails (used after fetch)
624func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
625	m.accounts = accounts
626	m.allEmails = emails
627	m.noMoreByAccount = make(map[string]bool)
628
629	// Rebuild tabs: empty for single account, "ALL" + accounts for multiple
630	var tabs []AccountTab
631	if len(accounts) <= 1 {
632		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
633	} else {
634		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
635		for _, acc := range accounts {
636			tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
637		}
638	}
639	m.tabs = tabs
640
641	// Re-group emails by account
642	m.emailsByAccount = make(map[string][]fetcher.Email)
643	for _, email := range emails {
644		m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
645	}
646
647	// Update email counts
648	m.emailCountByAcct = make(map[string]int)
649	for accID, accEmails := range m.emailsByAccount {
650		m.emailCountByAcct[accID] = len(accEmails)
651	}
652
653	m.updateList()
654}