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