189 lines
4.8 KiB
TypeScript
189 lines
4.8 KiB
TypeScript
import type { ConversationMessage, Email } from "@/lib/email-data"
|
||
|
||
/** Id fil pour étoile, important, libellés, composition. */
|
||
export function threadStoreId(email: Pick<Email, "id" | "threadHeadId">): string {
|
||
return email.threadHeadId ?? email.id
|
||
}
|
||
|
||
/** Message affiché en tête de fil en mode conversation. */
|
||
export function isThreadHeadMessage(
|
||
email: Pick<Email, "id" | "isThreadHead" | "threadHeadId">
|
||
): boolean {
|
||
if (email.isThreadHead === false) return false
|
||
if (email.isThreadHead === true) return true
|
||
return !email.threadHeadId || email.threadHeadId === email.id
|
||
}
|
||
|
||
function threadMetaFromHead(head: Email): Pick<
|
||
Email,
|
||
| "subject"
|
||
| "labels"
|
||
| "starred"
|
||
| "important"
|
||
| "spam"
|
||
| "deleted"
|
||
| "tag"
|
||
| "hasInvitation"
|
||
| "calendarInvitation"
|
||
| "scheduledSendAt"
|
||
| "scheduledToName"
|
||
| "snoozeWakeAt"
|
||
> {
|
||
return {
|
||
subject: head.subject,
|
||
labels: head.labels,
|
||
starred: head.starred,
|
||
important: head.important,
|
||
spam: head.spam,
|
||
deleted: head.deleted,
|
||
tag: head.tag,
|
||
hasInvitation: head.hasInvitation,
|
||
calendarInvitation: head.calendarInvitation,
|
||
scheduledSendAt: head.scheduledSendAt,
|
||
scheduledToName: head.scheduledToName,
|
||
snoozeWakeAt: head.snoozeWakeAt,
|
||
}
|
||
}
|
||
|
||
function priorToEmail(
|
||
msg: ConversationMessage,
|
||
head: Email,
|
||
threadMessageIds: string[]
|
||
): Email {
|
||
return {
|
||
...threadMetaFromHead(head),
|
||
id: msg.id,
|
||
sender: msg.sender,
|
||
senderEmail: msg.senderEmail,
|
||
date: msg.date,
|
||
preview: msg.preview,
|
||
body: msg.body,
|
||
attachments: msg.attachments,
|
||
hasAttachment: (msg.attachments?.length ?? 0) > 0,
|
||
read: msg.read ?? true,
|
||
threadHeadId: head.id,
|
||
threadMessageIds,
|
||
isThreadHead: false,
|
||
}
|
||
}
|
||
|
||
/** Découpe les fixtures legacy (`conversation[]`) en messages autonomes. */
|
||
export function normalizeLegacyEmailCatalog(raw: Email[]): Email[] {
|
||
const out: Email[] = []
|
||
|
||
for (const root of raw) {
|
||
const conv = root.conversation ?? []
|
||
if (conv.length === 0) {
|
||
out.push({
|
||
...root,
|
||
conversation: undefined,
|
||
threadHeadId: root.id,
|
||
threadMessageIds: [root.id],
|
||
isThreadHead: true,
|
||
})
|
||
continue
|
||
}
|
||
|
||
const threadMessageIds = [...conv.map((m) => m.id), root.id]
|
||
for (const msg of conv) {
|
||
out.push(priorToEmail(msg, root, threadMessageIds))
|
||
}
|
||
out.push({
|
||
...root,
|
||
conversation: undefined,
|
||
threadHeadId: root.id,
|
||
threadMessageIds,
|
||
isThreadHead: true,
|
||
})
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
/** Chronological split of a thread around the opened message (Gmail-style). */
|
||
export function splitThreadAroundOpenMessage<T extends { id: string; date: string }>(
|
||
messages: T[],
|
||
openId: string
|
||
): { before: T[]; after: T[] } {
|
||
const sorted = [...messages].sort(
|
||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||
)
|
||
const idx = sorted.findIndex((m) => m.id === openId)
|
||
if (idx < 0) {
|
||
return { before: sorted.filter((m) => m.id !== openId), after: [] }
|
||
}
|
||
return {
|
||
before: sorted.slice(0, idx),
|
||
after: sorted.slice(idx + 1),
|
||
}
|
||
}
|
||
|
||
/** Reconstruit la vue fil (conversation[]) pour l’aperçu / réponse. */
|
||
export function buildThreadViewEmail(
|
||
message: Email,
|
||
byId: Map<string, Email>
|
||
): Email {
|
||
const headId = message.threadHeadId ?? message.id
|
||
const head = byId.get(headId) ?? message
|
||
const ids = head.threadMessageIds ?? [headId]
|
||
const priorIds = ids.slice(0, -1)
|
||
|
||
const conversation: ConversationMessage[] = priorIds.map((id) => {
|
||
const m = byId.get(id)!
|
||
return {
|
||
id: m.id,
|
||
sender: m.sender,
|
||
senderEmail: m.senderEmail ?? "",
|
||
date: m.date,
|
||
body: m.body ?? "",
|
||
preview: m.preview,
|
||
attachments: m.attachments,
|
||
}
|
||
})
|
||
|
||
return { ...head, conversation }
|
||
}
|
||
|
||
export function getThreadMessageCount(
|
||
email: Pick<Email, "threadMessageIds" | "conversation">
|
||
): number {
|
||
if (email.threadMessageIds?.length) return email.threadMessageIds.length
|
||
return 1 + (email.conversation?.length ?? 0)
|
||
}
|
||
|
||
/** Lu en liste : fil entier en mode conversation, message seul sinon. */
|
||
export function isListRowRead(
|
||
email: Email,
|
||
readOverrides: Record<string, boolean>,
|
||
byId: Map<string, Email>,
|
||
conversationMode: boolean
|
||
): boolean {
|
||
if (
|
||
conversationMode &&
|
||
email.threadMessageIds &&
|
||
email.threadMessageIds.length > 1
|
||
) {
|
||
return email.threadMessageIds.every((id) => {
|
||
const m = byId.get(id)
|
||
if (!m) return true
|
||
return readOverrides[id] ?? m.read
|
||
})
|
||
}
|
||
return readOverrides[email.id] ?? email.read
|
||
}
|
||
|
||
/** Marque lu / non lu (un message ou tout le fil). */
|
||
export function readStateTargets(
|
||
email: Email,
|
||
conversationMode: boolean
|
||
): string[] {
|
||
if (
|
||
conversationMode &&
|
||
email.threadMessageIds &&
|
||
email.threadMessageIds.length > 1
|
||
) {
|
||
return [...email.threadMessageIds]
|
||
}
|
||
return [email.id]
|
||
}
|