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