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