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		}
200		if len(m.tabs) > 1 {
201			bindings = append(bindings,
202				key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
203				key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
204			)
205		}
206		return bindings
207	}
208
209	l.KeyMap.Quit.SetEnabled(false)
210
211	if m.width > 0 {
212		l.SetWidth(m.width)
213	}
214
215	m.list = l
216}
217
218func (m *Inbox) getTitle() string {
219	var title string
220	if m.currentAccountID == "" {
221		title = m.getBaseTitleWithCount() + " - All Accounts"
222	} else {
223		title = m.getBaseTitleWithCount()
224		for _, acc := range m.accounts {
225			if acc.ID == m.currentAccountID {
226				if acc.Name != "" {
227					title = fmt.Sprintf("%s - %s", m.getBaseTitleWithCount(), acc.Name)
228				} else {
229					title = fmt.Sprintf("%s - %s", m.getBaseTitleWithCount(), acc.Email)
230				}
231				break
232			}
233		}
234	}
235	if m.isRefreshing {
236		title += " (refreshing...)"
237	}
238	if m.isFetching {
239		title += " (loading more...)"
240	}
241	return title
242}
243
244func (m *Inbox) getBaseTitle() string {
245	switch m.mailbox {
246	case MailboxSent:
247		return "Sent"
248	default:
249		return "Inbox"
250	}
251}
252
253func (m *Inbox) getBaseTitleWithCount() string {
254	return fmt.Sprintf("%s (%d)", m.getBaseTitle(), m.emailsCount)
255}
256
257func (m *Inbox) Init() tea.Cmd {
258	return nil
259}
260
261func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
262	var cmds []tea.Cmd
263
264	switch msg := msg.(type) {
265	case tea.KeyMsg:
266		if m.list.FilterState() == list.Filtering {
267			break
268		}
269		switch keypress := msg.String(); keypress {
270		case "left", "h":
271			if len(m.tabs) > 1 {
272				m.activeTabIndex--
273				if m.activeTabIndex < 0 {
274					m.activeTabIndex = len(m.tabs) - 1
275				}
276				m.currentAccountID = m.tabs[m.activeTabIndex].ID
277				m.updateList()
278				return m, nil
279			}
280		case "right", "l":
281			if len(m.tabs) > 1 {
282				m.activeTabIndex++
283				if m.activeTabIndex >= len(m.tabs) {
284					m.activeTabIndex = 0
285				}
286				m.currentAccountID = m.tabs[m.activeTabIndex].ID
287				m.updateList()
288				return m, nil
289			}
290		case "d":
291			selectedItem, ok := m.list.SelectedItem().(item)
292			if ok {
293				return m, func() tea.Msg {
294					return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
295				}
296			}
297		case "a":
298			selectedItem, ok := m.list.SelectedItem().(item)
299			if ok {
300				return m, func() tea.Msg {
301					return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
302				}
303			}
304		case "enter":
305			selectedItem, ok := m.list.SelectedItem().(item)
306			if ok {
307				idx := selectedItem.originalIndex
308				uid := selectedItem.uid
309				accountID := selectedItem.accountID
310				return m, func() tea.Msg {
311					return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
312				}
313			}
314		}
315	case tea.WindowSizeMsg:
316		m.width = msg.Width
317		m.height = msg.Height
318		m.list.SetWidth(msg.Width)
319		return m, nil
320
321	case FetchingMoreEmailsMsg:
322		m.isFetching = true
323		m.list.Title = m.getTitle()
324		return m, nil
325
326	case EmailsAppendedMsg:
327		if msg.Mailbox != m.mailbox {
328			return m, nil
329		}
330		m.isFetching = false
331		m.list.Title = m.getTitle()
332
333		// Add emails to the appropriate account
334		for _, email := range msg.Emails {
335			m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
336			m.allEmails = append(m.allEmails, email)
337		}
338		m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
339
340		m.updateList()
341		return m, nil
342
343	case RefreshingEmailsMsg:
344		if msg.Mailbox != m.mailbox {
345			return m, nil
346		}
347		m.isRefreshing = true
348		m.list.Title = m.getTitle()
349		return m, nil
350
351	case EmailsRefreshedMsg:
352		if msg.Mailbox != m.mailbox {
353			return m, nil
354		}
355		m.isRefreshing = false
356
357		// Replace emails with fresh data
358		m.emailsByAccount = msg.EmailsByAccount
359
360		// Flatten all emails
361		var allEmails []fetcher.Email
362		for _, emails := range msg.EmailsByAccount {
363			allEmails = append(allEmails, emails...)
364		}
365
366		// Sort by date (newest first)
367		for i := 0; i < len(allEmails); i++ {
368			for j := i + 1; j < len(allEmails); j++ {
369				if allEmails[j].Date.After(allEmails[i].Date) {
370					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
371				}
372			}
373		}
374
375		m.allEmails = allEmails
376
377		// Update email counts
378		m.emailCountByAcct = make(map[string]int)
379		for accID, accEmails := range m.emailsByAccount {
380			m.emailCountByAcct[accID] = len(accEmails)
381		}
382
383		m.updateList()
384		return m, nil
385	}
386
387	var cmd tea.Cmd
388	m.list, cmd = m.list.Update(msg)
389	cmds = append(cmds, cmd)
390
391	if m.shouldFetchMore() {
392		cmds = append(cmds, m.fetchMoreCmds()...)
393	}
394	return m, tea.Batch(cmds...)
395}
396
397func (m *Inbox) shouldFetchMore() bool {
398	if m.isFetching {
399		return false
400	}
401	if len(m.list.Items()) == 0 {
402		return false
403	}
404	if m.list.FilterState() == list.Filtering {
405		return false
406	}
407	return m.list.Index() >= len(m.list.Items())-1
408}
409
410func (m *Inbox) fetchMoreCmds() []tea.Cmd {
411	var cmds []tea.Cmd
412	if m.currentAccountID == "" {
413		if len(m.accounts) == 0 {
414			return nil
415		}
416		for _, acc := range m.accounts {
417			accountID := acc.ID
418			offset := uint32(len(m.emailsByAccount[accountID]))
419			cmds = append(cmds, func(id string, off uint32) tea.Cmd {
420				return func() tea.Msg {
421					return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox}
422				}
423			}(accountID, offset))
424		}
425		return cmds
426	}
427
428	offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
429	cmds = append(cmds, func(id string, off uint32) tea.Cmd {
430		return func() tea.Msg {
431			return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox}
432		}
433	}(m.currentAccountID, offset))
434	return cmds
435}
436
437func (m *Inbox) View() string {
438	var b strings.Builder
439
440	// Render tabs if there are multiple accounts
441	if len(m.tabs) > 1 {
442		var tabViews []string
443		for i, tab := range m.tabs {
444			label := tab.Label
445			if tab.ID == "" {
446				label = "ALL"
447			}
448
449			if i == m.activeTabIndex {
450				tabViews = append(tabViews, activeTabStyle.Render(label))
451			} else {
452				tabViews = append(tabViews, tabStyle.Render(label))
453			}
454		}
455		tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
456		b.WriteString(tabBar)
457		b.WriteString("\n")
458	}
459
460	b.WriteString(m.list.View())
461	return "\n" + b.String()
462}
463
464// GetCurrentAccountID returns the currently selected account ID
465func (m *Inbox) GetCurrentAccountID() string {
466	return m.currentAccountID
467}
468
469// GetEmailAtIndex returns the email at the given index for the current view
470func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
471	var displayEmails []fetcher.Email
472	if m.currentAccountID == "" {
473		displayEmails = m.allEmails
474	} else {
475		displayEmails = m.emailsByAccount[m.currentAccountID]
476	}
477
478	if index >= 0 && index < len(displayEmails) {
479		return &displayEmails[index]
480	}
481	return nil
482}
483
484func (m *Inbox) GetMailbox() MailboxKind {
485	return m.mailbox
486}
487
488// RemoveEmail removes an email by UID and account ID
489func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
490	// Remove from account-specific list
491	if emails, ok := m.emailsByAccount[accountID]; ok {
492		var filtered []fetcher.Email
493		for _, e := range emails {
494			if e.UID != uid {
495				filtered = append(filtered, e)
496			}
497		}
498		m.emailsByAccount[accountID] = filtered
499	}
500
501	// Remove from all emails list
502	var filteredAll []fetcher.Email
503	for _, e := range m.allEmails {
504		if !(e.UID == uid && e.AccountID == accountID) {
505			filteredAll = append(filteredAll, e)
506		}
507	}
508	m.allEmails = filteredAll
509
510	m.updateList()
511}
512
513// SetEmails updates all emails (used after fetch)
514func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
515	m.accounts = accounts
516	m.allEmails = emails
517
518	// Rebuild tabs
519	tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}}
520	for _, acc := range accounts {
521		label := acc.Email
522		tabs = append(tabs, AccountTab{ID: acc.ID, Label: label, Email: acc.Email})
523	}
524	m.tabs = tabs
525
526	// Re-group emails by account
527	m.emailsByAccount = make(map[string][]fetcher.Email)
528	for _, email := range emails {
529		m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
530	}
531
532	// Update email counts
533	m.emailCountByAcct = make(map[string]int)
534	for accID, accEmails := range m.emailsByAccount {
535		m.emailCountByAcct[accID] = len(accEmails)
536	}
537
538	m.updateList()
539}