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