|
| 1 | +// The owner's reactions to what the bot wrote (spec 8.9): a button beside |
| 2 | +// the reply button opens the emoji, and the one picked sits under the reply, |
| 3 | +// saying whether the bot will see it with the next message or already did. |
| 4 | + |
| 5 | +import { SmilePlus } from "lucide-react"; |
| 6 | +import { |
| 7 | + createContext, |
| 8 | + useCallback, |
| 9 | + useContext, |
| 10 | + useEffect, |
| 11 | + useMemo, |
| 12 | + useRef, |
| 13 | + useState, |
| 14 | +} from "react"; |
| 15 | +import { useT } from "../../i18n"; |
| 16 | +import { REACTIONS, type Reaction } from "../../lib/protocol.gen"; |
| 17 | +import { useApi, useApp } from "../../store/context"; |
| 18 | +import { attempt } from "../../ui/toast"; |
| 19 | +import { useDismiss } from "../../ui/useDismiss"; |
| 20 | + |
| 21 | +export interface Reactions { |
| 22 | + byItem: Record<string, Reaction>; |
| 23 | + set(itemId: string, emoji: string | null): void; |
| 24 | +} |
| 25 | + |
| 26 | +/** The open chat's reactions; absent where none can be put. */ |
| 27 | +export const ReactionsOf = createContext<Reactions | null>(null); |
| 28 | + |
| 29 | +/** The bot's reactions, loaded on each connection and kept current. */ |
| 30 | +export function useReactions(botId: string): Reactions { |
| 31 | + const api = useApi(); |
| 32 | + const t = useT().chat.react; |
| 33 | + const open = useApp((state) => state.connection.kind === "open"); |
| 34 | + const [byItem, setByItem] = useState<Record<string, Reaction>>({}); |
| 35 | + useEffect(() => { |
| 36 | + if (!open) return; |
| 37 | + let live = true; |
| 38 | + void api.call("reactions.list", { botId }).then( |
| 39 | + (list) => live && setByItem(Object.fromEntries(list.map((one) => [one.itemId, one]))), |
| 40 | + () => undefined, |
| 41 | + ); |
| 42 | + const stop = api.subscribe((event) => { |
| 43 | + if (event.name !== "reaction.changed" || event.params.botId !== botId) return; |
| 44 | + const { itemId, reaction } = event.params; |
| 45 | + setByItem((before) => { |
| 46 | + const { [itemId]: _, ...rest } = before; |
| 47 | + return reaction ? { ...rest, [itemId]: reaction } : rest; |
| 48 | + }); |
| 49 | + }); |
| 50 | + return () => { |
| 51 | + live = false; |
| 52 | + stop(); |
| 53 | + }; |
| 54 | + }, [api, botId, open]); |
| 55 | + const set = useCallback( |
| 56 | + (itemId: string, emoji: string | null) => |
| 57 | + attempt(t.failed, () => api.call("reactions.set", { botId, itemId, emoji })), |
| 58 | + [api, botId, t.failed], |
| 59 | + ); |
| 60 | + return useMemo(() => ({ byItem, set }), [byItem, set]); |
| 61 | +} |
| 62 | + |
| 63 | +/** Opens the emoji to react with. */ |
| 64 | +export function ReactButton({ itemId, bot }: { itemId: string; bot: string }) { |
| 65 | + const t = useT().chat.react; |
| 66 | + const reactions = useContext(ReactionsOf); |
| 67 | + const [open, setOpen] = useState(false); |
| 68 | + const root = useRef<HTMLDivElement>(null); |
| 69 | + const close = useCallback(() => setOpen(false), []); |
| 70 | + useDismiss(open, root, close); |
| 71 | + if (!reactions) { |
| 72 | + return null; |
| 73 | + } |
| 74 | + const current = reactions.byItem[itemId]?.emoji ?? null; |
| 75 | + return ( |
| 76 | + <div ref={root} className="relative"> |
| 77 | + <button |
| 78 | + type="button" |
| 79 | + title={t.action} |
| 80 | + aria-label={t.to(bot)} |
| 81 | + aria-expanded={open} |
| 82 | + onClick={() => setOpen(!open)} |
| 83 | + className="grid size-7 place-items-center rounded-lg text-muted transition-colors hover:text-ink" |
| 84 | + > |
| 85 | + <SmilePlus aria-hidden size={15} /> |
| 86 | + </button> |
| 87 | + {open && ( |
| 88 | + <div |
| 89 | + role="menu" |
| 90 | + aria-label={t.to(bot)} |
| 91 | + className="absolute right-0 bottom-full z-20 mb-1 flex animate-rise gap-0.5 rounded-full border border-line bg-panel p-1 shadow-lift" |
| 92 | + > |
| 93 | + {REACTIONS.map((emoji) => ( |
| 94 | + <button |
| 95 | + key={emoji} |
| 96 | + type="button" |
| 97 | + role="menuitemradio" |
| 98 | + aria-checked={emoji === current} |
| 99 | + onClick={() => { |
| 100 | + close(); |
| 101 | + reactions.set(itemId, emoji === current ? null : emoji); |
| 102 | + }} |
| 103 | + className={`grid size-8 place-items-center rounded-full text-lg transition-transform hover:scale-125 ${ |
| 104 | + emoji === current ? "bg-accent/15" : "" |
| 105 | + }`} |
| 106 | + > |
| 107 | + {emoji} |
| 108 | + </button> |
| 109 | + ))} |
| 110 | + </div> |
| 111 | + )} |
| 112 | + </div> |
| 113 | + ); |
| 114 | +} |
| 115 | + |
| 116 | +/** Under the reply: the owner's reaction; a click takes it off. */ |
| 117 | +export function ReactionMark({ itemId, bot }: { itemId: string; bot: string }) { |
| 118 | + const t = useT().chat.react; |
| 119 | + const reactions = useContext(ReactionsOf); |
| 120 | + const reaction = reactions?.byItem[itemId]; |
| 121 | + if (!reactions || !reaction) { |
| 122 | + return null; |
| 123 | + } |
| 124 | + const seen = reaction.sentIn !== null; |
| 125 | + return ( |
| 126 | + <div className="mt-1.5 flex animate-rise items-center gap-2"> |
| 127 | + <button |
| 128 | + type="button" |
| 129 | + title={t.remove} |
| 130 | + aria-label={`${reaction.emoji} · ${t.remove}`} |
| 131 | + onClick={() => reactions.set(itemId, null)} |
| 132 | + className="flex h-7 items-center rounded-full border border-accent/40 bg-accent/10 px-2 text-base transition-colors hover:bg-accent/20" |
| 133 | + > |
| 134 | + {reaction.emoji} |
| 135 | + </button> |
| 136 | + <span className="text-muted text-xs">{seen ? t.seen(bot) : t.waiting(bot)}</span> |
| 137 | + </div> |
| 138 | + ); |
| 139 | +} |
0 commit comments