diff --git a/README.md b/README.md index f438f31..7317801 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/e2e/feedback.spec.ts b/e2e/feedback.spec.ts index 99fe209..a158d95 100644 --- a/e2e/feedback.spec.ts +++ b/e2e/feedback.spec.ts @@ -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 diff --git a/e2e/reactions.spec.ts b/e2e/reactions.spec.ts index 1c1f2b9..f93dab6 100644 --- a/e2e/reactions.spec.ts +++ b/e2e/reactions.spec.ts @@ -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.'); @@ -100,6 +101,11 @@ test('dashboard searches, selects, persists and toggles arbitrary reactions with '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 }); @@ -123,11 +129,11 @@ test('dashboard searches, selects, persists and toggles arbitrary reactions with 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(); @@ -160,6 +166,115 @@ test('dashboard searches, selects, persists and toggles arbitrary reactions with 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(); + } + 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, diff --git a/src/browser/components/message-reactions.tsx b/src/browser/components/message-reactions.tsx index 74af344..e8079b6 100644 --- a/src/browser/components/message-reactions.tsx +++ b/src/browser/components/message-reactions.tsx @@ -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, @@ -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; }) { + const [favourites] = useState(() => (actorId ? getEmojiFavourites(actorId) : [])); const [failed, setFailed] = useState(false); const errorId = useId(); const searchRef = useRef(null); @@ -70,6 +74,28 @@ function ReactionPicker({ }} > + {favourites.length > 0 ? ( +
+

Favourites

+
+ {favourites.map((emoji) => ( + + ))} +
+
+ ) : null} @@ -91,16 +117,18 @@ export function MessageReactions({ const [pending, setPending] = useState(null); const reacting = useRef(false); const retryRef = useRef(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); @@ -109,7 +137,7 @@ export function MessageReactions({ return (
@@ -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={ @@ -139,8 +167,8 @@ export function MessageReactions({ disabled={disabled || pending !== null} onClick={() => void react(emoji)} > - {pending === emoji ? : emoji} - {reactions.length || ''} + {pending === emoji ? : {emoji}} + {reactions.length > 1 ? {reactions.length} : null} ); })} @@ -150,7 +178,8 @@ export function MessageReactions({