From 98f7b27af0c7a3ea12469c149be48138ad00b43a Mon Sep 17 00:00:00 2001 From: Shabhareash Date: Tue, 23 Jun 2026 02:36:44 +0530 Subject: [PATCH] feat: collapse quoted reply text behind toggle (q key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect quoted/reply blocks (styled in rounded-border boxes) and collapse them into a single '▶ quoted text hidden' indicator by default. Pressing 'q' (configurable via toggle_quotes keybind) expands/collapses the quoted sections. Changes: - view/html.go: add CollapseQuotedText() that identifies rendered quote boxes by their ╭/╰ border chars and replaces them with a one-line summary - tui/email_view.go: add showQuotedText toggle, collapse quotes by default, re-render body on toggle; add 'q: toggle quotes' to help bar - config: add toggle_quotes keybind (default: q) to EmailKeys Closes #1612 --- config/default_keybinds.json | 1 + config/keybinds.go | 2 ++ send_test_email.py | 30 ++++++++++++++++++ tui/email_view.go | 48 +++++++++++++++++------------ view/html.go | 59 ++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 send_test_email.py diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 273654fe..06fdf7d8 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -25,6 +25,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 25654000..01250c9d 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -54,6 +54,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"` @@ -135,6 +136,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 7829f857..2c4a81ba 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 @@ -139,6 +140,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 @@ -205,6 +209,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) @@ -260,18 +281,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() @@ -338,15 +354,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) @@ -396,7 +404,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 f55b1518..51410d53 100644 --- a/view/html.go +++ b/view/html.go @@ -836,6 +836,65 @@ func renderCodeBlock(code, lang string) string { return "\n" + codeBoxStyle().Render(content) + "\n" } +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")