From b8f7064f6f5b802eafab237c568e90aed2b12dd5 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sun, 18 Jan 2026 21:24:28 +0400 Subject: [PATCH] feat: display replies in a blockquote (#112) --- view/html.go | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/view/html.go b/view/html.go index dc8cff8164c74747ff52e6b41e2261ddddc87ec6..3f0050f6c6dd27b4ee25fbece635f54d88368d26 100644 --- a/view/html.go +++ b/view/html.go @@ -526,6 +526,44 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod s.ReplaceWithHtml("\n") }) + // Handle blockquote elements (quoted replies) + // We collect quote data and use placeholders, then render after doc.Text() + var quoteData []struct { + from, date string + content string + } + doc.Find("blockquote").Each(func(i int, s *goquery.Selection) { + // Try to extract sender info from cite attribute or preceding text + cite, _ := s.Attr("cite") + quoteText := strings.TrimSpace(s.Text()) + + // Look for "On DATE, EMAIL wrote:" pattern in previous sibling or cite + var from, date string + prevText := "" + if prev := s.Prev(); prev.Length() > 0 { + prevText = strings.TrimSpace(prev.Text()) + } + + onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`) + if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil { + date = parseDateForDisplay(matches[1]) + from = matches[2] + // Remove the "On ... wrote:" from the previous element + s.Prev().Remove() + } else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil { + date = parseDateForDisplay(matches[1]) + from = matches[2] + } + + // Store quote data and use placeholder + quoteData = append(quoteData, struct { + from, date string + content string + }{from, date, quoteText}) + placeholder := fmt.Sprintf("\n[[MATCHA_QUOTE:%d]]\n", len(quoteData)-1) + s.ReplaceWithHtml(placeholder) + }) + // Format links and images doc.Find("a").Each(func(i int, s *goquery.Selection) { href, exists := s.Attr("href") @@ -591,5 +629,164 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod // Now expand the image row placeholders to actual newlines text = expandImageRowPlaceholders(text) + // Replace quote placeholders with styled quote boxes + quoteRegex := regexp.MustCompile(`\[\[MATCHA_QUOTE:(\d+)\]\]`) + text = quoteRegex.ReplaceAllStringFunc(text, func(match string) string { + idxStr := quoteRegex.FindStringSubmatch(match)[1] + var idx int + fmt.Sscanf(idxStr, "%d", &idx) + if idx >= 0 && idx < len(quoteData) { + q := quoteData[idx] + return renderQuoteBox(q.from, q.date, strings.Split(q.content, "\n")) + } + return match + }) + + // Style quoted reply sections (for plain text > quotes) + text = styleQuotedReplies(text) + return bodyStyle.Render(text), nil } + +// quoteBoxStyle is the style for the quoted reply box border +var quoteBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Foreground(lipgloss.Color("240")) + +// quoteHeaderStyle is the style for the header line in the quote box +var quoteHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + +// styleQuotedReplies detects quoted reply sections and styles them in a box +func styleQuotedReplies(text string) string { + lines := strings.Split(text, "\n") + var result []string + var quoteBlock []string + var quoteFrom, quoteDate string + inQuote := false + + // Regex to match "On DATE, EMAIL wrote:" pattern + // Matches various date formats + onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`) + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmedLine := strings.TrimSpace(line) + + // Check for "On DATE, EMAIL wrote:" header + if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil { + // If we were already in a quote block, render it first + if inQuote && len(quoteBlock) > 0 { + result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock)) + quoteBlock = nil + } + + // Parse the date and email from the match + dateStr := matches[1] + quoteFrom = matches[2] + quoteDate = parseDateForDisplay(dateStr) + inQuote = true + continue + } + + // Check if line starts with ">" (quoted text) + if strings.HasPrefix(trimmedLine, ">") { + if !inQuote { + // Start a new quote block without header info + inQuote = true + quoteFrom = "" + quoteDate = "" + } + // Remove the leading "> " and add to quote block + quotedContent := strings.TrimPrefix(trimmedLine, ">") + quotedContent = strings.TrimPrefix(quotedContent, " ") + quoteBlock = append(quoteBlock, quotedContent) + } else if inQuote { + // End of quote block - check if it's just whitespace + if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") { + // Empty line within quote block, keep it + quoteBlock = append(quoteBlock, "") + } else if trimmedLine == "" && len(quoteBlock) == 0 { + // Empty line before any quoted content, skip + continue + } else { + // End of quote block + if len(quoteBlock) > 0 { + result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock)) + quoteBlock = nil + } + inQuote = false + quoteFrom = "" + quoteDate = "" + result = append(result, line) + } + } else { + result = append(result, line) + } + } + + // Handle any remaining quote block + if inQuote && len(quoteBlock) > 0 { + result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock)) + } + + return strings.Join(result, "\n") +} + +// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM +func parseDateForDisplay(dateStr string) string { + // Common date formats to try + formats := []string{ + "Jan 2, 2006 at 3:04 PM", + "02:01:06 15:04", + "2006-01-02 15:04:05", + "Mon, 02 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 -0700", + "2 Jan 2006 15:04:05", + "January 2, 2006 at 3:04 PM", + "Jan 2, 2006 3:04 PM", + time.RFC1123Z, + time.RFC1123, + time.RFC822Z, + time.RFC822, + } + + for _, format := range formats { + if t, err := time.Parse(format, dateStr); err == nil { + return t.Format("02:01:06 15:04") + } + } + + // Return original if parsing fails + return dateStr +} + +// renderQuoteBox renders a quoted section in a styled box +func renderQuoteBox(from, date string, lines []string) string { + // Build header with email on left and date on right + var header string + if from != "" || date != "" { + if from != "" && date != "" { + header = quoteHeaderStyle.Render(from + " " + date) + } else if from != "" { + header = quoteHeaderStyle.Render(from) + } else { + header = quoteHeaderStyle.Render(date) + } + } + + // Join the quoted content + content := strings.Join(lines, "\n") + + // Build the box content + var boxContent string + if header != "" { + boxContent = header + "\n\n" + content + } else { + boxContent = content + } + + return quoteBoxStyle.Render(boxContent) +}