ultisuite-client/lib/thread-compose-preset.ts
2026-05-25 13:52:40 +02:00

285 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Email, ConversationMessage } from "@/lib/email-data"
import type {
Contact,
ComposeOpenPreset,
ThreadComposeKind,
} from "@/lib/compose-context"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
import { readXsMatches } from "@/hooks/use-xs"
import { getComposeIdentities } from "@/lib/stores/compose-identities-store"
import { buildBodyWithSignature, myEmailsFromIdentities } from "@/lib/compose/identity-map"
import { resolveComposeIdentity } from "@/lib/compose/resolve-compose-identity"
import { formatMailDetailDate } from "@/lib/mail-date"
import { cleanSenderName } from "@/lib/sender-display"
function appendDefaultSignature(html: string): string {
return buildBodyWithSignature(html, resolveComposeIdentity())
}
/** Signature juste après la zone de saisie, avant le bloc « message transféré ». */
function insertSignatureBeforeForwardedBlock(html: string): string {
const identity = resolveComposeIdentity()
const sigHtml = identity.signatureHtml
if (!sigHtml?.trim()) return html
const sigBlock = `<div id="ultimail-signature"><p>--</p>${sigHtml}</div>`
const lead = "<p></p>"
if (html.startsWith(lead)) {
return `${lead}${sigBlock}${html.slice(lead.length)}`
}
return `${lead}${sigBlock}${html}`
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
function myEmailSet(): Set<string> {
return myEmailsFromIdentities(getComposeIdentities())
}
function contactKey(email: string): string {
return email.trim().toLowerCase()
}
/** Tous les expéditeurs des messages du fil (hors « moi »), ordre chronologique conservé. */
export function collectThreadParticipants(email: Email): Contact[] {
const my = myEmailSet()
const seen = new Set<string>()
const out: Contact[] = []
const push = (name: string, addrRaw: string) => {
const addr = addrRaw.trim()
if (!addr) return
const k = contactKey(addr)
if (my.has(k) || seen.has(k)) return
seen.add(k)
out.push({
name: cleanSenderName(name) || addr,
email: addr,
})
}
for (const m of email.conversation ?? []) {
push(m.sender, m.senderEmail)
}
const mainAddr =
email.senderEmail ??
`${cleanSenderName(email.sender).toLowerCase().replace(/\s+/g, ".")}@example.com`
push(email.sender, mainAddr)
return out
}
function lastMessage(email: Email): ConversationMessage | null {
const conv = email.conversation ?? []
if (conv.length === 0) return null
return conv[conv.length - 1]!
}
function replySubject(subject: string): string {
const s = subject.trim()
if (/^re\s*:/i.test(s)) return s
return `Re: ${s}`
}
function forwardSubject(subject: string): string {
const s = subject.trim()
if (/^(fwd|fw|tr)\s*:/i.test(s)) return s
return `Fwd: ${s}`
}
function buildReferences(email: Email): string[] {
const ids: string[] = []
for (const m of email.conversation ?? []) {
ids.push(`<thread-msg-${m.id}@local>`)
}
ids.push(`<thread-msg-${email.id}@local>`)
return ids
}
function inReplyToFor(email: Email): string {
const last = lastMessage(email)
if (last) return `<thread-msg-${last.id}@local>`
return `<thread-msg-${email.id}@local>`
}
function formatQuoteDate(
dateIso: string,
senderEmail: string,
senderName: string
): string {
const who =
senderName && senderName !== senderEmail
? `${escapeHtml(senderName)} &lt;${escapeHtml(senderEmail)}&gt;`
: escapeHtml(senderEmail)
return `Le ${escapeHtml(formatMailDetailDate(dateIso))}, ${who} a écrit :`
}
function quotedBlock(html: string): string {
return `<blockquote type="cite" style="margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex;color:#500">${html}</blockquote>`
}
function replyQuotedHtml(email: Email): string {
const last = lastMessage(email)
if (last) {
const line = formatQuoteDate(last.date, last.senderEmail, cleanSenderName(last.sender))
return `<p><br></p><div style="color:#666;font-size:13px">${line}</div>${quotedBlock(last.body)}`
}
const addr =
email.senderEmail ??
`${cleanSenderName(email.sender).toLowerCase().replace(/\s+/g, ".")}@example.com`
const line = formatQuoteDate(email.date, addr, cleanSenderName(email.sender))
const body = email.body ?? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`
return `<p><br></p><div style="color:#666;font-size:13px">${line}</div>${quotedBlock(body)}`
}
function forwardParticipantsLine(email: Email): string {
const parts = collectThreadParticipants(email)
if (parts.length === 0) return "To: (inconnu)"
return `To: ${parts.map((p) => `${p.name} <${p.email}>`).join(", ")}`
}
/** Corps HTML du fil (messages précédents + message principal), sans en-tête « Forwarded ». */
function forwardConversationHtml(email: Email): string {
const blocks: string[] = []
for (const m of email.conversation ?? []) {
blocks.push(
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(m.sender))} &lt;${escapeHtml(m.senderEmail)}&gt;<br/>` +
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(m.date))}</div>${m.body}</div>`
)
}
const mainAddr =
email.senderEmail ??
`${cleanSenderName(email.sender).toLowerCase().replace(/\s+/g, ".")}@example.com`
blocks.push(
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} &lt;${escapeHtml(mainAddr)}&gt;<br/>` +
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(email.date))}</div>` +
`${email.body ?? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`}</div>`
)
return blocks.join("")
}
function forwardBodyHtml(email: Email): string {
const mainAddr =
email.senderEmail ??
`${cleanSenderName(email.sender).toLowerCase().replace(/\s+/g, ".")}@example.com`
const header =
`<p>---------- Forwarded message ---------</p>` +
`<p style="color:#222;font-size:13px;line-height:1.5">` +
`<strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} &lt;${escapeHtml(mainAddr)}&gt;<br/>` +
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(email.date))}<br/>` +
`<strong>Objet :</strong> ${escapeHtml(email.subject)}<br/>` +
`<strong>${escapeHtml(forwardParticipantsLine(email))}</strong></p>`
return `<p></p>${header}${forwardConversationHtml(email)}`
}
/** Tablette / tactile : composition plein écran (dock maximisé) au lieu dinline sous le fil. */
export function withTouchFullscreenComposePreset(
preset: ComposeOpenPreset
): ComposeOpenPreset {
if (typeof window === "undefined" || !readCoarsePointerMatches()) {
return preset
}
return {
...preset,
placement: "dock",
maximized: true,
}
}
/**
* Preset pour une fenêtre de composition inline (réponse / transfert) rattachée au fil `email`.
*/
export function buildThreadComposePreset(
email: Email,
kind: ThreadComposeKind
): ComposeOpenPreset {
const defaultIdentity = resolveComposeIdentity()
const participants = collectThreadParticipants(email)
const last = lastMessage(email)
const lastContact: Contact | null = last
? { name: cleanSenderName(last.sender), email: last.senderEmail }
: participants.length > 0
? participants[participants.length - 1]!
: null
const threading = {
inReplyTo: inReplyToFor(email),
references: buildReferences(email),
}
if (kind === "forward") {
const baseBody = insertSignatureBeforeForwardedBlock(forwardBodyHtml(email))
return {
from: defaultIdentity,
to: [],
cc: [],
bcc: [],
subject: forwardSubject(email.subject),
bodyHtml: baseBody,
placement: "inline",
threadEmailId: email.id,
threadKind: "forward",
focusToOnMount: true,
focusBodyOnMount: false,
threading,
}
}
if (kind === "reply") {
const to = lastContact ? [lastContact] : []
return {
from: defaultIdentity,
to,
cc: [],
bcc: [],
subject: replySubject(email.subject),
bodyHtml: appendDefaultSignature(replyQuotedHtml(email)),
placement: "inline",
threadEmailId: email.id,
threadKind: "reply",
focusToOnMount: false,
focusBodyOnMount: true,
threading,
}
}
/* replyAll */
const to: Contact[] = []
const cc: Contact[] = []
if (lastContact) {
to.push(lastContact)
for (const p of participants) {
if (p.email === lastContact.email) continue
cc.push(p)
}
} else if (participants.length > 0) {
to.push(participants[0]!)
for (let i = 1; i < participants.length; i++) {
cc.push(participants[i]!)
}
}
return {
from: defaultIdentity,
to,
cc,
bcc: [],
subject: replySubject(email.subject),
bodyHtml: appendDefaultSignature(replyQuotedHtml(email)),
placement: "inline",
threadEmailId: email.id,
threadKind: "replyAll",
focusToOnMount: false,
focusBodyOnMount: true,
threading,
showCc: cc.length > 0,
}
}