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