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