inbox.go

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