Skip to content

Editor: Add a filter for the emoji a note can be reacted with - #14132

Open
adamsilverstein wants to merge 45 commits into
WordPress:trunkfrom
adamsilverstein:backport-note-reaction-emoji-filter
Open

adamsilverstein wants to merge 45 commits into
WordPress:trunkfrom
adamsilverstein:backport-note-reaction-emoji-filter

Conversation

@adamsilverstein

Copy link
Copy Markdown
Member

Backport of the reaction emoji filter from WordPress/gutenberg#84200 - this one is stacked on #10930 and should be reviewed after it, the first commits here are from that PR.

Claude did the port from the Gutenberg side; here is the rundown:

Adds a wp_note_reaction_emojis filter so a site can add or remove the emoji a note can be reacted with, or turn adding reactions off with an empty list.

  • Adds wp_get_note_reaction_emojis(), which runs the filter and drops malformed entries, empty labels and duplicate keys, and wp_get_note_reaction_keys().
  • Adds wp_normalize_note_reaction_key(), which normalizes filtered keys to the stored form (lowercase, padded to four digits, U+FE0F dropped) and rejects surrogates and values past U+10FFFF.
  • The comments controller validates reactions against wp_get_note_reaction_keys(), replacing its private hardcoded list.
  • get_block_editor_settings() passes the list to the editor as noteReactionEmojis, so the reaction menu offers the same emoji the REST API accepts.

Existing reactions whose emoji is filtered out stay visible and can still be removed by their authors.

Only the commits after backport-note-reactions-meta-70 belong to this change: adamsilverstein/wordpress-develop@backport-note-reactions-meta-70...backport-note-reaction-emoji-filter

Trac ticket: https://core.trac.wordpress.org/ticket/66276

Test plan

  • Add a filter that drops the heart and adds a unicorn:
    add_filter( 'wp_note_reaction_emojis', function ( $emojis ) {
    	$emojis   = array_slice( $emojis, 1 );
    	$emojis[] = array( 'hexKey' => '1f984', 'label' => 'unicorn' );
    	return $emojis;
    } );
  • POST a 1f984 reaction to a note: 201. POST 2764: 400 rest_comment_invalid_reaction.
  • Return an empty array from the filter, then DELETE an existing reaction of your own: 200.
  • Check that noteReactionEmojis in the editor settings matches the filtered list.
  • phpunit --group comment,notes,restapi and phpunit --filter Tests_Blocks_Editor

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Porting the code and tests from the Gutenberg PR and writing this description. I will review and test.

🤖 Generated with Claude Code

https://claude.ai/code/session_017FGp5zYv14MKoTuh3RFuWi

adamsilverstein and others added 30 commits February 18, 2026 21:33
Introduce the `reaction` comment type to support emoji reactions on
collaborative Notes, replacing the previous `_wp_note_reactions` meta
approach.

Changes include:
- Add `reaction` to avatar comment types.
- Exclude reactions from admin comment lists and comment counts.
- Extend the REST API Comments Controller to handle reactions:
  permissions checks, validation (valid emoji slugs, parent must be a
  note, one emoji per user per note), auto-approval, and content
  allowed checks.
- Add PHPUnit tests for reaction creation, validation, and counting.
- Regenerate API fixtures.

Props flavor flavor.
See #63191.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Picks up new 'footnotes' meta registered on post types, plus
site_logo and site_icon settings exposed via the REST API.
Needed so 'git diff --exit-code' in the PHPUnit CI step passes
after merging trunk into the backport branch.
…nt_types().

Per review feedback on PR WordPress#10930, introduce wp_get_internal_comment_types()
in src/wp-includes/comment.php as the single source of truth for non-comment
comment types ('note', 'reaction'). The new helper is filterable so future
internal types can be added without touching every call site.

Apply it across the existing 'note'/'reaction' guards:

* WP_Comments_List_Table: comment_type filter and type__not_in.
* get_pending_comments_num(): exclude internal types from pending counts.
* wp_update_comment_count_now(): exclude internal types from approved counts.
* WP_REST_Comments_Controller: get_items / permissions / prepare / links /
  duplicate-flood checks across multiple sites.
