Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ function BotRunView({
<Repliable
key={head.id}
target={{ itemId: head.id, who: bot.name, text: head.body.text }}
react
>
<Markdown text={head.body.text} />
</Repliable>
Expand Down
22 changes: 13 additions & 9 deletions app/src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { BotRun, type Live } from "./BotRun";
import { ChatComposer } from "./ChatComposer";
import { InboundRow } from "./InboundRow";
import { NoticeRow } from "./NoticeRow";
import { ReactionsOf, useReactions } from "./Reactions";
import { type ReplyTarget, StartReply } from "./Replying";
import { chatRows, rowHas } from "./rows";
import { useChat } from "./useChat";
Expand Down Expand Up @@ -58,6 +59,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
/** What the owner's next message replies to (spec 9.3). */
const [replying, setReplying] = useState<ReplyTarget | null>(null);
const cancelReply = useCallback(() => setReplying(null), []);
const reactions = useReactions(bot.id);
// What was there when the owner opened this chat stays still; what
// arrives while they look animates in.
// biome-ignore lint/correctness/useExhaustiveDependencies: a new bot is a new chat
Expand Down Expand Up @@ -215,15 +217,17 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto
</div>
)}
<StartReply.Provider value={setReplying}>
<SeenSince.Provider value={openedAt}>
<ol
ref={listRef}
aria-label={t.chat.view.messages}
className="offscreen-rows flex flex-col gap-6"
>
{list}
</ol>
</SeenSince.Provider>
<ReactionsOf.Provider value={reactions}>
<SeenSince.Provider value={openedAt}>
<ol
ref={listRef}
aria-label={t.chat.view.messages}
className="offscreen-rows flex flex-col gap-6"
>
{list}
</ol>
</SeenSince.Provider>
</ReactionsOf.Provider>
</StartReply.Provider>
</div>
</div>
Expand Down
139 changes: 139 additions & 0 deletions app/src/features/chat/Reactions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// The owner's reactions to what the bot wrote (spec 8.9): a button beside
// the reply button opens the emoji, and the one picked sits under the reply,
// saying whether the bot will see it with the next message or already did.

import { SmilePlus } from "lucide-react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useT } from "../../i18n";
import { REACTIONS, type Reaction } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { attempt } from "../../ui/toast";
import { useDismiss } from "../../ui/useDismiss";

export interface Reactions {
byItem: Record<string, Reaction>;
set(itemId: string, emoji: string | null): void;
}

/** The open chat's reactions; absent where none can be put. */
export const ReactionsOf = createContext<Reactions | null>(null);

/** The bot's reactions, loaded on each connection and kept current. */
export function useReactions(botId: string): Reactions {
const api = useApi();
const t = useT().chat.react;
const open = useApp((state) => state.connection.kind === "open");
const [byItem, setByItem] = useState<Record<string, Reaction>>({});
useEffect(() => {
if (!open) return;
let live = true;
void api.call("reactions.list", { botId }).then(
(list) => live && setByItem(Object.fromEntries(list.map((one) => [one.itemId, one]))),
() => undefined,
);
const stop = api.subscribe((event) => {
if (event.name !== "reaction.changed" || event.params.botId !== botId) return;
const { itemId, reaction } = event.params;
setByItem((before) => {
const { [itemId]: _, ...rest } = before;
return reaction ? { ...rest, [itemId]: reaction } : rest;
});
});
return () => {
live = false;
stop();
};
}, [api, botId, open]);
const set = useCallback(
(itemId: string, emoji: string | null) =>
attempt(t.failed, () => api.call("reactions.set", { botId, itemId, emoji })),
[api, botId, t.failed],
);
return useMemo(() => ({ byItem, set }), [byItem, set]);
}

/** Opens the emoji to react with. */
export function ReactButton({ itemId, bot }: { itemId: string; bot: string }) {
const t = useT().chat.react;
const reactions = useContext(ReactionsOf);
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
useDismiss(open, root, close);
if (!reactions) {
return null;
}
const current = reactions.byItem[itemId]?.emoji ?? null;
return (
<div ref={root} className="relative">
<button
type="button"
title={t.action}
aria-label={t.to(bot)}
aria-expanded={open}
onClick={() => setOpen(!open)}
className="grid size-7 place-items-center rounded-lg text-muted transition-colors hover:text-ink"
>
<SmilePlus aria-hidden size={15} />
</button>
{open && (
<div
role="menu"
aria-label={t.to(bot)}
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"
>
{REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
role="menuitemradio"
aria-checked={emoji === current}
onClick={() => {
close();
reactions.set(itemId, emoji === current ? null : emoji);
}}
className={`grid size-8 place-items-center rounded-full text-lg transition-transform hover:scale-125 ${
emoji === current ? "bg-accent/15" : ""
}`}
>
{emoji}
</button>
))}
</div>
)}
</div>
);
}

