367 lines
14 KiB
TypeScript
367 lines
14 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 { HEART_EMOJI } from "@/components/chat/reaction-row";
|
|
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",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
}
|
|
|
|
/** 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 (
|
|
<div
|
|
// Neutral on both sides: this now sits on the page background rather
|
|
// than inside a bubble, so the old primary-foreground treatment (white
|
|
// text, for a primary bubble) would be invisible in light mode.
|
|
// Spacing comes from the column's gap — no margin of its own.
|
|
className="w-full rounded-lg border-l-2 border-primary/40 bg-muted/60 px-2 py-1 text-xs 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,
|
|
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<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;
|
|
/** 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 (
|
|
<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)}
|
|
onDoubleTap={() => onReact(m, HEART_EMOJI)}
|
|
>
|
|
{m.reply_to && !m.deleted_at && (
|
|
<div
|
|
className={cn(
|
|
"flex w-full",
|
|
mine ? "justify-end" : "justify-start",
|
|
)}
|
|
>
|
|
{/* 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. */}
|
|
<div className="max-w-[78%]">
|
|
<QuotedReply
|
|
reply={m.reply_to}
|
|
selfId={selfId}
|
|
nameFor={nameFor}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"flex w-full",
|
|
mine ? "justify-end" : "justify-start",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"group/bubble relative max-w-[78%] text-sm",
|
|
bubbleCorners(mine, i, messages.length),
|
|
// A shared post is a card in its own right: it brings its
|
|
// own border, rounding and background, so the bubble drops
|
|
// its chrome rather than drawing a second frame around it.
|
|
// Any caption gets the padding back on its own line below.
|
|
sharesAnEntity(m)
|
|
? "overflow-hidden bg-transparent"
|
|
: cn(
|
|
"px-3 py-2",
|
|
m.deleted_at
|
|
? "bg-muted italic text-muted-foreground"
|
|
: mine
|
|
? "bg-primary text-primary-foreground dark:text-white"
|
|
: "bg-muted text-foreground",
|
|
),
|
|
)}
|
|
>
|
|
{m.deleted_at ? (
|
|
<span>This message was deleted</span>
|
|
) : (
|
|
<>
|
|
{/* A shared post leads and its caption follows, the way
|
|
the post itself reads. Everything else keeps text
|
|
first, with attachments beneath it. */}
|
|
{sharesAnEntity(m) && (
|
|
<MessageAttachments attachments={m.attachments} />
|
|
)}
|
|
{m.body && (
|
|
<span
|
|
className={cn(
|
|
"block",
|
|
// The bubble gave up its padding for the card, so a
|
|
// caption supplies its own.
|
|
sharesAnEntity(m)
|
|
? "rounded-b-lg border border-t-0 border-border px-3 py-2"
|
|
: undefined,
|
|
)}
|
|
>
|
|
<MessageText body={m.body} entities={m.entities} />
|
|
</span>
|
|
)}
|
|
{!sharesAnEntity(m) && (
|
|
<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>
|
|
|
|
{m.reactions?.length > 0 && (
|
|
<button
|
|
type="button"
|
|
// ONE control, not one per emoji. Two different jobs depending
|
|
// on whether you are in the reaction list already: if you have
|
|
// reacted, this retracts it; if you have not, it opens the
|
|
// list of who reacted. Per-chip toggling would make "see who
|
|
// reacted" unreachable for anyone who had.
|
|
onClick={() => onTrailClick(m)}
|
|
aria-label={
|
|
m.reactions.some((r) => r.mine)
|
|
? "Remove your reaction"
|
|
: "See who reacted"
|
|
}
|
|
className={cn(
|
|
"-mt-1 inline-flex items-center gap-0.5 rounded-full border bg-background px-1.5 py-0.5 text-xs",
|
|
mine ? "self-end mr-1" : "self-start ml-1",
|
|
m.reactions.some((r) => r.mine)
|
|
? "border-primary/40 bg-primary/10"
|
|
: "border-border",
|
|
)}
|
|
>
|
|
{m.reactions.slice(0, MAX_TRAIL_EMOJI).map((r) => (
|
|
<span key={r.emoji}>{r.emoji}</span>
|
|
))}
|
|
{m.reactions.length > MAX_TRAIL_EMOJI && (
|
|
<span className="tabular-nums text-muted-foreground">
|
|
+{m.reactions.length - MAX_TRAIL_EMOJI}
|
|
</span>
|
|
)}
|
|
{/* Total people, not distinct emoji — "😂❤️ 5" reads as five
|
|
reactions, which is what it is. */}
|
|
{totalReactions(m) > 1 && (
|
|
<span className="ml-0.5 tabular-nums text-muted-foreground">
|
|
{totalReactions(m)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|