"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 { cn } from "@/lib/utils"; import type { 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", }); } 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, mine, selfId, nameFor, }: { reply: ReplyPreview; mine: boolean; 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, peer, receipts, showAvatar, selfId, onReply, onMenu, starredIds, onRetry, nameFor, }: { messages: ChatMessage[]; mine: boolean; peer?: ChatPeer | null; 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; }) { const last = messages[messages.length - 1]; return (
{!mine && showAvatar && ( )}
{messages.map((m, i) => ( // A deleted message has nothing to quote, so it isn't swipeable. onReply(m)} onMenu={(point) => onMenu(m, point)} >
{m.reply_to && !m.deleted_at && ( )} {m.deleted_at ? ( This message was deleted ) : ( <> {m.body && } {/* 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 === 0 && ( 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 && ( )}
))}
{/* 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 ? ( ) : ( ) )}
); }