/** Under the reply: the owner's reaction; a click takes it off. */
export function ReactionMark({ itemId, bot }: { itemId: string; bot: string }) {
const t = useT().chat.react;
const reactions = useContext(ReactionsOf);
const reaction = reactions?.byItem[itemId];
if (!reactions || !reaction) {
return null;
}
const seen = reaction.sentIn !== null;
return (
<div className="mt-1.5 flex animate-rise items-center gap-2">
<button
type="button"
title={t.remove}
aria-label={`${reaction.emoji} Β· ${t.remove}`}
onClick={() => reactions.set(itemId, null)}
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"
>
{reaction.emoji}
</button>
<span className="text-muted text-xs">{seen ? t.seen(bot) : t.waiting(bot)}</span>
</div>
);
}
33 changes: 22 additions & 11 deletions app/src/features/chat/Replying.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useT } from "../../i18n";
import { plainText } from "../../lib/plainText";
import type { ChatItemId, MessageReply } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { ReactButton, ReactionMark } from "./Reactions";

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

/** Wraps what can be replied to, with the button that shows on hover. */
/**
* Wraps what can be replied to, with the buttons that show on hover: reply,
* and for the bot's own replies, react (spec 8.9).
*/
export function Repliable({
target,
react = false,
className = "",
children,
}: {
target: ReplyTarget;
/** A bubble narrows it to its own width, for the button to sit on its corner. */
/** The bot's own reply: the owner can react to it too. */
react?: boolean;
/** A bubble narrows it to its own width, for the buttons to sit on its corner. */
className?: string;
children: ReactNode;
}) {
Expand All @@ -39,15 +46,19 @@ export function Repliable({
return (
<div className={`group/reply relative ${className}`}>
{children}
<button
type="button"
title={t.action}
aria-label={t.to(target.who)}
onClick={() => start(target)}
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"
>
<CornerUpLeft aria-hidden size={15} />
</button>
{react && <ReactionMark itemId={target.itemId} bot={target.who} />}
<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">
{react && <ReactButton itemId={target.itemId} bot={target.who} />}
<button
type="button"
title={t.action}
aria-label={t.to(target.who)}
onClick={() => start(target)}
className="grid size-7 place-items-center rounded-lg text-muted transition-colors hover:text-ink"
>
<CornerUpLeft aria-hidden size={15} />
</button>
</div>
</div>
);
}
Expand Down
67 changes: 67 additions & 0 deletions app/src/features/chat/reactions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp } from "../../test/app";

afterEach(cleanup);

async function setup() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
const writer = fake.addBot(ops.id, "Writer", "Writes");
fake.setBotState(scout.id, "idle");
const said = fake.chat.reply(scout.id, "Acme signs annual.");
fake.chat.turn(scout.id);
fake.conversation.say({ from: writer.id, to: scout.id, body: "The draft is ready." });
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
await screen.findByRole("list", { name: "Messages" });
return { fake, scout, said };
}

function react(emoji: string) {
fireEvent.click(screen.getByRole("button", { name: "React to what Scout wrote" }));
const menu = screen.getByRole("menu", { name: "React to what Scout wrote" });
fireEvent.click(within(menu).getByRole("menuitemradio", { name: emoji }));
}

