"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");
}
/** True when a message carries photos or video. */
function carriesMedia(m: ChatMessage): boolean {
return (m.attachments ?? []).some(
(a) => a.kind === "image" || a.kind === "video",
);
}
/**
* True when a message's content brings its own frame — a shared post card,
* stickers, or media. All three are images with their own edges, and a
* coloured bubble around them reads as a second frame.
*/
function rendersBare(m: ChatMessage): boolean {
return sharesAnEntity(m) || isStickerOnly(m) || carriesMedia(m);
}
/** 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 && (
// Aligned like the bubble it belongs to. The gesture wrapper is
// full-width, so without this the trail sits at the left edge
// while your own bubble is over on the right.
)}
))}
{/* 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 ? (
) : (
))}