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 // Only clear the refreshing indicator. The actual email data is
407 // merged by the main model (preserving paginated emails) and
408 // pushed to us via SetEmails, so we must not overwrite it here.
409 m.isRefreshing = false
410 m.list.Title = m.getTitle()
411 return m, nil
412 }
413
414 var cmd tea.Cmd
415 m.list, cmd = m.list.Update(msg)
416 cmds = append(cmds, cmd)
417
418 if m.shouldFetchMore() {
419 cmds = append(cmds, m.fetchMoreCmds()...)
420 }
421 return m, tea.Batch(cmds...)
422}
423
424func (m *Inbox) shouldFetchMore() bool {
425 if m.isFetching {
426 return false
427 }
428 if len(m.list.Items()) == 0 {
429 return false
430 }
431 if m.list.FilterState() == list.Filtering {
432 return false
433 }
434 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
435 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
436}
437
438func (m *Inbox) fetchMoreCmds() []tea.Cmd {
439 var cmds []tea.Cmd
440 limit := uint32(m.list.Height())
441 if limit < 20 {
442 limit = 20
443 }
444
445 if m.currentAccountID == "" {
446 if len(m.accounts) == 0 {
447 return nil
448 }
449 for _, acc := range m.accounts {
450 accountID := acc.ID
451 offset := uint32(len(m.emailsByAccount[accountID]))
452 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
453 return func() tea.Msg {
454 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
455 }
456 }(accountID, offset))
457 }
458 return cmds
459 }
460
461 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
462 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
463 return func() tea.Msg {
464 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
465 }
466 }(m.currentAccountID, offset))
467 return cmds
468}
469
470func (m *Inbox) View() tea.View {
471 var b strings.Builder
472
473 // Render tabs if there are multiple accounts
474 if len(m.tabs) > 1 {
475 var tabViews []string
476 for i, tab := range m.tabs {
477 label := tab.Label
478 if tab.ID == "" {
479 label = "ALL"
480 }
481
482 if i == m.activeTabIndex {
483 tabViews = append(tabViews, activeTabStyle.Render(label))
484 } else {
485 tabViews = append(tabViews, tabStyle.Render(label))
486 }
487 }
488 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
489 b.WriteString(tabBar)
490 b.WriteString("\n")
491 }
492
493 b.WriteString(m.list.View())
494
495 // Ensure we don't start gap calculation on the same line as the list
496 if !strings.HasSuffix(b.String(), "\n") {
497 b.WriteString("\n")
498 }
499
500 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
501
502 if m.height > 0 {
503 usedHeight := lipgloss.Height(b.String())
504 helpHeight := lipgloss.Height(helpView)
505
506 gap := m.height - usedHeight - helpHeight
507 if gap > 0 {
508 b.WriteString(strings.Repeat("\n", gap))
509 }
510 } else {
511 b.WriteString("\n")
512 }
513
514 b.WriteString(helpView)
515
516 return tea.NewView(b.String())
517}
518
519// GetCurrentAccountID returns the currently selected account ID
520func (m *Inbox) GetCurrentAccountID() string {
521 return m.currentAccountID
522}
523
524// GetEmailAtIndex returns the email at the given index for the current view
525func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
526 var displayEmails []fetcher.Email
527 if m.currentAccountID == "" {
528 displayEmails = m.allEmails
529 } else {
530 displayEmails = m.emailsByAccount[m.currentAccountID]
531 }
532
533 if index >= 0 && index < len(displayEmails) {
534 return &displayEmails[index]
535 }
536 return nil
537}
538
539func (m *Inbox) GetMailbox() MailboxKind {
540 return m.mailbox
541}
542
543// RemoveEmail removes an email by UID and account ID
544func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
545 // Remove from account-specific list
546 if emails, ok := m.emailsByAccount[accountID]; ok {
547 var filtered []fetcher.Email
548 for _, e := range emails {
549 if e.UID != uid {
550 filtered = append(filtered, e)
551 }
552 }
553 m.emailsByAccount[accountID] = filtered
554 }
555
556 // Remove from all emails list
557 var filteredAll []fetcher.Email
558 for _, e := range m.allEmails {
559 if !(e.UID == uid && e.AccountID == accountID) {
560 filteredAll = append(filteredAll, e)
561 }
562 }
563 m.allEmails = filteredAll
564
565 m.updateList()
566}
567
568// SetEmails updates all emails (used after fetch)
569func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
570 m.accounts = accounts
571 m.allEmails = emails
572
573 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
574 var tabs []AccountTab
575 if len(accounts) <= 1 {
576 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
577 } else {
578 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
579 for _, acc := range accounts {
580 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
581 }
582 }
583 m.tabs = tabs
584
585 // Re-group emails by account
586 m.emailsByAccount = make(map[string][]fetcher.Email)
587 for _, email := range emails {
588 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
589 }
590
591 // Update email counts
592 m.emailCountByAcct = make(map[string]int)
593 for accID, accEmails := range m.emailsByAccount {
594 m.emailCountByAcct[accID] = len(accEmails)
595 }
596
597 m.updateList()
598}