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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Claims are exclusive per project and tied to a leased credential. Revocation, ca

The default appearance follows the bent.build identity: bundled Geist Mono, cobalt blue, white or black backgrounds, and square controls. Custom project accents and other corner styles remain available. Project owners configure light, dark, or system mode; accent color; corner radius; density; and toolbar placement. The same tokens apply to the toolbar and its dialogs. Validated overrides are also available in `mountFeedback({ theme: ... })` or script attributes such as `data-accent`, `data-mode`, and `data-placement`.

System mode follows the website window's color preference, including live changes. Explicit project or embed modes take precedence. The emoji picker loads its dataset from the feedback service when opened. Its Emojibase license is available at `/api/emoji-data/v1/en/license.txt`.
System mode follows the website window's color preference, including live changes. Explicit project or embed modes take precedence. The emoji picker loads its dataset from the feedback service when opened. Its favourites row learns each user's choices and shows up to eight frequently used emojis, with choices saved in the browser. Messages show only reactions that have been added. The dataset's Emojibase license is available at `/api/emoji-data/v1/en/license.txt`.

## Development and self-hosting

Expand Down
4 changes: 3 additions & 1 deletion e2e/feedback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,9 @@ test('isolated toolbar shares the service session and completes the feedback-to-
'none',
);
await frame.getByText('Please increase the spacing around this iPhone button.').click();
await frame.getByRole('button', { name: 'React with 👍' }).click();
await frame.getByRole('button', { name: 'Add reaction', exact: true }).click();
await frame.getByRole('textbox', { name: 'Search emojis', exact: true }).fill('thumbs up');
await frame.getByRole('gridcell', { name: 'Thumbs up', exact: true }).click();
await frame.getByRole('button', { name: 'Send to agent' }).click();
await expect(frame.getByText('Queued. Waiting for the agent.')).toBeVisible();
const data = z
Expand Down
121 changes: 118 additions & 3 deletions e2e/reactions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Page } from '@playwright/test';
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import emojiData from 'emojibase-data/en/data.json' with { type: 'json' };
import { test, expect } from './fixtures.js';
import { projectSchema, threadSchema } from '../src/shared/models.js';
import { emojiSchema, projectSchema, threadSchema } from '../src/shared/models.js';

async function fixture(page: Page, baseURL: string | undefined, dark = false) {
if (!baseURL) throw new Error('Missing fixture URL.');
Expand Down Expand Up @@ -100,6 +101,11 @@
'aria-pressed',
'true',
);
for (const emoji of ['👍', '❤️', '👀', '🎉']) {
await expect(
dialog.getByRole('button', { name: `React with ${emoji}`, exact: true }),
).toHaveCount(0);
}
expect(requests.filter((url) => url.includes('/emoji-data/'))).toEqual([]);
await dialog.getByRole('button', { name: 'Add reaction', exact: true }).click();
const search = page.getByRole('textbox', { name: 'Search emojis', exact: true });
Expand All @@ -123,11 +129,11 @@
const group = dialog.getByRole('group', { name: 'Reactions', exact: true });
await expect(group).toHaveAttribute('aria-busy', 'true');
await expect(group.getByRole('button', { name: 'Add reaction', exact: true })).toBeDisabled();
await expect(group.getByRole('button', { name: 'React with 👍', exact: true })).toBeDisabled();
await expect(group.getByRole('button', { name: 'React with 👨‍👩‍👧‍👦', exact: true })).toBeDisabled();
releaseReaction();
const reaction = dialog.getByRole('button', { name: 'React with 🦋', exact: true });
await expect(reaction).toHaveAttribute('aria-pressed', 'true');
await expect(reaction).toContainText('1');
await expect(reaction).toHaveText('🦋');
await reaction.click();
await expect(reaction).toHaveCount(0);
await dialog.getByRole('button', { name: 'Add reaction', exact: true }).click();
Expand Down Expand Up @@ -160,6 +166,115 @@
expect(errors).toEqual([]);
});

