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