Add emoji reactions on comments and replies (#28)#63
Merged
Conversation
Implements Slack-style reactions, intentionally simpler than Slack: no recently-used persistence, no custom emojis. Reactions live on every comment node (opener and replies), with a curated quick-pick row plus the full emojibase set via frimousse, and a `:shortcode` autocomplete in the composer for keyboard-first users. Server: - New `comment_reactions` table keyed on (comment_id, author_client_id, emoji) so a user can react with multiple distinct emojis but never duplicate the same one. - New `POST /:uid/threads/:tid/comments/:cid/reactions` toggles the viewer's reaction atomically, returns the updated thread, broadcasts `comment.updated` so existing realtime subscribers refresh. - Each Comment in the wire format now carries `reactions` and a `react` capability; viewer-aware `reacted` flag lights up the user's own reactions. - Validation: 1-32 chars, no whitespace, gated on `canComment` (readers get 403). Client: - EmojiReactionPicker uses Radix Themes Popover with a quick-pick row (👍 ❤️ 😂 🎉 🚀 🤩 🤔 👀) above the full frimousse-powered picker. - `:shortcode` autocomplete in InlineComposer mirrors the @mention UX: curated map of ~120 GitHub-style shortcodes, word-boundary trigger (URLs / 1:1 / 12:30 don't fire), keyboard nav with ↑↓ Enter/Tab Esc. - Reaction chips render below each comment body; self-reactions get the accent color, hover tooltip shows reactor names. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds Slack-style emoji reactions to inline comments/replies, including a reaction picker UI, :shortcode composer autocomplete, and server persistence + API/wire support.
Changes:
- Server: introduce
comment_reactionsstorage and a toggle reaction endpoint; include reactions +capabilities.reactin thread wire responses. - Client: render reaction chips + picker on each comment node; add
:shortcodeemoji autocomplete in the inline composer. - Tests: add server reaction tests and client shortcode/autocomplete tests.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| bun.lock | Locks new dependency for emoji picker (frimousse). |
| apps/web/package.json | Adds frimousse dependency for the picker UI. |
| apps/server/src/db.ts | Adds comment_reactions table + indexes and TypeScript row type. |
| apps/server/src/routes/threads.ts | Adds POST .../reactions toggle endpoint and threads wire support for reactions/capabilities. |
| apps/server/test/comments.test.ts | Adds server tests for reactions toggle, validation, permissions, and scoping. |
| apps/web/src/lib/api.ts | Extends thread/comment types with reactions + node react capability; adds client toggle API wrapper. |
| apps/web/src/components/DocumentLayout.tsx | Wires reaction toggling into the discussion UI flow and refreshes threads after toggles. |
| apps/web/src/components/inline-comments/InlineCommentsLayer.tsx | Threads onReact handler down into inline comments UI. |
| apps/web/src/components/inline-comments/InlineCommentsList.tsx | Threads onReact handler to the list/card level. |
| apps/web/src/components/inline-comments/InlineThreadCard.tsx | Passes onReact into each comment/reply row and preserves react capability during proposal transforms. |
| apps/web/src/components/inline-comments/InlineCommentRow.tsx | Renders reaction chips, adds picker trigger, and implements per-row toggle handling. |
| apps/web/src/components/inline-comments/EmojiReactionPicker.tsx | New popover-based emoji picker with quick-pick row + searchable full picker. |
| apps/web/src/components/inline-comments/emojiShortcodes.ts | New curated shortcode map + detection/filtering helpers for composer autocomplete. |
| apps/web/src/components/inline-comments/InlineComposer.tsx | Adds :shortcode autocomplete UI and keyboard handling (with mention precedence). |
| apps/web/src/styles/app.css | Adds styling for reaction chips, picker popover, and shortcode menu icons. |
| apps/web/test/discussion-ui.test.ts | Adds client tests for shortcode detection/filtering/ranking/limits. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- EmojiReactionPicker: drop the nested <button>. Themes' Popover.Trigger renders its own button and strips `asChild`; pass styling + a11y props to the Trigger directly with the icon as its child. - filterShortcodes: scan the full map before slicing so `:s` returns `:star` even when earlier prefix matches fill the bucket; promote exact matches to the top deterministically. - Reaction chip a11y: stop hiding the emoji from AT and add an aria-label that includes emoji + count + whether the viewer reacted, so screen readers don't announce a bare "2". - toggleCommentReaction: scope the existence check + delete by `doc_uid` for defense-in-depth + consistency with the insert. - Add regression tests for the shortcode scan + exact-match promotion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reaction toggle: replace SELECT-then-INSERT/DELETE inside a deferred transaction with `INSERT OR IGNORE` + conditional DELETE keyed off `result.changes`. Single atomic statement per write — no transaction, no PK-violation 500 path under concurrent same-user same-emoji races. - listThreads: batch-load reactions for all comment ids on the page in one query, pass the preloaded map into toThreadWire. Single-thread mutation handlers keep the per-thread fallback (one extra query each is fine; the listing endpoint was the N+1 hotspot). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- emojiShortcodes.getActiveShortcode: align word-boundary check with @mention detection — reject only when prev is alphanumeric/underscore rather than rejecting any non-whitespace. URLs/times/ratios still short-circuit (digit/letter precedes the colon), but punctuation and brackets like `(:thumb` and `":thumb` now trigger correctly. - loadCommentReactionsWire: batch the IN-clause into chunks of 500 so documents with thousands of comment ids never exceed SQLite's SQLITE_MAX_VARIABLE_NUMBER (999 on older builds). - toggleCommentReaction: wrap INSERT OR IGNORE + conditional DELETE in a transaction. Each statement is individually atomic, but the pair isn't — a third party deleting between our two writes would silently flip the user's toggle. Cheap insurance against future multi-process bun:sqlite runtimes. - comment_reactions index: replace two single-column indexes with one composite (doc_uid, comment_id, created_at) so the hot read covers filter + sort without a filesort. - toggleCommentReaction (client): return the updated Thread; caller splices it into local state instead of doing a full listThreads refetch on every reaction toggle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- comment_reactions PK now leads with doc_uid so the schema's uniqueness scope matches every read+write (all of them filter by doc_uid). Without doc_uid in the PK, a hypothetical comment-id collision across documents would clash on rows that belong to unrelated docs. Added migrateCommentReactionsPrimaryKey to rebuild any pre-fix table created during this PR's development; idempotent via a `pk position` check. - EmojiReactionPicker quick-row: add `role="group"` so the existing aria-label is exposed to assistive tech and the buttons read as a grouped set. - New db migration test exercises the rebuild path on a seeded pre-fix table and confirms idempotence on a second open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- loadCommentReactionsWire: ORDER BY (comment_id, created_at) instead of just created_at so the sort lines up with the (doc_uid, comment_id, created_at) index — the planner can now satisfy filter + sort straight from the index instead of a temp filesort. Downstream grouping is per-comment-id, so the cross- comment order doesn't matter; only oldest-first within each comment does, which the new ORDER BY preserves. - db.ts index comment: rewrite to match the actual planner behavior (was misleading about why the index helps). - .ic-icon-btn:hover: scope to :not(:disabled) so disabled icon buttons (e.g. an in-flight reaction toggle) don't visually respond to hover while their dimmed/cursor-default styling says otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #28.
Summary
Slack-style emoji reactions on comments and replies, intentionally simpler than Slack: no recently-used persistence, no custom emojis. Three surfaces:
:shortcodeautocomplete in the composer — type:thumb, get a dropdown like@mention. Tab/Enter inserts the emoji.Server changes
comment_reactionstable keyed on(comment_id, author_client_id, emoji)— a user can react with several different emojis but never twice with the same one.POST /api/documents/:uid/threads/:tid/comments/:cid/reactionsbody{ emoji }toggles atomically (transaction-wrapped to survive double-click races).Commentnow carriesreactions: [{emoji, count, reacted, authors}]andcapabilities.react. Thereactedflag is viewer-aware so the UI can highlight the user's own reactions.canComment(role)(readers get 403).comment.updatedevent so connected clients debounce-refresh — no new event-type handler needed.Client changes
EmojiReactionPickeruses Radix ThemesPopoverwith frimousse for the full emoji set (lazy-loaded from CDN, ~17KB bundle impact).emojiShortcodes.tscarries a curated ~120-entry shortcode map (:thumbsup:,:tada:,:+1:,:rocket:,:star_struck:, ...).getActiveShortcodeonly triggers on word-boundary:so URLs,1:1, and12:30don't pop the menu.InlineComposerkeyboard handling: ↑/↓ to navigate, Enter/Tab to insert, Esc to dismiss. Mention takes precedence when both are technically active.Tests
+1/-1aliases, and limit enforcement.Reviewer notes
display_nameis snapshotted at react time. A subsequent rename will leave old reactions showing the old name in tooltips — acceptable drift, called out in the schema comment.frimoussefetches its emoji dataset fromcdn.jsdelivr.netlazily on first picker open. To avoid the third-party fetch, passemojibaseUrl="..."toEmojiPicker.Root(noted in the file's top comment).emojiShortcodes.ts.Test plan
count: 1andreacted: false:thum— autocomplete menu appears with 👍 / 👎 / etc.:thumis replaced with the emojihttps://example.com:443— no autocomplete fires🤖 Generated with Claude Code