email_view.go

  1package tui
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"os"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/viewport"
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/lipgloss/v2"
 12	"github.com/floatpane/matcha/fetcher"
 13	"github.com/floatpane/matcha/view"
 14)
 15
 16// clearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout
 17func clearKittyGraphics() {
 18	// Delete all images: a=d (action=delete), d=A (delete all)
 19	os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\")
 20	os.Stdout.Sync()
 21}
 22
 23var (
 24	emailHeaderStyle   = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1)
 25	attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
 26)
 27
 28type EmailView struct {
 29	viewport           viewport.Model
 30	email              fetcher.Email
 31	emailIndex         int
 32	attachmentCursor   int
 33	focusOnAttachments bool
 34	accountID          string
 35	mailbox            MailboxKind
 36	disableImages      bool
 37	showImages         bool
 38}
 39
 40func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
 41	// Pass the styles from the tui package to the view package
 42	inlineImages := inlineImagesFromAttachments(email.Attachments)
 43
 44	// Initial state for showImages matches config unless overridden later
 45	showImages := !disableImages
 46
 47	body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
 48	if err != nil {
 49		body = fmt.Sprintf("Error rendering body: %v", err)
 50	}
 51
 52	// Create header and compute heights that reduce viewport space.
 53	header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
 54	headerHeight := lipgloss.Height(header) + 2
 55
 56	attachmentHeight := 0
 57	if len(email.Attachments) > 0 {
 58		attachmentHeight = len(email.Attachments) + 2
 59	}
 60
 61	// Build viewport with initial size and set wrapped content.
 62	vp := viewport.New()
 63	vp.SetWidth(width)
 64	vp.SetHeight(height - headerHeight - attachmentHeight)
 65	wrapped := wrapBodyToWidth(body, vp.Width())
 66	vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
 67
 68	return &EmailView{
 69		viewport:      vp,
 70		email:         email,
 71		emailIndex:    emailIndex,
 72		accountID:     email.AccountID,
 73		mailbox:       mailbox,
 74		disableImages: disableImages,
 75		showImages:    showImages,
 76	}
 77}
 78
 79func (m *EmailView) Init() tea.Cmd {
 80	return nil
 81}
 82
 83func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 84	var cmd tea.Cmd
 85	var cmds []tea.Cmd
 86
 87	switch msg := msg.(type) {
 88	case tea.KeyPressMsg:
 89		// Handle 'esc' key locally
 90		if msg.String() == "esc" {
 91			if m.focusOnAttachments {
 92				m.focusOnAttachments = false
 93				return m, nil
 94			}
 95			// Clear Kitty graphics before returning to mailbox
 96			clearKittyGraphics()
 97			return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
 98		}
 99
100		if m.focusOnAttachments {
101			switch msg.String() {
102			case "up", "k":
103				if m.attachmentCursor > 0 {
104					m.attachmentCursor--
105				}
106			case "down", "j":
107				if m.attachmentCursor < len(m.email.Attachments)-1 {
108					m.attachmentCursor++
109				}
110			case "enter":
111				if len(m.email.Attachments) > 0 {
112					selected := m.email.Attachments[m.attachmentCursor]
113					idx := m.emailIndex
114					accountID := m.accountID
115					return m, func() tea.Msg {
116						return DownloadAttachmentMsg{
117							Index:     idx,
118							Filename:  selected.Filename,
119							PartID:    selected.PartID,
120							Data:      selected.Data,
121							AccountID: accountID,
122							Mailbox:   m.mailbox,
123						}
124					}
125				}
126			case "tab":
127				m.focusOnAttachments = false
128			}
129		} else {
130			switch msg.String() {
131			case "i":
132				if view.ImageProtocolSupported() {
133					m.showImages = !m.showImages
134					clearKittyGraphics()
135
136					inlineImages := inlineImagesFromAttachments(m.email.Attachments)
137					body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
138					if err != nil {
139						body = fmt.Sprintf("Error rendering body: %v", err)
140					}
141					wrapped := wrapBodyToWidth(body, m.viewport.Width())
142					m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
143					return m, nil
144				}
145			case "r":
146				// Clear Kitty graphics before opening composer
147				clearKittyGraphics()
148				return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
149			case "f":
150				// Clear Kitty graphics before opening composer
151				clearKittyGraphics()
152				return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
153			case "d":
154				accountID := m.accountID
155				uid := m.email.UID
156				// Clear Kitty graphics before transitioning
157				clearKittyGraphics()
158				return m, func() tea.Msg {
159					return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
160				}
161			case "a":
162				accountID := m.accountID
163				uid := m.email.UID
164				// Clear Kitty graphics before transitioning
165				clearKittyGraphics()
166				return m, func() tea.Msg {
167					return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
168				}
169			case "tab":
170				if len(m.email.Attachments) > 0 {
171					m.focusOnAttachments = true
172				}
173			}
174		}
175	case tea.WindowSizeMsg:
176		header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
177		headerHeight := lipgloss.Height(header) + 2
178		attachmentHeight := 0
179		if len(m.email.Attachments) > 0 {
180			attachmentHeight = len(m.email.Attachments) + 2
181		}
182		// Update viewport dimensions
183		m.viewport.SetWidth(msg.Width)
184		m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
185
186		// When the window size changes, wrap and clear kitty images to keep placement stable
187		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
188		body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
189		if err != nil {
190			body = fmt.Sprintf("Error rendering body: %v", err)
191		}
192		wrapped := wrapBodyToWidth(body, m.viewport.Width())
193		m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
194	}
195
196	m.viewport, cmd = m.viewport.Update(msg)
197	cmds = append(cmds, cmd)
198
199	return m, tea.Batch(cmds...)
200}
201
202func (m *EmailView) View() tea.View {
203	// Clear all Kitty graphics before rendering to prevent image stacking on scroll.
204	// This must be done synchronously via stdout before the frame is drawn,
205	// as escape sequences in the return string execute too late.
206	clearKittyGraphics()
207
208	header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject)
209	styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
210
211	var help string
212	if m.focusOnAttachments {
213		help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
214	} else {
215		shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
216		if view.ImageProtocolSupported() {
217			shortcuts = shortcuts + "• \uf03e i: toggle images"
218		}
219		help = helpStyle.Render(shortcuts)
220	}
221
222	var attachmentView string
223	if len(m.email.Attachments) > 0 {
224		var b strings.Builder
225		b.WriteString("Attachments:\n")
226		for i, attachment := range m.email.Attachments {
227			cursor := "  "
228			style := itemStyle
229			if m.focusOnAttachments && i == m.attachmentCursor {
230				cursor = "> "
231				style = selectedItemStyle
232			}
233			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
234			b.WriteString("\n")
235		}
236		attachmentView = attachmentBoxStyle.Render(b.String())
237	}
238
239	// m.viewport.View() returns a string in Bubbles v2 viewport
240	return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
241}
242
243// GetAccountID returns the account ID for this email
244func (m *EmailView) GetAccountID() string {
245	return m.accountID
246}
247
248func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
249	var imgs []view.InlineImage
250	for _, att := range atts {
251		if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
252			continue
253		}
254		imgs = append(imgs, view.InlineImage{
255			CID:    att.ContentID,
256			Base64: base64.StdEncoding.EncodeToString(att.Data),
257		})
258	}
259	return imgs
260}
261
262func wrapBodyToWidth(body string, width int) string {
263	return BodyStyle.Width(width).Render(body)
264}
265
266// GetEmail returns the email being viewed
267func (m *EmailView) GetEmail() fetcher.Email {
268	return m.email
269}