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