1package tui
2
3import (
4 "encoding/base64"
5 "fmt"
6 "strings"
7
8 "github.com/charmbracelet/bubbles/viewport"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11 "github.com/floatpane/matcha/fetcher"
12 "github.com/floatpane/matcha/view"
13)
14
15var (
16 emailHeaderStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1)
17 attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
18)
19
20type EmailView struct {
21 viewport viewport.Model
22 email fetcher.Email
23 emailIndex int
24 attachmentCursor int
25 focusOnAttachments bool
26 accountID string
27 mailbox MailboxKind
28}
29
30func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind) *EmailView {
31 // Pass the styles from the tui package to the view package
32 inlineImages := inlineImagesFromAttachments(email.Attachments)
33 body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle)
34 if err != nil {
35 body = fmt.Sprintf("Error rendering body: %v", err)
36 }
37
38 // Create header and compute heights that reduce viewport space.
39 header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
40 headerHeight := lipgloss.Height(header) + 2
41
42 attachmentHeight := 0
43 if len(email.Attachments) > 0 {
44 attachmentHeight = len(email.Attachments) + 2
45 }
46
47 // Build viewport with initial size and set wrapped content.
48 vp := viewport.New(width, height-headerHeight-attachmentHeight)
49 wrapped := wrapBodyToWidth(body, vp.Width)
50 vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
51
52 return &EmailView{
53 viewport: vp,
54 email: email,
55 emailIndex: emailIndex,
56 accountID: email.AccountID,
57 mailbox: mailbox,
58 }
59}
60
61func (m *EmailView) Init() tea.Cmd {
62 return nil
63}
64
65func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66 var cmd tea.Cmd
67 var cmds []tea.Cmd
68
69 switch msg := msg.(type) {
70 case tea.KeyMsg:
71 // Handle 'esc' key locally
72 if msg.Type == tea.KeyEsc {
73 if m.focusOnAttachments {
74 m.focusOnAttachments = false
75 return m, nil
76 }
77 m.viewport.SetContent("\x1b_Ga=d\x1b\\")
78 return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
79 }
80
81 if m.focusOnAttachments {
82 switch msg.String() {
83 case "up", "k":
84 if m.attachmentCursor > 0 {
85 m.attachmentCursor--
86 }
87 case "down", "j":
88 if m.attachmentCursor < len(m.email.Attachments)-1 {
89 m.attachmentCursor++
90 }
91 case "enter":
92 if len(m.email.Attachments) > 0 {
93 selected := m.email.Attachments[m.attachmentCursor]
94 idx := m.emailIndex
95 accountID := m.accountID
96 return m, func() tea.Msg {
97 return DownloadAttachmentMsg{
98 Index: idx,
99 Filename: selected.Filename,
100 PartID: selected.PartID,
101 Data: selected.Data,
102 AccountID: accountID,
103 Mailbox: m.mailbox,
104 }
105 }
106 }
107 case "tab":
108 m.focusOnAttachments = false
109 }
110 } else {
111 switch msg.String() {
112 case "r":
113 return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
114 case "d":
115 accountID := m.accountID
116 uid := m.email.UID
117 return m, func() tea.Msg {
118 return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
119 }
120 case "a":
121 accountID := m.accountID
122 uid := m.email.UID
123 return m, func() tea.Msg {
124 return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
125 }
126 case "tab":
127 if len(m.email.Attachments) > 0 {
128 m.focusOnAttachments = true
129 }
130 }
131 }
132 case tea.WindowSizeMsg:
133 header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
134 headerHeight := lipgloss.Height(header) + 2
135 attachmentHeight := 0
136 if len(m.email.Attachments) > 0 {
137 attachmentHeight = len(m.email.Attachments) + 2
138 }
139 // Update viewport dimensions
140 m.viewport.Width = msg.Width
141 m.viewport.Height = msg.Height - headerHeight - attachmentHeight
142
143 // When the window size changes, wrap and clear kitty images to keep placement stable
144 inlineImages := inlineImagesFromAttachments(m.email.Attachments)
145 body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle)
146 if err != nil {
147 body = fmt.Sprintf("Error rendering body: %v", err)
148 }
149 wrapped := wrapBodyToWidth(body, m.viewport.Width)
150 m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
151 }
152
153 m.viewport, cmd = m.viewport.Update(msg)
154 cmds = append(cmds, cmd)
155
156 return m, tea.Batch(cmds...)
157}
158
159func (m *EmailView) View() string {
160 header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject)
161 styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header)
162
163 var help string
164 if m.focusOnAttachments {
165 help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
166 } else {
167 help = helpStyle.Render("r: reply • d: delete • a: archive • tab: focus attachments • esc: back to inbox")
168 }
169
170 var attachmentView string
171 if len(m.email.Attachments) > 0 {
172 var b strings.Builder
173 b.WriteString("Attachments:\n")
174 for i, attachment := range m.email.Attachments {
175 cursor := " "
176 style := itemStyle
177 if m.focusOnAttachments && i == m.attachmentCursor {
178 cursor = "> "
179 style = selectedItemStyle
180 }
181 b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
182 b.WriteString("\n")
183 }
184 attachmentView = attachmentBoxStyle.Render(b.String())
185 }
186
187 return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help)
188}
189
190// GetAccountID returns the account ID for this email
191func (m *EmailView) GetAccountID() string {
192 return m.accountID
193}
194
195func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
196 var imgs []view.InlineImage
197 for _, att := range atts {
198 if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
199 continue
200 }
201 imgs = append(imgs, view.InlineImage{
202 CID: att.ContentID,
203 Base64: base64.StdEncoding.EncodeToString(att.Data),
204 })
205 }
206 return imgs
207}
208
209func wrapBodyToWidth(body string, width int) string {
210 return BodyStyle.Width(width).Render(body)
211}
212
213// GetEmail returns the email being viewed
214func (m *EmailView) GetEmail() fetcher.Email {
215 return m.email
216}