Skip to content
131 linesCodeBlameRaw
1import { SmilePlus } from "lucide-react";
2
3import type { ChatReaction } from "@g1t/contracts";
4
5import { useEmojiContext } from "./context";
6import { EmojiPickerPopover } from "./picker";
7import { EmojiGlyph } from "./render";
8import { Button } from "../ui/button";
9import { Hint } from "../ui/hint";
10import { reactorsLine } from "../../lib/emoji";
11
12/**
13 * A message's reactions: one pill per emoji with how many used it, lit when
14 * the viewer did. Hovering one says who; clicking it adds or takes back the
15 * viewer's own, at once (the page applies it before the service answers).
16 * `+` opens the picker. Without a way to react (no conversation around
17 * it), the pills only show.
18 */
19export function ReactionBar({ messageId, reactions }: { messageId: string; reactions: readonly ChatReaction[] | undefined }) {
20 const { react, me, byName, usercontent } = useEmojiContext();
21 if (!reactions?.length) return null;
22 const meId = me?.kind === "user" ? me.id : null;
23 return (
24 <div className="mt-1 flex flex-wrap items-center gap-1" aria-label="Reactions">
25 {reactions.map((reaction) => {
26 const custom = /^:([a-z0-9_+-]{2,32}):$/.exec(reaction.emoji);
27 const label = (
28 <span className="flex max-w-64 flex-col items-center gap-1 py-0.5 text-center">
29 <EmojiGlyph emoji={reaction.emoji} byName={byName} usercontent={usercontent} size={32} />
30 <span>
31 {reactorsLine(reaction, meId)} reacted
32 {custom && <span className="text-faint"> with {reaction.emoji}</span>}
33 </span>
34 </span>
35 );
36 return (
37 <Hint key={reaction.emoji} label={label}>
38 <button
39 type="button"
40 aria-pressed={reaction.me}
41 aria-label={`${reaction.emoji}, ${reaction.count} ${reaction.count === 1 ? "reaction" : "reactions"}${reaction.me ? ", including yours" : ""}`}
42 disabled={!react}
43 onClick={() => react?.(messageId, reaction.emoji, !reaction.me)}
44 className={`flex h-6 items-center gap-1 rounded-full border px-1.5 text-xs font-medium tabular-nums transition-colors disabled:cursor-default ${
45 reaction.me
46 ? "border-accent/60 bg-accent/15 text-accent hover:bg-accent/25"
47 : "border-line bg-surface text-muted hover:border-line-strong hover:bg-raised hover:text-fg"
48 }`}
49 >
50 <EmojiGlyph emoji={reaction.emoji} byName={byName} usercontent={usercontent} size={15} />
51 {reaction.count}
52 </button>
53 </Hint>
54 );
55 })}
56 {react && <AddReaction messageId={messageId} variant="pill" />}
57 </div>
58 );
59}
60
61/**
62 * Adding a reaction: the `+` pill after the reactions, or (`toolbar`) the
63 * smiley in a message's hover actions.
64 */
65export function AddReaction({ messageId, variant = "toolbar" }: { messageId: string; variant?: "pill" | "toolbar" }) {
66 const { react } = useEmojiContext();
67 if (!react) return null;
68 const button =
69 variant === "pill" ? (
70 <Button
71 type="button"
72 aria-label="Add a reaction"
73 variant="outline"
74 className="h-6 rounded-full bg-surface px-1.5 text-faint hover:bg-raised"
75 >
76 <SmilePlus size={14} />
77 </Button>
78 ) : (
79 <Button
80 type="button"
81 aria-label="Add a reaction"
82 variant="ghost"
83 size="icon-xs"
84 >
85 <SmilePlus size={15} />
86 </Button>
87 );
88 return (
89 <EmojiPickerPopover onPick={(emoji) => react(messageId, emoji, true)} side={variant === "pill" ? "top" : "bottom"} align={variant === "pill" ? "start" : "end"}>
90 <Hint label="Add a reaction">{button}</Hint>
91 </EmojiPickerPopover>
92 );
93}
94
95/** The reactions a phone's message sheet offers in one tap. */
96const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"];
97
98/**
99 * A row of one-tap reactions and the full picker, for a message's actions
100 * on a phone (no hover there). `onDone` closes whatever it sits in.
101 */
102export function QuickReactions({ messageId, onDone }: { messageId: string; onDone: () => void }) {
103 const { react } = useEmojiContext();
104 if (!react) return null;
105 const pick = (emoji: string) => {
106 react(messageId, emoji, true);
107 onDone();
108 };
109 return (
110 <div className="flex items-center justify-between gap-1 px-2 pb-2" aria-label="React">
111 {QUICK.map((emoji) => (
112 <Button
113 key={emoji}
114 type="button"
115 aria-label={`React with ${emoji}`}
116 onClick={() => pick(emoji)}
117 variant="ghost"
118 size="icon-lg"
119 className="size-11 rounded-full bg-surface text-[1.375rem] active:bg-raised"
120 >
121 {emoji}
122 </Button>
123 ))}
124 <EmojiPickerPopover onPick={pick} side="top" align="end">
125 <Button type="button" aria-label="More reactions" variant="ghost" size="icon" className="size-11 rounded-full bg-surface active:bg-raised">
126 <SmilePlus size={20} />
127 </Button>
128 </EmojiPickerPopover>
129 </div>
130 );
131}