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