describe("reacting to a reply", () => {
test("puts the emoji under the reply, waiting for the next message, then seen", async () => {
const { fake, scout, said } = await setup();
// Only the bot's own replies take one.
expect(screen.getAllByRole("button", { name: /^React to/ })).toHaveLength(1);

react("πŸ‘");
expect(await screen.findByText("Scout sees it with your next message")).toBeDefined();
expect(fake.calls.at(-1)).toMatchObject({
method: "reactions.set",
params: { botId: scout.id, itemId: said.id, emoji: "πŸ‘" },
});
// The bot was not sent anything.
expect(fake.calls.some((call) => call.method === "messages.send")).toBe(false);

const field = screen.getByRole("textbox", { name: "Message to Scout" });
fireEvent.change(field, { target: { value: "Next?" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(await screen.findByText("Scout saw it")).toBeDefined();
});

test("another emoji replaces it, the same one or a click on it takes it off", async () => {
await setup();
react("πŸ‘");
await screen.findByRole("button", { name: "πŸ‘ Β· Take the reaction off" });
react("πŸŽ‰");
await screen.findByRole("button", { name: "πŸŽ‰ Β· Take the reaction off" });
react("πŸŽ‰");
await waitFor(() =>
expect(screen.queryByRole("button", { name: /Take the reaction off/ })).toBeNull(),
);
react("❀️");
fireEvent.click(await screen.findByRole("button", { name: "❀️ · Take the reaction off" }));
await waitFor(() =>
expect(screen.queryByRole("button", { name: /Take the reaction off/ })).toBeNull(),
);
});
});
9 changes: 9 additions & 0 deletions app/src/i18n/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ export const chat = {
cancel: "Cancel the reply",
jump: "Show what this replies to",
},
/** The owner's reactions to the bot's replies (spec 8.9). */
react: {
action: "React",
to: (bot: string) => `React to what ${bot} wrote`,
remove: "Take the reaction off",
waiting: (bot: string) => `${bot} sees it with your next message`,
seen: (bot: string) => `${bot} saw it`,
failed: "Could not save the reaction",
},
approval: {
asks: (bot: string, action: string) => `${bot} asks to ${action}`,
wants: (bot: string, action: string) => `${bot} wants to ${action}`,
Expand Down
8 changes: 8 additions & 0 deletions app/src/i18n/es/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ export const chat: Messages["chat"] = {
cancel: "Cancelar la respuesta",
jump: "Mostrar el mensaje respondido",
},
react: {
action: "Reaccionar",
to: (bot) => `Reaccionar a lo que escribiΓ³ ${bot}`,
remove: "Quitar la reacciΓ³n",
waiting: (bot) => `${bot} lo verΓ‘ con tu prΓ³ximo mensaje`,
seen: (bot) => `${bot} lo vio`,
failed: "No se pudo guardar la reacciΓ³n",
},
approval: {
asks: (bot: string, action: string) => `${bot} pide ${action}`,
wants: (bot: string, action: string) => `${bot} quiere ${action}`,
Expand Down
8 changes: 8 additions & 0 deletions app/src/i18n/pt-BR/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ export const chat: Messages["chat"] = {
cancel: "Cancelar a resposta",
jump: "Mostrar a mensagem respondida",
},
react: {
action: "Reagir",
to: (bot) => `Reagir ao que ${bot} escreveu`,
remove: "Tirar a reaΓ§Γ£o",
waiting: (bot) => `${bot} vΓͺ junto com sua prΓ³xima mensagem`,
seen: (bot) => `${bot} viu`,
failed: "NΓ£o foi possΓ­vel salvar a reaΓ§Γ£o",
},
approval: {
asks: (bot: string, action: string) => `${bot} pede para ${action}`,
wants: (bot: string, action: string) => `${bot} quer ${action}`,
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FakeConversation } from "./fakeConversation";
import { crewHandlers } from "./fakeCrews";
import { FakeFiles } from "./fakeFiles";
import { FakeQuestions } from "./fakeQuestions";
import { FakeReactions } from "./fakeReactions";
import { FakeRoutines } from "./fakeRoutines";
import { conflict, invalid, notFound, slugify } from "./fakeRules";
import { FakeScreens } from "./fakeScreens";
Expand Down Expand Up @@ -77,6 +78,7 @@ export class FakeBotloft implements BotloftApi {
readonly browser = new FakeBrowser(this);
readonly screens = new FakeScreens(this);
readonly questions = new FakeQuestions(this);
readonly reactions = new FakeReactions(this);
/** Clock for created and updated times. */
now = Date.now();
closed = false;
Expand Down Expand Up @@ -261,6 +263,7 @@ export class FakeBotloft implements BotloftApi {
...this.screens.handlers(),
...this.routines.handlers(),
...this.questions.handlers(),
...this.reactions.handlers(),
...usageHandlers(this),
...searchHandlers(this),
};
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/fakeConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ export class FakeConversation {
this.fake.emit({ name: "message.created", params: message });
this.fake.emit({ name: "delivery.changed", params: delivery });
this.fake.chat.add(message.toBotId, { kind: "inbound", message });
// Every message of the owner takes the reactions waiting (spec 8.9).
if (message.fromKind === "owner") {
this.fake.reactions.sendWith(message.toBotId, message.id);
}
return delivery;
}

Expand Down
Loading
Loading