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 NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
109 // Build tabs: "ALL" + one per account
110 tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}}
111 for _, acc := range accounts {
112 label := acc.Email
113 tabs = append(tabs, AccountTab{ID: acc.ID, Label: label, Email: acc.Email})
114 }
115
116 // Group emails by account
117 emailsByAccount := make(map[string][]fetcher.Email)
118 for _, email := range emails {
119 emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
120 }
121
122 // Track email counts per account
123 emailCountByAcct := make(map[string]int)
124 for accID, accEmails := range emailsByAccount {
125 emailCountByAcct[accID] = len(accEmails)
126 }
127
128 inbox := &Inbox{
129 accounts: accounts,
130 emailsByAccount: emailsByAccount,
131 allEmails: emails,
132 tabs: tabs,
133 activeTabIndex: 0,
134 currentAccountID: "",
135 emailCountByAcct: emailCountByAcct,
136 mailbox: mailbox,
137 }
138
139 inbox.updateList()
140 return inbox
141}
142
143// NewInboxSingleAccount creates an inbox for a single account (legacy support)
144func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
145 return NewInbox(emails, nil)
146}
147
148func (m *Inbox) updateList() {
149 var displayEmails []fetcher.Email
150 var showAccountLabel bool
151
152 if m.currentAccountID == "" {
153 // "ALL" view - show all emails sorted by date
154 displayEmails = m.allEmails
155 showAccountLabel = len(m.accounts) > 1
156 } else {
157 // Specific account view
158 displayEmails = m.emailsByAccount[m.currentAccountID]
159 showAccountLabel = false
160 }
161
162 m.emailsCount = len(displayEmails)
163
164 items := make([]list.Item, len(displayEmails))
165 for i, email := range displayEmails {
166 accountEmail := ""
167 if showAccountLabel {
168 // Find the account email for display
169 for _, acc := range m.accounts {
170 if acc.ID == email.AccountID {
171 accountEmail = acc.Email
172 break
173 }
174 }
175 }
176
177 items[i] = item{
178 title: email.Subject,
179 desc: email.From,
180 originalIndex: i,
181 uid: email.UID,
182 accountID: email.AccountID,
183 accountEmail: accountEmail,
184 }
185 }
186
187 l := list.New(items, itemDelegate{}, 20, 14)
188 l.Title = m.getTitle()
189 l.SetShowStatusBar(true)
190 l.SetFilteringEnabled(true)
191 l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
192 l.Styles.PaginationStyle = paginationStyle
193 l.Styles.HelpStyle = inboxHelpStyle
194 l.SetStatusBarItemName("email", "emails")
195 l.AdditionalShortHelpKeys = func() []key.Binding {
196 bindings := []key.Binding{
197 key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
198 key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "archive")),
199 key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
200 }
201 if len(m.tabs) > 1 {
202 bindings = append(bindings,
203 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
204 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
205 )
206 }
207 return bindings
208 }
209
210 l.KeyMap.Quit.SetEnabled(false)
211
212 if m.width > 0 {
213 l.SetWidth(m.width)
214 }
215
216 m.list = l
217}
218
219func (m *Inbox) getTitle() string {
220 var title string
221 if m.currentAccountID == "" {
222 title = m.getBaseTitleWithCount() + " - All Accounts"
223 } else {
224 title = m.getBaseTitleWithCount()
225 for _, acc := range m.accounts {
226 if acc.ID == m.currentAccountID {
227 if acc.Name != "" {
228 title = fmt.Sprintf("%s - %s", m.getBaseTitleWithCount(), acc.Name)
229 } else {
230 title = fmt.Sprintf("%s - %s", m.getBaseTitleWithCount(), acc.Email)
231 }
232 break
233 }
234 }
235 }
236 if m.isRefreshing {
237 title += " (refreshing...)"
238 }
239 if m.isFetching {
240 title += " (loading more...)"
241 }
242 return title
243}
244
245func (m *Inbox) getBaseTitle() string {
246 switch m.mailbox {
247 case MailboxSent:
248 return "Sent"
249 default:
250 return "Inbox"
251 }
252}
253
254func (m *Inbox) getBaseTitleWithCount() string {
255 return fmt.Sprintf("%s (%d)", m.getBaseTitle(), m.emailsCount)
256}
257
258func (m *Inbox) Init() tea.Cmd {
259 return nil
260}
261
262func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
263 var cmds []tea.Cmd
264
265 switch msg := msg.(type) {
266 case tea.KeyMsg:
267 if m.list.FilterState() == list.Filtering {
268 break
269 }
270 switch keypress := msg.String(); keypress {
271 case "left", "h":
272 if len(m.tabs) > 1 {
273 m.activeTabIndex--
274 if m.activeTabIndex < 0 {
275 m.activeTabIndex = len(m.tabs) - 1
276 }
277 m.currentAccountID = m.tabs[m.activeTabIndex].ID
278 m.updateList()
279 return m, nil
280 }
281 case "right", "l":
282 if len(m.tabs) > 1 {
283 m.activeTabIndex++
284 if m.activeTabIndex >= len(m.tabs) {
285 m.activeTabIndex = 0
286 }
287 m.currentAccountID = m.tabs[m.activeTabIndex].ID
288 m.updateList()
289 return m, nil
290 }
291 case "d":
292 selectedItem, ok := m.list.SelectedItem().(item)
293 if ok {
294 return m, func() tea.Msg {
295 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
296 }
297 }
298 case "a":
299 selectedItem, ok := m.list.SelectedItem().(item)
300 if ok {
301 return m, func() tea.Msg {
302 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
303 }
304 }
305 case "r":
306 return m, func() tea.Msg {
307 return RequestRefreshMsg{Mailbox: m.mailbox}
308 }
309 case "enter":
310 selectedItem, ok := m.list.SelectedItem().(item)
311 if ok {
312 idx := selectedItem.originalIndex
313 uid := selectedItem.uid
314 accountID := selectedItem.accountID
315 return m, func() tea.Msg {
316 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
317 }
318 }
319 }
320 case tea.WindowSizeMsg:
321 m.width = msg.Width
322 m.height = msg.Height
323 m.list.SetWidth(msg.Width)
324 return m, nil
325
326 case FetchingMoreEmailsMsg:
327 m.isFetching = true
328 m.list.Title = m.getTitle()
329 return m, nil
330
331 case EmailsAppendedMsg:
332 if msg.Mailbox != m.mailbox {
333 return m, nil
334 }
335 m.isFetching = false
336 m.list.Title = m.getTitle()
337
338 // Add emails to the appropriate account
339 for _, email := range msg.Emails {
340 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
341 m.allEmails = append(m.allEmails, email)
342 }
343 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
344
345 m.updateList()
346 return m, nil
347
348 case RefreshingEmailsMsg:
349 if msg.Mailbox != m.mailbox {
350 return m, nil
351 }
352 m.isRefreshing = true
353 m.list.Title = m.getTitle()
354 return m, nil
355
356 case EmailsRefreshedMsg:
357 if msg.Mailbox != m.mailbox {
358 return m, nil
359 }
360 m.isRefreshing = false
361
362 // Replace emails with fresh data
363 m.emailsByAccount = msg.EmailsByAccount
364
365 // Flatten all emails
366 var allEmails []fetcher.Email
367 for _, emails := range msg.EmailsByAccount {
368 allEmails = append(allEmails, emails...)
369 }
370
371 // Sort by date (newest first)
372 for i := 0; i < len(allEmails); i++ {
373 for j := i + 1; j < len(allEmails); j++ {
374 if allEmails[j].Date.After(allEmails[i].Date) {
375 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
376 }
377 }
378 }
379
380 m.allEmails = allEmails
381
382 // Update email counts
383 m.emailCountByAcct = make(map[string]int)
384 for accID, accEmails := range m.emailsByAccount {
385 m.emailCountByAcct[accID] = len(accEmails)
386 }
387
388 m.updateList()
389 return m, nil
390 }
391
392 var cmd tea.Cmd
393 m.list, cmd = m.list.Update(msg)
394 cmds = append(cmds, cmd)
395
396 if m.shouldFetchMore() {
397 cmds = append(cmds, m.fetchMoreCmds()...)
398 }
399 return m, tea.Batch(cmds...)
400}
401
402func (m *Inbox) shouldFetchMore() bool {
403 if m.isFetching {
404 return false
405 }
406 if len(m.list.Items()) == 0 {
407 return false
408 }
409 if m.list.FilterState() == list.Filtering {
410 return false
411 }
412 return m.list.Index() >= len(m.list.Items())-1
413}
414
415func (m *Inbox) fetchMoreCmds() []tea.Cmd {
416 var cmds []tea.Cmd
417 if m.currentAccountID == "" {
418 if len(m.accounts) == 0 {
419 return nil
420 }
421 for _, acc := range m.accounts {
422 accountID := acc.ID
423 offset := uint32(len(m.emailsByAccount[accountID]))
424 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
425 return func() tea.Msg {
426 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox}
427 }
428 }(accountID, offset))
429 }
430 return cmds
431 }
432
433 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
434 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
435 return func() tea.Msg {
436 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox}
437 }
438 }(m.currentAccountID, offset))
439 return cmds
440}
441
442func (m *Inbox) View() string {
443 var b strings.Builder
444
445 // Render tabs if there are multiple accounts
446 if len(m.tabs) > 1 {
447 var tabViews []string
448 for i, tab := range m.tabs {
449 label := tab.Label
450 if tab.ID == "" {
451 label = "ALL"
452 }
453
454 if i == m.activeTabIndex {
455 tabViews = append(tabViews, activeTabStyle.Render(label))
456 } else {
457 tabViews = append(tabViews, tabStyle.Render(label))
458 }
459 }
460 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
461 b.WriteString(tabBar)
462 b.WriteString("\n")
463 }
464
465 b.WriteString(m.list.View())
466 return b.String()
467}
468
469// GetCurrentAccountID returns the currently selected account ID
470func (m *Inbox) GetCurrentAccountID() string {
471 return m.currentAccountID
472}
473
474// GetEmailAtIndex returns the email at the given index for the current view
475func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
476 var displayEmails []fetcher.Email
477 if m.currentAccountID == "" {
478 displayEmails = m.allEmails
479 } else {
480 displayEmails = m.emailsByAccount[m.currentAccountID]
481 }
482
483 if index >= 0 && index < len(displayEmails) {
484 return &displayEmails[index]
485 }
486 return nil
487}
488
489func (m *Inbox) GetMailbox() MailboxKind {
490 return m.mailbox
491}
492
493// RemoveEmail removes an email by UID and account ID
494func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
495 // Remove from account-specific list
496 if emails, ok := m.emailsByAccount[accountID]; ok {
497 var filtered []fetcher.Email
498 for _, e := range emails {
499 if e.UID != uid {
500 filtered = append(filtered, e)
501 }
502 }
503 m.emailsByAccount[accountID] = filtered
504 }
505
506 // Remove from all emails list
507 var filteredAll []fetcher.Email
508 for _, e := range m.allEmails {
509 if !(e.UID == uid && e.AccountID == accountID) {
510 filteredAll = append(filteredAll, e)
511 }
512 }
513 m.allEmails = filteredAll
514
515 m.updateList()
516}
517
518// SetEmails updates all emails (used after fetch)
519func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
520 m.accounts = accounts
521 m.allEmails = emails
522
523 // Rebuild tabs
524 tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}}
525 for _, acc := range accounts {
526 label := acc.Email
527 tabs = append(tabs, AccountTab{ID: acc.ID, Label: label, Email: acc.Email})
528 }
529 m.tabs = tabs
530
531 // Re-group emails by account
532 m.emailsByAccount = make(map[string][]fetcher.Email)
533 for _, email := range emails {
534 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
535 }
536
537 // Update email counts
538 m.emailCountByAcct = make(map[string]int)
539 for accID, accEmails := range m.emailsByAccount {
540 m.emailCountByAcct[accID] = len(accEmails)
541 }
542
543 m.updateList()
544}