feat: chat
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"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,
|
||||
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<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={peer?.avatar_url}
|
||||
name={peer?.name ?? "?"}
|
||||
className="size-7 shrink-0"
|
||||
textClassName="text-[10px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-[78%] flex-col gap-0.5",
|
||||
mine ? "items-end" : "items-start",
|
||||
!mine && !showAvatar && "ml-9",
|
||||
)}
|
||||
>
|
||||
{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(
|
||||
"group/bubble relative 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 === 0 && (
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user