Files
spsays-training/components/chat/message-group.tsx
T

267 lines
9.2 KiB
TypeScript

"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 (
<div
className={cn(
"mb-1 rounded-lg border-l-2 px-2 py-1 text-xs",
mine
? "border-primary-foreground/50 bg-black/10 text-primary-foreground/90 dark:text-white/90"
: "border-primary/50 bg-background/60 text-muted-foreground",
)}
>
<span className="block font-medium">{who}</span>
<span className="line-clamp-1 break-words">
{reply.deleted ? "This message was deleted" : reply.body}
</span>
</div>
);
}
export function MessageGroup({
messages,
mine,
sender,
receipts,
showAvatar,
selfId,
onReply,
onMenu,
starredIds,
onRetry,
nameFor,
}: {
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<string>;
/** 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 (
<div
className={cn(
"flex w-full items-end gap-2",
mine ? "justify-end" : "justify-start",
)}
>
{!mine && showAvatar && (
<UserAvatar
src={sender?.avatar_url}
name={sender?.name ?? "?"}
className="size-7 shrink-0"
textClassName="text-[10px]"
/>
)}
<div
// flex-1 rather than max-w: the column spans the row so a swipe can
// start anywhere on it. min-w-0 lets long words wrap instead of
// widening the row.
className={cn(
"flex min-w-0 flex-1 flex-col gap-0.5",
mine ? "items-end" : "items-start",
)}
>
{/* 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 && (
<span className="px-1 text-[11px] font-medium text-muted-foreground">
{sender?.name ?? "Unknown"}
</span>
)}
{messages.map((m, i) => (
// A deleted message has nothing to quote, so it isn't swipeable.
<MessageGestures
key={m.id}
disabled={!!m.deleted_at || !!m.pending}
onReply={() => onReply(m)}
onMenu={(point) => onMenu(m, point)}
>
<div
className={cn(
"flex w-full",
mine ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"group/bubble relative max-w-[78%] px-3 py-2 text-sm",
bubbleCorners(mine, i, messages.length),
m.deleted_at
? "bg-muted italic text-muted-foreground"
: mine
? "bg-primary text-primary-foreground dark:text-white"
: "bg-muted text-foreground",
)}
>
{m.reply_to && !m.deleted_at && (
<QuotedReply
reply={m.reply_to}
mine={mine}
selfId={selfId}
nameFor={nameFor}
/>
)}
{m.deleted_at ? (
<span>This message was deleted</span>
) : (
<>
{m.body && (
<MessageText body={m.body} entities={m.entities} />
)}
<MessageAttachments attachments={m.attachments} />
{/* 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 && (
<span className="italic opacity-80">
Sending attachment…
</span>
)}
{starredIds.has(m.id) && (
<Star
className="mt-1 size-3 fill-current opacity-70"
aria-label="Starred"
/>
)}
</>
)}
{/* 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 && (
<button
type="button"
onClick={() => onReply(m)}
aria-label="Reply to this message"
className={cn(
"absolute top-1/2 hidden -translate-y-1/2 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm",
"opacity-0 transition-opacity group-hover/bubble:opacity-100 focus-visible:opacity-100",
"hover:text-foreground sm:block",
mine ? "-left-9" : "-right-9",
)}
>
<Reply className="size-3.5" />
</button>
)}
</div>
</div>
</MessageGestures>
))}
<div className="flex items-center gap-1 px-1">
<time
dateTime={last.created_at}
className="text-[11px] text-muted-foreground"
>
{clockTime(last.created_at)}
</time>
{/* 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 && (
<button
type="button"
onClick={() => onRetry(last)}
className="inline-flex items-center gap-1 text-[11px] font-medium text-destructive hover:underline"
>
<RotateCcw className="size-3" />
Not sent — retry
</button>
)}
{mine &&
!last.deleted_at &&
!last.failed &&
(last.pending ? (
<Clock
role="img"
aria-label="Sending"
className="size-3.5 shrink-0 text-muted-foreground"
/>
) : (
<MessageTicks state={tickState(last.seq, receipts)} />
))}
</div>
</div>
</div>
);
}