feat: chat reactions
This commit is contained in:
@@ -13,6 +13,7 @@ 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";
|
||||
|
||||
@@ -24,6 +25,22 @@ function clockTime(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -45,12 +62,10 @@ function bubbleCorners(mine: boolean, index: number, total: number): string {
|
||||
*/
|
||||
function QuotedReply({
|
||||
reply,
|
||||
mine,
|
||||
selfId,
|
||||
nameFor,
|
||||
}: {
|
||||
reply: ReplyPreview;
|
||||
mine: boolean;
|
||||
selfId: string;
|
||||
nameFor: (senderId: string | null | undefined) => string;
|
||||
}) {
|
||||
@@ -63,12 +78,11 @@ function QuotedReply({
|
||||
: 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",
|
||||
)}
|
||||
// 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">
|
||||
@@ -90,6 +104,8 @@ export function MessageGroup({
|
||||
starredIds,
|
||||
onRetry,
|
||||
nameFor,
|
||||
onReact,
|
||||
onTrailClick,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
mine: boolean;
|
||||
@@ -109,6 +125,10 @@ export function MessageGroup({
|
||||
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];
|
||||
|
||||
@@ -153,7 +173,27 @@ export function MessageGroup({
|
||||
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",
|
||||
@@ -162,31 +202,51 @@ export function MessageGroup({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group/bubble relative max-w-[78%] px-3 py-2 text-sm",
|
||||
"group/bubble relative max-w-[78%] 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",
|
||||
// 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.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>
|
||||
) : (
|
||||
<>
|
||||
{/* 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 && (
|
||||
<MessageText body={m.body} entities={m.entities} />
|
||||
<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} />
|
||||
)}
|
||||
<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. */}
|
||||
@@ -224,6 +284,46 @@ export function MessageGroup({
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user