267 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|