Skip to content

Commit a86dc62

Browse files
authored
Merge pull request #154 from httpsphl/feat/reactions
feat(chat): react to a bot's reply without waking it
2 parents 90d6b76 + 78cd387 commit a86dc62

34 files changed

Lines changed: 913 additions & 30 deletions

‎app/src/features/chat/BotRun.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ function BotRunView({
165165
<Repliable
166166
key={head.id}
167167
target={{ itemId: head.id, who: bot.name, text: head.body.text }}
168+
react
168169
>
169170
<Markdown text={head.body.text} />
170171
</Repliable>

‎app/src/features/chat/ChatView.tsx‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { BotRun, type Live } from "./BotRun";
2525
import { ChatComposer } from "./ChatComposer";
2626
import { InboundRow } from "./InboundRow";
2727
import { NoticeRow } from "./NoticeRow";
28+
import { ReactionsOf, useReactions } from "./Reactions";
2829
import { type ReplyTarget, StartReply } from "./Replying";
2930
import { chatRows, rowHas } from "./rows";
3031
import { useChat } from "./useChat";
@@ -58,6 +59,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
5859
/** What the owner's next message replies to (spec 9.3). */
5960
const [replying, setReplying] = useState<ReplyTarget | null>(null);
6061
const cancelReply = useCallback(() => setReplying(null), []);
62+
const reactions = useReactions(bot.id);
6163
// What was there when the owner opened this chat stays still; what
6264
// arrives while they look animates in.
6365
// biome-ignore lint/correctness/useExhaustiveDependencies: a new bot is a new chat
@@ -215,15 +217,17 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
215217
</div>
216218
)}
217219
<StartReply.Provider value={setReplying}>
218-
<SeenSince.Provider value={openedAt}>
219-
<ol
220-
ref={listRef}
221-
aria-label={t.chat.view.messages}
222-
className="offscreen-rows flex flex-col gap-6"
223-
>
224-
{list}
225-
</ol>
226-
</SeenSince.Provider>
220+
<ReactionsOf.Provider value={reactions}>
221+
<SeenSince.Provider value={openedAt}>
222+
<ol
223+
ref={listRef}
224+
aria-label={t.chat.view.messages}
225+
className="offscreen-rows flex flex-col gap-6"
226+
>
227+
{list}
228+
</ol>
229+
</SeenSince.Provider>
230+
</ReactionsOf.Provider>
227231
</StartReply.Provider>
228232
</div>
229233
</div>
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
}

‎app/src/features/chat/Replying.tsx‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useT } from "../../i18n";
88
import { plainText } from "../../lib/plainText";
99
import type { ChatItemId, MessageReply } from "../../lib/protocol.gen";
1010
import { useApp } from "../../store/context";
11+
import { ReactButton, ReactionMark } from "./Reactions";
1112