test('favourites learn choices, rank frequent emojis and persist across reloads', async ({
page,
baseURL,
}) => {
await fixture(page, baseURL);
await page.goto('/');
await page.getByText('Searchable emoji feedback', { exact: true }).click();
const reactions = page.getByRole('group', { name: 'Reactions', exact: true });
const add = reactions.getByRole('button', { name: 'Add reaction', exact: true });
const search = page.getByRole('textbox', { name: 'Search emojis', exact: true });
const favourites = page.getByRole('group', { name: 'Favourite emojis', exact: true });
await add.click();
await expect(favourites).toHaveCount(0);
await search.fill('butterfly');
await page.getByRole('gridcell', { name: 'Butterfly', exact: true }).click();
const butterfly = reactions.getByRole('button', { name: 'React with 🦋', exact: true });
await expect(butterfly).toHaveAttribute('aria-pressed', 'true');
await butterfly.click();
await expect(butterfly).toHaveCount(0);
await add.click();
await favourites.getByRole('button', { name: 'React with 🦋', exact: true }).click();
await expect(butterfly).toHaveAttribute('aria-pressed', 'true');
await add.click();
await search.fill('rocket');
await page.getByRole('gridcell', { name: 'Rocket', exact: true }).click();
await expect(
reactions.getByRole('button', { name: 'React with 🚀', exact: true }),
).toHaveAttribute('aria-pressed', 'true');
await page.reload();
await page.getByText('Searchable emoji feedback', { exact: true }).click();
await add.click();
await expect(favourites.getByRole('button')).toHaveText(['🦋', '🚀']);
await search.press('Escape');
await reactions.getByRole('button', { name: 'React with 🚀', exact: true }).click();
await add.click();
// Removing a reaction does not count as another choice.
await expect(favourites.getByRole('button')).toHaveText(['🦋', '🚀']);
await favourites.getByRole('button', { name: 'React with 🚀', exact: true }).click();
await expect(
reactions.getByRole('button', { name: 'React with 🚀', exact: true }),
).toHaveAttribute('aria-pressed', 'true');
await add.click();
// Equally frequent choices put the most recent one first.
await expect(favourites.getByRole('button')).toHaveText(['🚀', '🦋']);
});

test('favourites combine choices from separate tabs without losing updates', async ({
page,
baseURL,
}) => {
await fixture(page, baseURL);
const other = await page.context().newPage();
for (const tab of [page, other]) {
await tab.goto('/');
await tab.getByText('Searchable emoji feedback', { exact: true }).click();
await tab.getByRole('button', { name: 'Add reaction', exact: true }).click();
await expect(tab.getByRole('group', { name: 'Favourite emojis', exact: true })).toHaveCount(0);
}
await page.getByRole('textbox', { name: 'Search emojis', exact: true }).fill('butterfly');
await other.getByRole('textbox', { name: 'Search emojis', exact: true }).fill('rocket');
await Promise.all([
page.getByRole('gridcell', { name: 'Butterfly', exact: true }).click(),
other.getByRole('gridcell', { name: 'Rocket', exact: true }).click(),
]);
for (const tab of [page, other]) {
const add = tab.getByRole('button', { name: 'Add reaction', exact: true });
await expect(add).toBeEnabled();
await add.click();
const favourites = tab.getByRole('group', { name: 'Favourite emojis', exact: true });
await expect(
favourites.getByRole('button', { name: 'React with 🦋', exact: true }),
).toBeVisible();
await expect(
favourites.getByRole('button', { name: 'React with 🚀', exact: true }),
).toBeVisible();

Check failure on line 243 in e2e/reactions.spec.ts

View workflow job for this annotation

GitHub Actions / Browser tests

[webkit-mobile] › e2e/reactions.spec.ts:215:1 › favourites combine choices from separate tabs without losing updates

1) [webkit-mobile] › e2e/reactions.spec.ts:215:1 › favourites combine choices from separate tabs without losing updates Error: expect(locator).toBeVisible() failed Locator: getByRole('group', { name: 'Favourite emojis', exact: true }).getByRole('button', { name: 'React with 🚀', exact: true }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" getByRole('group', { name: 'Favourite emojis', exact: true }).getByRole('button', { name: 'React with 🚀', exact: true }) with timeout 5000ms - waiting for getByRole('group', { name: 'Favourite emojis', exact: true }).getByRole('button', { name: 'React with 🚀', exact: true }) 241 | await expect( 242 | favourites.getByRole('button', { name: 'React with 🚀', exact: true }), > 243 | ).toBeVisible(); | ^ 244 | } 245 | await other.close(); 246 | }); at /home/runner/work/feedback-toolbar/feedback-toolbar/e2e/reactions.spec.ts:243:7
}
await other.close();
});

