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