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