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