diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 4c13dcee..11cd103c 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -23,6 +23,7 @@ "delete": "d", "archive": "a", "toggle_images": "i", + "toggle_quotes": "q", "rsvp_accept": "1", "rsvp_decline": "2", "rsvp_tentative": "3", diff --git a/config/keybinds.go b/config/keybinds.go index 7853c6ac..c51941bf 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -52,6 +52,7 @@ type EmailKeys struct { Delete string `json:"delete"` Archive string `json:"archive"` ToggleImages string `json:"toggle_images"` + ToggleQuotes string `json:"toggle_quotes"` RsvpAccept string `json:"rsvp_accept"` RsvpDecline string `json:"rsvp_decline"` RsvpTentative string `json:"rsvp_tentative"` @@ -131,6 +132,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { keyDelete: kb.Email.Delete, "archive": kb.Email.Archive, "toggle_images": kb.Email.ToggleImages, + "toggle_quotes": kb.Email.ToggleQuotes, "rsvp_accept": kb.Email.RsvpAccept, "rsvp_decline": kb.Email.RsvpDecline, "rsvp_tentative": kb.Email.RsvpTentative, diff --git a/send_test_email.py b/send_test_email.py new file mode 100644 index 00000000..3c32100f --- /dev/null +++ b/send_test_email.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Send a test email with quoted text to test the collapse feature.""" +import smtplib +from email.mime.text import MIMEText +import getpass + +EMAIL = "shabha2004@gmail.com" + +body = """\ +Hey, this is my reply! + +On Mon, Jun 23, 2026 at 2:00 AM you@example.com wrote: +> This is the original message. +> It has multiple lines. +> Third line of quoted text. +> Fourth line of quoted text. +> Fifth line to make it obvious. +""" + +msg = MIMEText(body) +msg["Subject"] = "Test quote collapse" +msg["From"] = EMAIL +msg["To"] = EMAIL + +password = getpass.getpass("Enter Gmail App Password: ") + +with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: + server.login(EMAIL, password) + server.send_message(msg) + print("Email sent successfully!") diff --git a/tui/email_view.go b/tui/email_view.go index e764d6aa..396f4ec1 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -61,6 +61,7 @@ type EmailView struct { mailbox MailboxKind disableImages bool showImages bool + showQuotedText bool isSMIME bool smimeTrusted bool isEncrypted bool @@ -138,6 +139,9 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma } body = applyBodyTransform(body, email) + // Collapse quoted text by default to reduce clutter + body = view.CollapseQuotedText(body) + // Create header and compute heights that reduce viewport space. header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject) headerHeight := lipgloss.Height(header) + 2 @@ -194,6 +198,23 @@ func (m *EmailView) Init() tea.Cmd { return nil } +// refreshBody re-renders the email body with current display settings +// (image visibility, quote collapse state) and updates the viewport content. +func (m *EmailView) refreshBody() { + inlineImages := inlineImagesFromAttachments(m.email.Attachments) + body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) + if err != nil { + body = fmt.Sprintf("Error rendering body: %v", err) + } + body = applyBodyTransform(body, m.email) + if !m.showQuotedText { + body = view.CollapseQuotedText(body) + } + m.imagePlacements = placements + wrapped := wrapBodyToWidth(body, m.viewport.Width()) + m.viewport.SetContent(wrapped + "\n") +} + func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd cmds := make([]tea.Cmd, 0, 1) @@ -249,18 +270,13 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if view.ImageProtocolSupported() { m.showImages = !m.showImages ClearKittyGraphics() - - inlineImages := inlineImagesFromAttachments(m.email.Attachments) - body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) - if err != nil { - body = fmt.Sprintf("Error rendering body: %v", err) - } - body = applyBodyTransform(body, m.email) - m.imagePlacements = placements - wrapped := wrapBodyToWidth(body, m.viewport.Width()) - m.viewport.SetContent(wrapped + "\n") + m.refreshBody() return m, nil } + case kb.Email.ToggleQuotes: + m.showQuotedText = !m.showQuotedText + m.refreshBody() + return m, nil case kb.Email.Reply: // Clear Kitty graphics before opening composer ClearKittyGraphics() @@ -327,15 +343,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // When the window size changes, wrap and clear kitty images to keep placement stable ClearKittyGraphics() - inlineImages := inlineImagesFromAttachments(m.email.Attachments) - body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) - if err != nil { - body = fmt.Sprintf("Error rendering body: %v", err) - } - body = applyBodyTransform(body, m.email) - m.imagePlacements = placements - wrapped := wrapBodyToWidth(body, m.viewport.Width()) - m.viewport.SetContent(wrapped + "\n") + m.refreshBody() } m.viewport, cmd = m.viewport.Update(msg) @@ -385,7 +393,7 @@ func (m *EmailView) View() tea.View { help = helpStyle.Render(helpText) } else { var shortcuts strings.Builder - shortcuts.WriteString("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox") + shortcuts.WriteString("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox • q: toggle quotes") if view.ImageProtocolSupported() { shortcuts.WriteString("• \uf03e i: toggle images") } diff --git a/view/html.go b/view/html.go index cd4e68dc..ff0c6b8d 100644 --- a/view/html.go +++ b/view/html.go @@ -791,6 +791,65 @@ func quoteHeaderStyle() lipgloss.Style { Foreground(theme.ActiveTheme.Secondary) } +func collapsedQuoteStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(theme.ActiveTheme.Secondary). + Italic(true) +} + +// CollapseQuotedText takes a rendered email body and replaces styled quote +// boxes (produced by renderQuoteBox / styleQuotedReplies) with a single-line +// collapsed indicator. Quote boxes are identified by the rounded-border +// characters (╭/╰) used by quoteBoxStyle. Each contiguous box is replaced +// with a "▶ quoted text hidden" line. +func CollapseQuotedText(body string) string { + lines := strings.Split(body, "\n") + var result []string + inQuoteBox := false + var from string + + // The rounded border top-left is ╭ and bottom-left is ╰. + // quoteBoxStyle uses lipgloss.RoundedBorder() which produces these. + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Detect the top of a quote box + if !inQuoteBox && strings.Contains(trimmed, "╭") && strings.Contains(trimmed, "╮") { + inQuoteBox = true + from = "" + continue + } + + if inQuoteBox { + // Try to extract the "from" header from the first content line + if from == "" { + // Strip border chars (│) and whitespace to get content + content := strings.TrimSpace(strings.Trim(trimmed, "│")) + if content != "" && !strings.Contains(trimmed, "╰") { + from = content + } + } + + // Detect the bottom of a quote box + if strings.Contains(trimmed, "╰") && strings.Contains(trimmed, "╯") { + inQuoteBox = false + var label string + if from != "" { + label = fmt.Sprintf("▶ quoted text from %s (press q to expand)", from) + } else { + label = "▶ quoted text hidden (press q to expand)" + } + result = append(result, collapsedQuoteStyle().Render(label)) + } + continue + } + + result = append(result, line) + } + + return strings.Join(result, "\n") +} + // styleQuotedReplies detects quoted reply sections and styles them in a box func styleQuotedReplies(text string) string { lines := strings.Split(text, "\n")