1package tui
2
3import (
4 "fmt"
5 "io"
6 "strings"
7
8 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/list"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/floatpane/matcha/config"
13 "github.com/floatpane/matcha/fetcher"
14)
15
16var (
17 // In bubbles v2, list.DefaultStyles() takes a boolean for hasDarkBackground
18 paginationStyle = list.DefaultStyles(true).PaginationStyle.PaddingLeft(4)
19 inboxHelpStyle = list.DefaultStyles(true).HelpStyle.PaddingLeft(4).PaddingBottom(1)
20 tabStyle = lipgloss.NewStyle().Padding(0, 2)
21 activeTabStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
22 tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
23)
24
25type item struct {
26 title, desc string
27 originalIndex int
28 uid uint32
29 accountID string
30 accountEmail string
31}
32
33func (i item) Title() string { return i.title }
34func (i item) Description() string { return i.desc }
35func (i item) FilterValue() string { return i.title + " " + i.desc }
36
37type itemDelegate struct{}
38
39func (d itemDelegate) Height() int { return 1 }
40func (d itemDelegate) Spacing() int { return 0 }
41func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
42func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
43 i, ok := listItem.(item)
44 if !ok {
45 return
46 }
47
48 str := fmt.Sprintf("%d. %s", index+1, i.title)
49
50 // For "ALL" view, show account indicator
51 if i.accountEmail != "" {
52 str = fmt.Sprintf("%d. [%s] %s", index+1, truncateEmail(i.accountEmail), i.title)
53 }
54
55 fn := itemStyle.Render
56 if index == m.Index() {
57 fn = func(s ...string) string {
58 return selectedItemStyle.Render("> " + s[0])
59 }
60 }
61
62 fmt.Fprint(w, fn(str))
63}
64
65// truncateEmail shortens an email for display
66func truncateEmail(email string) string {
67 parts := strings.Split(email, "@")
68 if len(parts) >= 1 && len(parts[0]) > 8 {
69 return parts[0][:8] + "..."
70 }
71 if len(parts) >= 1 {
72 return parts[0]
73 }
74 return email
75}
76
77// AccountTab represents a tab for an account
78type AccountTab struct {
79 ID string
80 Label string
81 Email string
82}
83
84type Inbox struct {
85 list list.Model
86 isFetching bool
87 isRefreshing bool
88 emailsCount int
89 accounts []config.Account
90 emailsByAccount map[string][]fetcher.Email
91 allEmails []fetcher.Email
92 tabs []AccountTab
93 activeTabIndex int
94 width int
95 height int
96 currentAccountID string // Empty means "ALL"
97 emailCountByAcct map[string]int
98 mailbox MailboxKind
99}
100
101func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
102 return NewInboxWithMailbox(emails, accounts, MailboxInbox)
103}
104
105func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
106 return NewInboxWithMailbox(emails, accounts, MailboxSent)
107}
108
109func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
110 return NewInboxWithMailbox(emails, accounts, MailboxTrash)
111}
112
113func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
114 return NewInboxWithMailbox(emails, accounts, MailboxArchive)
115}
116
117func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
118 // Build tabs: empty for single account, "ALL" + accounts for multiple
119 var tabs []AccountTab
120 if len(accounts) <= 1 {
121 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
122 } else {
123 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
124 for _, acc := range accounts {
125 // Use FetchEmail for display, fall back to Email if not set
126 displayEmail := acc.FetchEmail
127 if displayEmail == "" {
128 displayEmail = acc.Email
129 }
130 tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
131 }
132 }
133
134 // Group emails by account
135 emailsByAccount := make(map[string][]fetcher.Email)
136 for _, email := range emails {
137 emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
138 }
139
140 // Track email counts per account
141 emailCountByAcct := make(map[string]int)
142 for accID, accEmails := range emailsByAccount {
143 emailCountByAcct[accID] = len(accEmails)
144 }
145
146 inbox := &Inbox{
147 accounts: accounts,
148 emailsByAccount: emailsByAccount,
149 allEmails: emails,
150 tabs: tabs,
151 activeTabIndex: 0,
152 currentAccountID: "",
153 emailCountByAcct: emailCountByAcct,
154 mailbox: mailbox,
155 }
156
157 inbox.updateList()
158 return inbox
159}
160
161// NewInboxSingleAccount creates an inbox for a single account (legacy support)
162func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
163 return NewInbox(emails, nil)
164}
165
166func (m *Inbox) updateList() {
167 // Capture current index to restore later
168 currentIndex := m.list.Index()
169
170 var displayEmails []fetcher.Email
171 var showAccountLabel bool
172
173 if m.currentAccountID == "" {
174 // "ALL" view - show all emails sorted by date
175 displayEmails = m.allEmails
176 showAccountLabel = !(len(m.accounts) <= 1)
177 } else {
178 // Specific account view
179 displayEmails = m.emailsByAccount[m.currentAccountID]
180 showAccountLabel = false
181 }
182
183 m.emailsCount = len(displayEmails)
184
185 items := make([]list.Item, len(displayEmails))
186 for i, email := range displayEmails {
187 accountEmail := ""
188 if showAccountLabel {
189 // Find the account email for display
190 for _, acc := range m.accounts {
191 if acc.ID == email.AccountID {
192 accountEmail = acc.FetchEmail
193 break
194 }
195 }
196 }
197
198 items[i] = item{
199 title: email.Subject,
200 desc: email.From,
201 originalIndex: i,
202 uid: email.UID,
203 accountID: email.AccountID,
204 accountEmail: accountEmail,
205 }
206 }
207
208 l := list.New(items, itemDelegate{}, 20, 14)
209 l.Title = m.getTitle()
210 l.SetShowStatusBar(true)
211 l.SetFilteringEnabled(true)
212 l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
213 l.Styles.PaginationStyle = paginationStyle
214 l.Styles.HelpStyle = inboxHelpStyle
215 l.SetStatusBarItemName("email", "emails")
216 l.AdditionalShortHelpKeys = func() []key.Binding {
217 bindings := []key.Binding{
218 key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
219 key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
220 key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
221 }
222 if len(m.tabs) > 1 {
223 bindings = append(bindings,
224 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
225 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
226 )
227 }
228 return bindings
229 }
230
231 l.KeyMap.Quit.SetEnabled(false)
232
233 // Disable default help to render it manually at the bottom
234 l.SetShowHelp(false)
235
236 if m.width > 0 {
237 l.SetWidth(m.width)
238 }
239 if m.height > 0 {
240 l.SetHeight(m.height / 2)
241 }
242
243 // Restore index
244 // If index is out of bounds (e.g. list shrank), clamp it.
245 if currentIndex >= len(items) {
246 currentIndex = len(items) - 1
247 }
248 if currentIndex < 0 {
249 currentIndex = 0
250 }
251 l.Select(currentIndex)
252
253 m.list = l
254}
255
256func (m *Inbox) getTitle() string {
257 var title string
258 if m.currentAccountID == "" {
259 title = m.getBaseTitle() + " - All Accounts"
260 } else {
261 title = m.getBaseTitle()
262 for _, acc := range m.accounts {
263 if acc.ID == m.currentAccountID {
264 if acc.Name != "" {
265 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
266 } else {
267 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
268 }
269 break
270 }
271 }
272 }
273 if m.isRefreshing {
274 title += " (refreshing...)"
275 }
276 if m.isFetching {
277 title += " (loading more...)"
278 }
279 return title
280}
281
282func (m *Inbox) getBaseTitle() string {
283 switch m.mailbox {
284 case MailboxSent:
285 return "Sent"
286 case MailboxTrash:
287 return "Trash"
288 case MailboxArchive:
289 return "Archive"
290 default:
291 return "Inbox"
292 }
293}
294
295func (m *Inbox) Init() tea.Cmd {
296 return nil
297}
298
299func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
300 var cmds []tea.Cmd
301
302 switch msg := msg.(type) {
303 case tea.KeyPressMsg:
304 if m.list.FilterState() == list.Filtering {
305 break
306 }
307 switch keypress := msg.String(); keypress {
308 case "left", "h":
309 if len(m.tabs) > 1 {
310 m.activeTabIndex--
311 if m.activeTabIndex < 0 {
312 m.activeTabIndex = len(m.tabs) - 1
313 }
314 m.currentAccountID = m.tabs[m.activeTabIndex].ID
315 m.updateList()
316 return m, nil
317 }
318 case "right", "l":
319 if len(m.tabs) > 1 {
320 m.activeTabIndex++
321 if m.activeTabIndex >= len(m.tabs) {
322 m.activeTabIndex = 0
323 }
324 m.currentAccountID = m.tabs[m.activeTabIndex].ID
325 m.updateList()
326 return m, nil
327 }
328 case "d":
329 selectedItem, ok := m.list.SelectedItem().(item)
330 if ok {
331 return m, func() tea.Msg {
332 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
333 }
334 }
335 case "a":
336 selectedItem, ok := m.list.SelectedItem().(item)
337 if ok {
338 return m, func() tea.Msg {
339 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
340 }
341 }
342 case "r":
343 // Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
344 counts := make(map[string]int)
345 for k, v := range m.emailCountByAcct {
346 counts[k] = v
347 }
348 return m, func() tea.Msg {
349 return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
350 }
351 case "enter":
352 selectedItem, ok := m.list.SelectedItem().(item)
353 if ok {
354 idx := selectedItem.originalIndex
355 uid := selectedItem.uid
356 accountID := selectedItem.accountID
357 return m, func() tea.Msg {
358 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
359 }
360 }
361 }
362 case tea.WindowSizeMsg:
363 m.width = msg.Width
364 m.height = msg.Height
365 m.list.SetWidth(msg.Width)
366 m.list.SetHeight(msg.Height / 2)
367 if m.shouldFetchMore() {
368 return m, tea.Batch(m.fetchMoreCmds()...)
369 }
370 return m, nil
371
372 case FetchingMoreEmailsMsg:
373 m.isFetching = true
374 m.list.Title = m.getTitle()
375 return m, nil
376
377 case EmailsAppendedMsg:
378 if msg.Mailbox != m.mailbox {
379 return m, nil
380 }
381 m.isFetching = false
382 m.list.Title = m.getTitle()
383
384 // Add emails to the appropriate account
385 for _, email := range msg.Emails {
386 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
387 m.allEmails = append(m.allEmails, email)
388 }
389 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
390
391 m.updateList()
392 return m, nil
393
394 case RefreshingEmailsMsg:
395 if msg.Mailbox != m.mailbox {
396 return m, nil
397 }
398 m.isRefreshing = true
399 m.list.Title = m.getTitle()
400 return m, nil
401
402 case EmailsRefreshedMsg:
403 if msg.Mailbox != m.mailbox {
404 return m, nil
405 }
406 m.isRefreshing = false
407
408 // Replace emails with fresh data
409 m.emailsByAccount = msg.EmailsByAccount
410
411 // Flatten all emails
412 var allEmails []fetcher.Email
413 for _, emails := range msg.EmailsByAccount {
414 allEmails = append(allEmails, emails...)
415 }
416
417 // Sort by date (newest first)
418 for i := 0; i < len(allEmails); i++ {
419 for j := i + 1; j < len(allEmails); j++ {
420 if allEmails[j].Date.After(allEmails[i].Date) {
421 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
422 }
423 }
424 }
425
426 m.allEmails = allEmails
427
428 // Update email counts
429 m.emailCountByAcct = make(map[string]int)
430 for accID, accEmails := range m.emailsByAccount {
431 m.emailCountByAcct[accID] = len(accEmails)
432 }
433
434 m.updateList()
435 return m, nil
436 }
437
438 var cmd tea.Cmd
439 m.list, cmd = m.list.Update(msg)
440 cmds = append(cmds, cmd)
441
442 if m.shouldFetchMore() {
443 cmds = append(cmds, m.fetchMoreCmds()...)
444 }
445 return m, tea.Batch(cmds...)
446}
447
448func (m *Inbox) shouldFetchMore() bool {
449 if m.isFetching {
450 return false
451 }
452 if len(m.list.Items()) == 0 {
453 return false
454 }
455 if m.list.FilterState() == list.Filtering {
456 return false
457 }
458 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
459 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
460}
461
462func (m *Inbox) fetchMoreCmds() []tea.Cmd {
463 var cmds []tea.Cmd
464 limit := uint32(m.list.Height())
465 if limit < 20 {
466 limit = 20
467 }
468
469 if m.currentAccountID == "" {
470 if len(m.accounts) == 0 {
471 return nil
472 }
473 for _, acc := range m.accounts {
474 accountID := acc.ID
475 offset := uint32(len(m.emailsByAccount[accountID]))
476 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
477 return func() tea.Msg {
478 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
479 }
480 }(accountID, offset))
481 }
482 return cmds
483 }
484
485 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
486 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
487 return func() tea.Msg {
488 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
489 }
490 }(m.currentAccountID, offset))
491 return cmds
492}
493
494func (m *Inbox) View() tea.View {
495 var b strings.Builder
496
497 // Render tabs if there are multiple accounts
498 if len(m.tabs) > 1 {
499 var tabViews []string
500 for i, tab := range m.tabs {
501 label := tab.Label
502 if tab.ID == "" {
503 label = "ALL"
504 }
505
506 if i == m.activeTabIndex {
507 tabViews = append(tabViews, activeTabStyle.Render(label))
508 } else {
509 tabViews = append(tabViews, tabStyle.Render(label))
510 }
511 }
512 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
513 b.WriteString(tabBar)
514 b.WriteString("\n")
515 }
516
517 b.WriteString(m.list.View())
518
519 // Ensure we don't start gap calculation on the same line as the list
520 if !strings.HasSuffix(b.String(), "\n") {
521 b.WriteString("\n")
522 }
523
524 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
525
526 if m.height > 0 {
527 usedHeight := lipgloss.Height(b.String())
528 helpHeight := lipgloss.Height(helpView)
529
530 gap := m.height - usedHeight - helpHeight
531 if gap > 0 {
532 b.WriteString(strings.Repeat("\n", gap))
533 }
534 } else {
535 b.WriteString("\n")
536 }
537
538 b.WriteString(helpView)
539
540 return tea.NewView(b.String())
541}
542
543// GetCurrentAccountID returns the currently selected account ID
544func (m *Inbox) GetCurrentAccountID() string {
545 return m.currentAccountID
546}
547
548// GetEmailAtIndex returns the email at the given index for the current view
549func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
550 var displayEmails []fetcher.Email
551 if m.currentAccountID == "" {
552 displayEmails = m.allEmails
553 } else {
554 displayEmails = m.emailsByAccount[m.currentAccountID]
555 }
556
557 if index >= 0 && index < len(displayEmails) {
558 return &displayEmails[index]
559 }
560 return nil
561}
562
563func (m *Inbox) GetMailbox() MailboxKind {
564 return m.mailbox
565}
566
567// RemoveEmail removes an email by UID and account ID
568func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
569 // Remove from account-specific list
570 if emails, ok := m.emailsByAccount[accountID]; ok {
571 var filtered []fetcher.Email
572 for _, e := range emails {
573 if e.UID != uid {
574 filtered = append(filtered, e)
575 }
576 }
577 m.emailsByAccount[accountID] = filtered
578 }
579
580 // Remove from all emails list
581 var filteredAll []fetcher.Email
582 for _, e := range m.allEmails {
583 if !(e.UID == uid && e.AccountID == accountID) {
584 filteredAll = append(filteredAll, e)
585 }
586 }
587 m.allEmails = filteredAll
588
589 m.updateList()
590}
591
592// SetEmails updates all emails (used after fetch)
593func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
594 m.accounts = accounts
595 m.allEmails = emails
596
597 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
598 var tabs []AccountTab
599 if len(accounts) <= 1 {
600 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
601 } else {
602 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
603 for _, acc := range accounts {
604 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
605 }
606 }
607 m.tabs = tabs
608
609 // Re-group emails by account
610 m.emailsByAccount = make(map[string][]fetcher.Email)
611 for _, email := range emails {
612 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
613 }
614
615 // Update email counts
616 m.emailCountByAcct = make(map[string]int)
617 for accID, accEmails := range m.emailsByAccount {
618 m.emailCountByAcct[accID] = len(accEmails)
619 }
620
621 m.updateList()
622}