* is_avatar_comment_type(): default avatar comment types.

Props westonruter for the suggestion to centralize this list.
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Expose the curated reaction emoji list (heart, celebration, smile, eyes,
rocket) via a filterable helper so REST validation and schema can share
a single source. Mirrors gutenberg_get_note_reaction_emojis() from the
Gutenberg notes reactions feature.

See #63191.
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.
Add tests covering the updated reaction validation, summary, and
schema behaviors:

- Accept hex-codepoint emoji slugs (e.g. 1f468-200d-1f4bb).
- Reject raw emoji bytes.
- Allow re-adding a reaction after the previous one is trashed.
- reaction_summary aggregates per-emoji counts with the current user's
  reacted state and reaction ID.
- reaction_emojis schema property exposes the curated slug list.
- A note's children link targets reactions, not nested notes.

Update test_get_item_schema for the two new schema properties, and
test_get_note_with_children_link to expect type=reaction for note
children.

See #63191.
- Fix double-space alignment warning in test file (line 4702).
- Extend phpcs:ignore directives in prefetch_reaction_summaries() to
  cover WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,
  acknowledging PHPCS cannot count placeholders through the spread
  operator.

See #63191.
Apply review feedback from WordPress#10930:

- Bump `@since 7.0.0` to `@since 7.1.0` on PR-introduced docblocks
  in comment.php, link-template.php, and class-wp-rest-comments-controller.php.
- Remove the `wp_internal_comment_types` filter: as an internal helper,
  the list does not need to be filterable.
- Apply `wp_get_internal_comment_types()` in `WP_Comment_Query` so all
  internal types (not just `note`) are excluded by default.
- Exclude internal comment types from the `get_lastcommentmodified()`
  SQL queries so notes and reactions no longer affect the last
  modified date.
- Move `wp_get_note_reaction_emojis()` into
  `WP_REST_Comments_Controller::get_note_reaction_emojis()` as a
  protected static method while the icon strategy is still in flux.
- Simplify the reaction summary prefetch loop in
  `WP_REST_Comments_Controller::get_items()` with `wp_list_pluck`.
Port the reaction validation hardening from the upstream Gutenberg PR:

- Require the parent note to belong to the post the reaction targets.
- Reject hex-codepoint slugs outside assignable Unicode (above U+10FFFF
  or in the UTF-16 surrogate range).
- Store the validated, canonical slug rather than the raw request
  content, so markup can never split reaction_summary grouping.
- Align the reaction error codes with the Gutenberg implementation
  (rest_comment_invalid_parent, rest_comment_invalid_reaction,
  rest_comment_duplicate_reaction) so behavior does not change for
  clients when the feature ships in core.

See WordPress/gutenberg#76767
The pre-insert uniqueness check is not atomic, so two concurrent
requests for the same user/note/emoji can both insert an approved row.
After insert, keep the earliest matching reaction (lowest comment ID),
delete any later duplicates, and repoint the response to the survivor
if this request's own row lost the race. Every concurrent request
applies the same rule, so all requests settle on the same row.

Ported from the upstream Gutenberg PR.

See WordPress/gutenberg#76767
`wp_delete_comment()` reparents a deleted comment's children one level up
rather than deleting them, and `wp_trash_comment()` cascades a trashed
note only to its `note` children. Reactions therefore outlived the note
they belonged to: approved rows, still carrying the reactor's identity,
attached to nothing.

A reaction only means anything as a child of its note, so cascade
deletion, trashing and restoring to a note's reactions. Adds
`wp_get_note_reaction_ids()`. Replies are covered because core trashes
and deletes each one in turn.

Props adamsilverstein.
See #63191.
Reactions are validated as a set on create - author, parent note, target
post and canonical emoji slug - and the inherited generic update route
re-validates none of it. Anyone able to edit the note's post could PATCH
a reaction onto another user, move it to a note on a post they cannot
edit, or store a duplicate or invalid slug. Reject updates outright;
removing a reaction is a delete.

