"use client"; /** * One stack of consecutive messages from a single sender, rendered the way * Instagram does: the avatar appears once beside the last bubble, the corners * tighten between stacked bubbles, and one timestamp closes the stack. */ import { Clock, Reply, RotateCcw, Star } from "lucide-react"; import { UserAvatar } from "@/components/ui/user-avatar"; import { MessageGestures } from "@/components/chat/message-gestures"; import { MessageText } from "@/components/chat/message-text"; import { MessageAttachments } from "@/components/chat/message-attachments"; import { MessageTicks } from "@/components/chat/message-ticks"; import { tickState } from "@/lib/chat-format"; import { HEART_EMOJI } from "@/components/chat/reaction-row"; import { StickerGrid } from "@/components/chat/sticker-grid"; import { cn } from "@/lib/utils"; import type { Attachment, ChatMessage, ChatPeer, Receipts, ReplyPreview, } from "@/lib/chat"; /** Time only — the day is already established by the date separator above. */ function clockTime(iso: string): string { return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", }); } /** * True when a message carries a shared post, which renders as a standalone * card rather than bubble content. */ function sharesAnEntity(m: ChatMessage): boolean { return (m.attachments ?? []).some((a) => a.kind === "entity"); } /** Stickers on a message, if any. */ function stickersOn(m: ChatMessage): Attachment[] { return (m.attachments ?? []).filter((a) => a.kind === "sticker"); } /** * True when a message is nothing but stickers. Those render bare — artwork * inside a coloured bubble reads as a pasted image rather than a sticker. */ function isStickerOnly(m: ChatMessage): boolean { const atts = m.attachments ?? []; return !m.body && atts.length > 0 && atts.every((a) => a.kind === "sticker"); } /** Distinct emoji shown before the rest collapse into "+N". */ const MAX_TRAIL_EMOJI = 4; /** People who reacted, across all emoji. */ function totalReactions(m: ChatMessage): number { return (m.reactions ?? []).reduce((n, r) => n + r.count, 0); } function bubbleCorners(mine: boolean, index: number, total: number): string { if (total === 1) return "rounded-2xl"; const first = index === 0; const last = index === total - 1; // Only the edge nearest the sender tightens, so the stack reads as one unit. if (mine) { return cn( "rounded-2xl", !first && "rounded-tr-md", !last && "rounded-br-md", ); } return cn("rounded-2xl", !first && "rounded-tl-md", !last && "rounded-bl-md"); } /** * The quoted line above a reply. Kept to one line: it is a pointer back to the * original, not a second copy of it. */ function QuotedReply({ reply, selfId, nameFor, }: { reply: ReplyPreview; selfId: string; nameFor: (senderId: string | null | undefined) => string; }) { // The server hydrates sender_name, but it is empty for messages no user // wrote (Kautilya, system notices) — fall back to the participant lookup so // those quote as something meaningful rather than "Unknown". const who = reply.sender_id === selfId ? "You" : reply.sender_name || nameFor(reply.sender_id); return (
{who} {reply.deleted ? "This message was deleted" : reply.body}
); } export function MessageGroup({ messages, mine, sender, receipts, showAvatar, selfId, onReply, onMenu, starredIds, onRetry, nameFor, onReact, onTrailClick, }: { messages: ChatMessage[]; mine: boolean; /** Who sent this group of messages. Undefined for Kautilya, system * messages, or a member who has since left. */ sender?: ChatPeer; receipts: Receipts | null; /** Groups show an avatar; a DM's own side never does. */ showAvatar: boolean; selfId: string; onReply: (message: ChatMessage) => void; /** Opens the context menu for a message at a viewport point. */ onMenu: (message: ChatMessage, point: { x: number; y: number }) => void; /** Ids the caller has starred, so the bubble can show its star. */ starredIds: Set; /** Re-send a message whose delivery failed. */ onRetry: (message: ChatMessage) => void; /** Names a quoted sender; supplied by the thread's participant list. */ nameFor: (senderId: string | null | undefined) => string; /** Set or toggle a reaction. Picking an existing one removes it. */ onReact: (message: ChatMessage, emoji: string) => void; /** Tapping the trail: retract if you reacted, otherwise show who did. */ onTrailClick: (message: ChatMessage) => void; }) { const last = messages[messages.length - 1]; return (
{!mine && showAvatar && ( )}
{/* Name the sender once per run of messages. An avatar alone is thin identification in a group — two people with similar photos, or none at all, are indistinguishable at 28px. */} {!mine && showAvatar && ( {sender?.name ?? "Unknown"} )} {messages.map((m, i) => ( // A deleted message has nothing to quote, so it isn't swipeable. onReply(m)} onMenu={(point) => onMenu(m, point)} onDoubleTap={() => onReact(m, HEART_EMOJI)} > {m.reply_to && !m.deleted_at && (
{/* Outside the bubble, above it: the quote is context for the reply, not part of what was said. Capped to the bubble's own width so the two read as one stack. */}
)}
{m.deleted_at ? ( This message was deleted ) : ( <> {/* A shared post leads and its caption follows, the way the post itself reads. Everything else keeps text first, with attachments beneath it. */} {isStickerOnly(m) ? ( ) : ( sharesAnEntity(m) && ( ) )} {m.body && ( )} {!sharesAnEntity(m) && !isStickerOnly(m) && ( )} {/* An optimistic bubble for a captionless attachment has nothing to draw until the server answers — say so rather than render an empty box. */} {m.pending && !m.body && !m.attachments?.length && ( Sending attachment… )} {starredIds.has(m.id) && ( )} )} {/* Desktop affordance. Swiping is the mobile gesture, but a drag is not discoverable with a mouse — and a real button is also reachable by keyboard, which a gesture never is. */} {!m.deleted_at && !m.pending && ( )}
{m.reactions?.length > 0 && ( )}
))}
{/* Ticks belong only to your own messages — you can't know whether someone else's message reached you before it did. A message still in flight has earned no tick at all. */} {mine && last.failed && ( )} {mine && !last.deleted_at && !last.failed && (last.pending ? ( ) : ( ))}
); }