Files
webmail-client/frontend/src/components/thread/ThreadView.tsx
T

604 lines
23 KiB
TypeScript
Raw Normal View History

2026-07-19 18:58:01 +02:00
import { useMutation, useQueryClient } from "@tanstack/react-query";
2026-07-17 23:20:34 +02:00
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
2026-07-19 18:58:01 +02:00
import { api } from "../../lib/api";
import { printMessages } from "../../lib/print";
2026-07-19 18:58:01 +02:00
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
2026-07-17 23:20:34 +02:00
import { Composer, type ComposerInit } from "../compose/Composer";
import { Icon } from "../Icon";
import { MessageBody } from "./MessageBody";
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
// `messages` is ALWAYS ascending (oldest first); the ONLY branch on `order` is the
2026-07-19 18:58:01 +02:00
// visual reversal below. The composer anchor is attached to the newest message.
2026-07-17 23:20:34 +02:00
export interface ThreadViewProps {
subject: string;
messages: MessageEnvelope[];
order: "asc" | "desc";
2026-07-19 18:58:01 +02:00
mailboxes?: MailboxOut[];
accounts?: AccountOut[];
2026-07-17 23:20:34 +02:00
replyKindSignal?: { kind: string; seq: number };
}
function formatAddress(a: { name: string; email: string } | undefined): string {
if (!a || !a.email) return "(unknown)";
return a.name ? `${a.name} (${a.email})` : a.email;
}
function formatList(addrs: { name: string; email: string }[]): string {
return addrs.length > 0 ? addrs.map(formatAddress).join(", ") : "(no recipient)";
}
2026-07-19 18:58:01 +02:00
function recipientSummary(addrs: { name: string; email: string }[]): string {
if (addrs.length === 0) return "(no recipient)";
const first = addrs[0].name || addrs[0].email;
return addrs.length > 1 ? `${first} +${addrs.length - 1}` : first;
}
2026-07-17 23:20:34 +02:00
function attributionFor(msg: MessageEnvelope): string {
const d = new Date(msg.date);
const date = d.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
return `On ${date}, at ${time}, ${who} wrote:`;
}
function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): ComposerInit {
const from = msg.from_addr[0]?.email ?? "";
const toEmails = msg.to_addr.map((a) => a.email);
const norm = subject.replace(/^\s*(re|fwd?):\s*/i, "");
const quote = { attribution: attributionFor(msg), messageId: msg.id };
if (kind === "forward") {
return {
accountId: msg.account_id,
threadId: msg.thread_id,
replyToMessageId: msg.id,
kind,
subject: `Fwd: ${norm}`,
quote,
};
}
const to = kind === "reply_all" ? [from, ...toEmails].filter(Boolean).join(", ") : from;
const cc = kind === "reply_all" ? msg.cc_addr.map((a) => a.email).join(", ") : "";
return {
accountId: msg.account_id,
threadId: msg.thread_id,
replyToMessageId: msg.id,
kind,
to,
cc,
subject: `Re: ${norm}`,
quote,
};
}
function initials(msg: MessageEnvelope): string {
2026-07-19 18:58:01 +02:00
const name = (msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?").trim();
const words = name.split(/\s+/).filter(Boolean);
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
return name.slice(0, 1).toUpperCase();
}
// Stable per-sender hue so a sender's avatar + name share a colour (like the mockup).
function hue(msg: MessageEnvelope): number {
const key = (msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?").toLowerCase();
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
return h;
}
function fmtRelative(iso: string): string {
const d = new Date(iso);
const base = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const diff = Date.now() - d.getTime();
const mins = Math.round(diff / 60000);
const hrs = Math.round(diff / 3600000);
const days = Math.round(diff / 86400000);
let rel = "";
if (mins < 1) rel = "just now";
else if (mins < 60) rel = `${mins} min ago`;
else if (hrs < 24) rel = `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
else if (days < 30) rel = `${days} day${days === 1 ? "" : "s"} ago`;
return rel ? `${base} (${rel})` : `${base} ${d.getFullYear()}`;
}
function fmtFull(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const LOCATION_LABEL: Record<string, string> = {
inbox: "Inbox",
sent: "Sent",
archive: "Archive",
drafts: "Drafts",
trash: "Trash",
junk: "Spam",
};
function ActionsMenu({
onReply,
onPrint,
onDelete,
replyDisabled = false,
2026-07-19 18:58:01 +02:00
}: {
onReply: (kind: string) => void;
onPrint: () => void;
onDelete: () => void;
replyDisabled?: boolean;
2026-07-19 18:58:01 +02:00
}) {
const t = useT();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
// Clicking inside the sandboxed message-body iframe doesn't reach document
// mousedown, but it blurs the parent window — close the menu on that too.
const onBlur = () => setOpen(false);
2026-07-19 18:58:01 +02:00
document.addEventListener("mousedown", onDoc);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("mousedown", onDoc);
window.removeEventListener("blur", onBlur);
};
2026-07-19 18:58:01 +02:00
}, [open]);
const item = (label: string, onClick: () => void, disabled = false) => (
2026-07-19 18:58:01 +02:00
<button
disabled={disabled}
className={`block w-full px-3 py-1.5 text-left text-xs ${
disabled
? "cursor-not-allowed text-app-faint"
: "text-app-fg hover:bg-app-hover"
}`}
2026-07-19 18:58:01 +02:00
onClick={() => {
setOpen(false);
onClick();
}}
>
{label}
</button>
);
return (
<div ref={ref} className="relative">
<button
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs text-app-muted hover:bg-app-hover hover:text-app-fg"
onClick={() => setOpen((v) => !v)}
>
{t("mail.actions")}
<span className="text-[11px]">▾</span>
2026-07-19 18:58:01 +02:00
</button>
{open && (
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg">
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)}
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
2026-07-19 18:58:01 +02:00
{item(t("mail.print"), onPrint)}
<div className="my-1 border-t border-app-border" />
{item(t("mail.delete"), onDelete)}
</div>
)}
</div>
);
2026-07-17 23:20:34 +02:00
}
function MessageItem({
message,
expanded,
2026-07-19 18:58:01 +02:00
location,
align,
accentColor,
boxClassName,
zIndex,
showReplyBar,
canReply,
alsoIn,
2026-07-17 23:20:34 +02:00
onToggle,
onReply,
2026-07-19 18:58:01 +02:00
onToggleRead,
onPrint,
onDelete,
2026-07-17 23:20:34 +02:00
innerRef,
}: {
message: MessageEnvelope;
expanded: boolean;
2026-07-19 18:58:01 +02:00
location: string;
align: "start" | "end";
accentColor: string | null;
boxClassName: string;
zIndex: number;
showReplyBar: boolean;
canReply: boolean;
alsoIn: string[];
2026-07-17 23:20:34 +02:00
onToggle: () => void;
onReply: (kind: string) => void;
2026-07-19 18:58:01 +02:00
onToggleRead: () => void;
onPrint: () => void;
onDelete: () => void;
2026-07-17 23:20:34 +02:00
innerRef?: (el: HTMLDivElement | null) => void;
}) {
const t = useT();
2026-07-19 18:58:01 +02:00
const [showDetails, setShowDetails] = useState(false);
2026-07-17 23:20:34 +02:00
const replyBtn = (kind: string, label: string, icon: string) => (
<button
2026-07-19 18:58:01 +02:00
className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
2026-07-17 23:20:34 +02:00
onClick={() => onReply(kind)}
>
<Icon name={icon} size={14} />
{label}
</button>
);
2026-07-19 18:58:01 +02:00
const h = hue(message);
// Our own IMAP accounts use their assigned colour; everyone else a stable hue.
const avatarBg = accentColor ?? `hsl(${h} 45% 45%)`;
const nameColor = accentColor ?? `hsl(${h} 55% 62%)`;
const avatar = () => (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
style={{ backgroundColor: avatarBg }}
>
{initials(message)}
</span>
);
const senderName = (
<span className="font-semibold" style={{ color: nameColor }}>
{message.from_addr[0]?.name || message.from_addr[0]?.email || "(unknown)"}
</span>
);
const multiRecipient = message.to_addr.length + message.cc_addr.length > 1;
2026-07-17 23:20:34 +02:00
return (
<div
data-testid="message"
data-message-id={message.id}
ref={innerRef}
2026-07-19 18:58:01 +02:00
style={{ zIndex }}
className={`relative ${boxClassName}`}
2026-07-17 23:20:34 +02:00
>
2026-07-19 18:58:01 +02:00
{!expanded ? (
// Collapsed: single-line row (avatar · sender · inline snippet · date).
// Aligned to the visible (non-overlapped) edge of the stacked card.
<button
className={`flex w-full gap-3 px-4 py-2.5 text-left hover:bg-app-hover ${
align === "end" ? "items-end" : "items-start"
}`}
onClick={onToggle}
>
{avatar()}
<span className="min-w-0 flex-1 truncate text-[15px]">
2026-07-19 18:58:01 +02:00
{senderName}
<span className="ml-2 text-xs text-app-faint">{message.snippet}</span>
2026-07-17 23:20:34 +02:00
</span>
2026-07-19 18:58:01 +02:00
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
</button>
) : (
<div>
<div className="flex items-start gap-3 px-4 pb-2 pt-3">
{avatar()}
<div className="min-w-0 flex-1">
{/* line 1: sender + email ············· date (whole row collapses) */}
<button
className="flex w-full items-baseline justify-between gap-2 text-left"
onClick={onToggle}
>
<span className="min-w-0 truncate text-[15px]">
2026-07-19 18:58:01 +02:00
{senderName}
{message.from_addr[0]?.email && (
<span className="text-app-faint"> &lt;{message.from_addr[0].email}&gt;</span>
)}
</span>
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
</button>
{/* line 2: to … ▼ ············· location ○ reply actions */}
<div className="mt-0.5 flex items-center justify-between gap-2">
<button
className="flex min-w-0 items-center gap-1 text-left text-xs text-app-muted"
onClick={() => setShowDetails((v) => !v)}
aria-expanded={showDetails}
>
<span className="truncate">
{t("mail.to")} {recipientSummary(message.to_addr)}
</span>
<span className="text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
2026-07-19 18:58:01 +02:00
</button>
<div className="flex shrink-0 items-center gap-3 text-xs">
{location && <span className="font-semibold text-app-fg">{location}</span>}
<button
title={t("mail.markReadUnread")}
aria-label={t("mail.markReadUnread")}
onClick={onToggleRead}
className="grid h-4 w-4 place-items-center"
>
<span
className={`h-3 w-3 rounded-full border ${
message.is_unread
? "border-app-accent bg-app-accent"
: "border-app-faint"
}`}
/>
</button>
{canReply && (
<button
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
>
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
</button>
)}
<ActionsMenu
onReply={onReply}
onPrint={onPrint}
onDelete={onDelete}
replyDisabled={!canReply}
/>
2026-07-19 18:58:01 +02:00
</div>
</div>
{alsoIn.length > 0 && (
<div className="mt-0.5 truncate text-xs text-app-faint">
{t("mail.alsoIn")} {alsoIn.join(", ")}
</div>
)}
2026-07-19 18:58:01 +02:00
{showDetails && (
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-t border-app-border pt-2 text-xs text-app-muted">
<dt className="text-app-faint">{t("mail.from")}</dt>
<dd className="truncate">{formatList(message.from_addr)}</dd>
<dt className="text-app-faint">{t("mail.to")}</dt>
<dd className="truncate">{formatList(message.to_addr)}</dd>
{message.cc_addr.length > 0 && (
<>
<dt className="text-app-faint">Cc</dt>
<dd className="truncate">{formatList(message.cc_addr)}</dd>
</>
)}
<dt className="text-app-faint">{t("mail.subjectLabel")}</dt>
<dd className="truncate">{message.subject || "(no subject)"}</dd>
<dt className="text-app-faint">{t("mail.dateLabel")}</dt>
<dd>{fmtFull(message.date)}</dd>
<dt className="text-app-faint">{t("mail.sizeLabel")}</dt>
<dd>{fmtSize(message.size_bytes)}</dd>
</dl>
)}
</div>
</div>
<div className="px-4 pb-3">
<MessageBody message={message} />
</div>
{showReplyBar && canReply && (
2026-07-19 18:58:01 +02:00
<div className="flex gap-2 border-t border-app-border px-4 py-3">
2026-07-17 23:20:34 +02:00
{replyBtn("reply", t("mail.reply"), "reply")}
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
{replyBtn("forward", t("mail.forward"), "forward")}
</div>
)}
</div>
)}
</div>
);
}
2026-07-19 18:58:01 +02:00
export function ThreadView({
subject,
messages,
order,
mailboxes,
accounts,
replyKindSignal,
}: ThreadViewProps) {
2026-07-17 23:20:34 +02:00
const t = useT();
2026-07-19 18:58:01 +02:00
const queryClient = useQueryClient();
2026-07-17 23:20:34 +02:00
const newest = messages.length > 0 ? messages[messages.length - 1] : undefined;
const defaultExpanded = useMemo(() => {
const ids = new Set<string>();
for (const m of messages) if (m.is_unread) ids.add(m.id);
if (newest) ids.add(newest.id);
return ids;
}, [messages, newest]);
const [expanded, setExpanded] = useState<Set<string>>(defaultExpanded);
useEffect(() => setExpanded(defaultExpanded), [defaultExpanded]);
const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null);
2026-07-19 18:58:01 +02:00
const flags = useMutation({
mutationFn: ({ id, add, remove }: { id: string; add: string[]; remove: string[] }) =>
api.messageFlags(id, add, remove),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["thread"] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
// Mark read/unread changes the Inbox unread badge.
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
2026-07-19 18:58:01 +02:00
},
});
const move = useMutation({
mutationFn: ({ id, mailboxId }: { id: string; mailboxId: string }) =>
api.moveMessage(id, mailboxId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["thread"] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
2026-07-19 18:58:01 +02:00
},
});
const specialUse = useMemo(() => {
const m = new Map<string, string | null>();
for (const mb of mailboxes ?? []) m.set(mb.id, mb.special_use);
return m;
}, [mailboxes]);
const locationFor = (msg: MessageEnvelope) => {
const su = specialUse.get(msg.mailbox_id);
return su ? (LOCATION_LABEL[su] ?? su.charAt(0).toUpperCase() + su.slice(1)) : "";
};
const trashFor = (accountId: string) =>
(mailboxes ?? []).find((m) => m.account_id === accountId && m.special_use === "trash")?.id;
const accountColor = useMemo(() => {
const m = new Map<string, string>();
for (const a of accounts ?? []) m.set(a.email.toLowerCase(), a.color);
return m;
}, [accounts]);
const accentFor = (msg: MessageEnvelope) =>
accountColor.get((msg.from_addr[0]?.email ?? "").toLowerCase()) ?? null;
const toggleRead = (msg: MessageEnvelope) =>
flags.mutate(
msg.is_unread ? { id: msg.id, add: ["\\Seen"], remove: [] } : { id: msg.id, add: [], remove: ["\\Seen"] },
);
const deleteMessage = (msg: MessageEnvelope) => {
const trash = trashFor(msg.account_id);
if (trash) move.mutate({ id: msg.id, mailboxId: trash });
};
2026-07-17 23:20:34 +02:00
const openReply = (msg: MessageEnvelope, kind: string) => {
if (specialUse.get(msg.mailbox_id) === "trash") return; // no reply/forward from Trash
2026-07-17 23:20:34 +02:00
setExpanded((prev) => new Set(prev).add(msg.id));
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
};
useEffect(() => {
if (replyKindSignal && newest) openReply(newest, replyKindSignal.kind);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [replyKindSignal?.seq]);
const newestRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
newestRef.current?.scrollIntoView?.({ block: "nearest" });
}, [newest?.id]);
const visual = order === "asc" ? messages : [...messages].reverse();
2026-07-19 18:58:01 +02:00
const allExpanded = messages.length > 0 && messages.every((m) => expanded.has(m.id));
2026-07-17 23:20:34 +02:00
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
2026-07-19 18:58:01 +02:00
// Default action expands everything; collapsing leaves only the latest message open.
const toggleAll = () =>
setExpanded(
allExpanded ? new Set(newest ? [newest.id] : []) : new Set(messages.map((m) => m.id)),
);
const composerRef = useRef<HTMLDivElement | null>(null);
// Bring the inline composer into view when it opens (reply/reply-all/forward).
useEffect(() => {
if (composing) composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [composing]);
2026-07-17 23:20:34 +02:00
const composerFor = (id: string) =>
composing?.anchorId === id ? (
<div ref={composerRef} className="px-4 py-3">
2026-07-17 23:20:34 +02:00
<Composer init={composing.init} onClose={() => setComposing(null)} />
</div>
) : null;
return (
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
<div className="sticky top-0 z-10 flex h-[52px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
2026-07-19 18:58:01 +02:00
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("mail.print")}
aria-label={t("mail.print")}
onClick={() => void printMessages(subject, visual)}
2026-07-19 18:58:01 +02:00
>
<Icon name="print" size={16} />
</button>
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
aria-label={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
onClick={toggleAll}
>
<Icon name={allExpanded ? "collapse-all" : "expand-all"} size={16} />
</button>
</div>
2026-07-17 23:20:34 +02:00
</div>
2026-07-19 18:58:01 +02:00
<div className="p-3">
{visual.map((message, i) => {
const isNewest = Boolean(newest && newest.id === message.id);
const isExpanded = expanded.has(message.id);
const prev = i > 0 ? visual[i - 1] : undefined;
const prevCollapsed = prev ? !expanded.has(prev.id) : false;
// Cards overlap by a negative top margin (tuck up under the card above),
// with the newer card kept on top by z-index. The card above is OLDER in
// oldest-first but NEWER in newest-first, so the rule differs by order:
// - oldest-first: an expanded card tucks up over the older card above it
// (its own top stays clear). Only overlap a collapsed card above.
// - newest-first: the card above is newer, so tucking an expanded card up
// would hide its header under that newer card. Only collapsed cards tuck
// up; an expanded card keeps its gap, so its top stays clear of the newer
// message and the older card BELOW tucks under it instead.
const overlap = order === "desc" ? !isExpanded : prevCollapsed;
2026-07-19 18:58:01 +02:00
const margin = i === 0 ? "" : overlap ? "-mt-[20px]" : "mt-2";
const z = order === "asc" ? i + 1 : visual.length - i;
const boxClassName = `${margin} rounded-lg border border-app-border bg-app-surface ${
isExpanded ? "" : "shadow-sm"
}`;
// Reply/Reply-all/Forward make no sense on a trashed message.
const canReply = specialUse.get(message.mailbox_id) !== "trash";
const alsoIn = (message.also_in_account_ids ?? [])
.map((id) => accounts?.find((a) => a.id === id)?.email)
.filter((e): e is string => Boolean(e));
2026-07-17 23:20:34 +02:00
return (
<Fragment key={message.id}>
<MessageItem
message={message}
2026-07-19 18:58:01 +02:00
expanded={isExpanded}
location={locationFor(message)}
align={order === "asc" ? "start" : "end"}
accentColor={accentFor(message)}
boxClassName={boxClassName}
zIndex={z}
showReplyBar={isNewest && composing?.anchorId !== message.id}
canReply={canReply}
alsoIn={alsoIn}
2026-07-17 23:20:34 +02:00
onToggle={() => toggle(message.id)}
onReply={(kind) => openReply(message, kind)}
2026-07-19 18:58:01 +02:00
onToggleRead={() => toggleRead(message)}
onPrint={() => void printMessages(subject, [message])}
2026-07-19 18:58:01 +02:00
onDelete={() => deleteMessage(message)}
2026-07-17 23:20:34 +02:00
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
/>
2026-07-19 18:58:01 +02:00
{isNewest && (
<div data-testid="composer-anchor">
{composing?.anchorId === message.id ? composerFor(message.id) : null}
</div>
2026-07-17 23:20:34 +02:00
)}
2026-07-19 18:58:01 +02:00
{composing?.anchorId === message.id && !isNewest && composerFor(message.id)}
2026-07-17 23:20:34 +02:00
</Fragment>
);
})}
</div>
</div>
);
}