test('frequent favourites survive more than 32 distinct choices', async ({ page, baseURL }) => {
const f = await fixture(page, baseURL);
const actorId = f.thread.messages[0]?.actorId;
if (!actorId) throw new Error('Missing fixture actor.');
await page.goto('/');
const history = emojiData
.filter((entry) => entry.group !== undefined && emojiSchema.safeParse(entry.emoji).success)
.filter((entry) => !['🦋', '🚀'].includes(entry.emoji))
.slice(0, 31)
.map(({ emoji }) => ({ emoji, count: 1 }));
history.push({ emoji: '🦋', count: 20 });
await page.evaluate(
({ actorId, history }) => {
localStorage.setItem(`feedback-emoji-favourites-v1:${actorId}`, JSON.stringify(history));
},
{ actorId, history },
);
await page.getByText('Searchable emoji feedback', { exact: true }).click();
const add = page.getByRole('button', { name: 'Add reaction', exact: true });
await add.click();
await page.getByRole('textbox', { name: 'Search emojis', exact: true }).fill('rocket');
await page.getByRole('gridcell', { name: 'Rocket', exact: true }).click();
await expect(add).toBeEnabled();
await page.reload();
await page.getByText('Searchable emoji feedback', { exact: true }).click();
await add.click();
const favourites = page.getByRole('group', { name: 'Favourite emojis', exact: true });
await expect(favourites.getByRole('button').first()).toHaveText('🦋');
});

