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 // Disable default help to render it manually at the bottom
230 l.SetShowHelp(false)
231
232 if m.width > 0 {
233 l.SetWidth(m.width)
234 }
235 if m.height > 0 {
236 l.SetHeight(m.height / 2)
237 }
238
239 m.list = l
240}
241
242func (m *Inbox) getTitle() string {
243 var title string
244 if m.currentAccountID == "" {
245 title = m.getBaseTitle() + " - All Accounts"
246 } else {
247 title = m.getBaseTitle()
248 for _, acc := range m.accounts {
249 if acc.ID == m.currentAccountID {
250 if acc.Name != "" {
251 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
252 } else {
253 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
254 }
255 break
256 }
257 }
258 }
259 if m.isRefreshing {
260 title += " (refreshing...)"
261 }
262 if m.isFetching {
263 title += " (loading more...)"
264 }
265 return title
266}
267
268func (m *Inbox) getBaseTitle() string {
269 switch m.mailbox {
270 case MailboxSent:
271 return "Sent"
272 case MailboxTrash:
273 return "Trash"
274 case MailboxArchive:
275 return "Archive"
276 default:
277 return "Inbox"
278 }
279}
280
281func (m *Inbox) Init() tea.Cmd {
282 return nil
283}
284
285func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
286 var cmds []tea.Cmd
287
288 switch msg := msg.(type) {
289 case tea.KeyMsg:
290 if m.list.FilterState() == list.Filtering {
291 break
292 }
293 switch keypress := msg.String(); keypress {
294 case "left", "h":
295 if len(m.tabs) > 1 {
296 m.activeTabIndex--
297 if m.activeTabIndex < 0 {
298 m.activeTabIndex = len(m.tabs) - 1
299 }
300 m.currentAccountID = m.tabs[m.activeTabIndex].ID
301 m.updateList()
302 return m, nil
303 }
304 case "right", "l":
305 if len(m.tabs) > 1 {
306 m.activeTabIndex++
307 if m.activeTabIndex >= len(m.tabs) {
308 m.activeTabIndex = 0
309 }
310 m.currentAccountID = m.tabs[m.activeTabIndex].ID
311 m.updateList()
312 return m, nil
313 }
314 case "d":
315 selectedItem, ok := m.list.SelectedItem().(item)
316 if ok {
317 return m, func() tea.Msg {
318 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
319 }
320 }
321 case "a":
322 selectedItem, ok := m.list.SelectedItem().(item)
323 if ok {
324 return m, func() tea.Msg {
325 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
326 }
327 }
328 case "r":
329 return m, func() tea.Msg {
330 return RequestRefreshMsg{Mailbox: m.mailbox}
331 }
332 case "enter":
333 selectedItem, ok := m.list.SelectedItem().(item)
334 if ok {
335 idx := selectedItem.originalIndex
336 uid := selectedItem.uid
337 accountID := selectedItem.accountID
338 return m, func() tea.Msg {
339 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
340 }
341 }
342 }
343 case tea.WindowSizeMsg:
344 m.width = msg.Width
345 m.height = msg.Height
346 m.list.SetWidth(msg.Width)
347 m.list.SetHeight(msg.Height / 2)
348 if m.shouldFetchMore() {
349 return m, tea.Batch(m.fetchMoreCmds()...)
350 }
351 return m, nil
352
353 case FetchingMoreEmailsMsg:
354 m.isFetching = true
355 m.list.Title = m.getTitle()
356 return m, nil
357
358 case EmailsAppendedMsg:
359 if msg.Mailbox != m.mailbox {
360 return m, nil
361 }
362 m.isFetching = false
363 m.list.Title = m.getTitle()
364
365 // Add emails to the appropriate account
366 for _, email := range msg.Emails {
367 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
368 m.allEmails = append(m.allEmails, email)
369 }
370 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
371
372 m.updateList()
373 return m, nil
374
375 case RefreshingEmailsMsg:
376 if msg.Mailbox != m.mailbox {
377 return m, nil
378 }
379 m.isRefreshing = true
380 m.list.Title = m.getTitle()
381 return m, nil
382
383 case EmailsRefreshedMsg:
384 if msg.Mailbox != m.mailbox {
385 return m, nil
386 }
387 m.isRefreshing = false
388
389 // Replace emails with fresh data
390 m.emailsByAccount = msg.EmailsByAccount
391
392 // Flatten all emails
393 var allEmails []fetcher.Email
394 for _, emails := range msg.EmailsByAccount {
395 allEmails = append(allEmails, emails...)
396 }
397
398 // Sort by date (newest first)
399 for i := 0; i < len(allEmails); i++ {
400 for j := i + 1; j < len(allEmails); j++ {
401 if allEmails[j].Date.After(allEmails[i].Date) {
402 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
403 }
404 }
405 }
406
407 m.allEmails = allEmails
408
409 // Update email counts
410 m.emailCountByAcct = make(map[string]int)
411 for accID, accEmails := range m.emailsByAccount {
412 m.emailCountByAcct[accID] = len(accEmails)
413 }
414
415 m.updateList()
416 return m, nil
417 }
418
419 var cmd tea.Cmd
420 m.list, cmd = m.list.Update(msg)
421 cmds = append(cmds, cmd)
422
423 if m.shouldFetchMore() {
424 cmds = append(cmds, m.fetchMoreCmds()...)
425 }
426 return m, tea.Batch(cmds...)
427}
428
429func (m *Inbox) shouldFetchMore() bool {
430 if m.isFetching {
431 return false
432 }
433 if len(m.list.Items()) == 0 {
434 return false
435 }
436 if m.list.FilterState() == list.Filtering {
437 return false
438 }
439 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
440 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
441}
442
443func (m *Inbox) fetchMoreCmds() []tea.Cmd {
444 var cmds []tea.Cmd
445 limit := uint32(m.list.Height())
446 if limit < 20 {
447 limit = 20
448 }
449
450 if m.currentAccountID == "" {
451 if len(m.accounts) == 0 {
452 return nil
453 }
454 for _, acc := range m.accounts {
455 accountID := acc.ID
456 offset := uint32(len(m.emailsByAccount[accountID]))
457 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
458 return func() tea.Msg {
459 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
460 }
461 }(accountID, offset))
462 }
463 return cmds
464 }
465
466 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
467 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
468 return func() tea.Msg {
469 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
470 }
471 }(m.currentAccountID, offset))
472 return cmds
473}
474
475func (m *Inbox) View() string {
476 var b strings.Builder
477
478 // Render tabs if there are multiple accounts
479 if len(m.tabs) > 1 {
480 var tabViews []string
481 for i, tab := range m.tabs {
482 label := tab.Label
483 if tab.ID == "" {
484 label = "ALL"
485 }
486
487 if i == m.activeTabIndex {
488 tabViews = append(tabViews, activeTabStyle.Render(label))
489 } else {
490 tabViews = append(tabViews, tabStyle.Render(label))
491 }
492 }
493 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
494 b.WriteString(tabBar)
495 b.WriteString("\n")
496 }
497
498 b.WriteString(m.list.View())
499
500 // Calculate remaining height to push help to bottom
501 // m.height is total height.
502 // We need to account for tabs (if present) and the list height.
503 // The list height is set to m.height / 2.
504 // Tabs take about 3 lines (border + padding + content).
505
506 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
507
508 // If we have a known height, we can try to fill the space.
509 if m.height > 0 {
510 // Calculate how many lines we have used
511 usedHeight := 0
512 if len(m.tabs) > 1 {
513 // Re-render tabs just to measure height
514 var tabViews []string
515 for i, tab := range m.tabs {
516 label := tab.Label
517 if tab.ID == "" {
518 label = "ALL"
519 }
520
521 if i == m.activeTabIndex {
522 tabViews = append(tabViews, activeTabStyle.Render(label))
523 } else {
524 tabViews = append(tabViews, tabStyle.Render(label))
525 }
526 }
527 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
528 usedHeight += lipgloss.Height(tabBar)
529 }
530
531 // List
532 usedHeight += m.list.Height()
533
534 // Help
535 // Use lipgloss to measure help height
536 helpHeight := lipgloss.Height(helpView)
537
538 // Calculate gap
539 gap := m.height - usedHeight - helpHeight
540 if gap > 0 {
541 b.WriteString(strings.Repeat("\n", gap))
542 }
543 } else {
544 b.WriteString("\n")
545 }
546
547 b.WriteString(helpView)
548
549 return b.String()
550}
551
552// GetCurrentAccountID returns the currently selected account ID
553func (m *Inbox) GetCurrentAccountID() string {
554 return m.currentAccountID
555}
556
557// GetEmailAtIndex returns the email at the given index for the current view
558func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
559 var displayEmails []fetcher.Email
560 if m.currentAccountID == "" {
561 displayEmails = m.allEmails
562 } else {
563 displayEmails = m.emailsByAccount[m.currentAccountID]
564 }
565
566 if index >= 0 && index < len(displayEmails) {
567 return &displayEmails[index]
568 }
569 return nil
570}
571
572func (m *Inbox) GetMailbox() MailboxKind {
573 return m.mailbox
574}
575
576// RemoveEmail removes an email by UID and account ID
577func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
578 // Remove from account-specific list
579 if emails, ok := m.emailsByAccount[accountID]; ok {
580 var filtered []fetcher.Email
581 for _, e := range emails {
582 if e.UID != uid {
583 filtered = append(filtered, e)
584 }
585 }
586 m.emailsByAccount[accountID] = filtered
587 }
588
589 // Remove from all emails list
590 var filteredAll []fetcher.Email
591 for _, e := range m.allEmails {
592 if !(e.UID == uid && e.AccountID == accountID) {
593 filteredAll = append(filteredAll, e)
594 }
595 }
596 m.allEmails = filteredAll
597
598 m.updateList()
599}
600
601// SetEmails updates all emails (used after fetch)
602func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
603 m.accounts = accounts
604 m.allEmails = emails
605
606 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
607 var tabs []AccountTab
608 if len(accounts) <= 1 {
609 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
610 } else {
611 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
612 for _, acc := range accounts {
613 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
614 }
615 }
616 m.tabs = tabs
617
618 // Re-group emails by account
619 m.emailsByAccount = make(map[string][]fetcher.Email)
620 for _, email := range emails {
621 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
622 }
623
624 // Update email counts
625 m.emailCountByAcct = make(map[string]int)
626 for accID, accEmails := range m.emailsByAccount {
627 m.emailCountByAcct[accID] = len(accEmails)
628 }
629
630 m.updateList()
631}