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