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