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 folderName string // Custom folder name override for title
100 noMoreByAccount map[string]bool // Per-account: true when pagination returns 0 results
101 extraShortHelpKeys []key.Binding
102}
103
104func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
105 return NewInboxWithMailbox(emails, accounts, MailboxInbox)
106}
107
108func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
109 return NewInboxWithMailbox(emails, accounts, MailboxSent)
110}
111
112func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
113 return NewInboxWithMailbox(emails, accounts, MailboxTrash)
114}
115
116func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
117 return NewInboxWithMailbox(emails, accounts, MailboxArchive)
118}
119
120func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
121 // Build tabs: empty for single account, "ALL" + accounts for multiple
122 var tabs []AccountTab
123 if len(accounts) <= 1 {
124 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
125 } else {
126 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
127 for _, acc := range accounts {
128 // Use FetchEmail for display, fall back to Email if not set
129 displayEmail := acc.FetchEmail
130 if displayEmail == "" {
131 displayEmail = acc.Email
132 }
133 tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
134 }
135 }
136
137 // Group emails by account
138 emailsByAccount := make(map[string][]fetcher.Email)
139 for _, email := range emails {
140 emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
141 }
142
143 // Track email counts per account
144 emailCountByAcct := make(map[string]int)
145 for accID, accEmails := range emailsByAccount {
146 emailCountByAcct[accID] = len(accEmails)
147 }
148
149 inbox := &Inbox{
150 accounts: accounts,
151 emailsByAccount: emailsByAccount,
152 allEmails: emails,
153 tabs: tabs,
154 activeTabIndex: 0,
155 currentAccountID: "",
156 emailCountByAcct: emailCountByAcct,
157 mailbox: mailbox,
158 }
159
160 inbox.updateList()
161 return inbox
162}
163
164// NewInboxSingleAccount creates an inbox for a single account (legacy support)
165func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
166 return NewInbox(emails, nil)
167}
168
169func (m *Inbox) updateList() {
170 // Capture current index to restore later
171 currentIndex := m.list.Index()
172
173 var displayEmails []fetcher.Email
174 var showAccountLabel bool
175
176 if m.currentAccountID == "" {
177 // "ALL" view - show all emails sorted by date
178 displayEmails = m.allEmails
179 showAccountLabel = !(len(m.accounts) <= 1)
180 } else {
181 // Specific account view
182 displayEmails = m.emailsByAccount[m.currentAccountID]
183 showAccountLabel = false
184 }
185
186 m.emailsCount = len(displayEmails)
187
188 items := make([]list.Item, len(displayEmails))
189 for i, email := range displayEmails {
190 accountEmail := ""
191 if showAccountLabel {
192 // Find the account email for display
193 for _, acc := range m.accounts {
194 if acc.ID == email.AccountID {
195 accountEmail = acc.FetchEmail
196 break
197 }
198 }
199 }
200
201 items[i] = item{
202 title: email.Subject,
203 desc: email.From,
204 originalIndex: i,
205 uid: email.UID,
206 accountID: email.AccountID,
207 accountEmail: accountEmail,
208 }
209 }
210
211 l := list.New(items, itemDelegate{}, 20, 14)
212 l.Title = m.getTitle()
213 l.SetShowStatusBar(true)
214 l.SetFilteringEnabled(true)
215 l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
216 l.Styles.PaginationStyle = paginationStyle
217 l.Styles.HelpStyle = inboxHelpStyle
218 l.SetStatusBarItemName("email", "emails")
219 l.AdditionalShortHelpKeys = func() []key.Binding {
220 bindings := []key.Binding{
221 key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
222 key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
223 key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
224 }
225 if len(m.tabs) > 1 {
226 bindings = append(bindings,
227 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
228 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
229 )
230 }
231 bindings = append(bindings, m.extraShortHelpKeys...)
232 return bindings
233 }
234
235 l.KeyMap.Quit.SetEnabled(false)
236
237 // Disable default help to render it manually at the bottom
238 l.SetShowHelp(false)
239
240 if m.width > 0 {
241 l.SetWidth(m.width)
242 }
243 if m.height > 0 {
244 l.SetHeight(m.height / 2)
245 }
246
247 // Restore index
248 // If index is out of bounds (e.g. list shrank), clamp it.
249 if currentIndex >= len(items) {
250 currentIndex = len(items) - 1
251 }
252 if currentIndex < 0 {
253 currentIndex = 0
254 }
255 l.Select(currentIndex)
256
257 m.list = l
258}
259
260func (m *Inbox) getTitle() string {
261 var title string
262 if m.currentAccountID == "" {
263 title = m.getBaseTitle() + " - All Accounts"
264 } else {
265 title = m.getBaseTitle()
266 for _, acc := range m.accounts {
267 if acc.ID == m.currentAccountID {
268 if acc.Name != "" {
269 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
270 } else {
271 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
272 }
273 break
274 }
275 }
276 }
277 if m.isRefreshing {
278 title += " (refreshing...)"
279 }
280 if m.isFetching {
281 title += " (loading more...)"
282 }
283 return title
284}
285
286func (m *Inbox) getBaseTitle() string {
287 if m.folderName != "" {
288 return m.folderName
289 }
290 switch m.mailbox {
291 case MailboxSent:
292 return "Sent"
293 case MailboxTrash:
294 return "Trash"
295 case MailboxArchive:
296 return "Archive"
297 default:
298 return "Inbox"
299 }
300}
301
302func (m *Inbox) Init() tea.Cmd {
303 return nil
304}
305
306func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
307 var cmds []tea.Cmd
308
309 switch msg := msg.(type) {
310 case tea.KeyPressMsg:
311 if m.list.FilterState() == list.Filtering {
312 break
313 }
314 switch keypress := msg.String(); keypress {
315 case "left", "h":
316 if len(m.tabs) > 1 {
317 m.activeTabIndex--
318 if m.activeTabIndex < 0 {
319 m.activeTabIndex = len(m.tabs) - 1
320 }
321 m.currentAccountID = m.tabs[m.activeTabIndex].ID
322 m.updateList()
323 return m, nil
324 }
325 case "right", "l":
326 if len(m.tabs) > 1 {
327 m.activeTabIndex++
328 if m.activeTabIndex >= len(m.tabs) {
329 m.activeTabIndex = 0
330 }
331 m.currentAccountID = m.tabs[m.activeTabIndex].ID
332 m.updateList()
333 return m, nil
334 }
335 case "d":
336 selectedItem, ok := m.list.SelectedItem().(item)
337 if ok {
338 return m, func() tea.Msg {
339 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
340 }
341 }
342 case "a":
343 selectedItem, ok := m.list.SelectedItem().(item)
344 if ok {
345 return m, func() tea.Msg {
346 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
347 }
348 }
349 case "r":
350 // Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
351 counts := make(map[string]int)
352 for k, v := range m.emailCountByAcct {
353 counts[k] = v
354 }
355 return m, func() tea.Msg {
356 return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
357 }
358 case "enter":
359 selectedItem, ok := m.list.SelectedItem().(item)
360 if ok {
361 idx := selectedItem.originalIndex
362 uid := selectedItem.uid
363 accountID := selectedItem.accountID
364 return m, func() tea.Msg {
365 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
366 }
367 }
368 }
369 case tea.WindowSizeMsg:
370 m.width = msg.Width
371 m.height = msg.Height
372 m.list.SetWidth(msg.Width)
373 m.list.SetHeight(msg.Height / 2)
374 if m.shouldFetchMore() {
375 return m, tea.Batch(m.fetchMoreCmds()...)
376 }
377 return m, nil
378
379 case FetchingMoreEmailsMsg:
380 m.isFetching = true
381 m.list.Title = m.getTitle()
382 return m, nil
383
384 case EmailsAppendedMsg:
385 if msg.Mailbox != m.mailbox {
386 return m, nil
387 }
388 m.isFetching = false
389 m.list.Title = m.getTitle()
390
391 if len(msg.Emails) == 0 {
392 if m.noMoreByAccount == nil {
393 m.noMoreByAccount = make(map[string]bool)
394 }
395 m.noMoreByAccount[msg.AccountID] = true
396 return m, nil
397 }
398
399 // Add emails to the appropriate account
400 for _, email := range msg.Emails {
401 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
402 m.allEmails = append(m.allEmails, email)
403 }
404 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
405
406 m.updateList()
407 return m, nil
408
409 case RefreshingEmailsMsg:
410 if msg.Mailbox != m.mailbox {
411 return m, nil
412 }
413 m.isRefreshing = true
414 m.list.Title = m.getTitle()
415 return m, nil
416
417 case EmailsRefreshedMsg:
418 if msg.Mailbox != m.mailbox {
419 return m, nil
420 }
421 // Only clear the refreshing indicator. The actual email data is
422 // merged by the main model (preserving paginated emails) and
423 // pushed to us via SetEmails, so we must not overwrite it here.
424 m.isRefreshing = false
425 m.list.Title = m.getTitle()
426 return m, nil
427 }
428
429 var cmd tea.Cmd
430 m.list, cmd = m.list.Update(msg)
431 cmds = append(cmds, cmd)
432
433 if m.shouldFetchMore() {
434 cmds = append(cmds, m.fetchMoreCmds()...)
435 }
436 return m, tea.Batch(cmds...)
437}
438
439func (m *Inbox) shouldFetchMore() bool {
440 if m.isFetching {
441 return false
442 }
443 if m.allAccountsExhausted() {
444 return false
445 }
446 if len(m.list.Items()) == 0 {
447 return false
448 }
449 if m.list.FilterState() == list.Filtering {
450 return false
451 }
452 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
453 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
454}
455
456// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
457func (m *Inbox) allAccountsExhausted() bool {
458 if len(m.noMoreByAccount) == 0 {
459 return false
460 }
461 if m.currentAccountID != "" {
462 return m.noMoreByAccount[m.currentAccountID]
463 }
464 // "ALL" view: all accounts must be exhausted
465 for _, acc := range m.accounts {
466 if !m.noMoreByAccount[acc.ID] {
467 return false
468 }
469 }
470 return len(m.accounts) > 0
471}
472
473func (m *Inbox) fetchMoreCmds() []tea.Cmd {
474 var cmds []tea.Cmd
475 limit := uint32(m.list.Height())
476 if limit < 20 {
477 limit = 20
478 }
479
480 if m.currentAccountID == "" {
481 if len(m.accounts) == 0 {
482 return nil
483 }
484 for _, acc := range m.accounts {
485 accountID := acc.ID
486 if m.noMoreByAccount[accountID] {
487 continue
488 }
489 offset := uint32(len(m.emailsByAccount[accountID]))
490 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
491 return func() tea.Msg {
492 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
493 }
494 }(accountID, offset))
495 }
496 return cmds
497 }
498
499 if m.noMoreByAccount[m.currentAccountID] {
500 return nil
501 }
502 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
503 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
504 return func() tea.Msg {
505 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
506 }
507 }(m.currentAccountID, offset))
508 return cmds
509}
510
511func (m *Inbox) View() tea.View {
512 var b strings.Builder
513
514 // Render tabs if there are multiple accounts
515 if len(m.tabs) > 1 {
516 var tabViews []string
517 for i, tab := range m.tabs {
518 label := tab.Label
519 if tab.ID == "" {
520 label = "ALL"
521 }
522
523 if i == m.activeTabIndex {
524 tabViews = append(tabViews, activeTabStyle.Render(label))
525 } else {
526 tabViews = append(tabViews, tabStyle.Render(label))
527 }
528 }
529 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
530 b.WriteString(tabBar)
531 b.WriteString("\n")
532 }
533
534 b.WriteString(m.list.View())
535
536 // Ensure we don't start gap calculation on the same line as the list
537 if !strings.HasSuffix(b.String(), "\n") {
538 b.WriteString("\n")
539 }
540
541 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
542
543 if m.height > 0 {
544 usedHeight := lipgloss.Height(b.String())
545 helpHeight := lipgloss.Height(helpView)
546
547 gap := m.height - usedHeight - helpHeight
548 if gap > 0 {
549 b.WriteString(strings.Repeat("\n", gap))
550 }
551 } else {
552 b.WriteString("\n")
553 }
554
555 b.WriteString(helpView)
556
557 return tea.NewView(b.String())
558}
559
560// GetCurrentAccountID returns the currently selected account ID
561func (m *Inbox) GetCurrentAccountID() string {
562 return m.currentAccountID
563}
564
565// GetEmailAtIndex returns the email at the given index for the current view
566func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
567 var displayEmails []fetcher.Email
568 if m.currentAccountID == "" {
569 displayEmails = m.allEmails
570 } else {
571 displayEmails = m.emailsByAccount[m.currentAccountID]
572 }
573
574 if index >= 0 && index < len(displayEmails) {
575 return &displayEmails[index]
576 }
577 return nil
578}
579
580func (m *Inbox) GetMailbox() MailboxKind {
581 return m.mailbox
582}
583
584// RemoveEmail removes an email by UID and account ID
585func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
586 // Remove from account-specific list
587 if emails, ok := m.emailsByAccount[accountID]; ok {
588 var filtered []fetcher.Email
589 for _, e := range emails {
590 if e.UID != uid {
591 filtered = append(filtered, e)
592 }
593 }
594 m.emailsByAccount[accountID] = filtered
595 }
596
597 // Remove from all emails list
598 var filteredAll []fetcher.Email
599 for _, e := range m.allEmails {
600 if !(e.UID == uid && e.AccountID == accountID) {
601 filteredAll = append(filteredAll, e)
602 }
603 }
604 m.allEmails = filteredAll
605
606 m.updateList()
607}
608
609// SetSize sets the width and height of the inbox, then updates the list.
610func (m *Inbox) SetSize(width, height int) {
611 m.width = width
612 m.height = height
613 m.list.SetWidth(width)
614 m.list.SetHeight(height / 2)
615}
616
617// SetFolderName sets a custom folder name for the inbox title.
618func (m *Inbox) SetFolderName(name string) {
619 m.folderName = name
620 m.list.Title = m.getTitle()
621}
622
623// SetEmails updates all emails (used after fetch)
624func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
625 m.accounts = accounts
626 m.allEmails = emails
627 m.noMoreByAccount = make(map[string]bool)
628
629 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
630 var tabs []AccountTab
631 if len(accounts) <= 1 {
632 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
633 } else {
634 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
635 for _, acc := range accounts {
636 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
637 }
638 }
639 m.tabs = tabs
640
641 // Re-group emails by account
642 m.emailsByAccount = make(map[string][]fetcher.Email)
643 for _, email := range emails {
644 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
645 }
646
647 // Update email counts
648 m.emailCountByAcct = make(map[string]int)
649 for accID, accEmails := range m.emailsByAccount {
650 m.emailCountByAcct[accID] = len(accEmails)
651 }
652
653 m.updateList()
654}