Accept only slugs from the allowed emoji list. Hex codepoint keys were
accepted but nothing shipping today can decode them back, so they would
render as the raw storage key in place of the emoji and its label.

Repoint the post-insert duplicate cleanup whenever any matching row
survives, not only when this request still sees its own. A competing
request running the same cleanup can delete this request's row first,
leaving the response pointing at a row that no longer exists.

Props adamsilverstein.
See #63191.
Retarget every `@since` this change introduces, across the comment
lifecycle helpers, the reactions REST surface and the note permalink
handling.

Props adamsilverstein.
See #63191.
The reaction uniqueness check and the reaction summary both key on the
current user, but nothing forced the stored row to belong to them.
Passing `author` let anyone with 'moderate_comments' file a reaction
against another user, and passing author details alone left `user_id` at
0; either way the row was invisible to the one-emoji-per-user-per-note
check, so it could be added repeatedly and never removed.

Refuse an `author` that names somebody else, and set the reaction's
author fields from the current user regardless of what the request
carried. Notes and comments are unaffected.

Also adds coverage for removing a reaction, for the collection
pre-fetch path, and for summaries spanning several users.
wp_get_note_reaction_ids() defaulted to the 'all' comment status, which
in WP_Comment_Query means approved and pending only. A reaction the user
had already removed is trashed rather than deleted, so it fell outside
that default and wp_delete_comment() reparented it to the top level
instead of deleting it, leaving an orphan carrying the reactor's
identity. Default to 'any' and say so in the docblock.

Also covers the functions this feature generalized from 'note' to
wp_get_internal_comment_types() and had left untested: comment queries,
comment counts, pending counts, the admin list table, avatar types, and
the last comment modified date, which had no coverage at all.
A reaction stores either a curated slug from get_note_reaction_emojis() or
a lowercase hex codepoint sequence. Acceptance of the latter was dropped
while nothing shipping could decode one back into an emoji and a label, so
a hex slug would have rendered as its raw storage key. The editor's full
emoji picker is that decoder, and it submits this format for every pick
outside the curated list, which the API now has to accept rather than
reject with a 400.

A hex slug is still bounded to assignable Unicode code points: anything
above U+10FFFF or in the UTF-16 surrogate range is rejected, as is a
sequence that is not lowercase.

Props adamsilverstein.
See #63191.
…ons-meta-70

# Conflicts:
#	src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
Reactions are stored approved, and the comment feed queries only excluded
notes, so every reaction on a published post showed up in the site and
post comment feeds along with the reacting editor's display name.

The feed WHERE clauses now exclude every type from
wp_get_internal_comment_types(), built in one helper so the two feed
paths cannot drift apart again.

See #63191.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M
Removing a reaction trashes it rather than deleting it, and restoring a
note restored every trashed reaction under it. A reaction the user had
taken back came back approved, next to any live one with the same emoji,
breaking the one-emoji-per-user rule and inflating the reaction summary.

Reactions trashed by the note cascade are now flagged, and restoring the
note restores only those. The flag is cleared on any untrash, so it never
outlives the trash round trip.

See #63191.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M
The link had been rewritten to filter on `type=reaction` for every note,
so a note with replies advertised a link that never returned them, and
the 6.9 test asserting `type=note` had been changed to match. That breaks
any client that embeds a note's replies.

The link is restored to its trunk form. Reactions are already exposed
through `reaction_summary`, so they are also left out of the check that
decides whether the link appears at all, which keeps a note with only
reactions from advertising an empty children collection.

See #63191.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M
The reaction slug was read by passing `content` straight to
wp_strip_all_tags(), so a request using `content: { raw: 'heart' }`,
which prepare_item_for_database() accepts for every other comment,
raised a warning and was rejected as an invalid emoji.

The slug is now read from `content` or `content.raw` the same way
prepare_item_for_database() reads it, and trimmed to match what gets
stored.

See #63191.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M
Anyone who could edit the post could send `status: 'hold'` (or spam, or
trash) with a new reaction. The uniqueness check, the race cleanup and
the reaction summary only look at approved rows, so repeated requests
stacked up hidden duplicates for one user that the API could not later
fix, since reactions cannot be updated. Because the status was applied
after the race cleanup, it could also land on a row that another
request had created.

