feat: enhance email and contact detail views with snippet repair functionality
Some checks failed
E2E / Playwright e2e (push) Has been cancelled

- Updated email and contact detail views to utilize the repairSnippet function for improved snippet display.
- Refactored email-view-messages to ensure consistent snippet formatting across different components.
- Enhanced mail-mime-body utility to include additional repair logic for handling UTF-8 mojibake, improving text rendering quality.
This commit is contained in:
R3D347HR4Y 2026-06-18 11:10:26 +02:00
parent ce364dbdb4
commit 364ef0ef77
4 changed files with 73 additions and 15 deletions

View File

@ -22,6 +22,7 @@ import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { useMailSearch } from "@/lib/api/hooks/use-mail-queries"
import { repairSnippet } from "@/lib/mail-mime-body"
import { useComposeActions } from "@/lib/compose-context"
import { useNavStore } from "@/lib/stores/nav-store"
import {
@ -90,7 +91,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
return searchResult.data.slice(0, 10).map((msg) => ({
id: msg.id,
subject: msg.subject,
preview: msg.snippet,
preview: repairSnippet(msg.snippet) ?? msg.snippet,
date: msg.date,
}))
}, [searchResult])

View File

@ -103,7 +103,7 @@ function apiToLegacyEmail(
senderEmail: m.from[0]?.address ?? "",
date: m.date,
body: m.body_html ?? m.body_text ?? "",
preview: m.snippet,
preview: repairSnippet(m.snippet) ?? m.snippet,
})),
}
}

View File

@ -23,6 +23,7 @@ import {
type MessageHeaderDetails,
} from "@/lib/mail-message-header-details"
import type { EmailAttachment } from "@/lib/email-data"
import { repairMimeBodies, repairSnippet } from "@/lib/mail-mime-body"
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar"
import { MessageBodyContent } from "@/components/gmail/email-view/message-body-content"
@ -31,7 +32,6 @@ import {
MAIL_MESSAGE_HOVER_CLASS,
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes"
import { repairMimeBodies } from "@/lib/mail-mime-body"
import { plainTextToDisplayHtml } from "@/lib/mail-plain-text-html"
export function plainTextBodyFallback(
@ -47,8 +47,9 @@ export function formatApiMessageBody(
snippet: string | undefined,
loading: boolean
): string {
const snippetHtml = snippet?.trim()
? `<p style="color:var(--muted-foreground);">${snippet.trim()}</p>`
const preview = repairSnippet(snippet) ?? snippet
const snippetHtml = preview?.trim()
? `<p style="color:var(--muted-foreground);">${preview.trim()}</p>`
: ""
if (loading) {
@ -62,13 +63,13 @@ export function formatApiMessageBody(
return plainTextToDisplayHtml(text)
}
if (full) {
const s = snippet?.trim()
const s = preview?.trim()
if (s) {
return `<p style="color:var(--muted-foreground);">${s}</p>`
}
return `<p style="color:var(--muted-foreground);">Ce message na pas de contenu.</p>`
}
const s = snippet?.trim()
const s = preview?.trim()
return s
? `<p style="color:var(--muted-foreground);">${s}</p>`
: ""
@ -242,7 +243,9 @@ export function CollapsedMessage({
/>
</div>
</div>
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.snippet}</p>
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">
{repairSnippet(message.snippet) ?? message.snippet}
</p>
</div>
</div>
)

View File

@ -55,17 +55,55 @@ function decodeBytesToUtf8(bytes: Uint8Array, charset = ""): string {
}
}
const UTF8_MOJIBAKE_PAIR_RE = /[\u00C2\u00C3][\u0080-\u00BF]/
function looksLikeUtf8Mojibake(s: string): boolean {
return UTF8_MOJIBAKE_PAIR_RE.test(s)
}
/** UTF-8 misread as Latin-1: "réactivité" → "réactivité". */
function repairUtf8Mojibake(s: string): string {
if (!s || !looksLikeUtf8Mojibake(s)) return s
let out = ""
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i)
if ((code === 0xc2 || code === 0xc3) && i + 1 < s.length) {
const next = s.charCodeAt(i + 1)
if (next >= 0x80 && next <= 0xbf) {
const bytes = new Uint8Array([code, next])
try {
out += new TextDecoder("utf-8", { fatal: true }).decode(bytes)
i++
continue
} catch {
/* keep raw chars */
}
}
}
out += s[i]!
}
if (out === s) return s
return repairLoneMojibakeLeaders(out)
}
function repairLoneMojibakeLeaders(s: string): string {
return s
.replace(/\u00C3(?=[\s,.;:!?])/g, "à")
.replace(/\u00C2(?=[\s,.;:!?])/g, "Â")
}
function repairLegacyCharsetString(s: string): string {
if (!s) return s
let repaired = s
try {
new TextDecoder("utf-8", { fatal: true }).decode(
Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff)
)
return s
} catch {
const bytes = Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff)
return decodeBytesToUtf8(bytes)
repaired = decodeBytesToUtf8(bytes)
}
return repairUtf8Mojibake(repaired)
}
function decodeBase64Part(encoded: string, charset = ""): string {
@ -203,13 +241,29 @@ export function repairMimeBodies(
text = stripInvisibleTextRuns(text)
if (!looksLikeRawMime(text) && !looksLikeRawMime(html)) {
return { bodyText: text || bodyText, bodyHtml: html || bodyHtml }
return finalizeMimeBodies(text, bodyText, html, bodyHtml)
}
const parsed = parseEmbeddedMime(text || html)
if (!parsed) return { bodyText: text || bodyText, bodyHtml: html || bodyHtml }
if (!parsed) return finalizeMimeBodies(text, bodyText, html, bodyHtml)
return finalizeMimeBodies(
parsed.text || text || bodyText,
bodyText,
parsed.html || html || bodyHtml,
bodyHtml
)
}
function finalizeMimeBodies(
text: string | undefined,
bodyText: string | undefined,
html: string | undefined,
bodyHtml: string | undefined
): { bodyText?: string; bodyHtml?: string } {
const outText = repairUtf8Mojibake(repairLegacyCharsetString(text?.trim() ?? ""))
const outHtml = repairUtf8Mojibake(repairLegacyCharsetString(html?.trim() ?? ""))
return {
bodyText: parsed.text || text || bodyText,
bodyHtml: parsed.html || html || bodyHtml,
bodyText: outText || bodyText,
bodyHtml: outHtml || bodyHtml,
}
}
@ -418,6 +472,6 @@ export function repairSnippet(snippet?: string): string | undefined {
const decoded = decodeBareBase64IfNeeded(qp)
const raw = decoded !== trimmed ? decoded : snippet
const cleaned = stripInvisibleTextRuns(raw)
const polished = polishSnippetPreview(cleaned)
const polished = repairUtf8Mojibake(polishSnippetPreview(cleaned))
return polished || undefined
}