test('mobile dark toolbar picker fits its iframe, inherits theme and isolates host styles', async ({
page,
baseURL,
Expand Down
51 changes: 42 additions & 9 deletions src/browser/components/message-reactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defaultEmojiDataResolver } from 'frimousse';
import type { EmojiDataResolver } from 'frimousse';
import { Loader2, SmilePlus } from 'lucide-react';
import type { Thread } from '../../shared/models.js';
import { getEmojiFavourites, recordEmojiChoice } from '../emoji-favourites.js';
import { Button } from './ui/button.js';
import {
EmojiPicker,
Expand All @@ -14,12 +15,15 @@ import {
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from './ui/popover.js';

function ReactionPicker({
actorId,
onSelect,
retryRef,
}: {
actorId: string | undefined;
onSelect: (emoji: string) => void;
retryRef: RefObject<HTMLButtonElement | null>;
}) {
const [favourites] = useState(() => (actorId ? getEmojiFavourites(actorId) : []));
const [failed, setFailed] = useState(false);
const errorId = useId();
const searchRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -70,6 +74,28 @@ function ReactionPicker({
}}
>
<EmojiPickerSearch ref={searchRef} aria-label="Search emojis" placeholder="Search emojis" />
{favourites.length > 0 ? (
<div className="shrink-0 border-b p-2">
<p className="mb-1 px-1 text-xs text-muted-foreground">Favourites</p>
<div role="group" aria-label="Favourite emojis" className="flex">
{favourites.map((emoji) => (
<Button
key={emoji}
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0 p-0 text-lg"
aria-label={`React with ${emoji}`}
onClick={() => {
onSelect(emoji);
}}
>
{emoji}
</Button>
))}
</div>
</div>
) : null}
<EmojiPickerContent />
<EmojiPickerFooter />
</EmojiPicker>
Expand All @@ -91,16 +117,18 @@ export function MessageReactions({
const [pending, setPending] = useState<string | null>(null);
const reacting = useRef(false);
const retryRef = useRef<HTMLButtonElement>(null);
const emojis = [
...new Set(['👍', '❤️', '👀', '🎉', ...message.reactions.map((reaction) => reaction.emoji)]),
];
const emojis = [...new Set(message.reactions.map((reaction) => reaction.emoji))];
const react = async (emoji: string) => {
if (disabled || reacting.current) return;
reacting.current = true;
setPending(emoji);
setOpen(false);
const removing = message.reactions.some(
(reaction) => reaction.emoji === emoji && reaction.actorId === actorId,
);
try {
await onReact(emoji);
if (actorId && !removing) await recordEmojiChoice(actorId, emoji);
} finally {
reacting.current = false;
setPending(null);
Expand All @@ -109,7 +137,7 @@ export function MessageReactions({
return (
<div
role="group"
className="mt-2 flex flex-wrap gap-1"
className="mt-2 flex flex-wrap items-center gap-1"
aria-label="Reactions"
aria-busy={pending !== null}
>
Expand All @@ -125,7 +153,7 @@ export function MessageReactions({
type="button"
variant="outline"
size="xs"
className="h-7 px-1.5 text-xs aria-pressed:bg-accent"
className="h-7 min-w-9 gap-1.5 px-1.5 text-xs aria-pressed:bg-accent"
aria-label={`React with ${emoji}`}
aria-pressed={pressed}
aria-description={
Expand All @@ -139,8 +167,8 @@ export function MessageReactions({
disabled={disabled || pending !== null}
onClick={() => void react(emoji)}
>
{pending === emoji ? <Loader2 className="size-3 animate-spin" /> : emoji}
{reactions.length || ''}
{pending === emoji ? <Loader2 className="size-3 animate-spin" /> : <span>{emoji}</span>}
{reactions.length > 1 ? <span className="tabular-nums">{reactions.length}</span> : null}
</Button>
);
})}
Expand All @@ -150,7 +178,8 @@ export function MessageReactions({
<Button
type="button"
variant="outline"
size="icon-xs"
size="icon-sm"
className="w-9"
aria-label="Add reaction"
disabled={disabled || pending !== null}
/>
Expand All @@ -173,7 +202,11 @@ export function MessageReactions({
>
<PopoverTitle className="sr-only">Add reaction</PopoverTitle>
{open ? (
<ReactionPicker retryRef={retryRef} onSelect={(emoji) => void react(emoji)} />
<ReactionPicker
actorId={actorId}
retryRef={retryRef}
onSelect={(emoji) => void react(emoji)}
/>
) : null}
</PopoverContent>
</Popover>
Expand Down
64 changes: 64 additions & 0 deletions src/browser/emoji-favourites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { z } from 'zod';
import { emojiSchema } from '../shared/models.js';

const historySchema = z.array(z.object({ emoji: emojiSchema, count: z.number().int().positive() }));
type History = z.infer<typeof historySchema>;
const memory = new Map<string, History>();
const memoryOnly = new Set<string>();
const storageKey = (actorId: string) => `feedback-emoji-favourites-v1:${actorId}`;

function readHistory(actorId: string): History {
if (memoryOnly.has(actorId)) return memory.get(actorId) ?? [];
try {
const result = historySchema.safeParse(
JSON.parse(localStorage.getItem(storageKey(actorId)) ?? '[]'),
);
if (result.success) {
memory.set(actorId, result.data);
return result.data;
}
} catch {
// Embedded browsers can deny storage access. Keep choices for this session.
memoryOnly.add(actorId);
}
return memory.get(actorId) ?? [];
}

export function getEmojiFavourites(actorId: string): string[] {
return readHistory(actorId)
.toSorted((a, b) => b.count - a.count)
.slice(0, 8)
.map(({ emoji }) => emoji);
}

function updateHistory(actorId: string, emoji: string, persist: boolean): void {
const history = readHistory(actorId);
const count = (history.find((entry) => entry.emoji === emoji)?.count ?? 0) + 1;
const updated = [{ emoji, count }, ...history.filter((entry) => entry.emoji !== emoji)];
memory.set(actorId, updated);
if (!persist || memoryOnly.has(actorId)) {
memoryOnly.add(actorId);
return;
}
try {
localStorage.setItem(storageKey(actorId), JSON.stringify(updated));
} catch {
// The in-memory history still works when storage is unavailable or full.
memoryOnly.add(actorId);
}
}

export async function recordEmojiChoice(actorId: string, emoji: string): Promise<void> {
const browser: { locks?: LockManager } = navigator;
if (browser.locks) {
try {
await browser.locks.request(storageKey(actorId), () => {
updateHistory(actorId, emoji, true);
});
return;
} catch {
// Without a shared lock, keep this frame's choices in memory to avoid lost writes.
}
}
updateHistory(actorId, emoji, false);
}
Loading