A reaction request with any status other than approved is now rejected,
and reactions skip the post-insert status handling entirely.

See #63191.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M
Replace the `reacted` boolean and `my_reaction_id` with a single
`current_user_reaction` field holding the current user's reaction
comment ID, or 0 when they have not reacted. The boolean was derivable
from the ID, so one field keeps the response smaller and unambiguous,
per review feedback on the Gutenberg companion PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpDEqJD9APinkNrMN7Kem
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Avoid committing to a public "internal comment type" concept before a
custom comment types API exists. The list now lives in a private helper
that can change freely, while keeping the note and reaction exclusions
in one place instead of repeating the literals at every call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bw9jxkJwGzejoE6KbwXg2
Co-authored-by: Weston Ruter <westonruter@gmail.com>
adamsilverstein and others added 10 commits October 7, 2026 22:54
…ons-meta-70

# Conflicts:
#	src/wp-includes/class-wp-query.php
Store a reaction as the hex key of one of the five curated emoji (heart,
celebration, smile, eyes, rocket), the lowercase code points padded to four
digits that the editor's `emojiToHexKey()` produces. This matches the
Gutenberg implementation, so the client and server agree on a single
storage format.

The curated slugs and arbitrary hex sequences are no longer accepted, and
the `reaction_emojis` schema property is removed along with the list that
backed it, since the editor does not read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu
Only the user who added a reaction can delete it. Anyone who can edit the
post can edit a note, and the generic delete check follows that, but a
reaction belongs to its author.

Reject a reaction whose parent note is trashed or spammed, since it would
escape the trash cascade, and one anywhere in a resolved thread, since the
editor disables reactions once a thread's root note is approved. The walk
up to the thread root guards against a cyclic parent chain.

These match the checks in the Gutenberg implementation. Test notes that
receive reactions are now created open, as the editor creates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu
wp_untrash_comment() restores no children of any type, including a note's
replies, so bringing back only reactions was inconsistent and needed a
`_wp_trash_meta_with_note` marker to tell them apart from reactions the
user had removed. Leave restoring children to a cascade that covers every
child type together, matching the Gutenberg implementation.

Reactions are still trashed with their note and permanently deleted with
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu
The trashed, spam and resolved checks ran before the check that the parent
note belongs to the reaction's post. A user who can edit one post could
pass the ID of a note on a post they cannot edit and tell from the error
whether that note is trashed, spammed or resolved. Run the same-post check
first, so a note on another post always gets the same error.

`post` is already required for every comment type by
create_item_permissions_check(), which also checks edit access to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu
Mirror two Gutenberg tests: a reaction is readable only by its author and
users who can edit it, and a later page of notes still builds its reaction
summaries with two queries rather than one per note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu
Adds the wp_note_reaction_emojis filter so a site can add or remove
reaction emoji, or turn adding reactions off with an empty list. The REST
API validates reactions against the filtered list, and the list is passed
to the editor as the noteReactionEmojis setting so the reaction menu offers
the same emoji the server accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FGp5zYv14MKoTuh3RFuWi
…or tests.

get_block_editor_settings() needs the script and style globals the block
editor test class sets up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FGp5zYv14MKoTuh3RFuWi
See #66276.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FGp5zYv14MKoTuh3RFuWi
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props adamsilverstein.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

adamsilverstein and others added 5 commits October 9, 2026 10:55
Drop _wp_get_internal_comment_types() and hardcode 'note' and 'reaction'
where they are excluded or checked. Deciding what counts as an internal
comment type is better left to a future custom comment types API, since
reactions may later apply to public comments too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GEVg3cSmkAtseftqMbo4Zz
The previous commit regenerated wp-api-generated.js against a local site
with a different tagline, so CI's regenerated fixture no longer matched
and the "version-controlled files are not modified" check failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bw9jxkJwGzejoE6KbwXg2
The tests referenced #63191, an unrelated ticket. See #64638.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FGp5zYv14MKoTuh3RFuWi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant