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