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