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 }
200 if len(m.tabs) > 1 {
201 bindings = append(bindings,
202 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
203 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
204 )
205 }
206 return bindings
207 }
208
209 l.KeyMap.Quit.SetEnabled(false)
210
211 if m.width > 0 {
212 l.SetWidth(m.width)
213 }
214
215 m.list = l
216}
217
218func (m *Inbox) getTitle() string {
219 var title string
220 if m.currentAccountID == "" {
221 title = m.getBaseTitleWithCount() + " - All Accounts"
222 } else {
223 title = m.getBaseTitleWithCount()
224 for _, acc := range m.accounts {
225 if acc.ID == m.currentAccountID {
226 if acc.Name != "" {
227 title = fmt.Sprintf("%s - %s", m.getBaseTitleWithCount(), acc.Name)
228 } else {
229 title = fmt.Sprintf("%s - %s", m.getBaseTitleWithCount(), acc.Email)
230 }
231 break
232 }
233 }
234 }
235 if m.isRefreshing {
236 title += " (refreshing...)"
237 }
238 if m.isFetching {
239 title += " (loading more...)"
240 }
241 return title
242}
243
244func (m *Inbox) getBaseTitle() string {
245 switch m.mailbox {
246 case MailboxSent:
247 return "Sent"
248 default:
249 return "Inbox"
250 }
251}
252
253func (m *Inbox) getBaseTitleWithCount() string {
254 return fmt.Sprintf("%s (%d)", m.getBaseTitle(), m.emailsCount)
255}
256
257func (m *Inbox) Init() tea.Cmd {
258 return nil
259}
260
261func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
262 var cmds []tea.Cmd
263
264 switch msg := msg.(type) {
265 case tea.KeyMsg:
266 if m.list.FilterState() == list.Filtering {
267 break
268 }
269 switch keypress := msg.String(); keypress {
270 case "left", "h":
271 if len(m.tabs) > 1 {
272 m.activeTabIndex--
273 if m.activeTabIndex < 0 {
274 m.activeTabIndex = len(m.tabs) - 1
275 }
276 m.currentAccountID = m.tabs[m.activeTabIndex].ID
277 m.updateList()
278 return m, nil
279 }
280 case "right", "l":
281 if len(m.tabs) > 1 {
282 m.activeTabIndex++
283 if m.activeTabIndex >= len(m.tabs) {
284 m.activeTabIndex = 0
285 }
286 m.currentAccountID = m.tabs[m.activeTabIndex].ID
287 m.updateList()
288 return m, nil
289 }
290 case "d":
291 selectedItem, ok := m.list.SelectedItem().(item)
292 if ok {
293 return m, func() tea.Msg {
294 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
295 }
296 }
297 case "a":
298 selectedItem, ok := m.list.SelectedItem().(item)
299 if ok {
300 return m, func() tea.Msg {
301 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
302 }
303 }
304 case "enter":
305 selectedItem, ok := m.list.SelectedItem().(item)
306 if ok {
307 idx := selectedItem.originalIndex
308 uid := selectedItem.uid
309 accountID := selectedItem.accountID
310 return m, func() tea.Msg {
311 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
312 }
313 }
314 }
315 case tea.WindowSizeMsg:
316 m.width = msg.Width
317 m.height = msg.Height
318 m.list.SetWidth(msg.Width)
319 return m, nil
320
321 case FetchingMoreEmailsMsg:
322 m.isFetching = true
323 m.list.Title = m.getTitle()
324 return m, nil
325
326 case EmailsAppendedMsg:
327 if msg.Mailbox != m.mailbox {
328 return m, nil
329 }
330 m.isFetching = false
331 m.list.Title = m.getTitle()
332
333 // Add emails to the appropriate account
334 for _, email := range msg.Emails {
335 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
336 m.allEmails = append(m.allEmails, email)
337 }
338 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
339
340 m.updateList()
341 return m, nil
342
343 case RefreshingEmailsMsg:
344 if msg.Mailbox != m.mailbox {
345 return m, nil
346 }
347 m.isRefreshing = true
348 m.list.Title = m.getTitle()
349 return m, nil
350
351 case EmailsRefreshedMsg:
352 if msg.Mailbox != m.mailbox {
353 return m, nil
354 }
355 m.isRefreshing = false
356
357 // Replace emails with fresh data
358 m.emailsByAccount = msg.EmailsByAccount
359
360 // Flatten all emails
361 var allEmails []fetcher.Email
362 for _, emails := range msg.EmailsByAccount {
363 allEmails = append(allEmails, emails...)
364 }
365
366 // Sort by date (newest first)
367 for i := 0; i < len(allEmails); i++ {
368 for j := i + 1; j < len(allEmails); j++ {
369 if allEmails[j].Date.After(allEmails[i].Date) {
370 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
371 }
372 }
373 }
374
375 m.allEmails = allEmails
376
377 // Update email counts
378 m.emailCountByAcct = make(map[string]int)
379 for accID, accEmails := range m.emailsByAccount {
380 m.emailCountByAcct[accID] = len(accEmails)
381 }
382
383 m.updateList()
384 return m, nil
385 }
386
387 var cmd tea.Cmd
388 m.list, cmd = m.list.Update(msg)
389 cmds = append(cmds, cmd)
390
391 if m.shouldFetchMore() {
392 cmds = append(cmds, m.fetchMoreCmds()...)
393 }
394 return m, tea.Batch(cmds...)
395}
396
397func (m *Inbox) shouldFetchMore() bool {
398 if m.isFetching {
399 return false
400 }
401 if len(m.list.Items()) == 0 {
402 return false
403 }
404 if m.list.FilterState() == list.Filtering {
405 return false
406 }
407 return m.list.Index() >= len(m.list.Items())-1
408}
409
410func (m *Inbox) fetchMoreCmds() []tea.Cmd {
411 var cmds []tea.Cmd
412 if m.currentAccountID == "" {
413 if len(m.accounts) == 0 {
414 return nil
415 }
416 for _, acc := range m.accounts {
417 accountID := acc.ID
418 offset := uint32(len(m.emailsByAccount[accountID]))
419 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
420 return func() tea.Msg {
421 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox}
422 }
423 }(accountID, offset))
424 }
425 return cmds
426 }
427
428 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
429 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
430 return func() tea.Msg {
431 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox}
432 }
433 }(m.currentAccountID, offset))
434 return cmds
435}
436
437func (m *Inbox) View() string {
438 var b strings.Builder
439
440 // Render tabs if there are multiple accounts
441 if len(m.tabs) > 1 {
442 var tabViews []string
443 for i, tab := range m.tabs {
444 label := tab.Label
445 if tab.ID == "" {
446 label = "ALL"
447 }
448
449 if i == m.activeTabIndex {
450 tabViews = append(tabViews, activeTabStyle.Render(label))
451 } else {
452 tabViews = append(tabViews, tabStyle.Render(label))
453 }
454 }
455 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
456 b.WriteString(tabBar)
457 b.WriteString("\n")
458 }
459
460 b.WriteString(m.list.View())
461 return "\n" + b.String()
462}
463
464// GetCurrentAccountID returns the currently selected account ID
465func (m *Inbox) GetCurrentAccountID() string {
466 return m.currentAccountID
467}
468
469// GetEmailAtIndex returns the email at the given index for the current view
470func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
471 var displayEmails []fetcher.Email
472 if m.currentAccountID == "" {
473 displayEmails = m.allEmails
474 } else {
475 displayEmails = m.emailsByAccount[m.currentAccountID]
476 }
477
478 if index >= 0 && index < len(displayEmails) {
479 return &displayEmails[index]
480 }
481 return nil
482}
483
484func (m *Inbox) GetMailbox() MailboxKind {
485 return m.mailbox
486}
487
488// RemoveEmail removes an email by UID and account ID
489func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
490 // Remove from account-specific list
491 if emails, ok := m.emailsByAccount[accountID]; ok {
492 var filtered []fetcher.Email
493 for _, e := range emails {
494 if e.UID != uid {
495 filtered = append(filtered, e)
496 }
497 }
498 m.emailsByAccount[accountID] = filtered
499 }
500
501 // Remove from all emails list
502 var filteredAll []fetcher.Email
503 for _, e := range m.allEmails {
504 if !(e.UID == uid && e.AccountID == accountID) {
505 filteredAll = append(filteredAll, e)
506 }
507 }
508 m.allEmails = filteredAll
509
510 m.updateList()
511}
512
513// SetEmails updates all emails (used after fetch)
514func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
515 m.accounts = accounts
516 m.allEmails = emails
517
518 // Rebuild tabs
519 tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}}
520 for _, acc := range accounts {
521 label := acc.Email
522 tabs = append(tabs, AccountTab{ID: acc.ID, Label: label, Email: acc.Email})
523 }
524 m.tabs = tabs
525
526 // Re-group emails by account
527 m.emailsByAccount = make(map[string][]fetcher.Email)
528 for _, email := range emails {
529 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
530 }
531
532 // Update email counts
533 m.emailCountByAcct = make(map[string]int)
534 for accID, accEmails := range m.emailsByAccount {
535 m.emailCountByAcct[accID] = len(accEmails)
536 }
537
538 m.updateList()
539}