email_view.go

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