Notes: Add emoji reactions - stored as custom comment type#76767
Notes: Add emoji reactions - stored as custom comment type#76767adamsilverstein wants to merge 107 commits into
Conversation
Introduce a new component that displays a horizontal row of emoji buttons for adding reactions to notes. Features include: - Curated emoji set: 👍 👎 ❤️ 🎉 😄 😕 👀 🚀 - Keyboard navigation with arrow keys, Home, and End - Accessible with role="listbox" and role="option" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Introduce a component that displays current reactions with counts as pill-shaped buttons. Features include: - Shows reaction counts for each emoji - Highlights user's own reactions with distinct styling - Click to toggle (add/remove) reaction - "+" button opens emoji picker dropdown Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Introduce a popover component that shows who reacted and when. Features include: - Displays reactions grouped by emoji - Shows user avatars and names - Uses humanTimeDiff() for relative timestamps (e.g., "3 days ago") - Fetches user data for all reactors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Extend the useBlockCommentsActions hook with three new functions:
- onAddReaction: Add a reaction to a comment
- onRemoveReaction: Remove user's reaction from a comment
- onToggleReaction: Toggle reaction (add if not present, remove if present)
Reactions are stored in comment meta._wp_reactions with structure:
{ emoji: [{ userId, timestamp }] }
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Connect the reaction components to the Notes sidebar: - Import and render ReactionDisplay in CommentBoard - Add ReactionDetailsPopover for viewing reaction details - Add "See emoji reaction details" menu action - Pass onToggleReaction through component hierarchy - Get current user ID for highlighting own reactions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add SCSS styles for: - Reactions container with flexbox layout - Pill-shaped reaction buttons with active state - Add reaction button with dashed border - Emoji picker dropdown - Reaction details popover with user avatars Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add comprehensive E2E tests for the emoji reactions feature: - can add an emoji reaction to a note - can remove own emoji reaction by clicking it - can see emoji reaction details - reaction buttons are keyboard accessible - can add multiple different reactions to same note Also adds addReactionToComment helper to BlockCommentUtils. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Resolve merge conflicts with trunk's selectedNote editor state changes (#75177) while preserving emoji reaction features. Fix all 5 emoji reaction E2E tests that were timing out because the Dropdown popover was stealing focus from the thread, triggering the onBlur handler which collapsed the note and unmounted the emoji picker. - Add focusOnMount: false to the reaction Dropdown popoverProps - Add popover focus check to the thread onBlur handler - Update addReactionToComment E2E helper to wait for the emoji picker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…yling Replace the plus icon with a Google Docs-inspired smiley face SVG, change focusOnMount to 'firstElement' so the emoji picker captures focus and prevents the note from collapsing, and restyle the button to be perfectly round with a clean white background that appears on hover. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the "Add reaction" smiley button from below the note content to the upper-right header area alongside the resolve and actions buttons. Also register _wp_reactions as comment meta in PHP so the REST API accepts it, and fix the 500 error caused by spreading all comment meta (including potentially invalid _wp_note_status) when saving reactions. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Add variant="tertiary" to the reaction pill Button components so the WordPress default dark button styling doesn't override the custom light gray background. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
The Button component automatically adds the is-pressed class when aria-pressed is true, which sets a dark background (#1E1E1E). Override with matching specificity to keep the light blue active state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Increase height to 32px, use equal padding on all sides, and reduce gap between emoji and count to bring the aspect ratio closer to 1:1. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The reactions meta registration is a new feature targeting WordPress 7.0, not a 6.9 backport. Move it to its own file in lib/compat/wordpress-7.0/ per reviewer feedback.
The editor assumes users are logged in by default, so these guards are redundant. Matches the pattern used by other collab sidebar actions.
Reactions update comment metadata without adding, removing, or resizing comments, so reflowing is unnecessary.
Replace individual getUser() calls with a single getUsers() request using include, context: view, and _fields to reduce API calls and support low-capability users.
Negative reactions are better expressed as comments in a collaborative editing context. The thinking emoji provides a constructive "I need to consider this" signal instead.
The horizontal layout caused emojis to overflow and get cut off in the sidebar popover.
Start conservatively with ❤️ 🎉 😄 👀 🚀 to avoid skin-tone concerns and keep the picker compact. More reactions can be added later.
The file contained only a docblock and was require'd from lib/load.php. Block-comments / notes compat lives in wordpress-7.1/block-comments.php now; this shim was a leftover with no callers.
Two follow-ups from review of the re-add fix.
1) Force-delete the reaction comment instead of trashing it.
`deleteEntityRecord` defaults to the WP REST trash workflow, so
each toggle-off was leaving a row behind in `wp_comments`. Reactions
don't have a trash UX, so trashed reactions are pure debris that
accumulates unboundedly with usage. Pass `{ force: true }` so the
row is removed.
2) Replace full notes-list invalidation with a targeted single-record
refetch. The previous fix invalidated `getEntityRecords` for the
entire post's notes, causing a full list re-fetch (response payload
linear in N notes) on every emoji click. Instead, fetch only the
parent note via apiFetch and merge it back via
`receiveEntityRecords` with no `query` arg — that updates the
per-record cache, which the list selector reads through by ID. The
LIST view picks up the fresh `reaction_summary` without re-fetching
any other note.
All 38 block-notes E2E tests still pass.
|
Looks awesome with this new emoji picker! 🎉 |
There was a problem hiding this comment.
IIUC emojibase-data could also be served from a CDN, which probably should be considered prior to release given the size of this per locale.
There was a problem hiding this comment.
Yes, I think loading from CDN is an option, but I bundled because I wasn't sure we were able to do that here, do we already include other dependencies from a CDN? or do you mean giving developers the ability to load from a CDN?
There was a problem hiding this comment.
i guess this is almost like a locale that we do server from cdn?
There was a problem hiding this comment.
Haven't looked at the data format here but we already serve Twemoji from the dotorg CDN
There was a problem hiding this comment.
Thanks for the pointer — agreed CDN is the right path. The full emoji picker (and its data bundling) moved out of this PR to the stacked follow-up #78176, so this thread is no longer load-bearing here. I’ll carry the CDN consideration over to that PR (where the emojibase bundle/data delivery is actually being designed) so we can weigh dotorg CDN delivery against bundling there.
|
Perhaps the most important thing is whether all the text provided by the library is localizable. As far as I have investigated, some of the library's text is localized here. https://github.com/milesj/emojibase/tree/master/po Ideally, it should be localized for all locales supported by WordPress and be translatable via GlotPress for all of them. Perhaps it would be better to rely on external libraries only for emoji datasets and implement internationalization features and UI on our own. |
I'm open to this idea I will give it a try if you feel the additional flexibility is worth the maintenance burden. I guess we already have some of the components we need to create a similar experience to Frimousse already. My goal here was to explore how using an existing maintained library would work, and Frimousse seemed like the best candidate after comparing several. Emoji picker libraries consideredLicense-compatible (MIT or Apache-2.0, both compatible with WordPress's GPLv2) React emoji pickers, evaluated as alternatives to Frimousse:
Good point, I didn't even think about the need to localize the emojis. |
…add-success snackbar The existing 'can re-add the same reaction after removing it' test asserted only the post-add reaction button state. A regression on either fix (server status='approve' guard or client parent-note refetch) would still surface the user-visible 'You have already reacted with this emoji' snackbar that triggered the bug report. Make the test fail loudly on that exact symptom by: - Asserting 'Reaction removed.' appears after the toggle-off step (proves the delete actually completed before the re-add). - Asserting two 'Reaction added.' snackbars exist after the re-add (proves the second add succeeded, not just that the button is visible from a stale cache). - Asserting zero snackbars contain 'already reacted' (pins the exact server-side error that the bug surfaced).
… picker (#78129) Drops the Frimousse dependency in favor of a WordPress-native emoji picker built from existing components. Routes UI strings through GlotPress and ships per-locale Emojibase data for 28 locales (vs. English only previously). Same UX, same data source, ~125 net lines in packages/editor.
|
@adamsilverstein, Regarding the emoji picker, can we address that in a follow-up? It seems like there's still research needed on which libraries to use and whether to bundle them into the core. Perhaps it would be better to focus on building the foundation for basic emoji reactions in this PR. |
I am happy to break off a second pr to add the complete emoji picker behind the + button, and leave this PR to add only the initial smaller set of default available reactions. Note that the latest version doesn't rely on an external picker, instead using native components. Still, the PR has grown large, so I will move the custom picker back to a separate PR stacked on this one and rework this one to only support the default set. |
Align the reaction comment type handling with the latest Gutenberg notes reactions PR (WordPress/gutenberg#76767): - Accept emoji slugs as either a curated slug (heart, celebration, smile, eyes, rocket) or a lowercase hex-codepoint sequence (e.g. 1f44d for 👍 or 1f468-200d-1f4bb for 👨💻). Raw emoji bytes are rejected since the comments table is not guaranteed to be utf8mb4 across installs; clients normalize before submitting. - Scope the uniqueness check to active reactions only, so a user can re-add the same emoji after removing (trashing) it on the same note. - Point note's children link at reaction children, not at notes, so embedded children resolve to the reactions on the note. - Add a read-only reaction_emojis schema property exposing the allowed emoji list, so clients can discover accepted slugs via OPTIONS. - Add a reaction_summary field aggregating per-emoji counts on note responses, with reacted/my_reaction_id for the current user. - Pre-fetch reaction summaries in get_items() to avoid N+1 queries when listing many notes. See #63191.
Keep this PR focused on the foundation for basic emoji reactions: the curated 5-emoji quick row served from REST schema, the reaction custom comment type, and the reaction_summary aggregation. The "+" More-emojis trigger, the lazy-loaded native SearchControl+Composite picker, the per-locale Emojibase data plumbing (build copy step, PHP inline-script registration, label-overrides filter), and the emojibase-data devDependency move to a follow-up PR stacked on this one. Picker library selection and bundling strategy still need wider review, per t-hamano in #76767.
Adds the "+" More-emojis trigger as a sibling of the curated smiley trigger. Tapping it opens a lazy-loaded native picker built from SearchControl + Composite over Emojibase data served same-origin from the plugin (28 locales, fetched per-session on first open). Picks fold into the curated slug when they match (e.g. ❤ → `heart`) and store as a normalized hex-codepoint key otherwise (e.g. `1f44d` for 👍), so visually-equivalent presentations don't fragment the reaction_summary aggregation. Stacked on the basic-reactions baseline so picker library and bundling choices can be reviewed independently, per t-hamano in #76767.
Thanks for the suggestion. I reduced this PR to the simpler base emoji set and created a follow up PR #78176 (stacked on this PR) to add the complete emoji set picker. We can land this one first, then work on landing the second. |
Both bits only existed to support the full emoji picker, which is now a follow-up PR. Without the `+` trigger, no caller ever passes a hex codepoint key into the reaction storage, so the encode/decode pair and the HEX_KEY_RE fallback in getEmojiBySlug/getLabelBySlug never fire. The `.editor-collab-sidebar-panel__more-reaction-button` rule styled that trigger and has no remaining consumer.
Adds the "+" More-emojis trigger as a sibling of the curated smiley trigger. Tapping it opens a lazy-loaded native picker built from SearchControl + Composite over Emojibase data served same-origin from the plugin (28 locales, fetched per-session on first open). Picks fold into the curated slug when they match (e.g. ❤ → `heart`) and store as a normalized hex-codepoint key otherwise (e.g. `1f44d` for 👍), so visually-equivalent presentations don't fragment the reaction_summary aggregation. The hex encode/decode pair and the HEX_KEY_RE fallback in getEmojiBySlug/getLabelBySlug come along for the ride, since they only ever fire when this picker is enabled. Stacked on the basic-reactions baseline so picker library and bundling choices can be reviewed independently, per t-hamano in #76767.
Four near-identical tests (no parent, parent is a regular comment, content not in the emoji list, anonymous user) all built the same POST request and asserted a specific WP_Error. Replace them with a single parameterized test_cannot_create_reaction_with_invalid_input plus a named-key data provider, so the matrix of error cases is readable at a glance and each new case is one row instead of a new method. Net -23 lines; the duplicate-reaction and "can stack different reactions" tests stay separate because their request shape differs.
Five emojis fit in a single ~176px-wide row that drops below the smiley trigger without clipping. This matches the dominant pattern for emoji reactions (Facebook/Slack/GitHub/Linear/Notion) so the affordance reads as reactions instead of a generic dropdown menu. Composite gets orientation="horizontal" so ArrowLeft/ArrowRight drive the keyboard nav. The e2e test that exercises arrow keys is updated to match.
t-hamano
left a comment
There was a problem hiding this comment.
Sorry for the late review, @adamsilverstein!
I reviewed only the frontend code. For the backend code, I reviewed it in the core PR. WordPress/wordpress-develop#10930 (review)
| * @param {Object} props Component props. | ||
| * @param {Function} props.onToggleReaction Callback to toggle a reaction. | ||
| */ | ||
| export function AddReactionButton( { onToggleReaction } ) { |
There was a problem hiding this comment.
Standardize button height to 24px and remove unnecessary CSS.
Details
diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js
index 6852a3728b3..5543d5515e5 100644
--- a/packages/editor/src/components/collab-sidebar/reaction-display.js
+++ b/packages/editor/src/components/collab-sidebar/reaction-display.js
@@ -279,9 +279,10 @@ export function AddReactionButton( { onToggleReaction } ) {
contentClassName="editor-collab-sidebar-panel__add-reaction-popover"
renderToggle={ ( { isOpen, onToggle } ) => (
<Button
- size="compact"
+ size="small"
className="editor-collab-sidebar-panel__add-reaction-button"
icon={ smileyIcon }
+ iconSize={ 20 }
label={ __( 'Add reaction' ) }
aria-expanded={ isOpen }
onClick={ onToggle }
diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss
index b18515470f8..945a33a0d9d 100644
--- a/packages/editor/src/components/collab-sidebar/style.scss
+++ b/packages/editor/src/components/collab-sidebar/style.scss
@@ -226,17 +226,8 @@
}
.editor-collab-sidebar-panel__add-reaction-button {
- display: inline-flex;
- margin: 0;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px !important; // Override Button component's size constraints.
- min-width: 28px !important;
- padding: 0;
border-radius: $radius-round;
border: $border-width solid $gray-300;
- background-color: $white;
cursor: var(--wpds-cursor-control);
&:hover {
@@ -251,10 +242,7 @@
}
svg {
- fill: none;
color: $gray-700;
- width: 20px;
- height: 20px;
}
}| Before | After |
|---|---|
![]() |
![]() |
There was a problem hiding this comment.
Applied the suggested approach in b6e3cb1 — switched to size="small" + iconSize={20} and dropped the bespoke 28px/!important CSS in favor of the standard Button sizing.
| popoverProps={ POPOVER_PROPS } | ||
| contentClassName="editor-collab-sidebar-panel__add-reaction-popover" | ||
| renderToggle={ ( { isOpen, onToggle } ) => ( | ||
| <Button |
There was a problem hiding this comment.
Good call — resolved notes are archived conversations, no reason to keep reaction input live. Fixed in 89289cf: the add-reaction toggle is now disabled (and accessibleWhenDisabled) when note.status === "approved".
| fetchPromise = apiFetch( { | ||
| path: '/wp/v2/comments', | ||
| method: 'OPTIONS', | ||
| } ) |
There was a problem hiding this comment.
I feel this approach is unusual. The OPTIONS method is typically used to retrieve the allowed operations for an endpoint, not to fetch a list of data. My intuition suggests this should be a GET request, and perhaps a new endpoint is needed to retrieve a list of emojis 🤔
@Mamaduka, do you have any ideas?
There was a problem hiding this comment.
Fair concern — OPTIONS is unusual for data retrieval. The choice here was driven by wanting the curated emoji list to live in the REST schema (as the reaction_emojis schema default on /wp/v2/comments), since the schema is what the server already exposes as the authoritative shape for a comment. That avoided introducing a new endpoint just to fetch a 5-item curated list. The data lands in response.schema.properties.reaction_emojis.default, which is how OPTIONS is already used today to discover allowed values for other fields. Happy to revisit if @Mamaduka prefers a dedicated GET endpoint — that would be cleaner if the curated list is ever expected to grow significantly or vary per request. cc @Mamaduka
| */ | ||
| export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { | ||
| return emojis.find( ( r ) => r.value === slug )?.label ?? slug; | ||
| } |
There was a problem hiding this comment.
These logics might cause performance degradation when the reaction icon list grows extensively in the future. How about using a map instead?
We can probably do it this way.
const emojiBySlug = new Map( emojis.map( ( e ) => [ e.value, e ] ) );
const entry = emojiBySlug.get( slug );
console.log( entry?.value );
console.log( entry?.label );
console.log( entry?.emoji );There was a problem hiding this comment.
Done in b7db753. Replaced the per-render find() pair with a useMemo-built Map keyed by slug in ReactionDisplay, and exposed a buildEmojiBySlugMap helper from reaction-emoji-picker.js. Lookup is now O(1) regardless of how the curated set grows.
There was a problem hiding this comment.
For now, it might be better to inline this SVG data and not expose it to consumers yet.
There was a problem hiding this comment.
Agreed — pulled the smiley back inline in 033e765. Removed smiley.svg and the manifest entry from @wordpress/icons, and the SVG now lives directly in reaction-display.js (via SVG/Path from @wordpress/primitives to keep it cross-platform per ESLint react/forbid-elements). Easy to promote back to @wordpress/icons later if there’s a second consumer.
| "@wordpress/patterns": "file:../patterns", | ||
| "@wordpress/plugins": "file:../plugins", | ||
| "@wordpress/preferences": "file:../preferences", | ||
| "@wordpress/primitives": "file:../primitives", |
There was a problem hiding this comment.
It seems the primitives package is not used anywhere in this PR.
There was a problem hiding this comment.
Re-evaluated this in context of the smiley-icon discussion above: once the icon is inlined here (per 033e765), reaction-display.js imports SVG and Path from @wordpress/primitives (required by the react/forbid-elements ESLint rule for cross-platform compatibility). That makes the @wordpress/primitives dependency actually used by this PR, so leaving it in packages/editor/package.json is correct. Let me know if you’d rather a different approach for the inline SVG.
Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
Apply review feedback: drop the bespoke 28px-with-!important sizing on the curated add-reaction button in favor of the standard Button "small" size with iconSize=20. Removes redundant flex/centering CSS the Button component already handles.
A resolved note thread shouldn't accept new reactions — they're for in-progress conversations. Disable the curated reaction picker toggle when the note's status is "approved" and keep it accessible so screen readers still announce the control's disabled state.
Replace per-render array.find() calls in ReactionDisplay with a Map keyed by slug, memoized against the emoji list. O(1) lookup keeps the render cheap if the curated set grows (full picker landing later via the stacked PR).
Per review feedback, the smiley face shouldn't be promoted to the public icons API while the reactions feature is still settling. Move the SVG back inline in reaction-display.js (using SVG/Path from @wordpress/primitives so it stays cross-platform) and remove the smiley entry from @wordpress/icons' manifest and library.



Summary
Closes #75144
Adds the foundation for emoji reactions on block notes. This PR is scoped to a small curated 5-emoji quick row (heart, celebration, smile, eyes, rocket). The full searchable emoji picker behind a
+button lives in a follow-up PR stacked on this one — per @t-hamano's request, so library/bundling decisions can be reviewed separately.Storage: reactions as a custom comment type
Reactions are stored as individual
reactioncomment records (one per user-emoji-note triple), as suggested by @swissspidy, in preference to JSON blobs in comment meta:This is more flexible, aligns with WordPress data patterns (similar to the React plugin), and could eventually extend to regular comments. The custom type is hidden from
wp-admin/edit-comments.phpand excluded from comment counts.Curated emoji set
The default 5 emojis (heart, celebration, smile, eyes, rocket) are stored as ASCII slugs (e.g.
heart). Storing slugs sidesteps utf8 vs utf8mb4 portability issues on the comments table and gives stable grouping in thereaction_summaryaggregation.The list is filterable via the
gutenberg_note_reaction_emojisPHP filter and exposed to clients through the REST API schema (reaction_emojisproperty onOPTIONS /wp/v2/comments), implementing the approach suggested by @Mamaduka.Efficient reaction loading
Each note response includes a
reaction_summaryfield —{ [emojiSlug]: { count, reacted, my_reaction_id } }. The schema lives inclass-gutenberg-rest-comment-controller-7-1.php, populated inprepare_item_for_response().get_items()pre-fetches summaries for the entire result set with one aggregatedGROUP BYquery plus one query for the current user's own reactions, regardless of how many notes are returned (seeprefetch_reaction_summaries()).reaction_summary— names are fetched on tooltip hover, so the initial notes load stays cheap.Popover architecture
The curated picker uses
@wordpress/components/Dropdown, whose Popover portals to<body>. This is essential because the collab sidebar has anoverflow: hiddenchain reaching all the way up to.interface-interface-skeleton__sidebar(framework-level); a non-portaled popover would clip. The note thread'sonBlurhandler exempts.components-popoverso that focus moving into the picker doesn't deselect the note (which would unmount the trigger and close the picker mid-click).Related
Remaining feedback from #75549
useEntityRecordsfor reactions has noper_pagelimit. Consider lazy-loading reactions only when a note thread is opened. Comment@wordpress/icons(@ellatrix) — Low priority, fine for now. CommentAlready addressed
wordpress-7.1compat dir (@Mamaduka, @t-hamano) — Donereaction(@swissspidy) — Donegutenberg_note_reaction_emojisfilter, surfaced via REST schemaScreenshot
i switched to a horizontal layout
Screencast
horizontal.picker.mp4
Test plan
npm run test:e2e -- test/e2e/specs/editor/various/block-notes.spec.jsnpm run test:unit:php:base -- --filter=Gutenberg_REST_Comments