email_view.go

  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}