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