Notes: Add emoji reactions - stored as custom comment type#76767
Notes: Add emoji reactions - stored as custom comment type#76767adamsilverstein wants to merge 114 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.
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.
| 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.
There was a problem hiding this comment.
@Mamaduka this question is still open, let me know if I should adjust.
There was a problem hiding this comment.
After reconsidering, I still believe that the way OPTIONS is being used is incorrect. Could we remove the OPTIONS endpoint and have the client-side always use this hardcoded reaction list instead?
There was a problem hiding this comment.
After reconsidering, I still believe that the way OPTIONS is being used is incorrect. Could we remove the OPTIONS endpoint and have the client-side always use this hardcoded reaction list instead?
Sure, let's do that!
There was a problem hiding this comment.
Done in 87384c8 - dropped the OPTIONS fetch and the `reaction_emojis` schema property; the picker now uses the hardcoded curated list. The `gutenberg_note_reaction_emojis` filter still drives server-side validation.
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.
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.
…tions-try-addditional-comment-type # Conflicts: # lib/load.php # test/e2e/specs/editor/various/block-notes.spec.js
|
This is ready for review. @t-hamano @swissspidy @Mamaduka |
Resolve conflicts from trunk removing the wordpress-6.9 compat layer (the plugin now requires WP 6.9, where block-comments/notes ship in core): - lib/load.php: keep both the new attachments controller require (trunk) and the block-comments + reaction controller requires (PR). - collab-sidebar/note.js: keep both @wordpress/ui imports (Stack from the PR, Button as UIButton from trunk) in a single import statement. - 6.9 comment controller (deleted in trunk, modified in PR): accept the deletion. The reaction controller previously extended it; re-parent Gutenberg_REST_Comment_Controller_7_1 to core's WP_REST_Comments_Controller and reimplement check_post_type_supports_notes() locally, since core declares that helper private. Drop the now-meaningless remove_action for the deleted 6.9 registration. - wordpress-7.1/block-comments.php: drop remove_filter calls that targeted 6.9 gutenberg functions deleted by trunk; the _7_1 callbacks still apply.
|
I'm probably not the best one to review all of this, but I like the use of the custom comment type and the thoughts you put into fetching reactions in a performant way. Starting with a curated list of emoji also sounds reasonable to me. |
|
Seems like the icons selected here are an alternatively ordered subset of what GitHub shows for reactions itself. How were those chosen? Why were the 👍🏼 and 👎🏼 and 😕 ones left off? Otherwise some light testing with just one user account these works well. |
| } | ||
|
|
||
| $edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' ); | ||
| if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) { |
There was a problem hiding this comment.
What happens if $request['status'] isn't set?
Core has a separate check before:
if ( $is_note && ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) {
return new WP_Error(
'rest_cannot_create_note',
__( 'Sorry, you are not allowed to create notes for this post.' ),
array( 'status' => rest_authorization_required_code() )
);
}
in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php. Do we need to do the same thing here?
There was a problem hiding this comment.
Probably, good suggestion, that would make the answer to your question clearer. Will do.
There was a problem hiding this comment.
Good catch - added the explicit `edit_post` guard mirroring core in 9488cf1, so creation no longer depends on `status` being present.
@jeffpaul the initial icon set was discussed in a previous thread, I think mostly starting here: #75148 (comment) I've also got this follow up PR - #78176 - that will add the full set behind a (+) symbol, and maybe we can make the ones you use easier to find by surfacing them in the initial set? |
…tions-try-addditional-comment-type
|
ping @jasmussen for any feedback |
| array( 'status' => 400 ) | ||
| ); | ||
| } | ||
|
|
There was a problem hiding this comment.
Should we also validate that the comment is for the correct post, something like this:
if ( (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { return new WP_Error( 'rest_comment_invalid_parent', __( 'A reaction must be attached to a note on the same post.', 'gutenberg' ), array( 'status' => 400 ) ); }
There was a problem hiding this comment.
Added in 9488cf1 - a reaction whose parent note belongs to a different post is now rejected with `rest_comment_invalid_parent`.
| * @param WP_Comment $comment Comment object. | ||
| * @return array Links for the given comment. | ||
| */ | ||
| protected function prepare_links( $comment ) { |
There was a problem hiding this comment.
This changes note _links.children from note replies to reactions. Core’s note controller uses type=note&status=all for child embedding, so clients relying on _embed for note replies will stop seeing replies once a note has children. Reactions should use a separate relation or summary field rather than replacing the existing children contract.
There was a problem hiding this comment.
Agreed. Removed the `prepare_links` override in dee5d00 so notes keep core's reply-embedding contract; reactions already reach the client via the `reaction_summary` field and an on-hover fetch.
Require edit_post capability to create a note or reaction, mirroring core's note guard, so the check no longer depends on a status field being present in the request. Also validate that a reaction's parent note belongs to the same post the reaction targets. Addresses review feedback on PR #76767.
The prepare_links override repointed a note's _links.children from its replies (type=note) to reactions (type=reaction), breaking core's reply-embedding contract for clients relying on _embed. Reactions reach the client through the reaction_summary field and an on-hover fetch, so the override served nothing. Remove it and inherit core's behavior. Addresses review feedback on PR #76767.
The reaction picker fetched the curated emoji list via OPTIONS /wp/v2/comments, reading it from a reaction_emojis schema default. OPTIONS is for discovering allowed operations, not data retrieval, and the curated set is a fixed five-emoji row. Use the hardcoded REACTION_EMOJIS list directly, drop the useReactionEmojis hook and the client request, and remove the read-only reaction_emojis schema property. The gutenberg_note_reaction_emojis filter still drives server-side validation. Addresses review feedback on PR #76767.


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
Testing
Test in Playground: https://playground.wordpress.net/?gutenberg-pr=76767
For testing with multiple users, use the User Switching Plugin - https://wordpress.org/plugins/user-switching/
npm run test:e2e -- test/e2e/specs/editor/various/block-notes.spec.jsnpm run test:unit:php:base -- --filter=Gutenberg_REST_Comments