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 "d":
126				accountID := m.accountID
127				uid := m.email.UID
128				// Clear Kitty graphics before transitioning
129				clearKittyGraphics()
130				return m, func() tea.Msg {
131					return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
132				}
133			case "a":
134				accountID := m.accountID
135				uid := m.email.UID
136				// Clear Kitty graphics before transitioning
137				clearKittyGraphics()
138				return m, func() tea.Msg {
139					return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
140				}
141			case "tab":
142				if len(m.email.Attachments) > 0 {
143					m.focusOnAttachments = true
144				}
145			}
146		}
147	case tea.WindowSizeMsg:
148		header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
149		headerHeight := lipgloss.Height(header) + 2
150		attachmentHeight := 0
151		if len(m.email.Attachments) > 0 {
152			attachmentHeight = len(m.email.Attachments) + 2
153		}
154		// Update viewport dimensions
155		m.viewport.Width = msg.Width
156		m.viewport.Height = msg.Height - headerHeight - attachmentHeight
157
158		// When the window size changes, wrap and clear kitty images to keep placement stable
159		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
160		body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle)
161		if err != nil {
162			body = fmt.Sprintf("Error rendering body: %v", err)
163		}
164		wrapped := wrapBodyToWidth(body, m.viewport.Width)
165		m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
166	}
167
168	m.viewport, cmd = m.viewport.Update(msg)
169	cmds = append(cmds, cmd)
170
171	return m, tea.Batch(cmds...)
172}
173
174func (m *EmailView) View() string {
175	header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject)
176	styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header)
177
178	var help string
179	if m.focusOnAttachments {
180		help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
181	} else {
182		help = helpStyle.Render("\uf112 r: reply • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox")
183	}
184
185	var attachmentView string
186	if len(m.email.Attachments) > 0 {
187		var b strings.Builder
188		b.WriteString("Attachments:\n")
189		for i, attachment := range m.email.Attachments {
190			cursor := "  "
191			style := itemStyle
192			if m.focusOnAttachments && i == m.attachmentCursor {
193				cursor = "> "
194				style = selectedItemStyle
195			}
196			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
197			b.WriteString("\n")
198		}
199		attachmentView = attachmentBoxStyle.Render(b.String())
200	}
201
202	return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help)
203}
204
205// GetAccountID returns the account ID for this email
206func (m *EmailView) GetAccountID() string {
207	return m.accountID
208}
209
210func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
211	var imgs []view.InlineImage
212	for _, att := range atts {
213		if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
214			continue
215		}
216		imgs = append(imgs, view.InlineImage{
217			CID:    att.ContentID,
218			Base64: base64.StdEncoding.EncodeToString(att.Data),
219		})
220	}
221	return imgs
222}
223
224func wrapBodyToWidth(body string, width int) string {
225	return BodyStyle.Width(width).Render(body)
226}
227
228// GetEmail returns the email being viewed
229func (m *EmailView) GetEmail() fetcher.Email {
230	return m.email
231}