1213
/** What the owner is replying to, while they write. */
1314
export interface ReplyTarget {
@@ -20,14 +21,20 @@ export interface ReplyTarget {
2021
/** Starts a reply; absent where the chat cannot be written to. */
2122
export const StartReply = createContext<((target: ReplyTarget) => void) | null>(null);
2223

23-
/** Wraps what can be replied to, with the button that shows on hover. */
24+
/**
25+
* Wraps what can be replied to, with the buttons that show on hover: reply,
26+
* and for the bot's own replies, react (spec 8.9).
27+
*/
2428
export function Repliable({
2529
target,
30+
react = false,
2631
className = "",
2732
children,
2833
}: {
2934
target: ReplyTarget;
30-
/** A bubble narrows it to its own width, for the button to sit on its corner. */
35+
/** The bot's own reply: the owner can react to it too. */
36+
react?: boolean;
37+
/** A bubble narrows it to its own width, for the buttons to sit on its corner. */
3138
className?: string;
3239
children: ReactNode;
3340
}) {
@@ -39,15 +46,19 @@ export function Repliable({
3946
return (
4047
<div className={`group/reply relative ${className}`}>
4148
{children}
42-
<button
43-
type="button"
44-
title={t.action}
45-
aria-label={t.to(target.who)}
46-
onClick={() => start(target)}
47-
className="absolute -top-2 right-0 grid size-7 place-items-center rounded-lg border border-line bg-panel text-muted opacity-0 shadow-sm transition-[opacity,color] hover:text-ink focus-visible:opacity-100 group-hover/reply:opacity-100"
48-
>
49-
<CornerUpLeft aria-hidden size={15} />
50-
</button>
49+
{react && <ReactionMark itemId={target.itemId} bot={target.who} />}
50+
<div className="absolute -top-2 right-0 flex rounded-lg border border-line bg-panel opacity-0 shadow-sm transition-opacity focus-within:opacity-100 group-hover/reply:opacity-100">
51+
{react && <ReactButton itemId={target.itemId} bot={target.who} />}
52+
<button
53+
type="button"
54+
title={t.action}
55+
aria-label={t.to(target.who)}
56+
onClick={() => start(target)}
57+
className="grid size-7 place-items-center rounded-lg text-muted transition-colors hover:text-ink"
58+
>
59+
<CornerUpLeft aria-hidden size={15} />
60+
</button>
61+
</div>
5162
</div>
5263
);
5364
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { crewOpened, openBot, renderApp } from "../../test/app";
5+
6+
afterEach(cleanup);
7+
8+
async function setup() {
9+
const fake = new FakeBotloft();
10+
const ops = fake.addCrew("Ops");
11+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
12+
const writer = fake.addBot(ops.id, "Writer", "Writes");
13+
fake.setBotState(scout.id, "idle");
14+
const said = fake.chat.reply(scout.id, "Acme signs annual.");
15+
fake.chat.turn(scout.id);
16+
fake.conversation.say({ from: writer.id, to: scout.id, body: "The draft is ready." });
17+
renderApp(fake);
18+
await crewOpened("Ops");
19+
openBot("Scout");
20+
await screen.findByRole("list", { name: "Messages" });
21+
return { fake, scout, said };
22+
}
23+
24+
function react(emoji: string) {
25+
fireEvent.click(screen.getByRole("button", { name: "React to what Scout wrote" }));
26+
const menu = screen.getByRole("menu", { name: "React to what Scout wrote" });
27+
fireEvent.click(within(menu).getByRole("menuitemradio", { name: emoji }));
28+
}
29+
30+
describe("reacting to a reply", () => {
31+
test("puts the emoji under the reply, waiting for the next message, then seen", async () => {
32+
const { fake, scout, said } = await setup();
33+
// Only the bot's own replies take one.
34+
expect(screen.getAllByRole("button", { name: /^React to/ })).toHaveLength(1);
35+
36+
react("👍");
37+
expect(await screen.findByText("Scout sees it with your next message")).toBeDefined();
38+
expect(fake.calls.at(-1)).toMatchObject({
39+
method: "reactions.set",
40+
params: { botId: scout.id, itemId: said.id, emoji: "👍" },
41+
});
42+
// The bot was not sent anything.
43+
expect(fake.calls.some((call) => call.method === "messages.send")).toBe(false);
44+
45+
const field = screen.getByRole("textbox", { name: "Message to Scout" });
46+
fireEvent.change(field, { target: { value: "Next?" } });
47+
fireEvent.keyDown(field, { key: "Enter" });
48+
expect(await screen.findByText("Scout saw it")).toBeDefined();
49+
});
50+
51+
test("another emoji replaces it, the same one or a click on it takes it off", async () => {
52+
await setup();
53+
react("👍");
54+
await screen.findByRole("button", { name: "👍 · Take the reaction off" });
55+
react("🎉");
56+
await screen.findByRole("button", { name: "🎉 · Take the reaction off" });
57+
react("🎉");
58+
await waitFor(() =>
59+
expect(screen.queryByRole("button", { name: /Take the reaction off/ })).toBeNull(),
60+
);
61+
react("❤️");
62+
fireEvent.click(await screen.findByRole("button", { name: "❤️ · Take the reaction off" }));
63+
await waitFor(() =>
64+
expect(screen.queryByRole("button", { name: /Take the reaction off/ })).toBeNull(),
65+
);
66+
});
67+
});

‎app/src/i18n/en/chat.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ export const chat = {
103103
cancel: "Cancel the reply",
104104
jump: "Show what this replies to",
105105
},
106+
/** The owner's reactions to the bot's replies (spec 8.9). */
107+
react: {
108+
action: "React",
109+
to: (bot: string) => `React to what ${bot} wrote`,
110+
remove: "Take the reaction off",
111+
waiting: (bot: string) => `${bot} sees it with your next message`,
112+
seen: (bot: string) => `${bot} saw it`,
113+
failed: "Could not save the reaction",
114+
},
106115
approval: {
107116
asks: (bot: string, action: string) => `${bot} asks to ${action}`,
108117
wants: (bot: string, action: string) => `${bot} wants to ${action}`,

‎app/src/i18n/es/chat.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ export const chat: Messages["chat"] = {
9595
cancel: "Cancelar la respuesta",
9696
jump: "Mostrar el mensaje respondido",
9797
},
98+
react: {
99+
action: "Reaccionar",
100+
to: (bot) => `Reaccionar a lo que escribió ${bot}`,
101+
remove: "Quitar la reacción",
102+
waiting: (bot) => `${bot} lo verá con tu próximo mensaje`,
103+
seen: (bot) => `${bot} lo vio`,
104+
failed: "No se pudo guardar la reacción",
105+
},
98106
approval: {
99107
asks: (bot: string, action: string) => `${bot} pide ${action}`,
100108
wants: (bot: string, action: string) => `${bot} quiere ${action}`,

‎app/src/i18n/pt-BR/chat.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ export const chat: Messages["chat"] = {
9494
cancel: "Cancelar a resposta",
9595
jump: "Mostrar a mensagem respondida",
9696
},
97+
react: {
98+
action: "Reagir",
99+
to: (bot) => `Reagir ao que ${bot} escreveu`,
100+
remove: "Tirar a reação",
101+
waiting: (bot) => `${bot} vê junto com sua próxima mensagem`,
102+
seen: (bot) => `${bot} viu`,
103+
failed: "Não foi possível salvar a reação",
104+
},
97105
approval: {
98106
asks: (bot: string, action: string) => `${bot} pede para ${action}`,
99107
wants: (bot: string, action: string) => `${bot} quer ${action}`,

‎app/src/lib/fake.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FakeConversation } from "./fakeConversation";
1111
import { crewHandlers } from "./fakeCrews";
1212
import { FakeFiles } from "./fakeFiles";
1313
import { FakeQuestions } from "./fakeQuestions";
14+
import { FakeReactions } from "./fakeReactions";
1415
import { FakeRoutines } from "./fakeRoutines";
1516
import { conflict, invalid, notFound, slugify } from "./fakeRules";
1617
import { FakeScreens } from "./fakeScreens";
@@ -77,6 +78,7 @@ export class FakeBotloft implements BotloftApi {
7778
readonly browser = new FakeBrowser(this);
7879
readonly screens = new FakeScreens(this);
7980
readonly questions = new FakeQuestions(this);
81+
readonly reactions = new FakeReactions(this);
8082
/** Clock for created and updated times. */
8183
now = Date.now();
8284
closed = false;
@@ -261,6 +263,7 @@ export class FakeBotloft implements BotloftApi {
261263
...this.screens.handlers(),
262264
...this.routines.handlers(),
263265
...this.questions.handlers(),
266+
...this.reactions.handlers(),
264267
...usageHandlers(this),
265268
...searchHandlers(this),
266269
};

‎app/src/lib/fakeConversation.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ export class FakeConversation {
221221
this.fake.emit({ name: "message.created", params: message });
222222
this.fake.emit({ name: "delivery.changed", params: delivery });
223223
this.fake.chat.add(message.toBotId, { kind: "inbound", message });
224+
// Every message of the owner takes the reactions waiting (spec 8.9).
225+
if (message.fromKind === "owner") {
226+
this.fake.reactions.sendWith(message.toBotId, message.id);
227+
}
224228
return delivery;
225229
}
226230

0 commit comments

Comments
 (0)