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: empty for single account, "ALL" + accounts for multiple
110 var tabs []AccountTab
111 if len(accounts) <= 1 {
112 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
113 } else {
114 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
115 for _, acc := range accounts {
116 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.Email, Email: acc.Email})
117 }
118 }
119
120 // Group emails by account
121 emailsByAccount := make(map[string][]fetcher.Email)
122 for _, email := range emails {
123 emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
124 }
125
126 // Track email counts per account
127 emailCountByAcct := make(map[string]int)
128 for accID, accEmails := range emailsByAccount {
129 emailCountByAcct[accID] = len(accEmails)
130 }
131
132 inbox := &Inbox{
133 accounts: accounts,
134 emailsByAccount: emailsByAccount,
135 allEmails: emails,
136 tabs: tabs,
137 activeTabIndex: 0,
138 currentAccountID: "",
139 emailCountByAcct: emailCountByAcct,
140 mailbox: mailbox,
141 }
142
143 inbox.updateList()
144 return inbox
145}
146
147// NewInboxSingleAccount creates an inbox for a single account (legacy support)
148func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
149 return NewInbox(emails, nil)
150}
151
152func (m *Inbox) updateList() {
153 var displayEmails []fetcher.Email
154 var showAccountLabel bool
155
156 if m.currentAccountID == "" {
157 // "ALL" view - show all emails sorted by date
158 displayEmails = m.allEmails
159 showAccountLabel = !(len(m.accounts) <= 1)
160 } else {
161 // Specific account view
162 displayEmails = m.emailsByAccount[m.currentAccountID]
163 showAccountLabel = false
164 }
165
166 m.emailsCount = len(displayEmails)
167
168 items := make([]list.Item, len(displayEmails))
169 for i, email := range displayEmails {
170 accountEmail := ""
171 if showAccountLabel {
172 // Find the account email for display
173 for _, acc := range m.accounts {
174 if acc.ID == email.AccountID {
175 accountEmail = acc.Email
176 break
177 }
178 }
179 }
180
181 items[i] = item{
182 title: email.Subject,
183 desc: email.From,
184 originalIndex: i,
185 uid: email.UID,
186 accountID: email.AccountID,
187 accountEmail: accountEmail,
188 }
189 }
190
191 l := list.New(items, itemDelegate{}, 20, 14)
192 l.Title = m.getTitle()
193 l.SetShowStatusBar(true)
194 l.SetFilteringEnabled(true)
195 l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
196 l.Styles.PaginationStyle = paginationStyle
197 l.Styles.HelpStyle = inboxHelpStyle
198 l.SetStatusBarItemName("email", "emails")
199 l.AdditionalShortHelpKeys = func() []key.Binding {
200 bindings := []key.Binding{
201 key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
202 key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "archive")),
203 key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
204 }
205 if len(m.tabs) > 1 {
206 bindings = append(bindings,
207 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
208 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
209 )
210 }
211 return bindings
212 }
213
214 l.KeyMap.Quit.SetEnabled(false)
215
216 if m.width > 0 {
217 l.SetWidth(m.width)
218 }
219
220 m.list = l
221}
222
223func (m *Inbox) getTitle() string {
224 var title string
225 if m.currentAccountID == "" {
226 title = m.getBaseTitle() + " - All Accounts"
227 } else {
228 title = m.getBaseTitle()
229 for _, acc := range m.accounts {
230 if acc.ID == m.currentAccountID {
231 if acc.Name != "" {
232 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
233 } else {
234 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Email)
235 }
236 break
237 }
238 }
239 }
240 if m.isRefreshing {
241 title += " (refreshing...)"
242 }
243 if m.isFetching {
244 title += " (loading more...)"
245 }
246 return title
247}
248
249func (m *Inbox) getBaseTitle() string {
250 switch m.mailbox {
251 case MailboxSent:
252 return "Sent"
253 default:
254 return "Inbox"
255 }
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: empty for single account, "ALL" + accounts for multiple
524 var tabs []AccountTab
525 if len(accounts) <= 1 {
526 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
527 } else {
528 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
529 for _, acc := range accounts {
530 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.Email, Email: acc.Email})
531 }
532 }
533 m.tabs = tabs
534
535 // Re-group emails by account
536 m.emailsByAccount = make(map[string][]fetcher.Email)
537 for _, email := range emails {
538 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
539 }
540
541 // Update email counts
542 m.emailCountByAcct = make(map[string]int)
543 for accID, accEmails := range m.emailsByAccount {
544 m.emailCountByAcct[accID] = len(accEmails)
545 }
546
547 m.updateList()
548}