/** * Groups a thread's messages the way Instagram does: consecutive messages from * one sender, sent close together, render as a stack with a single avatar and * one timestamp, and a date separator marks each new day. * * Pure and message-shaped so it can be reasoned about (and checked) without a * DOM. */ import type { ChatMessage } from "@/lib/chat"; /** How long a pause before the same sender starts a new stack. */ export const GROUP_GAP_MS = 5 * 60 * 1000; export type ChatRow = | { kind: "date"; key: string; at: string } | { kind: "group"; key: string; senderId: string | null; messages: ChatMessage[]; }; function dayKey(iso: string): string { const d = new Date(iso); return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; } /** * `messages` must be ascending by seq (oldest first) — the order the thread is * rendered in. */ export function groupMessages( messages: ChatMessage[], gapMs: number = GROUP_GAP_MS, ): ChatRow[] { const rows: ChatRow[] = []; let current: Extract | null = null; let currentDay = ""; for (const m of messages) { const day = dayKey(m.created_at); if (day !== currentDay) { rows.push({ kind: "date", key: `date-${day}`, at: m.created_at }); currentDay = day; current = null; // a new day always starts a new stack } const last = current?.messages[current.messages.length - 1]; const gap = last ? new Date(m.created_at).getTime() - new Date(last.created_at).getTime() : Infinity; // System notices stand alone — they belong to no one and shouldn't be // absorbed into a neighbouring stack. const standalone = m.kind === "system"; const continues = current !== null && !standalone && current.senderId === (m.sender_id ?? null) && current.messages[0]?.kind !== "system" && gap <= gapMs; if (continues && current) { current.messages.push(m); continue; } current = { kind: "group", key: `group-${m.id}`, senderId: m.sender_id ?? null, messages: [m], }; rows.push(current); if (standalone) current = null; } return rows; }