From 9e4bfb3fd6f18f9da195694239e47afdc173733d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:21:51 -0800 Subject: [PATCH 001/362] Add ReactionEmojiPicker component for Notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../collab-sidebar/reaction-emoji-picker.js | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js new file mode 100644 index 00000000000000..d0afe6b806ee91 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -0,0 +1,87 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { useRef, useCallback } from '@wordpress/element'; + +/** + * Curated emoji set for reactions. + */ +export const REACTION_EMOJIS = [ + { emoji: '👍', label: __( 'Thumbs up' ) }, + { emoji: '👎', label: __( 'Thumbs down' ) }, + { emoji: '❤️', label: __( 'Heart' ) }, + { emoji: '🎉', label: __( 'Celebration' ) }, + { emoji: '😄', label: __( 'Smile' ) }, + { emoji: '😕', label: __( 'Confused' ) }, + { emoji: '👀', label: __( 'Eyes' ) }, + { emoji: '🚀', label: __( 'Rocket' ) }, +]; + +/** + * A horizontal row of emoji buttons for adding reactions. + * + * @param {Object} props Component props. + * @param {Function} props.onSelect Callback when an emoji is selected. + * @param {boolean} props.disabled Whether the picker is disabled. + */ +export default function ReactionEmojiPicker( { onSelect, disabled = false } ) { + const listRef = useRef( null ); + + const handleKeyDown = useCallback( ( event, index ) => { + const buttons = listRef.current?.querySelectorAll( '[role="option"]' ); + if ( ! buttons ) { + return; + } + + let nextIndex; + + switch ( event.key ) { + case 'ArrowRight': + event.preventDefault(); + nextIndex = ( index + 1 ) % buttons.length; + buttons[ nextIndex ].focus(); + break; + case 'ArrowLeft': + event.preventDefault(); + nextIndex = ( index - 1 + buttons.length ) % buttons.length; + buttons[ nextIndex ].focus(); + break; + case 'Home': + event.preventDefault(); + buttons[ 0 ].focus(); + break; + case 'End': + event.preventDefault(); + buttons[ buttons.length - 1 ].focus(); + break; + } + }, [] ); + + return ( +
+ { REACTION_EMOJIS.map( ( { emoji, label }, index ) => ( + + ) ) } +
+ ); +} From 461eead2959fef72ef6f8f5d922af55016a7deae Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:22:12 -0800 Subject: [PATCH 002/362] Add ReactionDisplay component for Notes 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 --- .../collab-sidebar/reaction-display.js | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/editor/src/components/collab-sidebar/reaction-display.js diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js new file mode 100644 index 00000000000000..8dad8b44e3f779 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -0,0 +1,144 @@ +/** + * WordPress dependencies + */ +import { __, sprintf, _n } from '@wordpress/i18n'; +import { Button, Dropdown } from '@wordpress/components'; +import { plus } from '@wordpress/icons'; +import clsx from 'clsx'; + +/** + * Internal dependencies + */ +import ReactionEmojiPicker from './reaction-emoji-picker'; + +/** + * Get the count of reactions for a specific emoji. + * + * @param {Object} reactions The reactions object. + * @param {string} emoji The emoji to count. + * @return {number} The count of reactions. + */ +function getReactionCount( reactions, emoji ) { + return reactions?.[ emoji ]?.length || 0; +} + +/** + * Check if the current user has reacted with a specific emoji. + * + * @param {Object} reactions The reactions object. + * @param {string} emoji The emoji to check. + * @param {number} currentUserId The current user's ID. + * @return {boolean} Whether the user has reacted. + */ +function hasUserReacted( reactions, emoji, currentUserId ) { + return ( + reactions?.[ emoji ]?.some( + ( reaction ) => reaction.userId === currentUserId + ) || false + ); +} + +/** + * Get all emojis that have reactions. + * + * @param {Object} reactions The reactions object. + * @return {string[]} Array of emojis with reactions. + */ +function getReactedEmojis( reactions ) { + if ( ! reactions ) { + return []; + } + return Object.keys( reactions ).filter( + ( emoji ) => reactions[ emoji ]?.length > 0 + ); +} + +/** + * Display current reactions with counts as pill-shaped buttons. + * + * @param {Object} props Component props. + * @param {Object} props.reactions The reactions object from comment meta. + * @param {number} props.currentUserId The current user's ID. + * @param {Function} props.onToggleReaction Callback to toggle a reaction. + */ +export default function ReactionDisplay( { + reactions, + currentUserId, + onToggleReaction, +} ) { + const reactedEmojis = getReactedEmojis( reactions ); + + return ( +
+ { reactedEmojis.map( ( emoji ) => { + const count = getReactionCount( reactions, emoji ); + const isActive = hasUserReacted( + reactions, + emoji, + currentUserId + ); + + return ( + + ); + } ) } + ( +
+ ); +} From 3432f960050953d13aceb0da8488d6266a85bcd5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:22:26 -0800 Subject: [PATCH 003/362] Add ReactionDetailsPopover component for Notes 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 --- .../reaction-details-popover.js | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 packages/editor/src/components/collab-sidebar/reaction-details-popover.js diff --git a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js new file mode 100644 index 00000000000000..54ef5c7f949046 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js @@ -0,0 +1,163 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + Popover, + __experimentalVStack as VStack, + __experimentalHStack as HStack, + __experimentalText as Text, +} from '@wordpress/components'; +import { humanTimeDiff } from '@wordpress/date'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { REACTION_EMOJIS } from './reaction-emoji-picker'; + +/** + * Get user display name by ID. + * + * @param {Object} users Map of user data by ID. + * @param {number} userId The user ID. + * @return {string} The user's display name or a fallback. + */ +function getUserName( users, userId ) { + return users?.[ userId ]?.name || __( 'Unknown user' ); +} + +/** + * Get user avatar URL by ID. + * + * @param {Object} users Map of user data by ID. + * @param {number} userId The user ID. + * @return {string|null} The user's avatar URL or null. + */ +function getUserAvatar( users, userId ) { + return users?.[ userId ]?.avatar_urls?.[ 48 ] || null; +} + +/** + * A popover showing who reacted and when. + * + * @param {Object} props Component props. + * @param {Object} props.reactions The reactions object from comment meta. + * @param {Function} props.onClose Callback to close the popover. + * @param {Object} props.anchor The anchor element for the popover. + */ +export default function ReactionDetailsPopover( { + reactions, + onClose, + anchor, +} ) { + // Collect all unique user IDs from reactions using useMemo for stable reference. + const userIdArray = useMemo( () => { + if ( ! reactions ) { + return []; + } + const userIdSet = new Set(); + Object.values( reactions ).forEach( ( reactionList ) => { + reactionList?.forEach( ( reaction ) => { + if ( reaction.userId ) { + userIdSet.add( reaction.userId ); + } + } ); + } ); + return Array.from( userIdSet ).sort( ( a, b ) => a - b ); + }, [ reactions ] ); + + // Fetch user data for all users who reacted. + const users = useSelect( + ( select ) => { + const { getUser } = select( coreStore ); + const userData = {}; + userIdArray.forEach( ( userId ) => { + const user = getUser( userId ); + if ( user ) { + userData[ userId ] = user; + } + } ); + return userData; + }, + [ userIdArray ] + ); + + if ( ! reactions ) { + return null; + } + + // Get emojis that have reactions, in the order they appear in REACTION_EMOJIS. + const orderedEmojis = REACTION_EMOJIS.map( ( { emoji } ) => emoji ).filter( + ( emoji ) => reactions[ emoji ]?.length > 0 + ); + + return ( + + + { orderedEmojis.map( ( emoji ) => { + const emojiData = REACTION_EMOJIS.find( + ( e ) => e.emoji === emoji + ); + const reactionList = reactions[ emoji ]; + + return ( + + + + { emoji } + + { emojiData?.label } + + + { reactionList.map( ( reaction, index ) => ( + + { getUserAvatar( + users, + reaction.userId + ) && ( + + ) } + + { getUserName( + users, + reaction.userId + ) } + + + { humanTimeDiff( + reaction.timestamp + ) } + + + ) ) } + + + ); + } ) } + + + ); +} From 6a8a24b03b71e556136902abbe99de7a36706afd Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:22:45 -0800 Subject: [PATCH 004/362] Add reaction CRUD functions to useBlockCommentsActions 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 --- .../src/components/collab-sidebar/hooks.js | 184 +++++++++++++++++- 1 file changed, 183 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 485043165794a3..9059d96fd07d1b 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -171,6 +171,7 @@ export function useBlockComments( postId ) { } export function useBlockCommentsActions( reflowComments = noop ) { + const registry = useRegistry(); const { createNotice } = useDispatch( noticesStore ); const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); @@ -333,7 +334,188 @@ export function useBlockCommentsActions( reflowComments = noop ) { } }; - return { onCreate, onEdit, onDelete }; + const onAddReaction = async ( { commentId, emoji } ) => { + try { + // Get current user from the store. + const currentUser = + registry.select( coreStore ).getCurrentUser() || {}; + const userId = currentUser.id; + + if ( ! userId ) { + throw new Error( + __( 'You must be logged in to add reactions.' ) + ); + } + + // Get current comment data. + const comment = registry + .select( coreStore ) + .getEntityRecord( 'root', 'comment', commentId ); + + if ( ! comment ) { + throw new Error( __( 'Comment not found.' ) ); + } + + // Get existing reactions or initialize empty object. + const existingReactions = comment.meta?._wp_reactions || {}; + + // Check if user already reacted with this emoji. + const emojiReactions = existingReactions[ emoji ] || []; + const alreadyReacted = emojiReactions.some( + ( reaction ) => reaction.userId === userId + ); + + if ( alreadyReacted ) { + return; + } + + // Add new reaction. + const newReaction = { + userId, + timestamp: new Date().toISOString(), + }; + + const updatedReactions = { + ...existingReactions, + [ emoji ]: [ ...emojiReactions, newReaction ], + }; + + await saveEntityRecord( + 'root', + 'comment', + { + id: commentId, + meta: { + ...comment.meta, + _wp_reactions: updatedReactions, + }, + }, + { throwOnError: true } + ); + + createNotice( 'snackbar', __( 'Reaction added.' ), { + type: 'snackbar', + isDismissible: true, + } ); + reflowComments(); + } catch ( error ) { + reflowComments(); + onError( error ); + } + }; + + const onRemoveReaction = async ( { commentId, emoji } ) => { + try { + // Get current user from the store. + const currentUser = + registry.select( coreStore ).getCurrentUser() || {}; + const userId = currentUser.id; + + if ( ! userId ) { + throw new Error( + __( 'You must be logged in to remove reactions.' ) + ); + } + + // Get current comment data. + const comment = registry + .select( coreStore ) + .getEntityRecord( 'root', 'comment', commentId ); + + if ( ! comment ) { + throw new Error( __( 'Comment not found.' ) ); + } + + // Get existing reactions. + const existingReactions = comment.meta?._wp_reactions || {}; + const emojiReactions = existingReactions[ emoji ] || []; + + // Filter out current user's reaction. + const updatedEmojiReactions = emojiReactions.filter( + ( reaction ) => reaction.userId !== userId + ); + + // Build updated reactions object, removing empty arrays. + const updatedReactions = { ...existingReactions }; + if ( updatedEmojiReactions.length > 0 ) { + updatedReactions[ emoji ] = updatedEmojiReactions; + } else { + delete updatedReactions[ emoji ]; + } + + await saveEntityRecord( + 'root', + 'comment', + { + id: commentId, + meta: { + ...comment.meta, + _wp_reactions: + Object.keys( updatedReactions ).length > 0 + ? updatedReactions + : undefined, + }, + }, + { throwOnError: true } + ); + + createNotice( 'snackbar', __( 'Reaction removed.' ), { + type: 'snackbar', + isDismissible: true, + } ); + reflowComments(); + } catch ( error ) { + reflowComments(); + onError( error ); + } + }; + + const onToggleReaction = async ( { commentId, emoji } ) => { + try { + // Get current user from the store. + const currentUser = + registry.select( coreStore ).getCurrentUser() || {}; + const userId = currentUser.id; + + if ( ! userId ) { + throw new Error( __( 'You must be logged in to react.' ) ); + } + + // Get current comment data. + const comment = registry + .select( coreStore ) + .getEntityRecord( 'root', 'comment', commentId ); + + if ( ! comment ) { + throw new Error( __( 'Comment not found.' ) ); + } + + // Check if user already reacted with this emoji. + const existingReactions = comment.meta?._wp_reactions || {}; + const emojiReactions = existingReactions[ emoji ] || []; + const alreadyReacted = emojiReactions.some( + ( reaction ) => reaction.userId === userId + ); + + if ( alreadyReacted ) { + await onRemoveReaction( { commentId, emoji } ); + } else { + await onAddReaction( { commentId, emoji } ); + } + } catch ( error ) { + reflowComments(); + onError( error ); + } + }; + + return { + onCreate, + onEdit, + onDelete, + onAddReaction, + onRemoveReaction, + onToggleReaction, + }; } export function useEnableFloatingSidebar( enabled = false ) { From 426927fc33b78af766b2f3d854b32e1503a04102 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:23:01 -0800 Subject: [PATCH 005/362] Integrate emoji reactions into Notes UI 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 --- .../src/components/collab-sidebar/comments.js | 53 +++++++++++++++++++ .../src/components/collab-sidebar/index.js | 3 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/comments.js b/packages/editor/src/components/collab-sidebar/comments.js index 264024f71266aa..51444f0c6d9bc2 100644 --- a/packages/editor/src/components/collab-sidebar/comments.js +++ b/packages/editor/src/components/collab-sidebar/comments.js @@ -33,6 +33,7 @@ import { store as blockEditorStore, privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies @@ -44,6 +45,8 @@ import { focusCommentThread, getCommentExcerpt } from './utils'; import { useFloatingThread } from './hooks'; import { AddComment } from './add-comment'; import { store as editorStore } from '../../store'; +import ReactionDisplay from './reaction-display'; +import ReactionDetailsPopover from './reaction-details-popover'; const { useBlockElement } = unlock( blockEditorPrivateApis ); const { Menu } = unlock( componentsPrivateApis ); @@ -53,6 +56,7 @@ export function Comments( { onEditComment, onAddReply, onCommentDelete, + onToggleReaction, newNoteFormState, setNewNoteFormState, commentSidebarRef, @@ -411,6 +415,7 @@ export function Comments( { onAddReply={ onAddReply } onCommentDelete={ handleDelete } onEditComment={ onEditComment } + onToggleReaction={ onToggleReaction } isSelected={ selectedThread === thread.id } setSelectedThread={ setSelectedThread } setNewNoteFormState={ setNewNoteFormState } @@ -441,6 +446,7 @@ function Thread( { onEditComment, onAddReply, onCommentDelete, + onToggleReaction, isSelected, setNewNoteFormState, commentSidebarRef, @@ -642,6 +648,7 @@ function Thread( { } } } onDelete={ onCommentDelete } + onToggleReaction={ onToggleReaction } reflowComments={ reflowComments } /> { isSelected && @@ -653,6 +660,7 @@ function Thread( { isExpanded={ isSelected } onEdit={ onEditComment } onDelete={ onCommentDelete } + onToggleReaction={ onToggleReaction } reflowComments={ reflowComments } /> ) ) } @@ -689,6 +697,7 @@ function Thread( { isExpanded={ isSelected } onEdit={ onEditComment } onDelete={ onCommentDelete } + onToggleReaction={ onToggleReaction } reflowComments={ reflowComments } /> ) } @@ -764,11 +773,21 @@ const CommentBoard = ( { isExpanded, onEdit, onDelete, + onToggleReaction, reflowComments, } ) => { const [ actionState, setActionState ] = useState( false ); const [ showConfirmDialog, setShowConfirmDialog ] = useState( false ); + const [ showReactionDetails, setShowReactionDetails ] = useState( false ); const actionButtonRef = useRef( null ); + const reactionDetailsAnchorRef = useRef( null ); + + // Get current user ID for reaction display. + const currentUserId = useSelect( ( select ) => { + const { getCurrentUser } = select( coreStore ); + const user = getCurrentUser(); + return user?.id; + }, [] ); const handleConfirmDelete = () => { onDelete( thread ); setActionState( false ); @@ -788,6 +807,11 @@ const CommentBoard = ( { ( thread.meta._wp_note_status === 'resolved' || thread.meta._wp_note_status === 'reopen' ); + // Check if there are any reactions. + const hasReactions = + thread.meta?._wp_reactions && + Object.keys( thread.meta._wp_reactions ).length > 0; + const actions = [ { id: 'edit', @@ -805,6 +829,14 @@ const CommentBoard = ( { onEdit( { id: thread.id, status: 'hold' } ); }, }, + { + id: 'reaction-details', + title: __( 'See emoji reaction details' ), + isEligible: () => hasReactions, + onClick: () => { + setShowReactionDetails( true ); + }, + }, { id: 'delete', title: __( 'Delete' ), @@ -963,6 +995,27 @@ const CommentBoard = ( { : thread?.content?.rendered } ) } + { isExpanded && ( +
+ + onToggleReaction?.( { + commentId: thread.id, + emoji, + } ) + } + /> +
+ ) } + { showReactionDetails && ( + setShowReactionDetails( false ) } + anchor={ reactionDetailsAnchorRef.current } + /> + ) } { 'delete' === actionState && ( Date: Mon, 2 Feb 2026 10:23:20 -0800 Subject: [PATCH 006/362] Add styles for emoji reactions in Notes 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 --- .../src/components/collab-sidebar/style.scss | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 5a13033159ce92..4c2280691d9d7e 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -184,3 +184,139 @@ } } } + +// Emoji Reactions +.editor-collab-sidebar-panel__reactions { + display: flex; + flex-wrap: wrap; + gap: $grid-unit-05; + align-items: center; + margin-top: $grid-unit-10; +} + +.editor-collab-sidebar-panel__reaction-button { + display: inline-flex; + align-items: center; + gap: $grid-unit-05; + padding: $grid-unit-05 $grid-unit-10; + border-radius: $radius-round; + border: $border-width solid $gray-300; + background-color: $gray-100; + font-size: $default-font-size; + cursor: pointer; + transition: background-color 0.1s ease; + + &:hover { + background-color: $gray-200; + } + + &:focus { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } + + &.is-active { + background-color: var(--wp-admin-theme-color--rgb, rgba(56, 88, 233, 0.1)); + border-color: var(--wp-admin-theme-color); + } +} + +.editor-collab-sidebar-panel__reaction-emoji { + font-size: 14px; + line-height: 1; +} + +.editor-collab-sidebar-panel__reaction-count { + font-size: $default-font-size; + font-weight: $font-weight-medium; + color: $gray-700; +} + +.editor-collab-sidebar-panel__add-reaction-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border-radius: $radius-round; + border: $border-width dashed $gray-400; + background-color: transparent; + cursor: pointer; + transition: background-color 0.1s ease, border-color 0.1s ease; + + &:hover { + background-color: $gray-100; + border-color: $gray-600; + } + + &:focus { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } + + svg { + fill: $gray-600; + } +} + +.editor-collab-sidebar-panel__add-reaction-popover { + padding: $grid-unit-10; +} + +.editor-collab-sidebar-panel__emoji-picker { + display: flex; + gap: $grid-unit-05; + flex-wrap: wrap; +} + +.editor-collab-sidebar-panel__emoji-option { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: $radius-medium; + background-color: transparent; + font-size: 18px; + cursor: pointer; + transition: background-color 0.1s ease; + + &:hover { + background-color: $gray-200; + } + + &:focus { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } +} + +.editor-collab-sidebar-panel__reaction-details { + padding: $grid-unit-20; + min-width: 200px; + max-width: 300px; +} + +.editor-collab-sidebar-panel__reaction-details-content { + max-height: 300px; + overflow-y: auto; +} + +.editor-collab-sidebar-panel__reaction-details-emoji { + font-size: 20px; + line-height: 1; +} + +.editor-collab-sidebar-panel__reaction-details-user { + padding: $grid-unit-05 0; +} + +.editor-collab-sidebar-panel__reaction-details-avatar { + width: 24px; + height: 24px; + border-radius: $radius-round; + flex-shrink: 0; +} From d440bba9fde3a4cc3a6588a2e1c88c6e5d787bd6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:23:31 -0800 Subject: [PATCH 007/362] Add E2E tests for emoji reactions in Notes 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 --- .../editor/various/block-comments.spec.js | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/test/e2e/specs/editor/various/block-comments.spec.js b/test/e2e/specs/editor/various/block-comments.spec.js index 4653976002068d..f411da904dbb79 100644 --- a/test/e2e/specs/editor/various/block-comments.spec.js +++ b/test/e2e/specs/editor/various/block-comments.spec.js @@ -830,6 +830,167 @@ test.describe( 'Block Comments', () => { await expect( thread ).toBeFocused(); } ); } ); + + test.describe( 'Emoji Reactions', () => { + test( 'can add an emoji reaction to a note', async ( { + page, + blockCommentUtils, + } ) => { + await blockCommentUtils.addBlockWithComment( { + type: 'core/paragraph', + attributes: { content: 'Testing emoji reactions' }, + comment: 'Test comment for reactions', + } ); + + await blockCommentUtils.addReactionToComment( 'Thumbs up' ); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Verify the reaction button appears with count. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '1' ); + } ); + + test( 'can remove own emoji reaction by clicking it', async ( { + page, + blockCommentUtils, + } ) => { + await blockCommentUtils.addBlockWithComment( { + type: 'core/paragraph', + attributes: { content: 'Testing reaction removal' }, + comment: 'Test comment for removing reactions', + } ); + + // Add a reaction. + await blockCommentUtils.addReactionToComment( 'Heart' ); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Click the reaction to remove it. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await reactionButton.click(); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction removed.' } ) + ).toBeVisible(); + + // Verify the reaction button is no longer visible. + await expect( reactionButton ).toBeHidden(); + } ); + + test( 'can see emoji reaction details', async ( { + page, + blockCommentUtils, + } ) => { + await blockCommentUtils.addBlockWithComment( { + type: 'core/paragraph', + attributes: { content: 'Testing reaction details' }, + comment: 'Test comment for reaction details', + } ); + + // Add a reaction. + await blockCommentUtils.addReactionToComment( 'Celebration' ); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Open the reaction details via menu. + await blockCommentUtils.clickBlockCommentActionMenuItem( + 'See emoji reaction details' + ); + + // Verify the popover is visible with user info. + const popover = page.locator( + '.editor-collab-sidebar-panel__reaction-details' + ); + await expect( popover ).toBeVisible(); + } ); + + test( 'reaction buttons are keyboard accessible', async ( { + page, + blockCommentUtils, + } ) => { + await blockCommentUtils.addBlockWithComment( { + type: 'core/paragraph', + attributes: { content: 'Testing keyboard accessibility' }, + comment: 'Test comment for keyboard access', + } ); + + // Open the emoji picker with keyboard. + const addReactionButton = page.getByRole( 'button', { + name: 'Add reaction', + } ); + await addReactionButton.focus(); + await page.keyboard.press( 'Enter' ); + + // Verify the picker is visible. + const emojiPicker = page.locator( + '.editor-collab-sidebar-panel__emoji-picker' + ); + await expect( emojiPicker ).toBeVisible(); + + // Navigate with arrow keys and select. + const firstEmoji = emojiPicker.getByRole( 'option' ).first(); + await firstEmoji.focus(); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + } ); + + test( 'can add multiple different reactions to same note', async ( { + page, + blockCommentUtils, + } ) => { + await blockCommentUtils.addBlockWithComment( { + type: 'core/paragraph', + attributes: { content: 'Testing multiple reactions' }, + comment: 'Test comment for multiple reactions', + } ); + + // Add first reaction. + await blockCommentUtils.addReactionToComment( 'Thumbs up' ); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Add second reaction. + await blockCommentUtils.addReactionToComment( 'Rocket' ); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Verify both reactions are visible. + const reactionButtons = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButtons ).toHaveCount( 2 ); + } ); + } ); } ); class BlockCommentUtils { @@ -898,4 +1059,13 @@ class BlockCommentUtils { .click(); await this.#page.getByRole( 'menuitem', { name: actionName } ).click(); } + + async addReactionToComment( emoji ) { + await this.#page + .getByRole( 'button', { name: 'Add reaction' } ) + .click(); + await this.#page + .getByRole( 'option', { name: new RegExp( emoji, 'i' ) } ) + .click(); + } } From 2b793d6bd93fb6a79762676118bb76057580c6cc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Feb 2026 14:28:29 -0800 Subject: [PATCH 008/362] Fix emoji reaction button: use smiley icon, fix focus, and improve styling 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 --- packages/editor/package.json | 1 + .../collab-sidebar/reaction-display.js | 38 ++++++++++++++++--- .../src/components/collab-sidebar/style.scss | 34 ++++++++++++++--- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index fe51cef6d9a5a1..600bd54da89c8c 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -94,6 +94,7 @@ "@wordpress/patterns": "file:../patterns", "@wordpress/plugins": "file:../plugins", "@wordpress/preferences": "file:../preferences", + "@wordpress/primitives": "file:../primitives", "@wordpress/private-apis": "file:../private-apis", "@wordpress/reusable-blocks": "file:../reusable-blocks", "@wordpress/rich-text": "file:../rich-text", diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index a13e709d0f189a..3f001733e657a9 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -3,7 +3,7 @@ */ import { __, sprintf, _n } from '@wordpress/i18n'; import { Button, Dropdown } from '@wordpress/components'; -import { plus } from '@wordpress/icons'; +import { SVG, Circle, Path } from '@wordpress/primitives'; import clsx from 'clsx'; /** @@ -11,6 +11,31 @@ import clsx from 'clsx'; */ import ReactionEmojiPicker from './reaction-emoji-picker'; +/** + * Smiley face icon for the add-reaction button, inspired by Google Docs. + */ +const smileyIcon = ( + + + + + + +); + /** * Get the count of reactions for a specific emoji. * @@ -118,15 +143,16 @@ export default function ReactionDisplay( { contentClassName="editor-collab-sidebar-panel__add-reaction-popover" popoverProps={ { placement: 'bottom-start', - // Prevent the popover from stealing focus, which would - // trigger the thread's onBlur and collapse the note. - focusOnMount: false, + // Focus the first element in the popover so the thread's + // onBlur handler detects focus inside .components-popover + // and does not collapse the note. + focusOnMount: 'firstElement', } } renderToggle={ ( { isOpen, onToggle } ) => ( ); } ) } - ( - + ) ) } - + ); } From 888c8373b75a2087d85f64908d2cf19fcd263b10 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 11:17:43 -0800 Subject: [PATCH 015/362] Move _wp_reactions meta to wordpress-7.0 compat 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. --- lib/compat/wordpress-6.9/block-comments.php | 33 ----------------- lib/compat/wordpress-7.0/block-comments.php | 41 +++++++++++++++++++++ lib/load.php | 1 + 3 files changed, 42 insertions(+), 33 deletions(-) create mode 100644 lib/compat/wordpress-7.0/block-comments.php diff --git a/lib/compat/wordpress-6.9/block-comments.php b/lib/compat/wordpress-6.9/block-comments.php index 0901dd7dcdbf5b..11d46ae8292d20 100644 --- a/lib/compat/wordpress-6.9/block-comments.php +++ b/lib/compat/wordpress-6.9/block-comments.php @@ -48,39 +48,6 @@ function gutenberg_register_block_comment_metadata() { }, ) ); - - register_meta( - 'comment', - '_wp_reactions', - array( - 'type' => 'object', - 'description' => __( 'Emoji reactions', 'gutenberg' ), - 'single' => true, - 'default' => array(), - 'show_in_rest' => array( - 'schema' => array( - 'type' => 'object', - 'additionalProperties' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'userId' => array( - 'type' => 'integer', - ), - 'timestamp' => array( - 'type' => 'string', - ), - ), - ), - ), - ), - ), - 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { - return current_user_can( 'edit_comment', $object_id ); - }, - ) - ); } add_action( 'init', 'gutenberg_register_block_comment_metadata' ); diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php new file mode 100644 index 00000000000000..387d7a770b31dd --- /dev/null +++ b/lib/compat/wordpress-7.0/block-comments.php @@ -0,0 +1,41 @@ + 'object', + 'description' => __( 'Emoji reactions', 'gutenberg' ), + 'single' => true, + 'default' => array(), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'userId' => array( + 'type' => 'integer', + ), + 'timestamp' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + ), + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); +} +add_action( 'init', 'gutenberg_register_block_comment_reaction_metadata' ); diff --git a/lib/load.php b/lib/load.php index 783c3f488a7178..a331461f146eef 100644 --- a/lib/load.php +++ b/lib/load.php @@ -72,6 +72,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-7.0/template-activate.php'; require __DIR__ . '/compat/wordpress-7.0/rest-api.php'; require __DIR__ . '/compat/wordpress-7.0/global-styles.php'; + require __DIR__ . '/compat/wordpress-7.0/block-comments.php'; // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; From a0310999a3da7fe55bc23b10f42c34c4e1fe8a84 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 11:18:56 -0800 Subject: [PATCH 016/362] Remove unnecessary login checks from reaction handlers The editor assumes users are logged in by default, so these guards are redundant. Matches the pattern used by other collab sidebar actions. --- .../src/components/collab-sidebar/hooks.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index c7cea08b6381a9..feb15d753de9b2 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -341,12 +341,6 @@ export function useBlockCommentsActions( reflowComments = noop ) { registry.select( coreStore ).getCurrentUser() || {}; const userId = currentUser.id; - if ( ! userId ) { - throw new Error( - __( 'You must be logged in to add reactions.' ) - ); - } - // Get current comment data. const comment = registry .select( coreStore ) @@ -410,12 +404,6 @@ export function useBlockCommentsActions( reflowComments = noop ) { registry.select( coreStore ).getCurrentUser() || {}; const userId = currentUser.id; - if ( ! userId ) { - throw new Error( - __( 'You must be logged in to remove reactions.' ) - ); - } - // Get current comment data. const comment = registry .select( coreStore ) @@ -475,10 +463,6 @@ export function useBlockCommentsActions( reflowComments = noop ) { registry.select( coreStore ).getCurrentUser() || {}; const userId = currentUser.id; - if ( ! userId ) { - throw new Error( __( 'You must be logged in to react.' ) ); - } - // Get current comment data. const comment = registry .select( coreStore ) From bbb61a9992e39cdafb14b58469f53dcb2d1bb2bb Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 11:19:31 -0800 Subject: [PATCH 017/362] Remove reflowComments from reaction handlers Reactions update comment metadata without adding, removing, or resizing comments, so reflowing is unnecessary. --- packages/editor/src/components/collab-sidebar/hooks.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index feb15d753de9b2..1ffe40dd72b9b8 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -390,9 +390,7 @@ export function useBlockCommentsActions( reflowComments = noop ) { type: 'snackbar', isDismissible: true, } ); - reflowComments(); } catch ( error ) { - reflowComments(); onError( error ); } }; @@ -449,9 +447,7 @@ export function useBlockCommentsActions( reflowComments = noop ) { type: 'snackbar', isDismissible: true, } ); - reflowComments(); } catch ( error ) { - reflowComments(); onError( error ); } }; @@ -485,7 +481,6 @@ export function useBlockCommentsActions( reflowComments = noop ) { await onAddReaction( { commentId, emoji } ); } } catch ( error ) { - reflowComments(); onError( error ); } }; From b28f67e572d1872669d9d582d75cb6dfcdb945f0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 11:22:21 -0800 Subject: [PATCH 018/362] Batch user fetching in reaction details popover Replace individual getUser() calls with a single getUsers() request using include, context: view, and _fields to reduce API calls and support low-capability users. --- .../reaction-details-popover.js | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js index 54ef5c7f949046..6fd3e4f2225ec5 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js +++ b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js @@ -69,16 +69,25 @@ export default function ReactionDetailsPopover( { return Array.from( userIdSet ).sort( ( a, b ) => a - b ); }, [ reactions ] ); - // Fetch user data for all users who reacted. + // Fetch user data for all users who reacted in a single request. const users = useSelect( ( select ) => { - const { getUser } = select( coreStore ); + if ( ! userIdArray.length ) { + return {}; + } + const { getUsers } = select( coreStore ); + const userList = getUsers( { + include: userIdArray, + context: 'view', + _fields: 'id,name,avatar_urls', + per_page: -1, + } ); + if ( ! userList ) { + return {}; + } const userData = {}; - userIdArray.forEach( ( userId ) => { - const user = getUser( userId ); - if ( user ) { - userData[ userId ] = user; - } + userList.forEach( ( user ) => { + userData[ user.id ] = user; } ); return userData; }, From 39f9d35207e4ac2f8921c0dc002342b87550747a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 14:57:21 -0800 Subject: [PATCH 019/362] Update reaction button icon per design feedback --- .../collab-sidebar/reaction-display.js | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 88973a6cc02069..5b46eb68b1deff 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -3,7 +3,7 @@ */ import { __, sprintf, _n } from '@wordpress/i18n'; import { Button, Dropdown } from '@wordpress/components'; -import { SVG, Circle, Path } from '@wordpress/primitives'; +import { SVG, Path } from '@wordpress/primitives'; import clsx from 'clsx'; /** @@ -12,26 +12,19 @@ import clsx from 'clsx'; import ReactionEmojiPicker from './reaction-emoji-picker'; /** - * Smiley face icon for the add-reaction button, inspired by Google Docs. + * Smiley face icon for the add-reaction button. */ const smileyIcon = ( - - - ); From e4f9139076eaca1d8b75f6cbee4d3ea7cdfda79f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 15:12:20 -0800 Subject: [PATCH 020/362] Replace thumbs down reaction with thinking emoji 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. --- .../src/components/collab-sidebar/reaction-emoji-picker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index ff088dec4e3e58..07273a984b23e6 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -9,7 +9,7 @@ import { Composite } from '@wordpress/components'; */ export const REACTION_EMOJIS = [ { emoji: '👍', label: __( 'Thumbs up' ) }, - { emoji: '👎', label: __( 'Thumbs down' ) }, + { emoji: '🤔', label: __( 'Thinking' ) }, { emoji: '❤️', label: __( 'Heart' ) }, { emoji: '🎉', label: __( 'Celebration' ) }, { emoji: '😄', label: __( 'Smile' ) }, From 30d4a7e575760f0e3de2fbc40741ea8eb9bab2a8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 08:50:42 -0800 Subject: [PATCH 021/362] type fix --- lib/compat/wordpress-6.9/block-comments.php | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/compat/wordpress-6.9/block-comments.php b/lib/compat/wordpress-6.9/block-comments.php index 11d46ae8292d20..83330a97600b77 100644 --- a/lib/compat/wordpress-6.9/block-comments.php +++ b/lib/compat/wordpress-6.9/block-comments.php @@ -48,6 +48,39 @@ function gutenberg_register_block_comment_metadata() { }, ) ); + + register_meta( + 'comment', + '_wp_reactions', + array( + 'type' => 'object', + 'description' => __( 'Emoji reactions', 'gutenberg' ), + 'single' => true, + 'default' => (object) array(), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'userId' => array( + 'type' => 'integer', + ), + 'timestamp' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + ), + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); } add_action( 'init', 'gutenberg_register_block_comment_metadata' ); From b2b46d19db9a70165986819dd5540d3bf61a4255 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 08:51:02 -0800 Subject: [PATCH 022/362] Linter and styling fixes --- .../reaction-details-popover.js | 29 ++++++++++++------- .../src/components/collab-sidebar/style.scss | 11 ++++--- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js index 6fd3e4f2225ec5..c92de9678e7bff 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js +++ b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js @@ -108,6 +108,8 @@ export default function ReactionDetailsPopover( { placement="bottom-start" onClose={ onClose } anchor={ anchor } + focusOnMount + shift className="editor-collab-sidebar-panel__reaction-details" > ) } - - { getUserName( - users, - reaction.userId - ) } - - - { humanTimeDiff( - reaction.timestamp - ) } - + + + { getUserName( + users, + reaction.userId + ) } + + + { humanTimeDiff( + reaction.timestamp + ) } + + ) ) } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 05df1c44ea006b..1337c9d5e806e7 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -325,13 +325,12 @@ } } -.editor-collab-sidebar-panel__reaction-details { - padding: $grid-unit-20; - min-width: 200px; - max-width: 300px; +.editor-collab-sidebar-panel__reaction-details .components-popover__content { + width: 260px; } .editor-collab-sidebar-panel__reaction-details-content { + padding: $grid-unit-30; max-height: 300px; overflow-y: auto; } @@ -351,3 +350,7 @@ border-radius: $radius-round; flex-shrink: 0; } + +.editor-collab-sidebar-panel__reaction-details-timestamp { + font-size: 12px; +} From b9f13837a89efb5e5dfd0bbaa48a3afe4ea1b6d0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 10 Feb 2026 08:31:09 +0800 Subject: [PATCH 023/362] Store emoji slugs in database instead of emoji characters --- .../reaction-details-popover.js | 27 ++++++++++--------- .../collab-sidebar/reaction-emoji-picker.js | 18 +++++++------ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js index c92de9678e7bff..d99e01dbc7bd79 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js +++ b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js @@ -16,7 +16,11 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import { REACTION_EMOJIS } from './reaction-emoji-picker'; +import { + REACTION_EMOJIS, + getEmojiBySlug, + getLabelBySlug, +} from './reaction-emoji-picker'; /** * Get user display name by ID. @@ -98,9 +102,9 @@ export default function ReactionDetailsPopover( { return null; } - // Get emojis that have reactions, in the order they appear in REACTION_EMOJIS. - const orderedEmojis = REACTION_EMOJIS.map( ( { emoji } ) => emoji ).filter( - ( emoji ) => reactions[ emoji ]?.length > 0 + // Get slugs that have reactions, in the order they appear in REACTION_EMOJIS. + const orderedSlugs = REACTION_EMOJIS.map( ( { value } ) => value ).filter( + ( slug ) => reactions[ slug ]?.length > 0 ); return ( @@ -116,19 +120,18 @@ export default function ReactionDetailsPopover( { spacing="3" className="editor-collab-sidebar-panel__reaction-details-content" > - { orderedEmojis.map( ( emoji ) => { - const emojiData = REACTION_EMOJIS.find( - ( e ) => e.emoji === emoji - ); - const reactionList = reactions[ emoji ]; + { orderedSlugs.map( ( slug ) => { + const reactionList = reactions[ slug ]; return ( - + - { emoji } + { getEmojiBySlug( slug ) } - { emojiData?.label } + + { getLabelBySlug( slug ) } + { reactionList.map( ( reaction, index ) => ( diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 07273a984b23e6..90d55991cce777 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -6,16 +6,18 @@ import { Composite } from '@wordpress/components'; /** * Curated emoji set for reactions. + * The `value` slug is used as the storage key in the database to avoid + * potential encoding issues with emoji characters. */ export const REACTION_EMOJIS = [ - { emoji: '👍', label: __( 'Thumbs up' ) }, - { emoji: '🤔', label: __( 'Thinking' ) }, - { emoji: '❤️', label: __( 'Heart' ) }, - { emoji: '🎉', label: __( 'Celebration' ) }, - { emoji: '😄', label: __( 'Smile' ) }, - { emoji: '😕', label: __( 'Confused' ) }, - { emoji: '👀', label: __( 'Eyes' ) }, - { emoji: '🚀', label: __( 'Rocket' ) }, + { emoji: '👍', label: __( 'Thumbs up' ), value: 'thumbs-up' }, + { emoji: '🤔', label: __( 'Thinking' ), value: 'thinking' }, + { emoji: '❤️', label: __( 'Heart' ), value: 'heart' }, + { emoji: '🎉', label: __( 'Celebration' ), value: 'celebration' }, + { emoji: '😄', label: __( 'Smile' ), value: 'smile' }, + { emoji: '😕', label: __( 'Confused' ), value: 'confused' }, + { emoji: '👀', label: __( 'Eyes' ), value: 'eyes' }, + { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; /** From d7445c7fb5f27fa6c245d682741b7619c71939c2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 10 Feb 2026 08:33:25 +0800 Subject: [PATCH 024/362] Use Button component in emoji picker / simplify CSS --- .../collab-sidebar/reaction-emoji-picker.js | 46 +++++++++--- .../src/components/collab-sidebar/style.scss | 75 +++---------------- 2 files changed, 45 insertions(+), 76 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 90d55991cce777..63877a2c2ac275 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Composite } from '@wordpress/components'; +import { Button, Composite } from '@wordpress/components'; /** * Curated emoji set for reactions. @@ -20,30 +20,52 @@ export const REACTION_EMOJIS = [ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; +/** + * Get the emoji character for a given reaction slug. + * + * @param {string} slug The reaction slug. + * @return {string} The emoji character, or the slug if not found. + */ +export function getEmojiBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug; +} + +/** + * Get the label for a given reaction slug. + * + * @param {string} slug The reaction slug. + * @return {string} The label, or the slug if not found. + */ +export function getLabelBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug; +} + /** * A selection of emoji buttons for adding reactions. * * @param {Object} props Component props. * @param {Function} props.onSelect Callback when an emoji is selected. - * @param {boolean} props.disabled Whether the picker is disabled. + * @return {WPElement} The ReactionEmojiPicker component. */ -export default function ReactionEmojiPicker( { onSelect, disabled = false } ) { +export default function ReactionEmojiPicker( { onSelect } ) { return ( - { REACTION_EMOJIS.map( ( { emoji, label } ) => ( + { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( onSelect( emoji ) } - disabled={ disabled } - accessibleWhenDisabled - aria-label={ label } - type="button" + key={ value } + render={ + ); } ) } @@ -148,34 +154,27 @@ export default function ReactionDisplay( { */ export function AddReactionButton( { onToggleReaction } ) { return ( - ( - ); } ) } - + ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index dd201c31445e8c..07373e2ef2ca3b 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -187,21 +187,6 @@ } // Emoji Reactions -.editor-collab-sidebar-panel__reactions-row { - display: flex; - flex-wrap: wrap; - gap: $grid-unit-05; - align-items: center; - margin-top: $grid-unit-10; -} - -.editor-collab-sidebar-panel__reactions { - display: flex; - flex-wrap: wrap; - gap: $grid-unit-05; - align-items: center; -} - .editor-collab-sidebar-panel__reaction-button { gap: $grid-unit-05; border: $border-width solid $gray-300; From 3b2512dd7116ee66b5cd614f7d3f1a929e8d2a2d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 17 Feb 2026 22:37:44 +0700 Subject: [PATCH 055/362] Remove stray add_action for nonexistent metadata registration function The merge from add-notes-emoji-reactions left behind an add_action call for gutenberg_register_block_comment_reaction_metadata, which doesn't exist in this branch. This branch uses custom comment types instead of comment metadata for storing reactions. Co-Authored-By: Claude Opus 4.6 --- lib/compat/wordpress-7.0/block-comments.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php index 3c75ea478734d5..cac43cac662bf3 100644 --- a/lib/compat/wordpress-7.0/block-comments.php +++ b/lib/compat/wordpress-7.0/block-comments.php @@ -59,4 +59,3 @@ function gutenberg_get_note_reaction_emojis() { */ return apply_filters( 'note_reaction_emojis', $default_emojis ); } -add_action( 'init', 'gutenberg_register_block_comment_reaction_metadata' ); From b9e42844e250a55edc191052efa552fc8396e398 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 08:00:44 +0700 Subject: [PATCH 056/362] Revert filterable emoji list for note reactions Remove the PHP filter (note_reaction_emojis) and editor settings injection for customizing the reaction emoji set. The JS components now use the hardcoded REACTION_EMOJIS list directly again. Co-Authored-By: Claude Opus 4.6 --- lib/block-editor-settings.php | 14 ----- lib/compat/wordpress-7.0/block-comments.php | 55 ------------------- .../collab-sidebar/reaction-display.js | 12 ++-- .../collab-sidebar/reaction-emoji-picker.js | 37 +++---------- 4 files changed, 12 insertions(+), 106 deletions(-) diff --git a/lib/block-editor-settings.php b/lib/block-editor-settings.php index c2d472540c8278..5ebf1ddd34499f 100644 --- a/lib/block-editor-settings.php +++ b/lib/block-editor-settings.php @@ -124,17 +124,3 @@ function gutenberg_get_block_editor_settings( $settings ) { return $settings; } add_filter( 'block_editor_settings_all', 'gutenberg_get_block_editor_settings', 0 ); - -/** - * Injects the filtered note reaction emoji list into block editor settings. - * - * @since 7.0.0 - * - * @param array $settings Existing block editor settings. - * @return array Updated block editor settings. - */ -function gutenberg_add_note_reaction_emojis_setting( $settings ) { - $settings['noteReactionEmojis'] = gutenberg_get_note_reaction_emojis(); - return $settings; -} -add_filter( 'block_editor_settings_all', 'gutenberg_add_note_reaction_emojis_setting' ); diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php index cac43cac662bf3..c00baee85a0151 100644 --- a/lib/compat/wordpress-7.0/block-comments.php +++ b/lib/compat/wordpress-7.0/block-comments.php @@ -4,58 +4,3 @@ * * @package gutenberg */ - -/** - * Returns the default emoji list for note reactions, filterable via the - * 'note_reaction_emojis' hook. - * - * Each emoji definition requires: - * - 'emoji' (string) The emoji character. - * - 'label' (string) Human-readable label. - * - 'value' (string) Storage slug used as the database key. - * - * @since 7.0.0 - * - * @return array[] Array of emoji definitions. - */ -function gutenberg_get_note_reaction_emojis() { - $default_emojis = array( - array( - 'emoji' => '❤️', - 'label' => __( 'Heart', 'gutenberg' ), - 'value' => 'heart', - ), - array( - 'emoji' => '🎉', - 'label' => __( 'Celebration', 'gutenberg' ), - 'value' => 'celebration', - ), - array( - 'emoji' => '😄', - 'label' => __( 'Smile', 'gutenberg' ), - 'value' => 'smile', - ), - array( - 'emoji' => '👀', - 'label' => __( 'Eyes', 'gutenberg' ), - 'value' => 'eyes', - ), - array( - 'emoji' => '🚀', - 'label' => __( 'Rocket', 'gutenberg' ), - 'value' => 'rocket', - ), - ); - - /** - * Filters the list of emoji available for note reactions. - * - * Each emoji requires an 'emoji' (character), 'label' (human-readable), - * and 'value' (storage slug). - * - * @since 7.0.0 - * - * @param array[] $emojis Array of emoji definitions. - */ - return apply_filters( 'note_reaction_emojis', $default_emojis ); -} diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 5d288dddbd79c8..b069ca40935f07 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -18,7 +18,6 @@ import { store as coreStore } from '@wordpress/core-data'; import ReactionEmojiPicker, { getEmojiBySlug, getLabelBySlug, - useReactionEmojis, } from './reaction-emoji-picker'; import { unlock } from '../../lock-unlock'; @@ -90,10 +89,9 @@ function getReactedSlugs( reactions ) { * @param {Object} users Map of user data by ID. * @param {Array} userIds Array of user IDs who reacted. * @param {string} slug The reaction slug. - * @param {Array} emojis The emoji list to look up labels. * @return {string} The tooltip text. */ -function getReactionTooltipText( users, userIds, slug, emojis ) { +function getReactionTooltipText( users, userIds, slug ) { const names = userIds .map( ( id ) => users?.[ id ]?.name ) .filter( Boolean ); @@ -102,7 +100,7 @@ function getReactionTooltipText( users, userIds, slug, emojis ) { return ''; } - const emojiLabel = getLabelBySlug( slug, emojis ); + const emojiLabel = getLabelBySlug( slug ); if ( names.length === 1 ) { return sprintf( @@ -146,7 +144,6 @@ function getReactionTooltipText( users, userIds, slug, emojis ) { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { reactions, onToggleReaction } ) { - const emojis = useReactionEmojis(); const currentUserId = useSelect( ( select ) => { const { getCurrentUser } = select( coreStore ); const user = getCurrentUser(); @@ -208,7 +205,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { slug, currentUserId ); - const emoji = getEmojiBySlug( slug, emojis ); + const emoji = getEmojiBySlug( slug ); const reactionUserIds = ( reactions?.[ slug ] || [] ).map( ( r ) => r.userId @@ -216,8 +213,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { const tooltipText = getReactionTooltipText( users, reactionUserIds, - slug, - emojis + slug ); // Use tooltip text when user data is loaded, otherwise fall back to basic label. diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index b92282336aec6a..0fc23a184bfa47 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -3,11 +3,9 @@ */ import { __ } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; -import { store as blockEditorStore } from '@wordpress/block-editor'; /** - * Default emoji set for reactions (JS-side fallback). + * Curated emoji set for reactions. * The `value` slug is used as the storage key in the database to avoid * potential encoding issues with emoji characters. */ @@ -19,41 +17,24 @@ export const REACTION_EMOJIS = [ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; -/** - * Hook that returns the reaction emoji list from editor settings, - * falling back to the hardcoded REACTION_EMOJIS default. - * - * @return {Array} The emoji list. - */ -export function useReactionEmojis() { - return useSelect( ( select ) => { - const { noteReactionEmojis } = select( blockEditorStore ).getSettings(); - return noteReactionEmojis?.length - ? noteReactionEmojis - : REACTION_EMOJIS; - }, [] ); -} - /** * Get the emoji character for a given reaction slug. * - * @param {string} slug The reaction slug. - * @param {Array} emojis Optional emoji list to search. + * @param {string} slug The reaction slug. * @return {string} The emoji character, or the slug if not found. */ -export function getEmojiBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.emoji ?? slug; +export function getEmojiBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug; } /** * Get the label for a given reaction slug. * - * @param {string} slug The reaction slug. - * @param {Array} emojis Optional emoji list to search. + * @param {string} slug The reaction slug. * @return {string} The label, or the slug if not found. */ -export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.label ?? slug; +export function getLabelBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug; } /** @@ -63,15 +44,13 @@ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { * @param {Function} props.onSelect Callback when an emoji is selected. */ export default function ReactionEmojiPicker( { onSelect } ) { - const emojis = useReactionEmojis(); - return ( - { emojis.map( ( { emoji, label, value } ) => ( + { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( Date: Wed, 18 Feb 2026 08:07:59 +0700 Subject: [PATCH 057/362] Rename note_reaction comment type to reaction Makes the reaction comment type more generic and future-proof so emoji reactions can eventually be used elsewhere in WordPress, not just on notes. Aligns with the approach used by the React plugin (wordpress.org/plugins/react). --- lib/compat/wordpress-6.9/block-comments.php | 10 +++--- ...-gutenberg-rest-comment-controller-6-9.php | 22 ++++++------- .../src/components/collab-sidebar/hooks.js | 4 +-- ...est-comments-controller-gutenberg-test.php | 32 +++++++++---------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/lib/compat/wordpress-6.9/block-comments.php b/lib/compat/wordpress-6.9/block-comments.php index 2e7acca52f030e..d85e0946bf8206 100644 --- a/lib/compat/wordpress-6.9/block-comments.php +++ b/lib/compat/wordpress-6.9/block-comments.php @@ -63,7 +63,7 @@ function gutenberg_register_block_comment_metadata() { if ( ! function_exists( 'update_get_avatar_comment_type' ) ) { function update_get_avatar_comment_type( $comment_type ) { $comment_type[] = 'note'; - $comment_type[] = 'note_reaction'; + $comment_type[] = 'reaction'; return $comment_type; } add_filter( 'get_avatar_comment_types', 'update_get_avatar_comment_type' ); @@ -89,7 +89,7 @@ function exclude_block_comments_from_admin( $clauses, $query ) { $query->set( 'type', '' ); global $wpdb; - $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'note_reaction'"; + $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'reaction'"; } return $clauses; @@ -110,7 +110,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments( $query ) { // Adjust the query if it is a comment count query. if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { if ( ! str_contains( $query, "comment_type != 'note'" ) ) { - $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'note_reaction' AND comment_approved", $query ); + $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'reaction' AND comment_approved", $query ); } } return $query; @@ -124,7 +124,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments( $query ) { * @return array Possibly modified arguments for get_comments(). */ function gutenberg_hide_note_from_comment_list_table( $args ) { - if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'note_reaction' ), true ) ) { + if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { unset( $args['type'] ); } return $args; @@ -145,7 +145,7 @@ function gutenberg_exclude_notes_from_comment_count( $new_count, $old_count, $po if ( null !== $new_count ) { return $new_count; } - $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'note_reaction'", $post_id ) ); + $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'reaction'", $post_id ) ); return $new_count; } add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10, 3 ); diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php index 2d0426e7b2ae60..c1d33aaf35abd7 100644 --- a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php +++ b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php @@ -10,7 +10,7 @@ class Gutenberg_REST_Comment_Controller_6_9 extends WP_REST_Comments_Controller { public function get_items_permissions_check( $request ) { - $is_note = in_array( $request['type'], array( 'note', 'note_reaction' ), true ); + $is_note = in_array( $request['type'], array( 'note', 'reaction' ), true ); $is_edit_context = 'edit' === $request['context']; if ( ! empty( $request['post'] ) ) { @@ -99,9 +99,9 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` or `note_reaction` type. + // Re-map edit context capabilities when requesting `note` or `reaction` type. // Note: This is only relevant change for the backport. - $edit_cap = in_array( $comment->comment_type, array( 'note', 'note_reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( 'rest_forbidden_context', @@ -132,7 +132,7 @@ public function get_item_permissions_check( $request ) { } public function create_item_permissions_check( $request ) { - $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'note_reaction' ), true ); + $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true ); // Note: This is only relevant change for the backport. if ( ! is_user_logged_in() && $is_note ) { @@ -292,7 +292,7 @@ public function create_item( $request ) { // Note: Removes non-default comment type check for the backport. // Do not allow comments to be created with a non-core type. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'note_reaction' ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.', 'gutenberg' ), @@ -300,8 +300,8 @@ public function create_item( $request ) { ); } - // Validate note_reaction specific requirements. - if ( ! empty( $request['type'] ) && 'note_reaction' === $request['type'] ) { + // Validate reaction-specific requirements. + if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { // Validate parent is a note. if ( empty( $request['parent'] ) ) { return new WP_Error( @@ -336,7 +336,7 @@ public function create_item( $request ) { array( 'parent' => $request['parent'], 'user_id' => get_current_user_id(), - 'type' => 'note_reaction', + 'type' => 'reaction', 'status' => 'any', ) ); @@ -432,7 +432,7 @@ public function create_item( $request ) { // Don't check for duplicates or flooding for notes and reactions. $prepared_comment['comment_approved'] = - in_array( $prepared_comment['comment_type'], array( 'note', 'note_reaction' ), true ) ? + in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -580,7 +580,7 @@ protected function prepare_links( $comment ) { // Embedding children for notes requires `type` and `status` inheritance. // Note: This is only relevant change for the backport. - if ( isset( $links['children'] ) && in_array( $comment->comment_type, array( 'note', 'note_reaction' ), true ) ) { + if ( isset( $links['children'] ) && in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ) { $args = array( 'parent' => $comment->comment_ID, 'type' => $comment->comment_type, @@ -658,7 +658,7 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { } // Note reactions always have content (the emoji slug). - if ( isset( $check['comment_type'] ) && 'note_reaction' === $check['comment_type'] ) { + if ( isset( $check['comment_type'] ) && 'reaction' === $check['comment_type'] ) { return true; } diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 2faa2f67c95311..8b4e09c0809c1f 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -62,7 +62,7 @@ export function useBlockComments( postId ) { 'comment', { post: postId, - type: 'note_reaction', + type: 'reaction', status: 'all', per_page: -1, }, @@ -419,7 +419,7 @@ export function useBlockCommentsActions( 'comment', { post: getCurrentPostId(), - type: 'note_reaction', + type: 'reaction', parent: commentId, content: emoji, status: 'approve', diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 7d0725756df637..2be8e9082631e1 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -480,14 +480,14 @@ protected function create_note( $post_id, $user_id ) { return $data['id']; } - public function test_create_note_reaction() { + public function test_create_reaction() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'heart', 'author' => self::$editor_id, @@ -502,19 +502,19 @@ public function test_create_note_reaction() { $data = $response->get_data(); $new_comment = get_comment( $data['id'] ); - $this->assertSame( 'note_reaction', $new_comment->comment_type ); + $this->assertSame( 'reaction', $new_comment->comment_type ); $this->assertSame( 'heart', $new_comment->comment_content ); $this->assertSame( (string) $note_id, $new_comment->comment_parent ); $this->assertSame( '1', $new_comment->comment_approved ); } - public function test_cannot_create_note_reaction_without_parent() { + public function test_cannot_create_reaction_without_parent() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'content' => 'heart', 'author' => self::$editor_id, ); @@ -527,14 +527,14 @@ public function test_cannot_create_note_reaction_without_parent() { $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } - public function test_cannot_create_note_reaction_with_invalid_emoji() { + public function test_cannot_create_reaction_with_invalid_emoji() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'invalid_emoji', 'author' => self::$editor_id, @@ -548,14 +548,14 @@ public function test_cannot_create_note_reaction_with_invalid_emoji() { $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); } - public function test_cannot_create_duplicate_note_reaction() { + public function test_cannot_create_duplicate_reaction() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'rocket', 'author' => self::$editor_id, @@ -585,7 +585,7 @@ public function test_can_create_different_reactions_on_same_note() { foreach ( $emojis as $emoji ) { $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => $emoji, 'author' => self::$editor_id, @@ -598,7 +598,7 @@ public function test_can_create_different_reactions_on_same_note() { } } - public function test_cannot_create_note_reaction_on_regular_comment() { + public function test_cannot_create_reaction_on_regular_comment() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $comment_id = self::factory()->comment->create( @@ -610,7 +610,7 @@ public function test_cannot_create_note_reaction_on_regular_comment() { $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $comment_id, 'content' => 'heart', 'author' => self::$editor_id, @@ -624,13 +624,13 @@ public function test_cannot_create_note_reaction_on_regular_comment() { $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } - public function test_create_note_reaction_requires_login() { + public function test_create_reaction_requires_login() { wp_set_current_user( 0 ); $post_id = self::factory()->post->create(); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'content' => 'heart', ); @@ -645,14 +645,14 @@ public function test_create_note_reaction_requires_login() { /** * @dataProvider data_valid_reaction_emojis */ - public function test_create_note_reaction_valid_emojis( $emoji ) { + public function test_create_reaction_valid_emojis( $emoji ) { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => $emoji, 'author' => self::$editor_id, From 5664b647e57a6c4a5b09dd697bb4aadff2846792 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 22:32:03 +0700 Subject: [PATCH 058/362] Move reaction PHP code to lib/compat/wordpress-7.1 --- lib/compat/wordpress-6.9/block-comments.php | 9 +- ...-gutenberg-rest-comment-controller-6-9.php | 90 +-- lib/compat/wordpress-7.1/block-comments.php | 108 ++++ ...-gutenberg-rest-comment-controller-7-1.php | 574 ++++++++++++++++++ lib/load.php | 4 + 5 files changed, 707 insertions(+), 78 deletions(-) create mode 100644 lib/compat/wordpress-7.1/block-comments.php create mode 100644 lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php diff --git a/lib/compat/wordpress-6.9/block-comments.php b/lib/compat/wordpress-6.9/block-comments.php index d85e0946bf8206..11d46ae8292d20 100644 --- a/lib/compat/wordpress-6.9/block-comments.php +++ b/lib/compat/wordpress-6.9/block-comments.php @@ -63,7 +63,6 @@ function gutenberg_register_block_comment_metadata() { if ( ! function_exists( 'update_get_avatar_comment_type' ) ) { function update_get_avatar_comment_type( $comment_type ) { $comment_type[] = 'note'; - $comment_type[] = 'reaction'; return $comment_type; } add_filter( 'get_avatar_comment_types', 'update_get_avatar_comment_type' ); @@ -89,7 +88,7 @@ function exclude_block_comments_from_admin( $clauses, $query ) { $query->set( 'type', '' ); global $wpdb; - $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'reaction'"; + $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note'"; } return $clauses; @@ -110,7 +109,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments( $query ) { // Adjust the query if it is a comment count query. if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { if ( ! str_contains( $query, "comment_type != 'note'" ) ) { - $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'reaction' AND comment_approved", $query ); + $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_approved", $query ); } } return $query; @@ -124,7 +123,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments( $query ) { * @return array Possibly modified arguments for get_comments(). */ function gutenberg_hide_note_from_comment_list_table( $args ) { - if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { + if ( ! empty( $_REQUEST['comment_type'] ) && 'note' === $_REQUEST['comment_type'] ) { unset( $args['type'] ); } return $args; @@ -145,7 +144,7 @@ function gutenberg_exclude_notes_from_comment_count( $new_count, $old_count, $po if ( null !== $new_count ) { return $new_count; } - $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'reaction'", $post_id ) ); + $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note'", $post_id ) ); return $new_count; } add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10, 3 ); diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php index c1d33aaf35abd7..e92d0570e76060 100644 --- a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php +++ b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php @@ -10,7 +10,7 @@ class Gutenberg_REST_Comment_Controller_6_9 extends WP_REST_Comments_Controller { public function get_items_permissions_check( $request ) { - $is_note = in_array( $request['type'], array( 'note', 'reaction' ), true ); + $is_note = 'note' === $request['type']; $is_edit_context = 'edit' === $request['context']; if ( ! empty( $request['post'] ) ) { @@ -99,9 +99,9 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` or `reaction` type. + // Re-map edit context capabilities when requesting `note` type. // Note: This is only relevant change for the backport. - $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + $edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( 'rest_forbidden_context', @@ -132,7 +132,7 @@ public function get_item_permissions_check( $request ) { } public function create_item_permissions_check( $request ) { - $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true ); + $is_note = ! empty( $request['type'] ) && 'note' === $request['type']; // Note: This is only relevant change for the backport. if ( ! is_user_logged_in() && $is_note ) { @@ -292,7 +292,7 @@ public function create_item( $request ) { // Note: Removes non-default comment type check for the backport. // Do not allow comments to be created with a non-core type. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note' ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.', 'gutenberg' ), @@ -300,58 +300,6 @@ public function create_item( $request ) { ); } - // Validate reaction-specific requirements. - if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { - // Validate parent is a note. - if ( empty( $request['parent'] ) ) { - return new WP_Error( - 'rest_comment_invalid_parent', - __( 'A reaction must have a parent note.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - - $parent_comment = get_comment( $request['parent'] ); - if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { - return new WP_Error( - 'rest_comment_invalid_parent', - __( 'A reaction must be attached to a note.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - - // Validate content is a valid emoji slug. - $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); - $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; - if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { - return new WP_Error( - 'rest_comment_invalid_reaction', - __( 'Invalid reaction emoji.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - - // Enforce uniqueness: prevent duplicate emoji per user per note. - $existing = get_comments( - array( - 'parent' => $request['parent'], - 'user_id' => get_current_user_id(), - 'type' => 'reaction', - 'status' => 'any', - ) - ); - - foreach ( $existing as $existing_reaction ) { - if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { - return new WP_Error( - 'rest_comment_duplicate_reaction', - __( 'You have already reacted with this emoji.', 'gutenberg' ), - array( 'status' => 409 ) - ); - } - } - } - $prepared_comment = $this->prepare_item_for_database( $request ); if ( is_wp_error( $prepared_comment ) ) { return $prepared_comment; @@ -430,9 +378,9 @@ public function create_item( $request ) { ); } - // Don't check for duplicates or flooding for notes and reactions. + // Don't check for duplicates or flooding for notes. $prepared_comment['comment_approved'] = - in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ? + 'note' === $prepared_comment['comment_type'] ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -551,7 +499,7 @@ public function create_item( $request ) { * @param string $post_type Post type name. * @return bool True if post type supports block comments, false otherwise. */ - private function check_post_type_supports_notes( $post_type ) { + protected function check_post_type_supports_notes( $post_type ) { $supports = get_all_post_type_supports( $post_type ); if ( ! isset( $supports['editor'] ) ) { return false; @@ -580,7 +528,7 @@ protected function prepare_links( $comment ) { // Embedding children for notes requires `type` and `status` inheritance. // Note: This is only relevant change for the backport. - if ( isset( $links['children'] ) && in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ) { + if ( isset( $links['children'] ) && 'note' === $comment->comment_type ) { $args = array( 'parent' => $comment->comment_ID, 'type' => $comment->comment_type, @@ -657,11 +605,6 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { return true; } - // Note reactions always have content (the emoji slug). - if ( isset( $check['comment_type'] ) && 'reaction' === $check['comment_type'] ) { - return true; - } - /* * Do not allow a comment to be created with missing or empty * comment_content. See wp_handle_comment_submission(). @@ -670,10 +613,11 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { } } -add_action( - 'rest_api_init', - function () { - $controller = new Gutenberg_REST_Comment_Controller_6_9(); - $controller->register_routes(); - } -); +/** + * Registers the Gutenberg REST comment controller for WordPress 6.9 compatibility. + */ +function gutenberg_register_comment_controller_6_9() { + $controller = new Gutenberg_REST_Comment_Controller_6_9(); + $controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_comment_controller_6_9' ); diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php new file mode 100644 index 00000000000000..a4c3fd17ec2463 --- /dev/null +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -0,0 +1,108 @@ +query_vars['type'] ) && '' === $query->query_vars['type'] ) { + $query->set( 'type', '' ); + + global $wpdb; + $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'reaction'"; + } + + return $clauses; +} +remove_action( 'comments_clauses', 'exclude_block_comments_from_admin', 10 ); +add_action( 'comments_clauses', 'gutenberg_exclude_block_comments_from_admin_7_1', 10, 2 ); + +/** + * Filter the comment count query to exclude notes and reactions. + * + * Replaces the 6.9 implementation to also exclude 'reaction' type. + * + * @param string $query The SQL query string. + * @return string The modified SQL query string. + */ +function gutenberg_filter_comment_count_query_exclude_block_comments_7_1( $query ) { + if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { + if ( ! str_contains( $query, "comment_type != 'note'" ) ) { + $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'reaction' AND comment_approved", $query ); + } + } + return $query; +} +remove_filter( 'query', 'gutenberg_filter_comment_count_query_exclude_block_comments' ); +add_filter( 'query', 'gutenberg_filter_comment_count_query_exclude_block_comments_7_1' ); + +/** + * Adjusts the comments list table query so notes and reactions never display. + * + * Replaces the 6.9 implementation to also handle 'reaction' type. + * + * @param array $args An array of get_comments() arguments. + * @return array Possibly modified arguments for get_comments(). + */ +function gutenberg_hide_note_from_comment_list_table_7_1( $args ) { + if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { + unset( $args['type'] ); + } + return $args; +} +remove_filter( 'comments_list_table_query_args', 'gutenberg_hide_note_from_comment_list_table' ); +add_filter( 'comments_list_table_query_args', 'gutenberg_hide_note_from_comment_list_table_7_1' ); + +/** + * Override comment_count to exclude notes and reactions from the comment count. + * + * Replaces the 6.9 implementation to also exclude 'reaction' type. + * + * @param int|null $new The new comment count. Default null. + * @param int $old The old comment count. + * @param int $post_id Post ID. + * @return int|null The modified comment count. + */ +function gutenberg_exclude_notes_from_comment_count_7_1( $new_count, $old_count, $post_id ) { + global $wpdb; + if ( null !== $new_count ) { + return $new_count; + } + $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'reaction'", $post_id ) ); + return $new_count; +} +remove_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10 ); +add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_1', 10, 3 ); diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php new file mode 100644 index 00000000000000..b8f13c0454d419 --- /dev/null +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -0,0 +1,574 @@ +is_note_or_reaction( $request['type'] ); + $is_edit_context = 'edit' === $request['context']; + + if ( ! empty( $request['post'] ) ) { + foreach ( (array) $request['post'] as $post_id ) { + $post = get_post( $post_id ); + + if ( $post && $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { + return new WP_Error( + 'rest_comment_not_supported_post_type', + __( 'Sorry, this post type does not support notes.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( ! empty( $post_id ) && $post && ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to read comments without a post.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + } + + if ( $is_edit_context && $is_note && ! empty( $request['post'] ) ) { + foreach ( (array) $request['post'] as $post_id ) { + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! current_user_can( 'edit_posts' ) ) { + $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); + $forbidden_params = array(); + + foreach ( $protected_params as $param ) { + if ( 'status' === $param ) { + if ( 'approve' !== $request[ $param ] ) { + $forbidden_params[] = $param; + } + } elseif ( 'type' === $param ) { + if ( 'comment' !== $request[ $param ] ) { + $forbidden_params[] = $param; + } + } elseif ( ! empty( $request[ $param ] ) ) { + $forbidden_params[] = $param; + } + } + + if ( ! empty( $forbidden_params ) ) { + return new WP_Error( + 'rest_forbidden_param', + /* translators: %s: List of forbidden parameters. */ + sprintf( __( 'Query parameter not permitted: %s', 'gutenberg' ), implode( ', ', $forbidden_params ) ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + return true; + } + + public function get_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + // Re-map edit context capabilities when requesting `note` or `reaction` type. + $edit_cap = $this->is_note_or_reaction( $comment->comment_type ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $post = get_post( $comment->comment_post_ID ); + + if ( ! $this->check_read_permission( $comment, $request ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to read this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( $post && ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + public function create_item_permissions_check( $request ) { + $is_note = ! empty( $request['type'] ) && $this->is_note_or_reaction( $request['type'] ); + + if ( ! is_user_logged_in() && $is_note ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + + if ( ! is_user_logged_in() ) { + if ( get_option( 'comment_registration' ) ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + $allow_anonymous = apply_filters( 'rest_allow_anonymous_comments', false, $request ); + + if ( ! $allow_anonymous ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + } + + if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_comment_invalid_author', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'author' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( isset( $request['author_ip'] ) && ! current_user_can( 'moderate_comments' ) ) { + if ( empty( $_SERVER['REMOTE_ADDR'] ) || $request['author_ip'] !== $_SERVER['REMOTE_ADDR'] ) { + return new WP_Error( + 'rest_comment_invalid_author_ip', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'author_ip' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + $edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' ); + if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) { + return new WP_Error( + 'rest_comment_invalid_status', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'status' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( empty( $request['post'] ) ) { + return new WP_Error( + 'rest_comment_invalid_post_id', + __( 'Sorry, you are not allowed to create this comment without a post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + $post = get_post( (int) $request['post'] ); + + if ( ! $post ) { + return new WP_Error( + 'rest_comment_invalid_post_id', + __( 'Sorry, you are not allowed to create this comment without a post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { + return new WP_Error( + 'rest_comment_not_supported_post_type', + __( 'Sorry, this post type does not support notes.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( 'draft' === $post->post_status && ! $is_note ) { + return new WP_Error( + 'rest_comment_draft_post', + __( 'Sorry, you are not allowed to create a comment on this post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( 'trash' === $post->post_status ) { + return new WP_Error( + 'rest_comment_trash_post', + __( 'Sorry, you are not allowed to create a comment on this post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! comments_open( $post->ID ) && ! $is_note ) { + return new WP_Error( + 'rest_comment_closed', + __( 'Sorry, comments are closed for this item.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Creates a comment. + * + * Extends the 6.9 implementation to support 'reaction' comment type + * with validation for parent note, valid emoji slugs, and uniqueness. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. + */ + public function create_item( $request ) { + if ( ! empty( $request['id'] ) ) { + return new WP_Error( + 'rest_comment_exists', + __( 'Cannot create existing comment.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Allow 'comment', 'note', and 'reaction' types. + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { + return new WP_Error( + 'rest_invalid_comment_type', + __( 'Cannot create a comment with that type.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Validate reaction-specific requirements. + if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { + // Validate parent is a note. + if ( empty( $request['parent'] ) ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must have a parent note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + $parent_comment = get_comment( $request['parent'] ); + if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Validate content is a valid emoji slug. + $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); + $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; + if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { + return new WP_Error( + 'rest_comment_invalid_reaction', + __( 'Invalid reaction emoji.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Enforce uniqueness: prevent duplicate emoji per user per note. + $existing = get_comments( + array( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'any', + ) + ); + + foreach ( $existing as $existing_reaction ) { + if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { + return new WP_Error( + 'rest_comment_duplicate_reaction', + __( 'You have already reacted with this emoji.', 'gutenberg' ), + array( 'status' => 409 ) + ); + } + } + } + + $prepared_comment = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $prepared_comment['comment_type'] = $request['type']; + + if ( ! isset( $prepared_comment['comment_content'] ) ) { + $prepared_comment['comment_content'] = ''; + } + + // Include note metadata into check_is_comment_content_allowed [backport]. + if ( isset( $request['meta']['_wp_note_status'] ) ) { + $prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status']; + } + + if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) { + return new WP_Error( + 'rest_comment_content_invalid', + __( 'Invalid comment content.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Setting remaining values before wp_insert_comment so we can use wp_allow_comment(). + if ( ! isset( $prepared_comment['comment_date_gmt'] ) ) { + $prepared_comment['comment_date_gmt'] = current_time( 'mysql', true ); + } + + // Set author data if the user's logged in. + $missing_author = empty( $prepared_comment['user_id'] ) + && empty( $prepared_comment['comment_author'] ) + && empty( $prepared_comment['comment_author_email'] ) + && empty( $prepared_comment['comment_author_url'] ); + + if ( is_user_logged_in() && $missing_author ) { + $user = wp_get_current_user(); + + $prepared_comment['user_id'] = $user->ID; + $prepared_comment['comment_author'] = $user->display_name; + $prepared_comment['comment_author_email'] = $user->user_email; + $prepared_comment['comment_author_url'] = $user->user_url; + } + + // Honor the discussion setting that requires a name and email address of the comment author. + if ( get_option( 'require_name_email' ) ) { + if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) { + return new WP_Error( + 'rest_comment_author_data_required', + __( 'Creating a comment requires valid author name and email values.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + } + + if ( ! isset( $prepared_comment['comment_author_email'] ) ) { + $prepared_comment['comment_author_email'] = ''; + } + + if ( ! isset( $prepared_comment['comment_author_url'] ) ) { + $prepared_comment['comment_author_url'] = ''; + } + + if ( ! isset( $prepared_comment['comment_agent'] ) ) { + $prepared_comment['comment_agent'] = ''; + } + + $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_comment ); + + if ( is_wp_error( $check_comment_lengths ) ) { + $error_code = $check_comment_lengths->get_error_code(); + return new WP_Error( + $error_code, + __( 'Comment field exceeds maximum length allowed.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Don't check for duplicates or flooding for notes and reactions. + $prepared_comment['comment_approved'] = + $this->is_note_or_reaction( $prepared_comment['comment_type'] ) ? + '1' : + wp_allow_comment( $prepared_comment, true ); + + if ( is_wp_error( $prepared_comment['comment_approved'] ) ) { + $error_code = $prepared_comment['comment_approved']->get_error_code(); + $error_message = $prepared_comment['comment_approved']->get_error_message(); + + if ( 'comment_duplicate' === $error_code ) { + return new WP_Error( + $error_code, + $error_message, + array( 'status' => 409 ) + ); + } + + if ( 'comment_flood' === $error_code ) { + return new WP_Error( + $error_code, + $error_message, + array( 'status' => 400 ) + ); + } + + return $prepared_comment['comment_approved']; + } + + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + $prepared_comment = apply_filters( 'rest_pre_insert_comment', $prepared_comment, $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $comment_id = wp_insert_comment( wp_filter_comment( wp_slash( (array) $prepared_comment ) ) ); + + if ( ! $comment_id ) { + return new WP_Error( + 'rest_comment_failed_create', + __( 'Creating comment failed.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + + if ( isset( $request['status'] ) ) { + $this->handle_status_param( $request['status'], $comment_id ); + } + + $comment = get_comment( $comment_id ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + do_action( 'rest_insert_comment', $comment, $request, true ); + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $comment_id ); + + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $comment, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $context = current_user_can( 'moderate_comments' ) ? 'edit' : 'view'; + $request->set_param( 'context', $context ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + do_action( 'rest_after_insert_comment', $comment, $request, true ); + + $response = $this->prepare_item_for_response( $comment, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) ); + + return $response; + } + + /** + * Prepares links for the request. + * + * Extends the 6.9 implementation to also handle 'reaction' type + * for children link embedding. + * + * @since 7.1.0 + * + * @param WP_Comment $comment Comment object. + * @return array Links for the given comment. + */ + protected function prepare_links( $comment ) { + $links = WP_REST_Comments_Controller::prepare_links( $comment ); + + // Embedding children for notes and reactions requires `type` and `status` inheritance. + if ( isset( $links['children'] ) && $this->is_note_or_reaction( $comment->comment_type ) ) { + $args = array( + 'parent' => $comment->comment_ID, + 'type' => $comment->comment_type, + 'status' => 'all', + ); + + $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) ); + + $links['children'] = array( + 'href' => $rest_url, + 'embeddable' => true, + ); + } + + return $links; + } + + /** + * Checks if comment content is allowed. + * + * Extends the 6.9 implementation to also allow reaction content + * (emoji slugs are always valid content). + * + * @since 7.1.0 + * + * @param array $prepared_comment The prepared comment data. + * @return bool True if the content is allowed, false otherwise. + */ + protected function check_is_comment_content_allowed( $prepared_comment ) { + // Note reactions always have content (the emoji slug). + if ( isset( $prepared_comment['comment_type'] ) && 'reaction' === $prepared_comment['comment_type'] ) { + return true; + } + + return parent::check_is_comment_content_allowed( $prepared_comment ); + } +} + +/** + * Registers the Gutenberg REST comment controller for WordPress 7.1 compatibility. + * + * Replaces the 6.9 controller registration to use the 7.1 controller + * which adds reaction support. + */ +function gutenberg_register_comment_controller_7_1() { + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $controller->register_routes(); +} +remove_action( 'rest_api_init', 'gutenberg_register_comment_controller_6_9' ); +add_action( 'rest_api_init', 'gutenberg_register_comment_controller_7_1' ); diff --git a/lib/load.php b/lib/load.php index a331461f146eef..ca7a5457042613 100644 --- a/lib/load.php +++ b/lib/load.php @@ -74,6 +74,10 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-7.0/global-styles.php'; require __DIR__ . '/compat/wordpress-7.0/block-comments.php'; + // WordPress 7.1 compat. + require __DIR__ . '/compat/wordpress-7.1/block-comments.php'; + require __DIR__ . '/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php'; + // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; require_once __DIR__ . '/class-wp-rest-edit-site-export-controller-gutenberg.php'; From db49d962e005932512d2f3b185e486e60a851db6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 23:10:55 +0700 Subject: [PATCH 059/362] Expose allowed reaction emojis via REST API schema Add a filterable gutenberg_get_note_reaction_emojis() helper and expose the emoji list as a read-only reaction_emojis property in the comments REST API schema. The client-side useReactionEmojis() hook reads this from the OPTIONS response so the emoji picker and display render what the server actually allows. Validation in create_item() now uses the same filtered list, so custom emojis added via the gutenberg_note_reaction_emojis filter are accepted end-to-end. Co-Authored-By: Claude Opus 4.6 --- lib/compat/wordpress-7.1/block-comments.php | 52 +++++++++ ...-gutenberg-rest-comment-controller-7-1.php | 46 +++++++- .../collab-sidebar/reaction-display.js | 12 +- .../collab-sidebar/reaction-emoji-picker.js | 71 ++++++++++-- ...est-comments-controller-gutenberg-test.php | 103 +++++++++++++++++- 5 files changed, 265 insertions(+), 19 deletions(-) diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index a4c3fd17ec2463..7940f597f5331f 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -106,3 +106,55 @@ function gutenberg_exclude_notes_from_comment_count_7_1( $new_count, $old_count, } remove_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10 ); add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_1', 10, 3 ); + +/** + * Returns the allowed emojis for note reactions. + * + * Each emoji is an associative array with: + * - `emoji` (string) The emoji character. + * - `label` (string) A translated human-readable label. + * - `value` (string) A slug used as the storage key. + * + * @since 7.1.0 + * + * @return array[] List of emoji definitions. + */ +function gutenberg_get_note_reaction_emojis() { + $default_emojis = array( + array( + 'emoji' => '❤️', + 'label' => __( 'Heart', 'gutenberg' ), + 'value' => 'heart', + ), + array( + 'emoji' => '🎉', + 'label' => __( 'Celebration', 'gutenberg' ), + 'value' => 'celebration', + ), + array( + 'emoji' => '😄', + 'label' => __( 'Smile', 'gutenberg' ), + 'value' => 'smile', + ), + array( + 'emoji' => '👀', + 'label' => __( 'Eyes', 'gutenberg' ), + 'value' => 'eyes', + ), + array( + 'emoji' => '🚀', + 'label' => __( 'Rocket', 'gutenberg' ), + 'value' => 'rocket', + ), + ); + + /** + * Filters the list of allowed emojis for note reactions. + * + * @since 7.1.0 + * + * @param array[] $emojis List of emoji definitions. Each item has + * `emoji`, `label`, and `value` keys. + */ + return apply_filters( 'gutenberg_note_reaction_emojis', $default_emojis ); +} diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index b8f13c0454d419..4bcdc2b641de59 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -11,6 +11,49 @@ class Gutenberg_REST_Comment_Controller_7_1 extends Gutenberg_REST_Comment_Controller_6_9 { + /** + * Retrieves the comment schema, adding reaction_emojis. + * + * Extends the parent schema with a read-only `reaction_emojis` + * property whose default value exposes the filtered emoji list. + * Clients can read this from the OPTIONS response to discover + * which reaction emojis the server accepts. + * + * @since 7.1.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = parent::get_item_schema(); + + $schema['properties']['reaction_emojis'] = array( + 'description' => __( 'Allowed emoji reactions for notes.', 'gutenberg' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'emoji' => array( + 'description' => __( 'The emoji character.', 'gutenberg' ), + 'type' => 'string', + ), + 'label' => array( + 'description' => __( 'A human-readable label for the emoji.', 'gutenberg' ), + 'type' => 'string', + ), + 'value' => array( + 'description' => __( 'The slug used as the storage key.', 'gutenberg' ), + 'type' => 'string', + ), + ), + ), + 'default' => gutenberg_get_note_reaction_emojis(), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ); + + return $schema; + } + /** * Checks whether the request type is a note or reaction. * @@ -312,7 +355,8 @@ public function create_item( $request ) { } // Validate content is a valid emoji slug. - $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); + $emojis = gutenberg_get_note_reaction_emojis(); + $valid_slugs = wp_list_pluck( $emojis, 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { return new WP_Error( diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index b069ca40935f07..4fa152e53d0133 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -18,6 +18,7 @@ import { store as coreStore } from '@wordpress/core-data'; import ReactionEmojiPicker, { getEmojiBySlug, getLabelBySlug, + useReactionEmojis, } from './reaction-emoji-picker'; import { unlock } from '../../lock-unlock'; @@ -89,9 +90,10 @@ function getReactedSlugs( reactions ) { * @param {Object} users Map of user data by ID. * @param {Array} userIds Array of user IDs who reacted. * @param {string} slug The reaction slug. + * @param {Array} emojis The emoji list to look up labels from. * @return {string} The tooltip text. */ -function getReactionTooltipText( users, userIds, slug ) { +function getReactionTooltipText( users, userIds, slug, emojis ) { const names = userIds .map( ( id ) => users?.[ id ]?.name ) .filter( Boolean ); @@ -100,7 +102,7 @@ function getReactionTooltipText( users, userIds, slug ) { return ''; } - const emojiLabel = getLabelBySlug( slug ); + const emojiLabel = getLabelBySlug( slug, emojis ); if ( names.length === 1 ) { return sprintf( @@ -144,6 +146,7 @@ function getReactionTooltipText( users, userIds, slug ) { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { reactions, onToggleReaction } ) { + const emojis = useReactionEmojis(); const currentUserId = useSelect( ( select ) => { const { getCurrentUser } = select( coreStore ); const user = getCurrentUser(); @@ -205,7 +208,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { slug, currentUserId ); - const emoji = getEmojiBySlug( slug ); + const emoji = getEmojiBySlug( slug, emojis ); const reactionUserIds = ( reactions?.[ slug ] || [] ).map( ( r ) => r.userId @@ -213,7 +216,8 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { const tooltipText = getReactionTooltipText( users, reactionUserIds, - slug + slug, + emojis ); // Use tooltip text when user data is loaded, otherwise fall back to basic label. diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 0fc23a184bfa47..fbd4cec1a52127 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -3,9 +3,11 @@ */ import { __ } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; /** - * Curated emoji set for reactions. + * Curated emoji set for reactions (fallback). * The `value` slug is used as the storage key in the database to avoid * potential encoding issues with emoji characters. */ @@ -17,24 +19,75 @@ export const REACTION_EMOJIS = [ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; +// Module-level cache so the OPTIONS request is made only once. +let cachedEmojis = null; +let fetchPromise = null; + +/** + * Hook that returns the allowed reaction emojis from the REST API schema. + * + * Fetches the emoji list from the `OPTIONS /wp/v2/comments` response + * and caches the result at module level. Falls back to the hardcoded + * REACTION_EMOJIS if the request fails or the schema property is missing. + * + * @return {Array} The list of allowed reaction emoji objects. + */ +export function useReactionEmojis() { + const [ emojis, setEmojis ] = useState( cachedEmojis || REACTION_EMOJIS ); + + useEffect( () => { + if ( cachedEmojis ) { + return; + } + + if ( ! fetchPromise ) { + fetchPromise = apiFetch( { + path: '/wp/v2/comments', + method: 'OPTIONS', + } ) + .then( ( response ) => { + const schemaEmojis = + response?.schema?.properties?.reaction_emojis + ?.default; + if ( Array.isArray( schemaEmojis ) && schemaEmojis.length ) { + cachedEmojis = schemaEmojis; + } else { + cachedEmojis = REACTION_EMOJIS; + } + } ) + .catch( () => { + cachedEmojis = REACTION_EMOJIS; + } ); + } + + fetchPromise.then( () => { + setEmojis( cachedEmojis ); + } ); + }, [] ); + + return emojis; +} + /** * Get the emoji character for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug. + * @param {Array} emojis Optional emoji list to search. * @return {string} The emoji character, or the slug if not found. */ -export function getEmojiBySlug( slug ) { - return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug; +export function getEmojiBySlug( slug, emojis = REACTION_EMOJIS ) { + return emojis.find( ( r ) => r.value === slug )?.emoji ?? slug; } /** * Get the label for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug. + * @param {Array} emojis Optional emoji list to search. * @return {string} The label, or the slug if not found. */ -export function getLabelBySlug( slug ) { - return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug; +export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { + return emojis.find( ( r ) => r.value === slug )?.label ?? slug; } /** @@ -44,13 +97,15 @@ export function getLabelBySlug( slug ) { * @param {Function} props.onSelect Callback when an emoji is selected. */ export default function ReactionEmojiPicker( { onSelect } ) { + const emojis = useReactionEmojis(); + return ( - { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( + { emojis.map( ( { emoji, label, value } ) => ( array( 'heart' ), - 'celebration' => array( 'celebration' ), - 'smile' => array( 'smile' ), - 'eyes' => array( 'eyes' ), - 'rocket' => array( 'rocket' ), + $emojis = gutenberg_get_note_reaction_emojis(); + $data = array(); + foreach ( $emojis as $emoji ) { + $data[ $emoji['value'] ] = array( $emoji['value'] ); + } + return $data; + } + + public function test_schema_includes_reaction_emojis() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'reaction_emojis', $data['schema']['properties'] ); + + $reaction_emojis_schema = $data['schema']['properties']['reaction_emojis']; + $this->assertTrue( $reaction_emojis_schema['readonly'] ); + $this->assertSame( 'array', $reaction_emojis_schema['type'] ); + $this->assertContains( 'view', $reaction_emojis_schema['context'] ); + $this->assertContains( 'edit', $reaction_emojis_schema['context'] ); + + // Verify the default matches the helper function output. + $expected = gutenberg_get_note_reaction_emojis(); + $this->assertSame( $expected, $reaction_emojis_schema['default'] ); + } + + public function test_reaction_emojis_filter_modifies_schema() { + $custom_emoji = array( + array( + 'emoji' => '👍', + 'label' => 'Thumbs Up', + 'value' => 'thumbsup', + ), + ); + + $filter = function () use ( $custom_emoji ) { + return $custom_emoji; + }; + add_filter( 'gutenberg_note_reaction_emojis', $filter ); + + // Re-instantiate the controller so the schema picks up the filtered value. + $server = rest_get_server(); + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $controller->register_routes(); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); + $response = $server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( + $custom_emoji, + $data['schema']['properties']['reaction_emojis']['default'] + ); + + remove_filter( 'gutenberg_note_reaction_emojis', $filter ); + } + + public function test_reaction_emojis_filter_affects_validation() { + $custom_emoji = array( + array( + 'emoji' => '👍', + 'label' => 'Thumbs Up', + 'value' => 'thumbsup', + ), ); + + $filter = function () use ( $custom_emoji ) { + return $custom_emoji; + }; + add_filter( 'gutenberg_note_reaction_emojis', $filter ); + + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + // The custom emoji should be accepted. + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => 'thumbsup', + 'author' => self::$editor_id, + ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + // A previously-default emoji should now be rejected. + $params['content'] = 'heart'; + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + + remove_filter( 'gutenberg_note_reaction_emojis', $filter ); } } From 8d5457d6f27a63d43eaa1e0c15e163426440f499 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 23 Mar 2026 11:49:00 -0700 Subject: [PATCH 060/362] Fix CI: add new PR to backport changelog and fix PHP alignment --- backport-changelog/7.0/10930.md | 1 + .../class-wp-rest-comments-controller-gutenberg-test.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backport-changelog/7.0/10930.md b/backport-changelog/7.0/10930.md index d2af26615dcc51..490e8f57459a85 100644 --- a/backport-changelog/7.0/10930.md +++ b/backport-changelog/7.0/10930.md @@ -2,3 +2,4 @@ https://github.com/WordPress/wordpress-develop/pull/10930 * https://github.com/WordPress/gutenberg/pull/75148 * https://github.com/WordPress/gutenberg/pull/75549 +* https://github.com/WordPress/gutenberg/pull/76767 diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 1448e8a7b823ba..fdcdb483b6cb1c 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -708,7 +708,7 @@ public function test_reaction_emojis_filter_modifies_schema() { add_filter( 'gutenberg_note_reaction_emojis', $filter ); // Re-instantiate the controller so the schema picks up the filtered value. - $server = rest_get_server(); + $server = rest_get_server(); $controller = new Gutenberg_REST_Comment_Controller_7_1(); $controller->register_routes(); From d97cf1aa673582adf1ba9629edcfff9274720331 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 23 Mar 2026 13:02:04 -0700 Subject: [PATCH 061/362] Fix children link type for notes, unmount safety, and stale closure - Fix children link to query for 'reaction' type when parent is a note - Add unmount cleanup flag to useReactionEmojis to prevent state updates after component unmounts - Wrap onToggleReaction in useCallback with reactionsMap dependency to prevent stale closure --- ...-gutenberg-rest-comment-controller-7-1.php | 6 +- .../src/components/collab-sidebar/hooks.js | 108 ++++++++++-------- .../collab-sidebar/reaction-emoji-picker.js | 10 +- 3 files changed, 72 insertions(+), 52 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index 4bcdc2b641de59..bc7f387c9f8112 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -566,9 +566,11 @@ protected function prepare_links( $comment ) { // Embedding children for notes and reactions requires `type` and `status` inheritance. if ( isset( $links['children'] ) && $this->is_note_or_reaction( $comment->comment_type ) ) { - $args = array( + // Notes have reaction children; reactions don't have children. + $child_type = 'note' === $comment->comment_type ? 'reaction' : $comment->comment_type; + $args = array( 'parent' => $comment->comment_ID, - 'type' => $comment->comment_type, + 'type' => $child_type, 'status' => 'all', ); diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 83a371e25e94c8..361a1f40640a49 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -384,58 +384,68 @@ export function useBlockCommentsActions( } }; - const onToggleReaction = async ( { commentId, emoji } ) => { - try { - // Get current user from the store. - const currentUser = - registry.select( coreStore ).getCurrentUser() || {}; - const userId = currentUser.id; - - // Check if the user already reacted with this emoji. - const noteReactions = reactionsMap[ commentId ] || {}; - const emojiReactions = noteReactions[ emoji ] || []; - const existingReaction = emojiReactions.find( - ( reaction ) => reaction.userId === userId - ); - - if ( existingReaction ) { - // Remove the reaction by deleting the comment record. - await deleteEntityRecord( - 'root', - 'comment', - existingReaction.reactionId, - undefined, - { throwOnError: true } - ); - - createNotice( 'snackbar', __( 'Reaction removed.' ), { - type: 'snackbar', - isDismissible: true, - } ); - } else { - // Add a new reaction as a comment record. - await saveEntityRecord( - 'root', - 'comment', - { - post: getCurrentPostId(), - type: 'reaction', - parent: commentId, - content: emoji, - status: 'approve', - }, - { throwOnError: true } + const onToggleReaction = useCallback( + async ( { commentId, emoji } ) => { + try { + // Get current user from the store. + const currentUser = + registry.select( coreStore ).getCurrentUser() || {}; + const userId = currentUser.id; + + // Check if the user already reacted with this emoji. + const noteReactions = reactionsMap[ commentId ] || {}; + const emojiReactions = noteReactions[ emoji ] || []; + const existingReaction = emojiReactions.find( + ( reaction ) => reaction.userId === userId ); - createNotice( 'snackbar', __( 'Reaction added.' ), { - type: 'snackbar', - isDismissible: true, - } ); + if ( existingReaction ) { + // Remove the reaction by deleting the comment record. + await deleteEntityRecord( + 'root', + 'comment', + existingReaction.reactionId, + undefined, + { throwOnError: true } + ); + + createNotice( 'snackbar', __( 'Reaction removed.' ), { + type: 'snackbar', + isDismissible: true, + } ); + } else { + // Add a new reaction as a comment record. + await saveEntityRecord( + 'root', + 'comment', + { + post: getCurrentPostId(), + type: 'reaction', + parent: commentId, + content: emoji, + status: 'approve', + }, + { throwOnError: true } + ); + + createNotice( 'snackbar', __( 'Reaction added.' ), { + type: 'snackbar', + isDismissible: true, + } ); + } + } catch ( error ) { + onError( error ); } - } catch ( error ) { - onError( error ); - } - }; + }, + [ + reactionsMap, + registry, + deleteEntityRecord, + saveEntityRecord, + getCurrentPostId, + createNotice, + ] + ); return { onCreate, diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index fbd4cec1a52127..c11268f53d551f 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -40,6 +40,8 @@ export function useReactionEmojis() { return; } + let isMounted = true; + if ( ! fetchPromise ) { fetchPromise = apiFetch( { path: '/wp/v2/comments', @@ -61,8 +63,14 @@ export function useReactionEmojis() { } fetchPromise.then( () => { - setEmojis( cachedEmojis ); + if ( isMounted ) { + setEmojis( cachedEmojis ); + } } ); + + return () => { + isMounted = false; + }; }, [] ); return emojis; From 5028f975f9c7cf452df3fbcac752602d08f21f08 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 23 Mar 2026 13:09:12 -0700 Subject: [PATCH 062/362] Fix schema tests to call get_item_schema() directly The OPTIONS dispatch on a collection route may not include the schema in the expected structure. Test the controller's schema directly. --- ...rest-comments-controller-gutenberg-test.php | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index fdcdb483b6cb1c..5cfa8e0a7063ab 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -676,13 +676,12 @@ public function data_valid_reaction_emojis() { } public function test_schema_includes_reaction_emojis() { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $schema = $controller->get_item_schema(); - $this->assertArrayHasKey( 'reaction_emojis', $data['schema']['properties'] ); + $this->assertArrayHasKey( 'reaction_emojis', $schema['properties'] ); - $reaction_emojis_schema = $data['schema']['properties']['reaction_emojis']; + $reaction_emojis_schema = $schema['properties']['reaction_emojis']; $this->assertTrue( $reaction_emojis_schema['readonly'] ); $this->assertSame( 'array', $reaction_emojis_schema['type'] ); $this->assertContains( 'view', $reaction_emojis_schema['context'] ); @@ -708,17 +707,12 @@ public function test_reaction_emojis_filter_modifies_schema() { add_filter( 'gutenberg_note_reaction_emojis', $filter ); // Re-instantiate the controller so the schema picks up the filtered value. - $server = rest_get_server(); $controller = new Gutenberg_REST_Comment_Controller_7_1(); - $controller->register_routes(); - - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); - $response = $server->dispatch( $request ); - $data = $response->get_data(); + $schema = $controller->get_item_schema(); $this->assertSame( $custom_emoji, - $data['schema']['properties']['reaction_emojis']['default'] + $schema['properties']['reaction_emojis']['default'] ); remove_filter( 'gutenberg_note_reaction_emojis', $filter ); From c6b5c810c83edf7f942315186c050579a99e7b28 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 23 Mar 2026 13:35:47 -0700 Subject: [PATCH 063/362] Fix prettier formatting in reaction-emoji-picker --- .../components/collab-sidebar/reaction-emoji-picker.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index c11268f53d551f..9e26b39dbb5fde 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -49,9 +49,11 @@ export function useReactionEmojis() { } ) .then( ( response ) => { const schemaEmojis = - response?.schema?.properties?.reaction_emojis - ?.default; - if ( Array.isArray( schemaEmojis ) && schemaEmojis.length ) { + response?.schema?.properties?.reaction_emojis?.default; + if ( + Array.isArray( schemaEmojis ) && + schemaEmojis.length + ) { cachedEmojis = schemaEmojis; } else { cachedEmojis = REACTION_EMOJIS; From 64cd35e1d453290fdf021c1637dc8b400fa60b8e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 3 Apr 2026 10:44:57 -0700 Subject: [PATCH 064/362] Add reaction_summary field to note REST responses Add a computed reaction_summary field to note comment REST responses that returns grouped reaction counts, whether the current user reacted, and the user's reaction ID for each emoji. This eliminates the need for clients to fetch all individual reaction records separately. Includes batch pre-fetching in get_items() to avoid N+1 queries when returning multiple notes. --- ...-gutenberg-rest-comment-controller-7-1.php | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index bc7f387c9f8112..1b9a8f36ec50c9 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -51,6 +51,30 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['reaction_summary'] = array( + 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji slug.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'count' => array( + 'description' => __( 'Total number of reactions with this emoji.', 'gutenberg' ), + 'type' => 'integer', + ), + 'reacted' => array( + 'description' => __( 'Whether the current user reacted with this emoji.', 'gutenberg' ), + 'type' => 'boolean', + ), + 'my_reaction_id' => array( + 'description' => __( 'The current user\'s reaction comment ID, or 0 if not reacted.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + ); + return $schema; } @@ -550,6 +574,183 @@ public function create_item( $request ) { return $response; } + /** + * Pre-fetched reaction summaries for batch queries. + * + * Populated by get_items() to avoid N+1 queries when returning + * multiple note comments. + * + * @since 7.1.0 + * @var array|null + */ + protected $reaction_summaries = null; + + /** + * Retrieves a collection of comments. + * + * Extends the parent to pre-fetch reaction summaries for all returned + * note comments in a single batch query, avoiding N+1 queries. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. + */ + public function get_items( $request ) { + $fields = $this->get_fields_for_response( $request ); + + // Pre-fetch reaction summaries when requesting notes with reaction_summary field. + if ( + ! empty( $request['type'] ) && + 'note' === $request['type'] && + rest_is_field_included( 'reaction_summary', $fields ) + ) { + // Run the same query logic as parent to get the comment IDs. + $registered = $this->get_collection_params(); + $parameter_mappings = array( + 'author' => 'author__in', + 'author_email' => 'author_email', + 'author_exclude' => 'author__not_in', + 'exclude' => 'comment__not_in', + 'include' => 'comment__in', + 'offset' => 'offset', + 'order' => 'order', + 'parent' => 'parent__in', + 'parent_exclude' => 'parent__not_in', + 'per_page' => 'number', + 'post' => 'post__in', + 'search' => 'search', + 'status' => 'status', + 'type' => 'type', + ); + + $prepared_args = array(); + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $prepared_args[ $wp_param ] = $request[ $api_param ]; + } + } + + // Only fetch IDs for the pre-fetch query. + $prepared_args['fields'] = 'ids'; + + $query = new WP_Comment_Query(); + $note_ids = $query->query( $prepared_args ); + + if ( ! empty( $note_ids ) ) { + $this->prefetch_reaction_summaries( array_map( 'intval', $note_ids ) ); + } + } + + $response = parent::get_items( $request ); + + $this->reaction_summaries = null; + + return $response; + } + + /** + * Pre-fetches reaction summaries for a set of note IDs. + * + * Runs a single aggregated query to build summaries for all notes, + * avoiding N+1 queries in prepare_item_for_response(). + * + * @since 7.1.0 + * + * @param int[] $note_ids Array of note comment IDs. + */ + protected function prefetch_reaction_summaries( $note_ids ) { + global $wpdb; + + $this->reaction_summaries = array(); + + if ( empty( $note_ids ) ) { + return; + } + + $current_user_id = get_current_user_id(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count, + GROUP_CONCAT(CONCAT(comment_ID, ':', user_id) SEPARATOR ',') AS details + FROM {$wpdb->comments} + WHERE comment_parent IN (" . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') + AND comment_type = %s + AND comment_approved = %s + GROUP BY comment_parent, comment_content', + ...array_merge( $note_ids, array( 'reaction', '1' ) ) + ) + ); + + // Initialize empty summaries for all note IDs. + foreach ( $note_ids as $note_id ) { + $this->reaction_summaries[ $note_id ] = array(); + } + + if ( ! $results ) { + return; + } + + foreach ( $results as $row ) { + $note_id = (int) $row->comment_parent; + $slug = wp_strip_all_tags( $row->comment_content ); + + $my_reaction_id = 0; + if ( $current_user_id && ! empty( $row->details ) ) { + $pairs = explode( ',', $row->details ); + foreach ( $pairs as $pair ) { + list( $comment_id, $user_id ) = explode( ':', $pair ); + if ( (int) $user_id === $current_user_id ) { + $my_reaction_id = (int) $comment_id; + break; + } + } + } + + $this->reaction_summaries[ $note_id ][ $slug ] = array( + 'count' => (int) $row->reaction_count, + 'reacted' => $my_reaction_id > 0, + 'my_reaction_id' => $my_reaction_id, + ); + } + } + + /** + * Prepares a single comment output for response. + * + * Extends the parent to include reaction_summary for note comments. + * + * @since 7.1.0 + * + * @param WP_Comment $item Comment object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $response = parent::prepare_item_for_response( $item, $request ); + $fields = $this->get_fields_for_response( $request ); + + if ( 'note' === $item->comment_type && rest_is_field_included( 'reaction_summary', $fields ) ) { + // Use pre-fetched data if available, otherwise query individually. + if ( null !== $this->reaction_summaries && isset( $this->reaction_summaries[ (int) $item->comment_ID ] ) ) { + $summary = $this->reaction_summaries[ (int) $item->comment_ID ]; + } else { + $this->prefetch_reaction_summaries( array( (int) $item->comment_ID ) ); + $summary = $this->reaction_summaries[ (int) $item->comment_ID ] ?? array(); + // Reset so the next call also queries individually. + $this->reaction_summaries = null; + } + + $data = $response->get_data(); + $data['reaction_summary'] = $summary; + $response->set_data( $data ); + } + + return $response; + } + /** * Prepares links for the request. * From 1c9587378e744601a34f9079735ec1b8a7ae7cf7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 3 Apr 2026 10:48:12 -0700 Subject: [PATCH 065/362] Use reaction_summary from REST API instead of fetching all reactions Replace the separate unbounded useEntityRecords call for reactions with the reaction_summary field returned on each note. This eliminates a separate API request and the per_page:-1 fetch of all individual reaction records, addressing unbounded request and performance concerns. Also removes the eager user data fetch for tooltips, using count-based labels instead. --- .../src/components/collab-sidebar/hooks.js | 62 ++----- .../collab-sidebar/reaction-display.js | 161 ++---------------- 2 files changed, 28 insertions(+), 195 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 361a1f40640a49..2c5ac55e119c23 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -57,51 +57,21 @@ export function useBlockComments( postId ) { { enabled: !! postId && typeof postId === 'number' } ); - const { records: reactionRecords } = useEntityRecords( - 'root', - 'comment', - { - post: postId, - type: 'reaction', - status: 'all', - per_page: -1, - }, - { enabled: !! postId && typeof postId === 'number' } - ); - - // Build a reactionsMap: { [noteId]: { [emojiSlug]: [{ userId, date, reactionId }] } } + // Build reactionsMap from the reaction_summary field on each note. + // Shape: { [noteId]: { [emojiSlug]: { count, reacted, my_reaction_id } } } const reactionsMap = useMemo( () => { - if ( ! reactionRecords || reactionRecords.length === 0 ) { + if ( ! threads || threads.length === 0 ) { return {}; } const map = {}; - reactionRecords.forEach( ( reaction ) => { - const noteId = reaction.parent; - const slug = - typeof reaction.content === 'object' - ? reaction.content?.raw || reaction.content?.rendered - : reaction.content; - // Strip HTML tags from rendered content. - const cleanSlug = slug?.replace?.( /<[^>]*>/g, '' )?.trim(); - if ( ! noteId || ! cleanSlug ) { - return; + threads.forEach( ( thread ) => { + if ( thread.reaction_summary ) { + map[ thread.id ] = thread.reaction_summary; } - - if ( ! map[ noteId ] ) { - map[ noteId ] = {}; - } - if ( ! map[ noteId ][ cleanSlug ] ) { - map[ noteId ][ cleanSlug ] = []; - } - map[ noteId ][ cleanSlug ].push( { - userId: reaction.author, - date: reaction.date, - reactionId: reaction.id, - } ); } ); return map; - }, [ reactionRecords ] ); + }, [ threads ] ); const { getBlockAttributes } = useSelect( blockEditorStore ); const { clientIds } = useSelect( ( select ) => { @@ -221,7 +191,6 @@ export function useBlockCommentsActions( reflowComments = noop, reactionsMap = {} ) { - const registry = useRegistry(); const { createNotice } = useDispatch( noticesStore ); const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); @@ -387,24 +356,16 @@ export function useBlockCommentsActions( const onToggleReaction = useCallback( async ( { commentId, emoji } ) => { try { - // Get current user from the store. - const currentUser = - registry.select( coreStore ).getCurrentUser() || {}; - const userId = currentUser.id; - - // Check if the user already reacted with this emoji. + // Check if the user already reacted via reaction_summary. const noteReactions = reactionsMap[ commentId ] || {}; - const emojiReactions = noteReactions[ emoji ] || []; - const existingReaction = emojiReactions.find( - ( reaction ) => reaction.userId === userId - ); + const emojiData = noteReactions[ emoji ]; - if ( existingReaction ) { + if ( emojiData?.reacted && emojiData?.my_reaction_id ) { // Remove the reaction by deleting the comment record. await deleteEntityRecord( 'root', 'comment', - existingReaction.reactionId, + emojiData.my_reaction_id, undefined, { throwOnError: true } ); @@ -439,7 +400,6 @@ export function useBlockCommentsActions( }, [ reactionsMap, - registry, deleteEntityRecord, saveEntityRecord, getCurrentPostId, diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 4fa152e53d0133..04a76a33f7dcea 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -8,9 +8,7 @@ import { privateApis as componentsPrivateApis, } from '@wordpress/components'; import { SVG, Path } from '@wordpress/primitives'; -import { useSelect } from '@wordpress/data'; -import { useMemo, useState } from '@wordpress/element'; -import { store as coreStore } from '@wordpress/core-data'; +import { useState } from '@wordpress/element'; /** * Internal dependencies @@ -45,34 +43,29 @@ const smileyIcon = ( /** * Get the count of reactions for a specific slug. * - * @param {Object} reactions The reactions object (keyed by slug). + * @param {Object} reactions The reactions summary (keyed by slug). * @param {string} slug The reaction slug to count. * @return {number} The count of reactions. */ function getReactionCount( reactions, slug ) { - return reactions?.[ slug ]?.length || 0; + return reactions?.[ slug ]?.count || 0; } /** * Check if the current user has reacted with a specific slug. * - * @param {Object} reactions The reactions object (keyed by slug). - * @param {string} slug The reaction slug to check. - * @param {number} currentUserId The current user's ID. + * @param {Object} reactions The reactions summary (keyed by slug). + * @param {string} slug The reaction slug to check. * @return {boolean} Whether the user has reacted. */ -function hasUserReacted( reactions, slug, currentUserId ) { - return ( - reactions?.[ slug ]?.some( - ( reaction ) => reaction.userId === currentUserId - ) || false - ); +function hasUserReacted( reactions, slug ) { + return reactions?.[ slug ]?.reacted || false; } /** * Get all reaction slugs that have reactions. * - * @param {Object} reactions The reactions object (keyed by slug). + * @param {Object} reactions The reactions summary (keyed by slug). * @return {string[]} Array of slugs with reactions. */ function getReactedSlugs( reactions ) { @@ -80,61 +73,7 @@ function getReactedSlugs( reactions ) { return []; } return Object.keys( reactions ).filter( - ( slug ) => reactions[ slug ]?.length > 0 - ); -} - -/** - * Generate GitHub-style tooltip text for a reaction. - * - * @param {Object} users Map of user data by ID. - * @param {Array} userIds Array of user IDs who reacted. - * @param {string} slug The reaction slug. - * @param {Array} emojis The emoji list to look up labels from. - * @return {string} The tooltip text. - */ -function getReactionTooltipText( users, userIds, slug, emojis ) { - const names = userIds - .map( ( id ) => users?.[ id ]?.name ) - .filter( Boolean ); - - if ( names.length === 0 ) { - return ''; - } - - const emojiLabel = getLabelBySlug( slug, emojis ); - - if ( names.length === 1 ) { - return sprintf( - /* translators: 1: user name, 2: emoji label. */ - __( '%1$s reacted with %2$s emoji' ), - names[ 0 ], - emojiLabel - ); - } - - if ( names.length === 2 ) { - return sprintf( - /* translators: 1: first user name, 2: second user name, 3: emoji label. */ - __( '%1$s and %2$s reacted with %3$s emoji' ), - names[ 0 ], - names[ 1 ], - emojiLabel - ); - } - - const othersCount = names.length - 2; - return sprintf( - /* translators: 1: first user name, 2: second user name, 3: number of other users, 4: emoji label. */ - _n( - '%1$s, %2$s, and %3$d other reacted with %4$s emoji', - '%1$s, %2$s, and %3$d others reacted with %4$s emoji', - othersCount - ), - names[ 0 ], - names[ 1 ], - othersCount, - emojiLabel + ( slug ) => reactions[ slug ]?.count > 0 ); } @@ -142,59 +81,13 @@ function getReactionTooltipText( users, userIds, slug, emojis ) { * Display current reactions with counts as pill-shaped buttons. * * @param {Object} props Component props. - * @param {Object} props.reactions The reactions object from comment meta. + * @param {Object} props.reactions The reaction summary (keyed by slug). * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { reactions, onToggleReaction } ) { const emojis = useReactionEmojis(); - const currentUserId = useSelect( ( select ) => { - const { getCurrentUser } = select( coreStore ); - const user = getCurrentUser(); - return user?.id; - }, [] ); const reactedSlugs = getReactedSlugs( reactions ); - // Collect all unique user IDs from reactions. - const userIdArray = useMemo( () => { - if ( ! reactions ) { - return []; - } - const userIdSet = new Set(); - Object.values( reactions ).forEach( ( reactionList ) => { - reactionList?.forEach( ( reaction ) => { - if ( reaction.userId ) { - userIdSet.add( reaction.userId ); - } - } ); - } ); - return Array.from( userIdSet ).sort( ( a, b ) => a - b ); - }, [ reactions ] ); - - // Fetch user data for all users who reacted. - const users = useSelect( - ( select ) => { - if ( ! userIdArray.length ) { - return {}; - } - const { getUsers } = select( coreStore ); - const userList = getUsers( { - include: userIdArray, - context: 'view', - _fields: 'id,name', - per_page: -1, - } ); - if ( ! userList ) { - return {}; - } - const userData = {}; - userList.forEach( ( user ) => { - userData[ user.id ] = user; - } ); - return userData; - }, - [ userIdArray ] - ); - if ( reactedSlugs.length === 0 ) { return null; } @@ -203,36 +96,16 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { { reactedSlugs.map( ( slug ) => { const count = getReactionCount( reactions, slug ); - const isActive = hasUserReacted( - reactions, - slug, - currentUserId - ); + const isActive = hasUserReacted( reactions, slug ); const emoji = getEmojiBySlug( slug, emojis ); + const emojiLabel = getLabelBySlug( slug, emojis ); - const reactionUserIds = ( reactions?.[ slug ] || [] ).map( - ( r ) => r.userId + const buttonLabel = sprintf( + /* translators: 1: emoji label, 2: count of reactions */ + _n( '%1$s, %2$d reaction', '%1$s, %2$d reactions', count ), + emojiLabel, + count ); - const tooltipText = getReactionTooltipText( - users, - reactionUserIds, - slug, - emojis - ); - - // Use tooltip text when user data is loaded, otherwise fall back to basic label. - const buttonLabel = tooltipText - ? tooltipText - : sprintf( - /* translators: 1: emoji, 2: count of reactions */ - _n( - '%1$s, %2$d reaction', - '%1$s, %2$d reactions', - count - ), - emoji, - count - ); return ( + ); +} + /** * Display current reactions with counts as pill-shaped buttons. * * @param {Object} props Component props. + * @param {number} props.noteId The parent note comment ID. * @param {Object} props.reactions The reaction summary (keyed by slug). * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ -export default function ReactionDisplay( { reactions, onToggleReaction } ) { +export default function ReactionDisplay( { + noteId, + reactions, + onToggleReaction, +} ) { const emojis = useReactionEmojis(); const reactedSlugs = getReactedSlugs( reactions ); @@ -79,42 +247,18 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { { reactedSlugs.map( ( slug ) => { const count = getReactionCount( reactions, slug ); const isActive = hasUserReacted( reactions, slug ); - const emoji = getEmojiBySlug( slug, emojis ); - const emojiLabel = getLabelBySlug( slug, emojis ); - - const buttonLabel = sprintf( - /* translators: 1: emoji label, 2: count of reactions */ - _n( '%1$s, %2$d reaction', '%1$s, %2$d reactions', count ), - emojiLabel, - count - ); return ( - + noteId={ noteId } + slug={ slug } + count={ count } + isActive={ isActive } + emoji={ getEmojiBySlug( slug, emojis ) } + emojiLabel={ getLabelBySlug( slug, emojis ) } + onToggleReaction={ onToggleReaction } + /> ); } ) } From 0a0350d9b186b0510fc65cf332b03689c3d4a8ea Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 3 Apr 2026 17:29:01 -0700 Subject: [PATCH 071/362] Replace GROUP_CONCAT with separate user query for reaction summaries Split the single GROUP_CONCAT-based query into two simpler queries: one for aggregated counts and one for the current user's reaction IDs. This avoids MySQL's group_concat_max_len truncation on notes with many reactions. --- ...-gutenberg-rest-comment-controller-7-1.php | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index a4072bdb263e4f..8c08c6302bb90c 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -670,13 +670,13 @@ protected function prefetch_reaction_summaries( $note_ids ) { $current_user_id = get_current_user_id(); + // Query 1: Get aggregated counts per emoji per note. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $results = $wpdb->get_results( + $counts = $wpdb->get_results( $wpdb->prepare( - "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count, - GROUP_CONCAT(CONCAT(comment_ID, ':', user_id) SEPARATOR ',') AS details - FROM {$wpdb->comments} - WHERE comment_parent IN (" . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') + 'SELECT comment_parent, comment_content, COUNT(*) AS reaction_count + FROM ' . $wpdb->comments . ' + WHERE comment_parent IN (' . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') AND comment_type = %s AND comment_approved = %s GROUP BY comment_parent, comment_content', @@ -684,30 +684,44 @@ protected function prefetch_reaction_summaries( $note_ids ) { ) ); + // Query 2: Get the current user's own reaction IDs (if logged in). + $my_reactions = array(); + if ( $current_user_id ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $user_rows = $wpdb->get_results( + $wpdb->prepare( + 'SELECT comment_ID, comment_parent, comment_content + FROM ' . $wpdb->comments . ' + WHERE comment_parent IN (' . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') + AND comment_type = %s + AND comment_approved = %s + AND user_id = %d', + ...array_merge( $note_ids, array( 'reaction', '1', $current_user_id ) ) + ) + ); + + if ( $user_rows ) { + foreach ( $user_rows as $row ) { + $key = (int) $row->comment_parent . ':' . wp_strip_all_tags( $row->comment_content ); + $my_reactions[ $key ] = (int) $row->comment_ID; + } + } + } + // Initialize empty summaries for all note IDs. foreach ( $note_ids as $note_id ) { $this->reaction_summaries[ $note_id ] = array(); } - if ( ! $results ) { + if ( ! $counts ) { return; } - foreach ( $results as $row ) { - $note_id = (int) $row->comment_parent; - $slug = wp_strip_all_tags( $row->comment_content ); - - $my_reaction_id = 0; - if ( $current_user_id && ! empty( $row->details ) ) { - $pairs = explode( ',', $row->details ); - foreach ( $pairs as $pair ) { - list( $comment_id, $user_id ) = explode( ':', $pair ); - if ( (int) $user_id === $current_user_id ) { - $my_reaction_id = (int) $comment_id; - break; - } - } - } + foreach ( $counts as $row ) { + $note_id = (int) $row->comment_parent; + $slug = wp_strip_all_tags( $row->comment_content ); + $key = $note_id . ':' . $slug; + $my_reaction_id = $my_reactions[ $key ] ?? 0; $this->reaction_summaries[ $note_id ][ $slug ] = array( 'count' => (int) $row->reaction_count, From 9064196bdd5fd0302748f0e4bb57741968be07f3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 19:55:29 -0700 Subject: [PATCH 072/362] Notes: Document rationale for storing reactions as a custom comment type Add a docblock to lib/compat/wordpress-7.1/block-comments.php explaining why reactions use a custom 'reaction' comment type instead of comment meta: free authorship/timestamps from comment APIs, generic type that can attach to other resources, and per-row writes to avoid serialized-meta races. Links to the discussion in PRs #75549 and #75148. --- lib/compat/wordpress-7.1/block-comments.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index 7940f597f5331f..bc8a114677e8df 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -5,6 +5,21 @@ * Extends note-related block comment functions to also handle * the 'reaction' comment type for emoji reactions on notes. * + * Why a custom comment type (vs. comment meta on the parent note)? + * + * - Each reaction has a real author, date, and ID, so existing comment + * APIs handle authorship, timestamps, and deletion without bespoke code. + * This avoids client-side date math and timezone bugs. + * - The type is generic ('reaction', not 'note_reaction') so it can later + * attach to any commentable resource (blocks, posts, other comment types), + * not just block notes. + * - Race conditions when adding/removing reactions concurrently are handled + * by the comments table (one row per reaction) rather than by read-modify- + * write on a serialized meta value. + * + * Background discussion: https://github.com/WordPress/gutenberg/pull/75549 + * and https://github.com/WordPress/gutenberg/pull/75148. + * * @package gutenberg */ From acf83b7ed176c01b5a9ed1648f32ec13054e19e8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 19:58:52 -0700 Subject: [PATCH 073/362] Notes: Update reaction E2E tests to renamed fixture and method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trunk's collab-sidebar refactor renamed the test fixture and helper: - blockCommentUtils → blockNoteUtils - addBlockWithComment → addBlockWithNote Update the Emoji Reactions describe block to use the current names so Playwright stops failing with 'Test has unknown parameter blockCommentUtils'. --- .../specs/editor/various/block-notes.spec.js | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 21498b9b8d6ece..35de6aa96fba7d 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -852,15 +852,15 @@ test.describe( 'Block Notes', () => { test.describe( 'Emoji Reactions', () => { test( 'can add an emoji reaction to a note', async ( { page, - blockCommentUtils, + blockNoteUtils, } ) => { - await blockCommentUtils.addBlockWithComment( { + await blockNoteUtils.addBlockWithNote( { type: 'core/paragraph', attributes: { content: 'Testing emoji reactions' }, comment: 'Test comment for reactions', } ); - await blockCommentUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'Heart' ); await expect( page @@ -878,16 +878,16 @@ test.describe( 'Block Notes', () => { test( 'can remove own emoji reaction by clicking it', async ( { page, - blockCommentUtils, + blockNoteUtils, } ) => { - await blockCommentUtils.addBlockWithComment( { + await blockNoteUtils.addBlockWithNote( { type: 'core/paragraph', attributes: { content: 'Testing reaction removal' }, comment: 'Test comment for removing reactions', } ); // Add a reaction. - await blockCommentUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'Heart' ); await expect( page .getByRole( 'button', { name: 'Dismiss this notice' } ) @@ -912,16 +912,16 @@ test.describe( 'Block Notes', () => { test( 'can see reaction tooltip on hover', async ( { page, - blockCommentUtils, + blockNoteUtils, } ) => { - await blockCommentUtils.addBlockWithComment( { + await blockNoteUtils.addBlockWithNote( { type: 'core/paragraph', attributes: { content: 'Testing reaction tooltip' }, comment: 'Test comment for reaction tooltip', } ); // Add a reaction. - await blockCommentUtils.addReactionToComment( 'Celebration' ); + await blockNoteUtils.addReactionToComment( 'Celebration' ); await expect( page .getByRole( 'button', { name: 'Dismiss this notice' } ) @@ -943,9 +943,9 @@ test.describe( 'Block Notes', () => { test( 'reaction buttons are keyboard accessible', async ( { page, - blockCommentUtils, + blockNoteUtils, } ) => { - await blockCommentUtils.addBlockWithComment( { + await blockNoteUtils.addBlockWithNote( { type: 'core/paragraph', attributes: { content: 'Testing keyboard accessibility' }, comment: 'Test comment for keyboard access', @@ -979,16 +979,16 @@ test.describe( 'Block Notes', () => { test( 'can add multiple different reactions to same note', async ( { page, - blockCommentUtils, + blockNoteUtils, } ) => { - await blockCommentUtils.addBlockWithComment( { + await blockNoteUtils.addBlockWithNote( { type: 'core/paragraph', attributes: { content: 'Testing multiple reactions' }, comment: 'Test comment for multiple reactions', } ); // Add first reaction. - await blockCommentUtils.addReactionToComment( 'Smile' ); + await blockNoteUtils.addReactionToComment( 'Smile' ); await expect( page .getByRole( 'button', { name: 'Dismiss this notice' } ) @@ -996,7 +996,7 @@ test.describe( 'Block Notes', () => { ).toBeVisible(); // Add second reaction. - await blockCommentUtils.addReactionToComment( 'Rocket' ); + await blockNoteUtils.addReactionToComment( 'Rocket' ); await expect( page .getByRole( 'button', { name: 'Dismiss this notice' } ) From 73b0fd759b10d74f5888d7b32ab7ef12debb684f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 20:26:16 -0700 Subject: [PATCH 074/362] Notes: Use --wpds-cursor-control token on add-reaction button The Lint Styles check enforces using the design system cursor token for interactive non-link controls (rule added in trunk). Replace 'cursor: pointer' on .editor-collab-sidebar-panel__add-reaction-button to satisfy the rule. --- packages/editor/src/components/collab-sidebar/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 3336b659868310..992af275fff55f 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -235,7 +235,7 @@ border-radius: $radius-round; border: $border-width solid $gray-300; background-color: $white; - cursor: pointer; + cursor: var(--wpds-cursor-control); &:hover { background-color: $gray-100; From ea192002fe8d44326ccadece1c618c20414e7fed Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 20:48:58 -0700 Subject: [PATCH 075/362] Notes: Centralize internal comment types via gutenberg_get_internal_comment_types(). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the change being proposed for core in https://github.com/WordPress/wordpress-develop/pull/10930. Introduces a single helper that returns the list of internal comment types ('note', 'reaction'), filterable so future additions only need to update one place. Apply it across all the existing 'note'/'reaction' guards in the 7.1 compat layer: * gutenberg_update_get_avatar_comment_type_7_1() — avatar-eligible types. * gutenberg_exclude_block_comments_from_admin_7_1() — admin query exclude. * gutenberg_filter_comment_count_query_exclude_block_comments_7_1() — pending-count SQL guard. * gutenberg_hide_note_from_comment_list_table_7_1() — list table args. * gutenberg_exclude_notes_from_comment_count_7_1() — approved-count SQL. * Gutenberg_REST_Comment_Controller_7_1::is_note_or_reaction() — REST controller's per-request type check (used by permissions, prepare, validation, and links). Props westonruter for the suggestion to centralize this list. --- lib/compat/wordpress-7.1/block-comments.php | 56 ++++++++++++++++--- ...-gutenberg-rest-comment-controller-7-1.php | 10 ++-- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index bc8a114677e8df..adfa68db6fd6b3 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -24,7 +24,34 @@ */ /** - * Updates the comment type for avatars to include reactions. + * Returns the list of internal comment types used by core features. + * + * Internal comment types (currently 'note' and 'reaction') back editor + * functionality such as block notes and emoji reactions, and should be + * excluded from front-end comment listings, counts, and similar contexts + * that target user discussion. Centralizing the list keeps every guard + * in sync when new internal types are added. + * + * Mirrors the planned `wp_get_internal_comment_types()` core helper + * (see https://github.com/WordPress/wordpress-develop/pull/10930). + * + * @since 7.1.0 + * + * @return string[] List of internal comment type slugs. + */ +function gutenberg_get_internal_comment_types() { + /** + * Filters the list of internal comment types. + * + * @since 7.1.0 + * + * @param string[] $types List of internal comment type slugs. + */ + return apply_filters( 'gutenberg_internal_comment_types', array( 'note', 'reaction' ) ); +} + +/** + * Updates the comment type for avatars to include internal comment types. * * Replaces the 6.9 implementation to also add the 'reaction' type * to the list of comment types for which avatars should be retrieved. @@ -33,9 +60,7 @@ * @return array The updated array of comment types. */ function gutenberg_update_get_avatar_comment_type_7_1( $comment_type ) { - $comment_type[] = 'note'; - $comment_type[] = 'reaction'; - return $comment_type; + return array_values( array_unique( array_merge( $comment_type, gutenberg_get_internal_comment_types() ) ) ); } remove_filter( 'get_avatar_comment_types', 'update_get_avatar_comment_type' ); add_filter( 'get_avatar_comment_types', 'gutenberg_update_get_avatar_comment_type_7_1' ); @@ -57,7 +82,10 @@ function gutenberg_exclude_block_comments_from_admin_7_1( $clauses, $query ) { $query->set( 'type', '' ); global $wpdb; - $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'reaction'"; + $internal_types = gutenberg_get_internal_comment_types(); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $clauses['where'] .= ' AND ' . $wpdb->prepare( "{$wpdb->comments}.comment_type NOT IN ( $type_placeholders )", $internal_types ); } return $clauses; @@ -76,7 +104,11 @@ function gutenberg_exclude_block_comments_from_admin_7_1( $clauses, $query ) { function gutenberg_filter_comment_count_query_exclude_block_comments_7_1( $query ) { if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { if ( ! str_contains( $query, "comment_type != 'note'" ) ) { - $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'reaction' AND comment_approved", $query ); + $type_clauses = array(); + foreach ( gutenberg_get_internal_comment_types() as $internal_type ) { + $type_clauses[] = "comment_type != '" . esc_sql( $internal_type ) . "'"; + } + $query = str_replace( 'comment_approved', implode( ' AND ', $type_clauses ) . ' AND comment_approved', $query ); } } return $query; @@ -93,7 +125,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments_7_1( $query * @return array Possibly modified arguments for get_comments(). */ function gutenberg_hide_note_from_comment_list_table_7_1( $args ) { - if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { + if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], gutenberg_get_internal_comment_types(), true ) ) { unset( $args['type'] ); } return $args; @@ -116,7 +148,15 @@ function gutenberg_exclude_notes_from_comment_count_7_1( $new_count, $old_count, if ( null !== $new_count ) { return $new_count; } - $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'reaction'", $post_id ) ); + $internal_types = gutenberg_get_internal_comment_types(); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + $new_count = (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ( $type_placeholders )", + array_merge( array( $post_id ), $internal_types ) + ) + ); return $new_count; } remove_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10 ); diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index 8c08c6302bb90c..b0702461c87048 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -79,13 +79,13 @@ public function get_item_schema() { } /** - * Checks whether the request type is a note or reaction. + * Checks whether the request type is an internal comment type (note or reaction). * * @param string $type The comment type from the request. - * @return bool True if the type is 'note' or 'reaction'. + * @return bool True if the type is one of the internal comment types. */ protected function is_note_or_reaction( $type ) { - return in_array( $type, array( 'note', 'reaction' ), true ); + return in_array( $type, gutenberg_get_internal_comment_types(), true ); } public function get_items_permissions_check( $request ) { @@ -349,8 +349,8 @@ public function create_item( $request ) { ); } - // Allow 'comment', 'note', and 'reaction' types. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { + // Allow 'comment' plus internal comment types (note, reaction). + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array_merge( array( 'comment' ), gutenberg_get_internal_comment_types() ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.', 'gutenberg' ), From 265f24ab63999965c1d4c76123a672e272848144 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 08:10:28 -0700 Subject: [PATCH 076/362] Notes: Add Frimousse-based full emoji picker behind a + button Preserves the existing curated 5-emoji quick row and adds a `+` button at the end that opens a Frimousse-based full emoji picker in a Popover. Frimousse is a lightweight (MIT-licensed, GPLv2-compatible), headless, composable emoji picker built for collaborative tools. It is rendered through compound components, allowing it to be styled with WPDS tokens (surface, dimension, border-radius, focus stroke, font, cursor) so the picker visually matches surrounding WPDS UI without forking the library. The picker is loaded via React.lazy + Suspense so its code and its emoji-data fetch are only paid for when a user actually opens it. Reactions chosen from the full picker are stored as the raw emoji character (e.g. heart) rather than a curated slug (e.g. heart). Server-side validation is relaxed to accept either a curated slug or a short (<= 16 chars) string, capping length to keep the column from storing anything resembling text content. Note: by default Frimousse fetches emoji data from the jsdelivr CDN. For production we should point emojibaseUrl at a self-hosted bundle. --- ...-gutenberg-rest-comment-controller-7-1.php | 11 +- package-lock.json | 20 +++ packages/editor/package.json | 1 + .../collab-sidebar/frimousse-picker.js | 72 +++++++++++ .../collab-sidebar/reaction-emoji-picker.js | 93 +++++++++++--- .../src/components/collab-sidebar/style.scss | 119 ++++++++++++++++-- 6 files changed, 288 insertions(+), 28 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/frimousse-picker.js diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index b0702461c87048..5ca2023411505e 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -378,11 +378,18 @@ public function create_item( $request ) { ); } - // Validate content is a valid emoji slug. + // Validate the reaction content. We accept either a curated emoji + // slug (e.g. "heart") or a short raw emoji character chosen from + // the full picker (e.g. "👍"). Length is capped so this column + // can never store anything resembling text content. $emojis = gutenberg_get_note_reaction_emojis(); $valid_slugs = wp_list_pluck( $emojis, 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; - if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { + + $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); + $is_short_emoji = '' !== $emoji_slug && mb_strlen( $emoji_slug ) <= 16; + + if ( ! $is_curated_slug && ! $is_short_emoji ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.', 'gutenberg' ), diff --git a/package-lock.json b/package-lock.json index 18bda54bc275ec..2eedcac077a0cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32854,6 +32854,25 @@ "node": ">= 0.6" } }, + "node_modules/frimousse": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.3.0.tgz", + "integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==", + "license": "MIT", + "workspaces": [ + ".", + "site" + ], + "peerDependencies": { + "react": "^18 || ^19", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -60050,6 +60069,7 @@ "date-fns": "^3.6.0", "diff": "^4.0.2", "fast-deep-equal": "^3.1.3", + "frimousse": "^0.3.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "remove-accents": "^0.5.0", diff --git a/packages/editor/package.json b/packages/editor/package.json index 08d9f769f1c81b..441a0ed94d1973 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -112,6 +112,7 @@ "date-fns": "^3.6.0", "diff": "^4.0.2", "fast-deep-equal": "^3.1.3", + "frimousse": "^0.3.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "remove-accents": "^0.5.0", diff --git a/packages/editor/src/components/collab-sidebar/frimousse-picker.js b/packages/editor/src/components/collab-sidebar/frimousse-picker.js new file mode 100644 index 00000000000000..62d895c6368ba8 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/frimousse-picker.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import { EmojiPicker } from 'frimousse'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Frimousse-based full emoji picker, styled with WPDS tokens. + * + * Emoji data is fetched from the default Emojibase CDN + * (https://cdn.jsdelivr.net/npm/emojibase-data) on first open. For a + * production rollout we'd point `emojibaseUrl` at a self-hosted bundle. + * + * @param {Object} props Component props. + * @param {Function} props.onSelect Called with the selected emoji character. + */ +export default function FrimoussePicker( { onSelect } ) { + return ( + onSelect( emoji ) } + columns={ 8 } + > + + + + { __( 'Loading…' ) } + + + { __( 'No emoji found.' ) } + + ( +
+ { category.label } +
+ ), + Row: ( { children, ...rest } ) => ( +
+ { children } +
+ ), + Emoji: ( { emoji, ...rest } ) => ( + + ), + } } + /> +
+
+ ); +} diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 9e26b39dbb5fde..6d003dddea10b0 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -2,9 +2,22 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Button, Composite } from '@wordpress/components'; -import { useState, useEffect } from '@wordpress/element'; +import { Button, Composite, Popover } from '@wordpress/components'; +import { + useState, + useEffect, + useRef, + lazy, + Suspense, +} from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; +import { plus as plusIcon } from '@wordpress/icons'; + +/** + * Lazy-load the Frimousse picker so its emoji data fetch and ~30KB of + * picker code are only paid for when a user actually opens it. + */ +const FrimoussePicker = lazy( () => import( './frimousse-picker' ) ); /** * Curated emoji set for reactions (fallback). @@ -101,36 +114,78 @@ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { } /** - * A selection of emoji buttons for adding reactions. + * A row of curated emoji buttons plus a `+` button that opens a full + * Frimousse-based picker in a Popover. * * @param {Object} props Component props. - * @param {Function} props.onSelect Callback when an emoji is selected. + * @param {Function} props.onSelect Callback invoked with the chosen emoji. + * For curated picks this is the slug + * (e.g. `heart`); for picks from the full + * picker it is the emoji character itself. */ export default function ReactionEmojiPicker( { onSelect } ) { const emojis = useReactionEmojis(); + const [ isFullPickerOpen, setIsFullPickerOpen ] = useState( false ); + const moreButtonRef = useRef( null ); return ( - - { emojis.map( ( { emoji, label, value } ) => ( + <> + + { emojis.map( ( { emoji, label, value } ) => ( + onSelect( value ) } + aria-label={ label } + className="editor-collab-sidebar-panel__emoji-option" + /> + } + > + { emoji } + + ) ) } onSelect( value ) } - aria-label={ label } - className="editor-collab-sidebar-panel__emoji-option" + icon={ plusIcon } + onClick={ () => + setIsFullPickerOpen( ( open ) => ! open ) + } + aria-expanded={ isFullPickerOpen } + aria-haspopup="dialog" + label={ __( 'More emojis' ) } + className="editor-collab-sidebar-panel__emoji-more" /> } + /> + + { isFullPickerOpen && ( + setIsFullPickerOpen( false ) } + onFocusOutside={ () => setIsFullPickerOpen( false ) } + className="editor-collab-sidebar-panel__frimousse-popover" > - { emoji } -
- ) ) } -
+ + { + setIsFullPickerOpen( false ); + onSelect( emoji ); + } } + /> + + + ) } + ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 992af275fff55f..87a9d2a94a2b93 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -39,8 +39,10 @@ } &:focus { - outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); - outline-offset: calc((-1 * var(--wp-admin-border-width-focus))); + outline: + var(--wp-admin-border-width-focus) solid + var(--wp-admin-theme-color); + outline-offset: calc(( -1 * var(--wp-admin-border-width-focus) )); } &.is-floating { @@ -242,7 +244,9 @@ } &:focus { - outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline: + var(--wp-admin-border-width-focus) solid + var(--wp-admin-theme-color); outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); } @@ -254,19 +258,21 @@ } } - .editor-collab-sidebar-panel__add-reaction-popover { padding: $grid-unit-10; } -.editor-collab-sidebar-panel .editor-collab-sidebar-panel__add-reaction-popover { +.editor-collab-sidebar-panel +.editor-collab-sidebar-panel__add-reaction-popover { min-width: 40px; } .editor-collab-sidebar-panel__emoji-picker { display: flex; - flex-direction: column; - gap: 0; + flex-direction: row; + align-items: center; + gap: var(--wpds-dimension-gap-xs); + padding: var(--wpds-dimension-padding-xs); } .editor-collab-sidebar-panel__emoji-option { @@ -275,3 +281,102 @@ justify-content: center; } +.editor-collab-sidebar-panel__emoji-more { + cursor: var(--wpds-cursor-control); +} + +// Frimousse popover: anchor a fixed-size picker surface with WPDS surface +// tokens so it visually matches the WPDS toolbar/menu surfaces nearby. +.editor-collab-sidebar-panel__frimousse-popover { + .components-popover__content { + width: var(--wpds-dimension-surface-width-md); + padding: 0; + border-radius: var(--wpds-border-radius-md); + box-shadow: var(--wpds-elevation-md); + background-color: var(--wpds-color-bg-surface-neutral-weak); + } +} + +.editor-collab-sidebar-panel__frimousse { + display: flex; + flex-direction: column; + height: 320px; + font-family: var(--wpds-typography-font-family-body); + font-size: var(--wpds-typography-font-size-sm); + color: var(--wpds-color-fg-content-neutral); + isolation: isolate; +} + +.editor-collab-sidebar-panel__frimousse-search { + margin: var(--wpds-dimension-padding-sm); + padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-interactive-neutral); + border-radius: var(--wpds-border-radius-sm); + background-color: var(--wpds-color-bg-surface-neutral-weak); + color: inherit; + font: inherit; + + &:focus { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline-offset: -1px; + } +} + +.editor-collab-sidebar-panel__frimousse-viewport { + flex: 1; + overflow: auto; + position: relative; + padding-inline: var(--wpds-dimension-padding-xs); +} + +.editor-collab-sidebar-panel__frimousse-loading, +.editor-collab-sidebar-panel__frimousse-empty { + display: grid; + place-items: center; + height: 100%; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.editor-collab-sidebar-panel__frimousse-list { + padding-bottom: var(--wpds-dimension-padding-sm); +} + +.editor-collab-sidebar-panel__frimousse-category { + position: sticky; + top: 0; + padding: var(--wpds-dimension-padding-sm); + background-color: var(--wpds-color-bg-surface-neutral-weak); + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + font-weight: var(--wpds-typography-font-weight-medium); + z-index: 1; +} + +.editor-collab-sidebar-panel__frimousse-row { + display: flex; + gap: var(--wpds-dimension-gap-xs); +} + +.editor-collab-sidebar-panel__frimousse-emoji { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--wpds-border-radius-sm); + background: transparent; + cursor: var(--wpds-cursor-control); + font-size: 18px; + line-height: 1; + + &[data-active="true"] { + background-color: var(--wpds-color-bg-interactive-neutral-weak-active); + } + + &:focus-visible { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline-offset: -1px; + } +} From 9df4145b0a995177f3429e494c2b8ce892234567 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 09:09:15 -0700 Subject: [PATCH 077/362] Notes: Bundle Frimousse emoji data, normalize reaction storage Two related changes that drop the CDN runtime dependency and harden storage of reactions across WordPress installs. Bundle Frimousse data: - Add emojibase-data dependency - frimousse-picker imports en/data.json + en/messages.json directly and installs a one-time window.fetch interceptor that returns the bundled JSON for any URL prefixed with a sentinel host. The picker passes that sentinel as emojibaseUrl, so all of Frimousse's data loading is satisfied locally and no jsdelivr CDN call is ever made - Trade-off: the editor bundle inflates by ~100KB gzipped because esbuild does not currently chunk-split lazy() imports. We accept this for the prototype; a follow-up should either enable splitting or ship the data as a static asset and resolve its URL via PHP Normalize reaction storage: - Curated picks continue to store as their slug (e.g. "heart") - Frimousse picks normalize to a lowercase hex-codepoint sequence joined by "-" (e.g. "1f44d" for thumbs up; "1f468-200d-1f4bb" for technologist). Variation selector U+FE0F is stripped so a Frimousse pick of the heart emoji collapses into the curated "heart" slug - Picks are mapped through emojiToStorageKey before submission - getEmojiBySlug / getLabelBySlug now fall back through hexKeyToEmoji for non-curated keys so display works without server-side help - PHP validation accepts either a curated slug or the hex pattern (^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$) and rejects raw emoji bytes. The comments table is not guaranteed to be utf8mb4 across all WordPress installs; ASCII storage keys sidestep that risk and keep reaction_summary grouping stable --- ...-gutenberg-rest-comment-controller-7-1.php | 18 ++-- package-lock.json | 14 +++ packages/editor/package.json | 1 + .../collab-sidebar/frimousse-picker.js | 64 ++++++++++++- .../collab-sidebar/reaction-emoji-picker.js | 90 +++++++++++++++++-- 5 files changed, 172 insertions(+), 15 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index 5ca2023411505e..a86b4f723b4b57 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -378,18 +378,24 @@ public function create_item( $request ) { ); } - // Validate the reaction content. We accept either a curated emoji - // slug (e.g. "heart") or a short raw emoji character chosen from - // the full picker (e.g. "👍"). Length is capped so this column - // can never store anything resembling text content. + // Validate the reaction content. We accept either: + // - a curated slug (e.g. "heart"), or + // - a lowercase hex-codepoint sequence joined by `-` (e.g. + // "1f44d" for 👍 or "1f468-200d-1f4bb" for 👨‍💻). + // Raw emoji bytes are rejected because the comments table is + // not guaranteed to be utf8mb4 across all WordPress installs; + // clients normalize before submitting. $emojis = gutenberg_get_note_reaction_emojis(); $valid_slugs = wp_list_pluck( $emojis, 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); - $is_short_emoji = '' !== $emoji_slug && mb_strlen( $emoji_slug ) <= 16; + $is_hex_key = (bool) preg_match( + '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', + $emoji_slug + ); - if ( ! $is_curated_slug && ! $is_short_emoji ) { + if ( ! $is_curated_slug && ! $is_hex_key ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.', 'gutenberg' ), diff --git a/package-lock.json b/package-lock.json index 2eedcac077a0cb..de64f06d0155b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30278,6 +30278,19 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" }, + "node_modules/emojibase-data": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-17.0.0.tgz", + "integrity": "sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==", + "license": "MIT", + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + }, + "peerDependencies": { + "emojibase": "*" + } + }, "node_modules/emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", @@ -60068,6 +60081,7 @@ "colord": "^2.7.0", "date-fns": "^3.6.0", "diff": "^4.0.2", + "emojibase-data": "17.0.0", "fast-deep-equal": "^3.1.3", "frimousse": "^0.3.0", "memize": "^2.1.0", diff --git a/packages/editor/package.json b/packages/editor/package.json index 441a0ed94d1973..06d84d5964162c 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -111,6 +111,7 @@ "colord": "^2.7.0", "date-fns": "^3.6.0", "diff": "^4.0.2", + "emojibase-data": "^17.0.0", "fast-deep-equal": "^3.1.3", "frimousse": "^0.3.0", "memize": "^2.1.0", diff --git a/packages/editor/src/components/collab-sidebar/frimousse-picker.js b/packages/editor/src/components/collab-sidebar/frimousse-picker.js index 62d895c6368ba8..38753802021c0c 100644 --- a/packages/editor/src/components/collab-sidebar/frimousse-picker.js +++ b/packages/editor/src/components/collab-sidebar/frimousse-picker.js @@ -2,6 +2,8 @@ * External dependencies */ import { EmojiPicker } from 'frimousse'; +import emojibaseData from 'emojibase-data/en/data.json'; +import emojibaseMessages from 'emojibase-data/en/messages.json'; /** * WordPress dependencies @@ -11,18 +13,74 @@ import { __ } from '@wordpress/i18n'; /** * Frimousse-based full emoji picker, styled with WPDS tokens. * - * Emoji data is fetched from the default Emojibase CDN - * (https://cdn.jsdelivr.net/npm/emojibase-data) on first open. For a - * production rollout we'd point `emojibaseUrl` at a self-hosted bundle. + * Frimousse normally fetches Emojibase data from the jsdelivr CDN. We + * cannot ship a CDN dependency in WordPress Core, so we bundle the + * `emojibase-data/en/{data,messages}.json` files via esbuild and serve + * them in-process by intercepting the picker's two known fetch URLs. + * + * The picker is loaded lazily by reaction-emoji-picker.js, so the + * additional bundle (~100KB gzipped for the JSON + the picker code) + * is only paid once a user opens the more-emojis popover. * * @param {Object} props Component props. * @param {Function} props.onSelect Called with the selected emoji character. */ + +// Sentinel base URL: anything that won't ever resolve to a real network +// host. The interceptor below matches on this prefix. +const EMOJIBASE_LOCAL_URL = 'https://wordpress.local/__emojibase'; + +let interceptorInstalled = false; + +/** + * Install a one-time `window.fetch` interceptor that returns the bundled + * Emojibase JSON for any URL prefixed with EMOJIBASE_LOCAL_URL. All other + * URLs pass through unchanged. Idempotent so repeat picker mounts are + * safe. + */ +function installEmojibaseFetchInterceptor() { + if ( + interceptorInstalled || + typeof window === 'undefined' || + typeof window.fetch !== 'function' + ) { + return; + } + const originalFetch = window.fetch.bind( window ); + window.fetch = ( input, init ) => { + const url = typeof input === 'string' ? input : input?.url ?? ''; + if ( url.startsWith( EMOJIBASE_LOCAL_URL ) ) { + let payload; + if ( url.endsWith( '/data.json' ) ) { + payload = emojibaseData; + } else if ( url.endsWith( '/messages.json' ) ) { + payload = emojibaseMessages; + } + if ( payload ) { + return Promise.resolve( + new Response( JSON.stringify( payload ), { + status: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'public, max-age=31536000', + }, + } ) + ); + } + } + return originalFetch( input, init ); + }; + interceptorInstalled = true; +} + +installEmojibaseFetchInterceptor(); + export default function FrimoussePicker( { onSelect } ) { return ( onSelect( emoji ) } + emojibaseUrl={ EMOJIBASE_LOCAL_URL } columns={ 8 } > c.codePointAt( 0 ).toString( 16 ) ) + .join( '-' ); +} + +/** + * Convert a hex-codepoint sequence back to its emoji character. + * + * @param {string} hexKey Lowercase hex codepoints joined by `-`. + * @return {string} The emoji character, or the input on parse failure. + */ +export function hexKeyToEmoji( hexKey ) { + if ( typeof hexKey !== 'string' || ! HEX_KEY_RE.test( hexKey ) ) { + return hexKey; + } + try { + return String.fromCodePoint( + ...hexKey.split( '-' ).map( ( p ) => parseInt( p, 16 ) ) + ); + } catch { + return hexKey; + } +} + +/** + * Map a chosen emoji character to its storage key. If the emoji matches + * a curated reaction (after stripping VS-16) returns its slug, otherwise + * returns the hex-codepoint key. + * + * @param {string} emoji The emoji character. + * @param {Array} emojis Curated emoji list to match against. + * @return {string} The storage key (slug or hex codepoints). + */ +export function emojiToStorageKey( emoji, emojis = REACTION_EMOJIS ) { + const hex = emojiToHexKey( emoji ); + const curated = emojis.find( ( r ) => emojiToHexKey( r.emoji ) === hex ); + return curated ? curated.value : hex; +} + /** * Get the emoji character for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug or hex-codepoint key. * @param {Array} emojis Optional emoji list to search. * @return {string} The emoji character, or the slug if not found. */ export function getEmojiBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.emoji ?? slug; + const curated = emojis.find( ( r ) => r.value === slug ); + if ( curated ) { + return curated.emoji; + } + if ( HEX_KEY_RE.test( slug ) ) { + return hexKeyToEmoji( slug ); + } + return slug; } /** * Get the label for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug or hex-codepoint key. * @param {Array} emojis Optional emoji list to search. - * @return {string} The label, or the slug if not found. + * @return {string} The label, or the emoji character if non-curated. */ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.label ?? slug; + const curated = emojis.find( ( r ) => r.value === slug ); + if ( curated ) { + return curated.label; + } + return getEmojiBySlug( slug, emojis ); } /** @@ -180,7 +258,7 @@ export default function ReactionEmojiPicker( { onSelect } ) { { setIsFullPickerOpen( false ); - onSelect( emoji ); + onSelect( emojiToStorageKey( emoji, emojis ) ); } } /> From e430b3d992d459cc25b32037cf626ed6b89b6209 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 09:29:24 -0700 Subject: [PATCH 078/362] Notes: Move full picker to a sibling button + add E2E tests The first prototype put the "+" trigger inside the curated picker's popover, which forced a nested Popover to open the Frimousse picker. Nested popovers conflicted with the parent Menu's focus and dismissal handling, and the Composite role mixed listbox semantics with a button that opened a dialog. Restructure into two sibling Menu components living next to each other: - AddReactionButton (smiley) opens the curated 5-emoji popover, which is reverted to its original vertical column layout - MoreEmojiButton (plus) opens a Popover containing the lazy-loaded Frimousse picker Both menus share the same onToggleReaction prop, so picks from either flow through the same emojiToStorageKey normalization pipeline. E2E tests: add four new tests covering - the + button opens the Frimousse picker - a Frimousse pick that matches a curated emoji collapses to the curated slug (verifies storage normalization end-to-end) - a non-curated Frimousse pick renders the chosen emoji from its hex-codepoint storage key - Escape closes the Frimousse popover Selectors use Frimousse's stable `grid` / `gridcell` ARIA roles instead of internal class names. The keyboard-accessibility test now uses ArrowDown rather than ArrowRight inside the vertical curated listbox; ArrowRight was previously a no-op but now escapes to the adjacent More-emojis menu trigger. --- .../src/components/collab-sidebar/note.js | 13 +- .../collab-sidebar/reaction-display.js | 57 ++++++- .../collab-sidebar/reaction-emoji-picker.js | 96 +++--------- .../src/components/collab-sidebar/style.scss | 10 +- .../specs/editor/various/block-notes.spec.js | 139 +++++++++++++++++- 5 files changed, 227 insertions(+), 88 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/note.js b/packages/editor/src/components/collab-sidebar/note.js index f4e09e5651a84c..58521bc6b05050 100644 --- a/packages/editor/src/components/collab-sidebar/note.js +++ b/packages/editor/src/components/collab-sidebar/note.js @@ -21,7 +21,10 @@ import { moreVertical, published } from '@wordpress/icons'; */ import { NoteCard } from './note-card'; import { NoteForm } from './note-form'; -import ReactionDisplay, { AddReactionButton } from './reaction-display'; +import ReactionDisplay, { + AddReactionButton, + MoreEmojiButton, +} from './reaction-display'; import { unlock } from '../../lock-unlock'; const { Menu } = unlock( componentsPrivateApis ); @@ -205,6 +208,14 @@ export function Note( { } ) } /> + + onToggleReaction?.( { + commentId: note.id, + emoji, + } ) + } + /> import( './frimousse-picker' ) ); + const { Menu } = unlock( componentsPrivateApis ); /** @@ -266,7 +274,8 @@ export default function ReactionDisplay( { } /** - * Standalone add-reaction button with emoji picker dropdown. + * Standalone add-reaction button with the curated emoji picker + * dropdown (the 5-emoji quick row). * * @param {Object} props Component props. * @param {Function} props.onToggleReaction Callback to toggle a reaction. @@ -299,3 +308,45 @@ export function AddReactionButton( { onToggleReaction } ) { ); } + +/** + * Standalone "+" button that opens a full Frimousse-based emoji picker. + * Sibling of AddReactionButton; not nested inside it so the curated and + * full picker popovers never conflict. + * + * @param {Object} props Component props. + * @param {Function} props.onToggleReaction Callback to toggle a reaction. + */ +export function MoreEmojiButton( { onToggleReaction } ) { + const [ isOpen, setIsOpen ] = useState( false ); + const emojis = useReactionEmojis(); + return ( + + + } + /> + + + { + setIsOpen( false ); + onToggleReaction( + emojiToStorageKey( emoji, emojis ) + ); + } } + /> + + + + ); +} diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 21dad6a09f28de..970c95abf0760d 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -2,22 +2,9 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Button, Composite, Popover } from '@wordpress/components'; -import { - useState, - useEffect, - useRef, - lazy, - Suspense, -} from '@wordpress/element'; +import { Button, Composite } from '@wordpress/components'; +import { useState, useEffect } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; -import { plus as plusIcon } from '@wordpress/icons'; - -/** - * Lazy-load the Frimousse picker so its emoji data fetch and ~30KB of - * picker code are only paid for when a user actually opens it. - */ -const FrimoussePicker = lazy( () => import( './frimousse-picker' ) ); /** * Curated emoji set for reactions (fallback). @@ -192,78 +179,39 @@ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { } /** - * A row of curated emoji buttons plus a `+` button that opens a full - * Frimousse-based picker in a Popover. + * A row of curated emoji buttons. The `+` "More emojis" trigger lives + * outside this picker (as a sibling of the smiley trigger) so the + * full-picker popover never has to nest inside the curated popover. * * @param {Object} props Component props. - * @param {Function} props.onSelect Callback invoked with the chosen emoji. - * For curated picks this is the slug - * (e.g. `heart`); for picks from the full - * picker it is the emoji character itself. + * @param {Function} props.onSelect Called with the chosen slug when the + * user picks a curated emoji. */ export default function ReactionEmojiPicker( { onSelect } ) { const emojis = useReactionEmojis(); - const [ isFullPickerOpen, setIsFullPickerOpen ] = useState( false ); - const moreButtonRef = useRef( null ); return ( - <> - - { emojis.map( ( { emoji, label, value } ) => ( - onSelect( value ) } - aria-label={ label } - className="editor-collab-sidebar-panel__emoji-option" - /> - } - > - { emoji } - - ) ) } + + { emojis.map( ( { emoji, label, value } ) => ( - setIsFullPickerOpen( ( open ) => ! open ) - } - aria-expanded={ isFullPickerOpen } - aria-haspopup="dialog" - label={ __( 'More emojis' ) } - className="editor-collab-sidebar-panel__emoji-more" + onClick={ () => onSelect( value ) } + aria-label={ label } + className="editor-collab-sidebar-panel__emoji-option" /> } - /> - - { isFullPickerOpen && ( - setIsFullPickerOpen( false ) } - onFocusOutside={ () => setIsFullPickerOpen( false ) } - className="editor-collab-sidebar-panel__frimousse-popover" > - - { - setIsFullPickerOpen( false ); - onSelect( emojiToStorageKey( emoji, emojis ) ); - } } - /> - - - ) } - + { emoji } +
+ ) ) } +
); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 87a9d2a94a2b93..e1133b6dfc5132 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -269,10 +269,8 @@ .editor-collab-sidebar-panel__emoji-picker { display: flex; - flex-direction: row; - align-items: center; - gap: var(--wpds-dimension-gap-xs); - padding: var(--wpds-dimension-padding-xs); + flex-direction: column; + gap: 0; } .editor-collab-sidebar-panel__emoji-option { @@ -281,10 +279,6 @@ justify-content: center; } -.editor-collab-sidebar-panel__emoji-more { - cursor: var(--wpds-cursor-control); -} - // Frimousse popover: anchor a fixed-size picker surface with WPDS surface // tokens so it visually matches the WPDS toolbar/menu surfaces nearby. .editor-collab-sidebar-panel__frimousse-popover { diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 35de6aa96fba7d..a0706cb5e10974 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -964,10 +964,12 @@ test.describe( 'Block Notes', () => { ); await expect( emojiPicker ).toBeVisible(); - // Navigate with arrow keys and select. + // Navigate with arrow keys and select. The picker is a vertical + // listbox, so ArrowDown moves to the next option. ArrowRight + // would now escape to the adjacent "More emojis" menu trigger. const firstEmoji = emojiPicker.getByRole( 'option' ).first(); await firstEmoji.focus(); - await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'Enter' ); await expect( @@ -1009,6 +1011,100 @@ test.describe( 'Block Notes', () => { ); await expect( reactionButtons ).toHaveCount( 2 ); } ); + + test( 'can open the full emoji picker via the + button', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing full emoji picker' }, + comment: 'Open the full picker', + } ); + + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + + await blockNoteUtils.waitForFrimoussePicker(); + + // The search field is present. + await expect( + page.getByPlaceholder( 'Search emoji' ) + ).toBeVisible(); + } ); + + test( 'a Frimousse pick that matches a curated emoji stores as the curated slug', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Curated normalization' }, + comment: 'Pick heart from full picker', + } ); + + await blockNoteUtils.pickFrimousseEmojiBySearch( 'red heart' ); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // The same reaction button shape that the curated row produces + // — contains the heart emoji and a count of 1. If storage + // normalization were broken we'd get a stray hex-key button. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( '❤' ); + await expect( reactionButton ).toContainText( '1' ); + } ); + + test( 'a Frimousse pick that is not curated renders the chosen emoji', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Non-curated emoji' }, + comment: 'Pick thumbs up from full picker', + } ); + + await blockNoteUtils.pickFrimousseEmojiBySearch( 'thumbs up' ); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( '👍' ); + } ); + + test( 'pressing Escape closes the Frimousse popover', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Escape closes picker' }, + comment: 'Close picker with Escape', + } ); + + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFrimoussePicker(); + + await page.keyboard.press( 'Escape' ); + + await expect( + page.getByPlaceholder( 'Search emoji' ) + ).toBeHidden(); + } ); } ); } ); @@ -1100,4 +1196,43 @@ class BlockNoteUtils { .getByRole( 'option', { name: new RegExp( emoji, 'i' ) } ) .click(); } + + /** + * Wait for the Frimousse picker to finish loading its bundled + * Emojibase data and render at least one emoji button. The grid / + * gridcell roles come from Frimousse itself and are stable across + * className changes. + */ + async waitForFrimoussePicker() { + await expect( + this.#page.getByPlaceholder( 'Search emoji' ) + ).toBeVisible(); + await expect( + this.#page.getByRole( 'grid' ).getByRole( 'gridcell' ).first() + ).toBeVisible(); + } + + /** + * Click the + button to open Frimousse, search by name, and click + * the first matching emoji. + * + * @param {string} search Search term (matched against Emojibase + * labels, e.g. "red heart" or "thumbs up"). + */ + async pickFrimousseEmojiBySearch( search ) { + await this.#page.getByRole( 'button', { name: 'More emojis' } ).click(); + await this.waitForFrimoussePicker(); + + await this.#page.getByPlaceholder( 'Search emoji' ).fill( search ); + + // Wait for the search to actually filter. Frimousse's gridcells + // expose the emoji label as their accessible name, so once the + // first cell carries a name matching `search` we know the grid + // has finished re-laying-out. + const match = this.#page + .getByRole( 'gridcell', { name: new RegExp( search, 'i' ) } ) + .first(); + await expect( match ).toBeVisible(); + await match.click(); + } } From f5ffa574377e7e529eade823324bebefafee60a8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 10:11:38 -0700 Subject: [PATCH 079/362] Notes: Serve emojibase data from plugins_url, drop bundle inflation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut imported emoji-data JSON directly into the picker source and used a window.fetch interceptor to satisfy Frimousse's data load. That worked but inflated the editor bundle by ~770KB raw / ~100KB gzipped because esbuild does not currently chunk-split lazy() imports. Move the data out of the JS bundle and serve it as static files: - New build step bin/copy-emojibase-data.mjs copies en/data.json and en/messages.json out of node_modules/emojibase-data into build/emojibase-data/. The other ~50MB of unused emojibase-data is left behind. Wired into bin/build.mjs after wp-build runs. - emojibase-data is moved from packages/editor's deps to the root devDependencies; npm consumers of @wordpress/editor no longer pull the 50MB unpacked package. - New PHP file lib/compat/wordpress-7.1/emojibase-data.php uses wp_add_inline_script('wp-editor', ..., 'before') to set window.gutenbergEmojibaseUrl to plugins_url('build/emojibase-data'). - frimousse-picker.js drops the JSON imports and the fetch interceptor and reads emojibaseUrl from the global. Frimousse fetches its data same-origin from plugins_url() and the editor bundle drops back to pre-bundling weight. - MoreEmojiButton renders nothing when window.gutenbergEmojibaseUrl is unset, so npm consumers of @wordpress/editor without their own hosted dataset don't accidentally fall through to the jsdelivr CDN. Bundle size for build/scripts/editor/index.min.js drops from ~520KB gzipped (with the JSON inlined) back to ~422KB gzipped — i.e. ~100KB gzipped reclaimed. Drive-by: the plus icon in the new MoreEmojiButton was invisible because it inherited the smiley trigger's `svg { fill: none }` rule (the smiley uses path-level `fill="currentColor"` to override that; the plus path has no attribute). Give the More button its own class that sets `fill: currentColor`. The pinned "All notes" sidebar still clips the popover since Menu.Popover does not portal here. That clipping pre-dates this work and applies to the curated picker too; tracking as a follow-up. --- bin/build.mjs | 6 ++ bin/copy-emojibase-data.mjs | 65 ++++++++++++++++ lib/compat/wordpress-7.1/emojibase-data.php | 28 +++++++ lib/load.php | 1 + package-lock.json | 3 +- package.json | 1 + packages/editor/package.json | 1 - .../collab-sidebar/frimousse-picker.js | 77 ++++--------------- .../collab-sidebar/reaction-display.js | 11 ++- .../src/components/collab-sidebar/style.scss | 37 +++++++++ 10 files changed, 167 insertions(+), 63 deletions(-) create mode 100644 bin/copy-emojibase-data.mjs create mode 100644 lib/compat/wordpress-7.1/emojibase-data.php diff --git a/bin/build.mjs b/bin/build.mjs index b97441f0013858..894977948e975a 100755 --- a/bin/build.mjs +++ b/bin/build.mjs @@ -143,6 +143,12 @@ async function build() { env: { ...process.env, NODE_ENV: 'production' }, } ); + // Step 7.4: Copy emojibase locale data into build/ so the editor + // can fetch it from plugins_url() instead of bundling ~770KB of + // JSON into the main JS bundle. + console.log( '\n📦 Copying emojibase data...' ); + await exec( 'node', [ './bin/copy-emojibase-data.mjs' ] ); + // Step 7.5: Build blocks manifests console.log( '\n📦 Building blocks manifests...' ); const blocksDirs = [ diff --git a/bin/copy-emojibase-data.mjs b/bin/copy-emojibase-data.mjs new file mode 100644 index 00000000000000..19737b29864f96 --- /dev/null +++ b/bin/copy-emojibase-data.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Copy the locale data files Frimousse expects from the bundled + * emojibase-data package into Gutenberg's `build/emojibase-data/` + * directory so the plugin can serve them via plugins_url() instead of + * inlining ~770KB of JSON into the editor JS bundle. + * + * Runs as a step in `bin/build.mjs`, after wp-build has populated + * `build/`. Exits 0 even when emojibase-data is missing — the editor + * gracefully degrades by hiding the "More emojis" trigger. + */ + +import { mkdir, copyFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); +const ROOT_DIR = path.resolve( __dirname, '..' ); +const SRC_DIR = path.join( ROOT_DIR, 'node_modules', 'emojibase-data' ); +const DEST_DIR = path.join( ROOT_DIR, 'build', 'emojibase-data' ); + +// Frimousse only ever fetches data.json and messages.json. We ship just +// those two files per locale; the rest of the package (~50MB unpacked) +// stays out of the plugin distribution. +const FILES = [ 'data.json', 'messages.json' ]; + +// Locales the plugin ships. Keep this small for now — adding a locale +// is ~85KB gzipped. English covers the strings shown to users today. +const LOCALES = [ 'en' ]; + +async function copyEmojibaseData() { + if ( ! existsSync( SRC_DIR ) ) { + console.warn( + '⚠️ emojibase-data not found at', + SRC_DIR, + '— skipping. Run `npm install` to fetch it.' + ); + return; + } + + for ( const locale of LOCALES ) { + const localeDest = path.join( DEST_DIR, locale ); + await mkdir( localeDest, { recursive: true } ); + for ( const file of FILES ) { + const from = path.join( SRC_DIR, locale, file ); + const to = path.join( localeDest, file ); + if ( ! existsSync( from ) ) { + console.warn( `⚠️ emojibase-data missing ${ from }` ); + continue; + } + await copyFile( from, to ); + } + } + console.log( + ` ✔ Copied emojibase data for ${ LOCALES.join( + ', ' + ) } to build/emojibase-data/` + ); +} + +copyEmojibaseData().catch( ( error ) => { + console.error( '❌ Failed to copy emojibase data:', error ); + process.exit( 1 ); +} ); diff --git a/lib/compat/wordpress-7.1/emojibase-data.php b/lib/compat/wordpress-7.1/emojibase-data.php new file mode 100644 index 00000000000000..312076672e81ee --- /dev/null +++ b/lib/compat/wordpress-7.1/emojibase-data.php @@ -0,0 +1,28 @@ + { - const url = typeof input === 'string' ? input : input?.url ?? ''; - if ( url.startsWith( EMOJIBASE_LOCAL_URL ) ) { - let payload; - if ( url.endsWith( '/data.json' ) ) { - payload = emojibaseData; - } else if ( url.endsWith( '/messages.json' ) ) { - payload = emojibaseMessages; - } - if ( payload ) { - return Promise.resolve( - new Response( JSON.stringify( payload ), { - status: 200, - headers: { - 'content-type': 'application/json', - 'cache-control': 'public, max-age=31536000', - }, - } ) - ); - } - } - return originalFetch( input, init ); - }; - interceptorInstalled = true; -} - -installEmojibaseFetchInterceptor(); - export default function FrimoussePicker( { onSelect } ) { + const emojibaseUrl = + typeof window !== 'undefined' && window.gutenbergEmojibaseUrl + ? window.gutenbergEmojibaseUrl + : undefined; + return ( onSelect( emoji ) } - emojibaseUrl={ EMOJIBASE_LOCAL_URL } + emojibaseUrl={ emojibaseUrl } columns={ 8 } > diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index e1133b6dfc5132..f76ae67470135c 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -258,6 +258,43 @@ } } +// Mirror the smiley trigger but render the plus icon with a filled +// path. The shared add-reaction-button rule sets `fill: none` on its +// SVG (the smiley uses path-level `fill="currentColor"` which beats +// it); the plus icon's single path has no attribute and was hidden. +.editor-collab-sidebar-panel__more-reaction-button { + display: inline-flex; + margin: 0; + align-items: center; + justify-content: center; + width: 28px; + height: 28px !important; + 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 { + background-color: $gray-100; + } + + &:focus { + outline: + var(--wp-admin-border-width-focus) solid + var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } + + svg { + fill: currentColor; + color: $gray-700; + width: 20px; + height: 20px; + } +} + .editor-collab-sidebar-panel__add-reaction-popover { padding: $grid-unit-10; } From 9495517ff5405ec5d61a103ba1126fa981d0cc76 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 11:50:07 -0700 Subject: [PATCH 080/362] Notes: Replace Menu.Popover with Dropdown so reaction popovers escape sidebar clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned "All notes" sidebar (and the framework-level .interface-interface-skeleton__sidebar) sets overflow: hidden, which clipped both the curated and Frimousse pickers when they extended past the sidebar's edges. Switching the smiley and "+" triggers from Ariakit-backed `Menu.Popover` to `@wordpress/components/Dropdown` portals the popover to , so its content is no longer constrained by ancestor overflow. Dropdown also owns its own open/close state, so the manual `useState` and `Menu.TriggerButton` plumbing goes away. The note thread's onBlur handler deselects when focus leaves the thread DOM. With the popover now portaled, focus moving into the picker's first option looked like a leave; extend the existing `[role="dialog"]` exemption to also cover `.components-popover`, so focus moving into a popover anchored from inside the note keeps the note selected (and the trigger rendered). Style update mirrors the markup change: `contentClassName` lands on `.components-popover__content` directly, so the previously nested `.editor-collab-sidebar-panel__frimousse-popover .components-popover__content` selector flattens. Drops the `.editor-collab-sidebar-panel .editor-collab-sidebar-panel__add-reaction-popover` descendant rule — no longer matches once the popover is portaled out of the panel. All 33 block-notes E2E tests pass, including the 9 reaction tests. --- .../components/collab-sidebar/note-thread.js | 12 +++ .../collab-sidebar/reaction-display.js | 84 +++++++++---------- .../src/components/collab-sidebar/style.scss | 19 ++--- 3 files changed, 60 insertions(+), 55 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/note-thread.js b/packages/editor/src/components/collab-sidebar/note-thread.js index 1c2dbcdd368cb5..73d845ecaa342d 100644 --- a/packages/editor/src/components/collab-sidebar/note-thread.js +++ b/packages/editor/src/components/collab-sidebar/note-thread.js @@ -93,6 +93,12 @@ export function NoteThread( { ); const isDialogFocused = event.relatedTarget?.closest( '[role="dialog"]' ); + // Popovers anchored from inside the note (e.g. the emoji + // reaction pickers) portal their content to , so focus + // moving into them looks like focus leaving the thread. + const isPopoverFocused = event.relatedTarget?.closest( + '.components-popover' + ); const isTabbing = isKeyboardTabbingRef.current; // When another note is clicked, do nothing because the current note is automatically closed. @@ -103,6 +109,12 @@ export function NoteThread( { if ( isDialogFocused ) { return; } + // When opening a popover from inside the note, the popover + // content is portaled outside the thread DOM but the note + // should remain selected for the trigger to stay rendered. + if ( isPopoverFocused ) { + return; + } // When tabbing, do nothing if the focus is within the current note. if ( isTabbing && diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 0c8d7b7aa00ced..a885db744ed2a2 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -2,10 +2,7 @@ * WordPress dependencies */ import { __, sprintf, _n } from '@wordpress/i18n'; -import { - Button, - privateApis as componentsPrivateApis, -} from '@wordpress/components'; +import { Button, Dropdown } from '@wordpress/components'; import { Stack } from '@wordpress/ui'; import { smiley as smileyIcon, plus as plusIcon } from '@wordpress/icons'; import { useState, useCallback, lazy, Suspense } from '@wordpress/element'; @@ -21,7 +18,6 @@ import ReactionEmojiPicker, { getLabelBySlug, useReactionEmojis, } from './reaction-emoji-picker'; -import { unlock } from '../../lock-unlock'; /** * Lazy-load the Frimousse-based full picker. Its bundle (Frimousse + @@ -30,7 +26,11 @@ import { unlock } from '../../lock-unlock'; */ const FrimoussePicker = lazy( () => import( './frimousse-picker' ) ); -const { Menu } = unlock( componentsPrivateApis ); +// `Dropdown`'s popover is rendered in a portal anchored to , +// so it escapes the `overflow: hidden` chain on the collab sidebar +// (`.interface-interface-skeleton__sidebar`, +// `.editor-collab-sidebar`, `.editor-collab-sidebar-panel`). +const POPOVER_PROPS = { placement: 'bottom-end' }; /** * Get the count of reactions for a specific slug. @@ -281,31 +281,29 @@ export default function ReactionDisplay( { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export function AddReactionButton( { onToggleReaction } ) { - const [ isOpen, setIsOpen ] = useState( false ); return ( - - - } - /> - + ( + + ) } + /> ); } @@ -324,38 +322,36 @@ export function AddReactionButton( { onToggleReaction } ) { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export function MoreEmojiButton( { onToggleReaction } ) { - const [ isOpen, setIsOpen ] = useState( false ); const emojis = useReactionEmojis(); if ( typeof window === 'undefined' || ! window.gutenbergEmojibaseUrl ) { return null; } return ( - - - } - /> - + ( + + ) } + /> ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index f76ae67470135c..5f9de5873356a2 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -295,12 +295,11 @@ } } +// Class is applied via `Dropdown`'s `contentClassName`, which lands +// on `.components-popover__content` itself — so style the class +// directly rather than nesting through `.components-popover__content`. .editor-collab-sidebar-panel__add-reaction-popover { padding: $grid-unit-10; -} - -.editor-collab-sidebar-panel -.editor-collab-sidebar-panel__add-reaction-popover { min-width: 40px; } @@ -319,13 +318,11 @@ // Frimousse popover: anchor a fixed-size picker surface with WPDS surface // tokens so it visually matches the WPDS toolbar/menu surfaces nearby. .editor-collab-sidebar-panel__frimousse-popover { - .components-popover__content { - width: var(--wpds-dimension-surface-width-md); - padding: 0; - border-radius: var(--wpds-border-radius-md); - box-shadow: var(--wpds-elevation-md); - background-color: var(--wpds-color-bg-surface-neutral-weak); - } + width: var(--wpds-dimension-surface-width-md); + padding: 0; + border-radius: var(--wpds-border-radius-md); + box-shadow: var(--wpds-elevation-md); + background-color: var(--wpds-color-bg-surface-neutral-weak); } .editor-collab-sidebar-panel__frimousse { From 05a6c1fff3cfa0ec6deb3d9ff0d09d6a5d1ad6ab Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 12:23:53 -0700 Subject: [PATCH 081/362] Notes: Add E2E tests pinning the popover-portal and selection-persistence contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regression guards specific to the Dropdown migration: 1. `reaction picker portals outside the collab sidebar` — asserts the popover content has no `.editor-collab-sidebar-panel` ancestor, so that reverting the portal (e.g. by switching back to a non- portaling primitive) fails loudly instead of silently re-clipping. 2. `note remains selected while reaction picker is open` — asserts the thread keeps `aria-expanded="true"` after the picker opens, pinning the `.components-popover` exemption in `note-thread.js`'s onBlur handler. Without this, removing that exemption would unmount the trigger and close the picker mid-click — but the existing 9 tests would still pass because they don't observe selection state between open and close. --- .../specs/editor/various/block-notes.spec.js | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index a0706cb5e10974..ffc5999c6fa081 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1105,6 +1105,61 @@ test.describe( 'Block Notes', () => { page.getByPlaceholder( 'Search emoji' ) ).toBeHidden(); } ); + + test( 'reaction picker portals outside the collab sidebar', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing popover portal' }, + comment: 'Popover portal', + } ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + + const popover = page.locator( + '.editor-collab-sidebar-panel__add-reaction-popover' + ); + await expect( popover ).toBeVisible(); + + // The popover must portal out of the sidebar; otherwise the + // `overflow: hidden` chain on `.editor-collab-sidebar-panel` + // (and the framework `.interface-interface-skeleton__sidebar`) + // would clip the picker. Pin the contract by asserting the + // popover has no sidebar-panel ancestor. + await expect( popover ).toHaveCount( 1 ); + const isPortaled = await popover.evaluate( + ( el ) => ! el.closest( '.editor-collab-sidebar-panel' ) + ); + expect( isPortaled ).toBe( true ); + } ); + + test( 'note remains selected while reaction picker is open', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing selection persistence' }, + comment: 'Selection persistence', + } ); + + const thread = page.getByRole( 'treeitem', { + name: /Note: Selection persistence/, + } ); + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await expect( + page.locator( '.editor-collab-sidebar-panel__emoji-picker' ) + ).toBeVisible(); + + // Focus has moved into the portaled popover, but the note's + // onBlur handler exempts `.components-popover` so the thread + // stays selected and the trigger stays mounted. + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + } ); } ); } ); From b1c45e55ad377e357a5245336613e08a0fc1a8c5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 12:40:04 -0700 Subject: [PATCH 082/362] Notes: Wrap Frimousse popover tightly to its picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Dropdown` applies `padding: 8px` to the inner `.components-popover__content` by default. Combined with the `.components-popover__content { width: min-content }` default and a fixed `width: var(--wpds-dimension-surface-width-md)` on the inner Frimousse picker, the popover surface ended up 16px wider than the picker — visible as a band of empty surface to the right of the grid. `contentClassName` from `Dropdown` lands on the outer `.components-popover` wrapper, not on `.components-popover__content`, so the previous `padding: 0` rule didn't override Dropdown's default. Nest the override through `.components-popover__content` to bump specificity above `.components-dropdown__content .components-popover__content`. Drops the dead `.editor-collab-sidebar-panel__add-reaction-popover` rule (also targeting the wrong layer) — Dropdown's default 8px padding is what the curated picker wants anyway. Adds an E2E regression guard `Frimousse popover wraps tightly to the picker width` that compares bounding boxes; preferred over a pixel screenshot since emoji rendering differs across OSes. --- .../src/components/collab-sidebar/style.scss | 36 +++++++++++-------- .../specs/editor/various/block-notes.spec.js | 31 ++++++++++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 5f9de5873356a2..6f3d620a7aa909 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -295,13 +295,9 @@ } } -// Class is applied via `Dropdown`'s `contentClassName`, which lands -// on `.components-popover__content` itself — so style the class -// directly rather than nesting through `.components-popover__content`. -.editor-collab-sidebar-panel__add-reaction-popover { - padding: $grid-unit-10; - min-width: 40px; -} +// Curated picker uses Dropdown's default `.components-popover__content` +// padding (8px) for breathing room around the listbox; no extra rule +// needed. .editor-collab-sidebar-panel__emoji-picker { display: flex; @@ -315,19 +311,31 @@ justify-content: center; } -// Frimousse popover: anchor a fixed-size picker surface with WPDS surface -// tokens so it visually matches the WPDS toolbar/menu surfaces nearby. +// Frimousse popover: width is driven by the picker itself (below) so the +// popover wrapper fits its content via Popover's default +// `width: min-content`. Setting width on the wrapper instead leaves +// horizontal slack between the picker and the popover edge. +// +// `contentClassName` from `Dropdown` lands on the outer +// `.components-popover` wrapper. The inner `.components-popover__content` +// is where the visible surface and Dropdown's default 8px padding live — +// override that here with a higher-specificity selector so the Frimousse +// picker can manage its own internal padding (search margin, viewport +// padding-inline, list padding-bottom) without an extra ring of empty +// surface around it. .editor-collab-sidebar-panel__frimousse-popover { - width: var(--wpds-dimension-surface-width-md); - padding: 0; - border-radius: var(--wpds-border-radius-md); - box-shadow: var(--wpds-elevation-md); - background-color: var(--wpds-color-bg-surface-neutral-weak); + .components-popover__content { + padding: 0; + border-radius: var(--wpds-border-radius-md); + box-shadow: var(--wpds-elevation-md); + background-color: var(--wpds-color-bg-surface-neutral-weak); + } } .editor-collab-sidebar-panel__frimousse { display: flex; flex-direction: column; + width: var(--wpds-dimension-surface-width-md); height: 320px; font-family: var(--wpds-typography-font-family-body); font-size: var(--wpds-typography-font-size-sm); diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index ffc5999c6fa081..1986931b950c04 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1160,6 +1160,37 @@ test.describe( 'Block Notes', () => { // stays selected and the trigger stays mounted. await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); } ); + + test( 'Frimousse popover wraps tightly to the picker width', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing tight popover layout' }, + comment: 'Tight layout', + } ); + + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFrimoussePicker(); + + const popover = page.locator( + '.editor-collab-sidebar-panel__frimousse-popover' + ); + const picker = page.locator( + '.editor-collab-sidebar-panel__frimousse' + ); + + const popoverBox = await popover.boundingBox(); + const pickerBox = await picker.boundingBox(); + + // Popover wrapper must wrap tightly to the picker. If the + // popover is wider, the surface background renders as a + // visible band beside the picker. + expect( + Math.abs( popoverBox.width - pickerBox.width ) + ).toBeLessThanOrEqual( 1 ); + } ); } ); } ); From 06c81693d1fef6966319f3c2452b6201612e9089 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 16:52:54 -0700 Subject: [PATCH 083/362] Notes: Size Frimousse picker to its 8-column grid, not an arbitrary surface token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker was set to var(--wpds-dimension-surface-width-md) (~400px), but the 8-column emoji grid only consumes ~252px. The visible result was emojis filling the left two-thirds of the popover with a wide empty band on the right. Width now derives from the actual grid: (8 × 32px button) + (7 × 4px gap) + (2 × 4px viewport padding) + a 17px scrollbar gutter, so the popover wraps the visible emoji area and the buttons don't shrink under flex pressure when the scrollbar is present. Two related changes: - Bumps the emoji button from 28px to 32px (better tap target, matches GitHub/Slack-style pickers). - Sets `scrollbar-gutter: stable` on the viewport so the gutter is always reserved — without it, the layout would shift when the picker scrolls. Adds an E2E regression guard `emoji grid fills the picker width` that asserts the last emoji's right edge sits within ~24px of the picker's right edge (covering only viewport padding + scrollbar gutter). The original bug had ~145px of slack; this catches anything remotely close to that. --- .../src/components/collab-sidebar/style.scss | 32 ++++++++++++---- .../specs/editor/various/block-notes.spec.js | 37 +++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 6f3d620a7aa909..d08c3b01e04f79 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -335,7 +335,14 @@ .editor-collab-sidebar-panel__frimousse { display: flex; flex-direction: column; - width: var(--wpds-dimension-surface-width-md); + // Width sized to the 8-column emoji grid plus the viewport's + // reserved scrollbar gutter, so the popover wraps the visible + // emoji area without buttons shrinking under flex pressure when + // the scrollbar is present. + // (8 buttons × 32px) + (7 gaps × 4px) + (2 viewport padding × 4px) + // + scrollbar gutter (reserved via `scrollbar-gutter: stable` on + // the viewport below; ~17px on most platforms). + width: calc(8 * 32px + 7 * 4px + 2 * 4px + 17px); height: 320px; font-family: var(--wpds-typography-font-family-body); font-size: var(--wpds-typography-font-size-sm); @@ -345,15 +352,21 @@ .editor-collab-sidebar-panel__frimousse-search { margin: var(--wpds-dimension-padding-sm); - padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); - border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-interactive-neutral); + padding: + var(--wpds-dimension-padding-sm) + var(--wpds-dimension-padding-md); + border: + var(--wpds-border-width-xs) solid + var(--wpds-color-stroke-interactive-neutral); border-radius: var(--wpds-border-radius-sm); background-color: var(--wpds-color-bg-surface-neutral-weak); color: inherit; font: inherit; &:focus { - outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline: + var(--wpds-border-width-focus) solid + var(--wpds-color-stroke-focus-brand); outline-offset: -1px; } } @@ -361,6 +374,9 @@ .editor-collab-sidebar-panel__frimousse-viewport { flex: 1; overflow: auto; + // Always reserve scrollbar space so the emoji grid doesn't shift + // or shrink when content height crosses the scroll threshold. + scrollbar-gutter: stable; position: relative; padding-inline: var(--wpds-dimension-padding-xs); } @@ -397,8 +413,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 32px; + height: 32px; padding: 0; border: 0; border-radius: var(--wpds-border-radius-sm); @@ -412,7 +428,9 @@ } &:focus-visible { - outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline: + var(--wpds-border-width-focus) solid + var(--wpds-color-stroke-focus-brand); outline-offset: -1px; } } diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 1986931b950c04..e8e06f86e964a0 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1191,6 +1191,43 @@ test.describe( 'Block Notes', () => { Math.abs( popoverBox.width - pickerBox.width ) ).toBeLessThanOrEqual( 1 ); } ); + + test( 'emoji grid fills the picker width', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing grid fills picker' }, + comment: 'Grid fill', + } ); + + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFrimoussePicker(); + + const picker = page.locator( + '.editor-collab-sidebar-panel__frimousse' + ); + const lastEmojiInFirstRow = page + .locator( '.editor-collab-sidebar-panel__frimousse-row' ) + .first() + .locator( '.editor-collab-sidebar-panel__frimousse-emoji' ) + .last(); + + const pickerBox = await picker.boundingBox(); + const emojiBox = await lastEmojiInFirstRow.boundingBox(); + const horizontalSlack = + pickerBox.x + pickerBox.width - ( emojiBox.x + emojiBox.width ); + + // Last emoji in a full row should sit close to the picker's + // right edge. Tolerance allows for viewport padding (4px) + // plus a reserved scrollbar gutter (~17px on most + // platforms). Catches regressions where the picker is sized + // wider than the emoji grid (buttons fill only the left + // fraction, with a large empty band to the right — the + // original bug had ~145px of slack). + expect( horizontalSlack ).toBeLessThanOrEqual( 24 ); + } ); } ); } ); From ca79a82329ffcda26215e20e260f680adfe53278 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 17:06:26 -0700 Subject: [PATCH 084/362] Notes: Allow re-adding a reaction after removing it on the same note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues conspired to make the same emoji reaction unre-addable on the note (or block) it had just been removed from. Server: the duplicate-reaction guard on POST /comments queried existing reactions with `status => 'any'`, which includes trashed comments. Since WP REST DELETE trashes comments by default, the just-removed reaction was still present in trash and matched the duplicate check, returning `rest_comment_duplicate_reaction`. Limit the lookup to `status => 'approve'` — only active reactions can legitimately block a re-add. Client: `reactionsMap` is derived from each note's server-computed `reaction_summary` field, cached on the note's entity record. Mutating a reaction comment doesn't invalidate that field, so a subsequent toggle on the same emoji read stale `reacted` / `my_reaction_id` values and tried to delete a now-missing record instead of routing into the add path. Invalidate the notes list resolution after each toggle so each parent's `reaction_summary` refetches. Adds `can re-add the same reaction after removing it` E2E test. Verified to fail without either fix and pass with both. --- ...-gutenberg-rest-comment-controller-7-1.php | 4 ++- .../src/components/collab-sidebar/hooks.js | 24 +++++++++++++- .../specs/editor/various/block-notes.spec.js | 31 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index a86b4f723b4b57..8303b32eae13b5 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -404,12 +404,14 @@ public function create_item( $request ) { } // Enforce uniqueness: prevent duplicate emoji per user per note. + // Limit to active reactions — trashed reactions are invisible and + // must not block the user from re-adding the same emoji. $existing = get_comments( array( 'parent' => $request['parent'], 'user_id' => get_current_user_id(), 'type' => 'reaction', - 'status' => 'any', + 'status' => 'approve', ) ); diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 035966abd61dab..dd1d01288bcbd1 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -155,7 +155,8 @@ export function useNoteThreads( postId ) { export function useNoteActions( reactionsMap = {} ) { const { createNotice } = useDispatch( noticesStore ); - const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); + const { saveEntityRecord, deleteEntityRecord, invalidateResolution } = + useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); const { getBlockAttributes, getSelectedBlockClientId } = useSelect( blockEditorStore ); @@ -345,6 +346,26 @@ export function useNoteActions( reactionsMap = {} ) { isDismissible: true, } ); } + + // `reaction_summary` is computed server-side and cached + // on each parent note's entity record. Mutating a + // reaction comment doesn't invalidate that field, so a + // subsequent toggle for the same emoji on the same note + // reads stale `reacted` / `my_reaction_id` data — a + // removed reaction can't be re-added because the toggle + // still tries to delete a now-missing comment record. + // Refetch the notes list so each parent's + // `reaction_summary` is fresh. + invalidateResolution( 'getEntityRecords', [ + 'root', + 'comment', + { + post: getCurrentPostId(), + type: 'note', + status: 'all', + per_page: -1, + }, + ] ); } catch ( error ) { onError( error ); } @@ -355,6 +376,7 @@ export function useNoteActions( reactionsMap = {} ) { saveEntityRecord, getCurrentPostId, createNotice, + invalidateResolution, ] ); diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index e8e06f86e964a0..8f30849178300e 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -876,6 +876,37 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toContainText( '1' ); } ); + test( 'can re-add the same reaction after removing it', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing re-add reaction' }, + comment: 'Re-add reaction', + } ); + + await blockNoteUtils.addReactionToComment( 'Heart' ); + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '1' ); + + // Remove the reaction. + await reactionButton.click(); + await expect( reactionButton ).toBeHidden(); + + // Add the same reaction again. This used to fail because the + // parent note's cached `reaction_summary` still reported the + // removed heart as `reacted`, so the toggle attempted to + // delete a now-missing comment record instead of adding. + await blockNoteUtils.addReactionToComment( 'Heart' ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '❤' ); + await expect( reactionButton ).toContainText( '1' ); + } ); + test( 'can remove own emoji reaction by clicking it', async ( { page, blockNoteUtils, From 4d7448937810153a4e75a1387e64ec61fcefd027 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 17:15:24 -0700 Subject: [PATCH 085/362] Notes: Drop empty wordpress-7.0/block-comments.php compat shim 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. --- lib/compat/wordpress-7.0/block-comments.php | 6 ------ lib/load.php | 1 - 2 files changed, 7 deletions(-) delete mode 100644 lib/compat/wordpress-7.0/block-comments.php diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php deleted file mode 100644 index c00baee85a0151..00000000000000 --- a/lib/compat/wordpress-7.0/block-comments.php +++ /dev/null @@ -1,6 +0,0 @@ - Date: Thu, 7 May 2026 20:29:00 -0700 Subject: [PATCH 086/362] Notes: Force-delete reactions and refetch only the toggled note's record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/collab-sidebar/hooks.js | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index dd1d01288bcbd1..b094015cf96596 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -16,6 +16,7 @@ import { privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { store as noticesStore } from '@wordpress/notices'; +import apiFetch from '@wordpress/api-fetch'; import { getScrollContainer } from '@wordpress/dom'; import { decodeEntities } from '@wordpress/html-entities'; import { store as interfaceStore } from '@wordpress/interface'; @@ -155,7 +156,7 @@ export function useNoteThreads( postId ) { export function useNoteActions( reactionsMap = {} ) { const { createNotice } = useDispatch( noticesStore ); - const { saveEntityRecord, deleteEntityRecord, invalidateResolution } = + const { saveEntityRecord, deleteEntityRecord, receiveEntityRecords } = useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); const { getBlockAttributes, getSelectedBlockClientId } = @@ -313,12 +314,16 @@ export function useNoteActions( reactionsMap = {} ) { const emojiData = noteReactions[ emoji ]; if ( emojiData?.reacted && emojiData?.my_reaction_id ) { - // Remove the reaction by deleting the comment record. + // Force-delete the reaction comment rather than + // trashing it (the WP REST default). Reactions + // don't have a trash workflow, and a trashed + // reaction would otherwise linger in `wp_comments` + // indefinitely each time the user toggles it off. await deleteEntityRecord( 'root', 'comment', emojiData.my_reaction_id, - undefined, + { force: true }, { throwOnError: true } ); @@ -347,25 +352,23 @@ export function useNoteActions( reactionsMap = {} ) { } ); } - // `reaction_summary` is computed server-side and cached - // on each parent note's entity record. Mutating a - // reaction comment doesn't invalidate that field, so a - // subsequent toggle for the same emoji on the same note - // reads stale `reacted` / `my_reaction_id` data — a - // removed reaction can't be re-added because the toggle - // still tries to delete a now-missing comment record. - // Refetch the notes list so each parent's - // `reaction_summary` is fresh. - invalidateResolution( 'getEntityRecords', [ - 'root', - 'comment', - { - post: getCurrentPostId(), - type: 'note', - status: 'all', - per_page: -1, - }, - ] ); + // `reaction_summary` is computed server-side and + // cached on the parent note's entity record. Mutating + // a reaction comment doesn't invalidate that field, so + // a subsequent toggle would read stale `reacted` / + // `my_reaction_id` data and route into the wrong + // branch (deleting an already-removed comment). + // + // Refetch only the parent note (1 record) and merge it + // into the entity store. `receiveEntityRecords` with + // no `query` arg updates the per-record cache, which + // the list selector reads through by ID — so the LIST + // view picks up the fresh `reaction_summary` without + // re-fetching every other note on the post. + const refreshed = await apiFetch( { + path: `/wp/v2/comments/${ commentId }`, + } ); + receiveEntityRecords( 'root', 'comment', [ refreshed ] ); } catch ( error ) { onError( error ); } @@ -376,7 +379,7 @@ export function useNoteActions( reactionsMap = {} ) { saveEntityRecord, getCurrentPostId, createNotice, - invalidateResolution, + receiveEntityRecords, ] ); From f2a21acdd5abdda6c143fe57f1eaf33585e9d32e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 10 May 2026 16:52:02 -0700 Subject: [PATCH 087/362] Notes: Pin re-add-reaction test against duplicate-reaction error and 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). --- .../specs/editor/various/block-notes.spec.js | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 8f30849178300e..1bcff51b20b2ef 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -896,15 +896,40 @@ test.describe( 'Block Notes', () => { // Remove the reaction. await reactionButton.click(); await expect( reactionButton ).toBeHidden(); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction removed.' } ) + ).toBeVisible(); - // Add the same reaction again. This used to fail because the - // parent note's cached `reaction_summary` still reported the - // removed heart as `reacted`, so the toggle attempted to - // delete a now-missing comment record instead of adding. + // Add the same reaction again. This used to fail two ways: + // 1) the parent note's cached `reaction_summary` still + // reported the removed heart as `reacted`, so the toggle + // attempted to delete a now-missing comment record + // instead of routing to add; and 2) the server's + // duplicate-reaction guard included trashed comments, + // so the just-removed reaction blocked the re-add with + // `rest_comment_duplicate_reaction` ("You have already + // reacted with this emoji"). await blockNoteUtils.addReactionToComment( 'Heart' ); await expect( reactionButton ).toBeVisible(); await expect( reactionButton ).toContainText( '❤' ); await expect( reactionButton ).toContainText( '1' ); + + // Both adds must have surfaced a success snackbar (initial + // add + re-add), and the duplicate-reaction error must + // never appear — pins both fixes (client refetch + server + // status='approve' query) against regression. + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toHaveCount( 2 ); + await expect( + page.locator( '.components-snackbar__content', { + hasText: /already reacted/i, + } ) + ).toHaveCount( 0 ); } ); test( 'can remove own emoji reaction by clicking it', async ( { From 96ad388e05fcdf557345946bc02876c6f9982430 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Sun, 10 May 2026 17:30:44 -0700 Subject: [PATCH 088/362] Notes: Replace Frimousse picker with native SearchControl + Composite 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. --- bin/copy-emojibase-data.mjs | 53 +- lib/compat/wordpress-7.1/emojibase-data.php | 101 +++- package-lock.json | 20 - packages/editor/package.json | 1 - .../components/collab-sidebar/emoji-picker.js | 472 ++++++++++++++++++ .../collab-sidebar/frimousse-picker.js | 87 ---- .../collab-sidebar/reaction-display.js | 21 +- .../collab-sidebar/reaction-emoji-picker.js | 2 +- .../src/components/collab-sidebar/style.scss | 53 +- .../collab-sidebar/test/emoji-picker.js | 202 ++++++++ phpunit/emoji-picker-data-test.php | 181 +++++++ .../specs/editor/various/block-notes.spec.js | 93 ++-- 12 files changed, 1092 insertions(+), 194 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/emoji-picker.js delete mode 100644 packages/editor/src/components/collab-sidebar/frimousse-picker.js create mode 100644 packages/editor/src/components/collab-sidebar/test/emoji-picker.js create mode 100644 phpunit/emoji-picker-data-test.php diff --git a/bin/copy-emojibase-data.mjs b/bin/copy-emojibase-data.mjs index 19737b29864f96..434f62bc3115b8 100644 --- a/bin/copy-emojibase-data.mjs +++ b/bin/copy-emojibase-data.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node /** - * Copy the locale data files Frimousse expects from the bundled - * emojibase-data package into Gutenberg's `build/emojibase-data/` - * directory so the plugin can serve them via plugins_url() instead of - * inlining ~770KB of JSON into the editor JS bundle. + * Copy Emojibase locale data into Gutenberg's `build/emojibase-data/` + * directory so the editor's emoji picker can fetch translated emoji + * labels and category names same-origin without inlining the data into + * the JS bundle. Files are loaded per-locale at runtime (one locale per + * editor session), so the disk cost on the build artifact does not + * translate into a network cost for users. * * Runs as a step in `bin/build.mjs`, after wp-build has populated * `build/`. Exits 0 even when emojibase-data is missing — the editor @@ -20,14 +22,45 @@ const ROOT_DIR = path.resolve( __dirname, '..' ); const SRC_DIR = path.join( ROOT_DIR, 'node_modules', 'emojibase-data' ); const DEST_DIR = path.join( ROOT_DIR, 'build', 'emojibase-data' ); -// Frimousse only ever fetches data.json and messages.json. We ship just -// those two files per locale; the rest of the package (~50MB unpacked) -// stays out of the plugin distribution. +// We only ever fetch data.json and messages.json. Shipping just those +// two files per locale keeps the rest of the upstream package +// (~50MB unpacked) out of the plugin distribution. const FILES = [ 'data.json', 'messages.json' ]; -// Locales the plugin ships. Keep this small for now — adding a locale -// is ~85KB gzipped. English covers the strings shown to users today. -const LOCALES = [ 'en' ]; +// All locales Emojibase ships translated data for. Keep in sync with +// `EMOJIBASE_LOCALES` in `packages/editor/src/components/collab-sidebar/ +// emoji-picker.js`. Each locale adds ~85KB gzipped on disk; only the +// active locale is fetched per editor session. +const LOCALES = [ + 'bn', + 'da', + 'de', + 'en', + 'en-gb', + 'es', + 'es-mx', + 'et', + 'fi', + 'fr', + 'hi', + 'hu', + 'it', + 'ja', + 'ko', + 'lt', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'ru', + 'sv', + 'th', + 'uk', + 'vi', + 'zh', + 'zh-hant', +]; async function copyEmojibaseData() { if ( ! existsSync( SRC_DIR ) ) { diff --git a/lib/compat/wordpress-7.1/emojibase-data.php b/lib/compat/wordpress-7.1/emojibase-data.php index 312076672e81ee..6b4711727394cd 100644 --- a/lib/compat/wordpress-7.1/emojibase-data.php +++ b/lib/compat/wordpress-7.1/emojibase-data.php @@ -1,9 +1,9 @@ translated label`. + */ + $overrides = apply_filters( + 'gutenberg_emoji_picker_label_overrides', + $defaults + ); + + // Coerce defensively: a misbehaving filter callback that returns + // non-string values would otherwise crash the picker's JS-side + // label handling (`label.toLowerCase()` in searchEmojis()). + $overrides = is_array( $overrides ) ? $overrides : array(); + $overrides = array_filter( $overrides, 'is_string' ); + + wp_add_inline_script( + 'wp-editor', + 'window.gutenbergEmojiLabelOverrides = ' . wp_json_encode( (object) $overrides ) . ';', + 'before' + ); +} +add_action( 'init', 'gutenberg_emoji_picker_label_overrides_register_inline_script' ); diff --git a/package-lock.json b/package-lock.json index 530478dc1dc980..0e72b19d6f065a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32869,25 +32869,6 @@ "node": ">= 0.6" } }, - "node_modules/frimousse": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.3.0.tgz", - "integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==", - "license": "MIT", - "workspaces": [ - ".", - "site" - ], - "peerDependencies": { - "react": "^18 || ^19", - "typescript": ">=5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -60084,7 +60065,6 @@ "date-fns": "^3.6.0", "diff": "^4.0.2", "fast-deep-equal": "^3.1.3", - "frimousse": "^0.3.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "remove-accents": "^0.5.0", diff --git a/packages/editor/package.json b/packages/editor/package.json index 441a0ed94d1973..08d9f769f1c81b 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -112,7 +112,6 @@ "date-fns": "^3.6.0", "diff": "^4.0.2", "fast-deep-equal": "^3.1.3", - "frimousse": "^0.3.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "remove-accents": "^0.5.0", diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.js b/packages/editor/src/components/collab-sidebar/emoji-picker.js new file mode 100644 index 00000000000000..3cb2e6196997e9 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.js @@ -0,0 +1,472 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { Composite, SearchControl } from '@wordpress/components'; +import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; + +/** + * Locales for which Emojibase ships translated labels and tags. Built + * from the directory listing of the `emojibase-data` npm package, kept + * in sync with the `LOCALES` set copied by `bin/copy-emojibase-data.mjs`. + */ +export const EMOJIBASE_LOCALES = new Set( [ + 'bn', + 'da', + 'de', + 'en', + 'en-gb', + 'es', + 'es-mx', + 'et', + 'fi', + 'fr', + 'hi', + 'hu', + 'it', + 'ja', + 'ko', + 'lt', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'ru', + 'sv', + 'th', + 'uk', + 'vi', + 'zh', + 'zh-hant', +] ); + +/** + * Map a BCP-47 / WordPress locale string to the closest Emojibase locale, + * falling back to English. Tries the full tag (e.g. `pt-br`) first, then + * the language portion (`pt`), then `en`. + * + * @param {string} raw The user-facing locale, e.g. `pt-BR` or `fr_FR`. + * @return {string} An Emojibase locale key. + */ +export function resolveEmojibaseLocale( raw ) { + if ( typeof raw !== 'string' || ! raw ) { + return 'en'; + } + const normalized = raw.toLowerCase().replace( '_', '-' ); + if ( EMOJIBASE_LOCALES.has( normalized ) ) { + return normalized; + } + // Special-case Traditional Chinese variants. Must run before the + // language-portion fallback, since `zh` is itself a supported locale + // and would otherwise swallow `zh-tw`/`zh-hk`/`zh-mo`. + if ( [ 'zh-tw', 'zh-hk', 'zh-mo' ].includes( normalized ) ) { + return 'zh-hant'; + } + const lang = normalized.split( '-' )[ 0 ]; + if ( EMOJIBASE_LOCALES.has( lang ) ) { + return lang; + } + return 'en'; +} + +/** + * Detect the active locale for the picker. Prefers `` (set by + * WordPress to the site language) and falls back to `navigator.language`. + * + * @return {string} A locale key Emojibase ships data for. + */ +function detectLocale() { + if ( typeof document !== 'undefined' && document.documentElement?.lang ) { + return resolveEmojibaseLocale( document.documentElement.lang ); + } + if ( typeof navigator !== 'undefined' && navigator.language ) { + return resolveEmojibaseLocale( navigator.language ); + } + return 'en'; +} + +// Module-level cache so opening the picker twice doesn't refetch. +const dataCache = new Map(); +const inflight = new Map(); + +/** + * Fetch and cache Emojibase `data.json` + `messages.json` for a given + * locale. Resolves with `{ data, messages }` or rejects on a network + * error so callers can render an error/empty state. + * + * @param {string} baseUrl Same-origin base URL for the emojibase-data + * directory (e.g. plugin's + * `build/emojibase-data`). + * @param {string} locale Emojibase locale key. + * @return {Promise<{data: Array, messages: Object}>} Loaded dataset. + */ +function loadEmojibaseData( baseUrl, locale ) { + const cacheKey = `${ baseUrl }|${ locale }`; + if ( dataCache.has( cacheKey ) ) { + return Promise.resolve( dataCache.get( cacheKey ) ); + } + if ( inflight.has( cacheKey ) ) { + return inflight.get( cacheKey ); + } + const promise = Promise.all( [ + fetch( `${ baseUrl }/${ locale }/data.json` ).then( ( r ) => { + if ( ! r.ok ) { + throw new Error( `Failed to load ${ locale }/data.json` ); + } + return r.json(); + } ), + fetch( `${ baseUrl }/${ locale }/messages.json` ).then( ( r ) => { + if ( ! r.ok ) { + throw new Error( `Failed to load ${ locale }/messages.json` ); + } + return r.json(); + } ), + ] ) + .then( ( [ data, messages ] ) => { + const value = { data, messages }; + dataCache.set( cacheKey, value ); + inflight.delete( cacheKey ); + return value; + } ) + .catch( ( error ) => { + inflight.delete( cacheKey ); + throw error; + } ); + inflight.set( cacheKey, promise ); + return promise; +} + +/** + * React hook that fetches Emojibase data for a given base URL and locale. + * + * @param {string} baseUrl Same-origin URL pointing at the emojibase data dir. + * @param {string} locale Emojibase locale key. + * @return {{ data: Array|null, messages: Object|null, isLoading: boolean, error: Error|null }} + * Loading state for the dataset. + */ +function useEmojibaseData( baseUrl, locale ) { + const [ state, setState ] = useState( () => { + const cached = dataCache.get( `${ baseUrl }|${ locale }` ); + return { + data: cached?.data || null, + messages: cached?.messages || null, + isLoading: ! cached, + error: null, + }; + } ); + + useEffect( () => { + let cancelled = false; + const cached = dataCache.get( `${ baseUrl }|${ locale }` ); + if ( cached ) { + setState( { + data: cached.data, + messages: cached.messages, + isLoading: false, + error: null, + } ); + return; + } + setState( ( prev ) => ( { ...prev, isLoading: true, error: null } ) ); + loadEmojibaseData( baseUrl, locale ) + .then( ( { data, messages } ) => { + if ( cancelled ) { + return; + } + setState( { + data, + messages, + isLoading: false, + error: null, + } ); + } ) + .catch( ( error ) => { + if ( cancelled ) { + return; + } + setState( { + data: null, + messages: null, + isLoading: false, + error, + } ); + } ); + return () => { + cancelled = true; + }; + }, [ baseUrl, locale ] ); + + return state; +} + +const COLUMNS = 8; + +/** + * Group emoji records by their Emojibase `group` key, preserving + * Emojibase's natural ordering (which follows Unicode CLDR). Entries + * with no `group` (e.g. component code points) are skipped. + * + * @param {Array} data Emoji records from `data.json`. + * @return {Array<{ key: number, emojis: Array }>} Ordered category buckets. + */ +export function groupEmojis( data ) { + const buckets = new Map(); + for ( const entry of data ) { + if ( typeof entry.group !== 'number' ) { + continue; + } + if ( ! buckets.has( entry.group ) ) { + buckets.set( entry.group, [] ); + } + buckets.get( entry.group ).push( entry ); + } + return Array.from( buckets.entries() ) + .sort( ( a, b ) => a[ 0 ] - b[ 0 ] ) + .map( ( [ key, emojis ] ) => ( { key, emojis } ) ); +} + +/** + * Slice a flat list of emoji into rows of `COLUMNS` items so the grid + * keeps a stable column count even as the visible list shrinks during + * search. + * + * @param {Array} emojis Emoji records. + * @return {Array} Rows of up to `COLUMNS` emoji each. + */ +export function chunkRows( emojis ) { + const rows = []; + for ( let i = 0; i < emojis.length; i += COLUMNS ) { + rows.push( emojis.slice( i, i + COLUMNS ) ); + } + return rows; +} + +/** + * Run a case-insensitive search over the emoji label and Emojibase + * tags, applying any per-emoji label override before matching so users + * searching for an overridden label still get the expected hit. + * Returns the unfiltered list when the query is empty. + * + * @param {Array} emojis Emoji records. + * @param {string} query Search query. + * @param {Object|null} overrides Map of `hexcode => translated label`. + * @return {Array} Matching emoji records. + */ +export function searchEmojis( emojis, query, overrides ) { + const trimmed = query.trim().toLowerCase(); + if ( ! trimmed ) { + return emojis; + } + return emojis.filter( ( entry ) => { + // Match against both the overridden label (if any) and the + // original Emojibase label so a user searching in either name + // still finds the emoji — useful when an English-language user + // types "red heart" against a "Heart" override, or a translator + // types the local-language label against the English fallback. + const override = overrides?.[ entry.hexcode ]; + if ( override && override.toLowerCase().includes( trimmed ) ) { + return true; + } + if ( entry.label && entry.label.toLowerCase().includes( trimmed ) ) { + return true; + } + if ( Array.isArray( entry.tags ) ) { + for ( const tag of entry.tags ) { + if ( tag.toLowerCase().includes( trimmed ) ) { + return true; + } + } + } + return false; + } ); +} + +/** + * Full searchable emoji picker built from WPDS components + * (`SearchControl`, `Composite`) and styled with WPDS tokens. Emoji + * data and labels come from Emojibase per-locale files served + * same-origin from `window.gutenbergEmojibaseUrl`; UI chrome strings + * go through `@wordpress/i18n` so GlotPress can translate them. + * + * @param {Object} props Component props. + * @param {Function} props.onSelect Called with the selected emoji character. + */ +export default function EmojiPicker( { onSelect } ) { + const baseUrl = + typeof window !== 'undefined' ? window.gutenbergEmojibaseUrl : null; + const labelOverrides = + typeof window !== 'undefined' && window.gutenbergEmojiLabelOverrides + ? window.gutenbergEmojiLabelOverrides + : null; + const [ locale ] = useState( detectLocale ); + const { data, messages, isLoading, error } = useEmojibaseData( + baseUrl, + locale + ); + const [ query, setQuery ] = useState( '' ); + const viewportRef = useRef( null ); + + /** + * Resolve the user-facing label for an emoji record. Prefers the + * server-supplied override (typically a `__()`-translated string for + * locales Emojibase doesn't cover) over the Emojibase data label. + * + * @param {Object} entry Emojibase emoji record. + * @return {string} The label to render and use as the accessible name. + */ + const labelFor = ( entry ) => + labelOverrides?.[ entry.hexcode ] || entry.label || ''; + + const groups = useMemo( + () => ( data ? groupEmojis( data ) : [] ), + [ data ] + ); + + const groupLabelByKey = useMemo( () => { + const map = new Map(); + if ( messages?.groups ) { + for ( const g of messages.groups ) { + map.set( g.order, g.message ); + } + } + return map; + }, [ messages ] ); + + const visibleGroups = useMemo( () => { + if ( ! groups.length ) { + return []; + } + if ( ! query.trim() ) { + return groups.map( ( g ) => ( { + ...g, + rows: chunkRows( g.emojis ), + } ) ); + } + return groups + .map( ( g ) => { + const filtered = searchEmojis( + g.emojis, + query, + labelOverrides + ); + return { + ...g, + emojis: filtered, + rows: chunkRows( filtered ), + }; + } ) + .filter( ( g ) => g.emojis.length > 0 ); + }, [ groups, query, labelOverrides ] ); + + const matchCount = useMemo( + () => visibleGroups.reduce( ( n, g ) => n + g.emojis.length, 0 ), + [ visibleGroups ] + ); + + // Announce result counts during search so screen readers stay in sync + // with the visible grid as the user types. + useEffect( () => { + if ( ! query.trim() || isLoading ) { + return; + } + if ( matchCount === 0 ) { + speak( __( 'No emoji found.' ) ); + } + }, [ query, matchCount, isLoading ] ); + + // Reset scroll position when the search query changes so the user + // always sees the top match instead of a stale scroll offset. + useEffect( () => { + if ( viewportRef.current ) { + viewportRef.current.scrollTop = 0; + } + }, [ query ] ); + + if ( ! baseUrl ) { + return null; + } + + return ( +
+
+ +
+
+ { isLoading && ( +
+ { __( 'Loading…' ) } +
+ ) } + { error && ! isLoading && ( +
+ { __( 'Couldn’t load emojis.' ) } +
+ ) } + { ! isLoading && ! error && matchCount === 0 && ( +
+ { __( 'No emoji found.' ) } +
+ ) } + { ! isLoading && ! error && matchCount > 0 && ( + + { visibleGroups.map( ( group ) => ( +
+
+ { groupLabelByKey.get( group.key ) || '' } +
+ { group.rows.map( ( row, rowIndex ) => ( + + { row.map( ( emoji ) => ( + + onSelect( emoji.emoji ) + } + > + { emoji.emoji } + + ) ) } + + ) ) } +
+ ) ) } +
+ ) } +
+
+ ); +} diff --git a/packages/editor/src/components/collab-sidebar/frimousse-picker.js b/packages/editor/src/components/collab-sidebar/frimousse-picker.js deleted file mode 100644 index 33c87356a7640c..00000000000000 --- a/packages/editor/src/components/collab-sidebar/frimousse-picker.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * External dependencies - */ -import { EmojiPicker } from 'frimousse'; - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Frimousse-based full emoji picker, styled with WPDS tokens. - * - * Frimousse fetches its dataset (~770KB raw, ~85KB gzipped) from - * `${emojibaseUrl}/${locale}/{data,messages}.json`. We do not want a - * runtime CDN dependency, so the Gutenberg plugin ships those files in - * `build/emojibase-data/` and exposes the directory URL via PHP as - * `window.gutenbergEmojibaseUrl`. When this picker is consumed outside - * the plugin (e.g. an npm consumer of @wordpress/editor), the consumer - * can set the same global to a URL of their choice — the bundle does - * not embed the data, so a URL must be provided one way or another. - * - * The picker is loaded lazily by reaction-display.js, so the Frimousse - * code itself only ships in the editor bundle when this file is - * imported. - * - * @param {Object} props Component props. - * @param {Function} props.onSelect Called with the selected emoji character. - */ -export default function FrimoussePicker( { onSelect } ) { - const emojibaseUrl = - typeof window !== 'undefined' && window.gutenbergEmojibaseUrl - ? window.gutenbergEmojibaseUrl - : undefined; - - return ( - onSelect( emoji ) } - emojibaseUrl={ emojibaseUrl } - columns={ 8 } - > - - - - { __( 'Loading…' ) } - - - { __( 'No emoji found.' ) } - - ( -
- { category.label } -
- ), - Row: ( { children, ...rest } ) => ( -
- { children } -
- ), - Emoji: ( { emoji, ...rest } ) => ( - - ), - } } - /> -
-
- ); -} diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index a885db744ed2a2..d18af43f6066e4 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -20,11 +20,11 @@ import ReactionEmojiPicker, { } from './reaction-emoji-picker'; /** - * Lazy-load the Frimousse-based full picker. Its bundle (Frimousse + - * the bundled Emojibase JSON) is only fetched when a user opens the - * "More emojis" popover for the first time in a session. + * Lazy-load the full emoji picker. Its bundle is only fetched when a + * user opens the "More emojis" popover for the first time in a session; + * the Emojibase JSON dataset is fetched separately on first open. */ -const FrimoussePicker = lazy( () => import( './frimousse-picker' ) ); +const FullEmojiPicker = lazy( () => import( './emoji-picker' ) ); // `Dropdown`'s popover is rendered in a portal anchored to , // so it escapes the `overflow: hidden` chain on the collab sidebar @@ -308,15 +308,14 @@ export function AddReactionButton( { onToggleReaction } ) { } /** - * Standalone "+" button that opens a full Frimousse-based emoji picker. - * Sibling of AddReactionButton; not nested inside it so the curated and - * full picker popovers never conflict. + * Standalone "+" button that opens the full emoji picker. Sibling of + * AddReactionButton; not nested inside it so the curated and full picker + * popovers never conflict. * * Renders nothing when `window.gutenbergEmojibaseUrl` is unset — the * Gutenberg plugin sets it via PHP, but npm consumers of @wordpress/editor * must opt in by providing a URL pointing at a self-hosted emojibase - * dataset. We hide the trigger rather than fall through to Frimousse's - * default jsdelivr CDN. + * dataset. * * @param {Object} props Component props. * @param {Function} props.onToggleReaction Callback to toggle a reaction. @@ -329,7 +328,7 @@ export function MoreEmojiButton( { onToggleReaction } ) { return ( ( + ) ) } + + ); +} From ae75200ac4029d4648c46cb423bbcf947438dec5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:22:12 -0800 Subject: [PATCH 108/362] Add ReactionDisplay component for Notes 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 --- .../collab-sidebar/reaction-display.js | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/editor/src/components/collab-sidebar/reaction-display.js diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js new file mode 100644 index 00000000000000..8dad8b44e3f779 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -0,0 +1,144 @@ +/** + * WordPress dependencies + */ +import { __, sprintf, _n } from '@wordpress/i18n'; +import { Button, Dropdown } from '@wordpress/components'; +import { plus } from '@wordpress/icons'; +import clsx from 'clsx'; + +/** + * Internal dependencies + */ +import ReactionEmojiPicker from './reaction-emoji-picker'; + +/** + * Get the count of reactions for a specific emoji. + * + * @param {Object} reactions The reactions object. + * @param {string} emoji The emoji to count. + * @return {number} The count of reactions. + */ +function getReactionCount( reactions, emoji ) { + return reactions?.[ emoji ]?.length || 0; +} + +/** + * Check if the current user has reacted with a specific emoji. + * + * @param {Object} reactions The reactions object. + * @param {string} emoji The emoji to check. + * @param {number} currentUserId The current user's ID. + * @return {boolean} Whether the user has reacted. + */ +function hasUserReacted( reactions, emoji, currentUserId ) { + return ( + reactions?.[ emoji ]?.some( + ( reaction ) => reaction.userId === currentUserId + ) || false + ); +} + +/** + * Get all emojis that have reactions. + * + * @param {Object} reactions The reactions object. + * @return {string[]} Array of emojis with reactions. + */ +function getReactedEmojis( reactions ) { + if ( ! reactions ) { + return []; + } + return Object.keys( reactions ).filter( + ( emoji ) => reactions[ emoji ]?.length > 0 + ); +} + +/** + * Display current reactions with counts as pill-shaped buttons. + * + * @param {Object} props Component props. + * @param {Object} props.reactions The reactions object from comment meta. + * @param {number} props.currentUserId The current user's ID. + * @param {Function} props.onToggleReaction Callback to toggle a reaction. + */ +export default function ReactionDisplay( { + reactions, + currentUserId, + onToggleReaction, +} ) { + const reactedEmojis = getReactedEmojis( reactions ); + + return ( +
+ { reactedEmojis.map( ( emoji ) => { + const count = getReactionCount( reactions, emoji ); + const isActive = hasUserReacted( + reactions, + emoji, + currentUserId + ); + + return ( + + ); + } ) } + ( +
+ ); +} From c1e5de32c28a89c67679f8477c0820bae8ab68bb Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:22:26 -0800 Subject: [PATCH 109/362] Add ReactionDetailsPopover component for Notes 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 --- .../reaction-details-popover.js | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 packages/editor/src/components/collab-sidebar/reaction-details-popover.js diff --git a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js new file mode 100644 index 00000000000000..54ef5c7f949046 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js @@ -0,0 +1,163 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + Popover, + __experimentalVStack as VStack, + __experimentalHStack as HStack, + __experimentalText as Text, +} from '@wordpress/components'; +import { humanTimeDiff } from '@wordpress/date'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { REACTION_EMOJIS } from './reaction-emoji-picker'; + +/** + * Get user display name by ID. + * + * @param {Object} users Map of user data by ID. + * @param {number} userId The user ID. + * @return {string} The user's display name or a fallback. + */ +function getUserName( users, userId ) { + return users?.[ userId ]?.name || __( 'Unknown user' ); +} + +/** + * Get user avatar URL by ID. + * + * @param {Object} users Map of user data by ID. + * @param {number} userId The user ID. + * @return {string|null} The user's avatar URL or null. + */ +function getUserAvatar( users, userId ) { + return users?.[ userId ]?.avatar_urls?.[ 48 ] || null; +} + +/** + * A popover showing who reacted and when. + * + * @param {Object} props Component props. + * @param {Object} props.reactions The reactions object from comment meta. + * @param {Function} props.onClose Callback to close the popover. + * @param {Object} props.anchor The anchor element for the popover. + */ +export default function ReactionDetailsPopover( { + reactions, + onClose, + anchor, +} ) { + // Collect all unique user IDs from reactions using useMemo for stable reference. + const userIdArray = useMemo( () => { + if ( ! reactions ) { + return []; + } + const userIdSet = new Set(); + Object.values( reactions ).forEach( ( reactionList ) => { + reactionList?.forEach( ( reaction ) => { + if ( reaction.userId ) { + userIdSet.add( reaction.userId ); + } + } ); + } ); + return Array.from( userIdSet ).sort( ( a, b ) => a - b ); + }, [ reactions ] ); + + // Fetch user data for all users who reacted. + const users = useSelect( + ( select ) => { + const { getUser } = select( coreStore ); + const userData = {}; + userIdArray.forEach( ( userId ) => { + const user = getUser( userId ); + if ( user ) { + userData[ userId ] = user; + } + } ); + return userData; + }, + [ userIdArray ] + ); + + if ( ! reactions ) { + return null; + } + + // Get emojis that have reactions, in the order they appear in REACTION_EMOJIS. + const orderedEmojis = REACTION_EMOJIS.map( ( { emoji } ) => emoji ).filter( + ( emoji ) => reactions[ emoji ]?.length > 0 + ); + + return ( + + + { orderedEmojis.map( ( emoji ) => { + const emojiData = REACTION_EMOJIS.find( + ( e ) => e.emoji === emoji + ); + const reactionList = reactions[ emoji ]; + + return ( + + + + { emoji } + + { emojiData?.label } + + + { reactionList.map( ( reaction, index ) => ( + + { getUserAvatar( + users, + reaction.userId + ) && ( + + ) } + + { getUserName( + users, + reaction.userId + ) } + + + { humanTimeDiff( + reaction.timestamp + ) } + + + ) ) } + + + ); + } ) } + + + ); +} From 3634a3ae1ecdebf2b0fc8abf2d95829ecf77487c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:22:45 -0800 Subject: [PATCH 110/362] Add reaction CRUD functions to useBlockCommentsActions 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 --- .../src/components/collab-sidebar/hooks.js | 186 +++++++++++++++++- 1 file changed, 184 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 8ed506c0587579..6328dc224a75e4 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -269,7 +269,8 @@ function clearInlineNoteMarker( } } -export function useNoteActions() { +export function useNoteActions( reflowComments = () => {} ) { + const registry = useRegistry(); const { createNotice } = useDispatch( noticesStore ); const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); @@ -491,7 +492,188 @@ export function useNoteActions() { } }; - return { onCreate, onEdit, onDelete }; + const onAddReaction = async ( { commentId, emoji } ) => { + try { + // Get current user from the store. + const currentUser = + registry.select( coreStore ).getCurrentUser() || {}; + const userId = currentUser.id; + + if ( ! userId ) { + throw new Error( + __( 'You must be logged in to add reactions.' ) + ); + } + + // Get current comment data. + const comment = registry + .select( coreStore ) + .getEntityRecord( 'root', 'comment', commentId ); + + if ( ! comment ) { + throw new Error( __( 'Comment not found.' ) ); + } + + // Get existing reactions or initialize empty object. + const existingReactions = comment.meta?._wp_reactions || {}; + + // Check if user already reacted with this emoji. + const emojiReactions = existingReactions[ emoji ] || []; + const alreadyReacted = emojiReactions.some( + ( reaction ) => reaction.userId === userId + ); + + if ( alreadyReacted ) { + return; + } + + // Add new reaction. + const newReaction = { + userId, + timestamp: new Date().toISOString(), + }; + + const updatedReactions = { + ...existingReactions, + [ emoji ]: [ ...emojiReactions, newReaction ], + }; + + await saveEntityRecord( + 'root', + 'comment', + { + id: commentId, + meta: { + ...comment.meta, + _wp_reactions: updatedReactions, + }, + }, + { throwOnError: true } + ); + + createNotice( 'snackbar', __( 'Reaction added.' ), { + type: 'snackbar', + isDismissible: true, + } ); + reflowComments(); + } catch ( error ) { + reflowComments(); + onError( error ); + } + }; + + const onRemoveReaction = async ( { commentId, emoji } ) => { + try { + // Get current user from the store. + const currentUser = + registry.select( coreStore ).getCurrentUser() || {}; + const userId = currentUser.id; + + if ( ! userId ) { + throw new Error( + __( 'You must be logged in to remove reactions.' ) + ); + } + + // Get current comment data. + const comment = registry + .select( coreStore ) + .getEntityRecord( 'root', 'comment', commentId ); + + if ( ! comment ) { + throw new Error( __( 'Comment not found.' ) ); + } + + // Get existing reactions. + const existingReactions = comment.meta?._wp_reactions || {}; + const emojiReactions = existingReactions[ emoji ] || []; + + // Filter out current user's reaction. + const updatedEmojiReactions = emojiReactions.filter( + ( reaction ) => reaction.userId !== userId + ); + + // Build updated reactions object, removing empty arrays. + const updatedReactions = { ...existingReactions }; + if ( updatedEmojiReactions.length > 0 ) { + updatedReactions[ emoji ] = updatedEmojiReactions; + } else { + delete updatedReactions[ emoji ]; + } + + await saveEntityRecord( + 'root', + 'comment', + { + id: commentId, + meta: { + ...comment.meta, + _wp_reactions: + Object.keys( updatedReactions ).length > 0 + ? updatedReactions + : undefined, + }, + }, + { throwOnError: true } + ); + + createNotice( 'snackbar', __( 'Reaction removed.' ), { + type: 'snackbar', + isDismissible: true, + } ); + reflowComments(); + } catch ( error ) { + reflowComments(); + onError( error ); + } + }; + + const onToggleReaction = async ( { commentId, emoji } ) => { + try { + // Get current user from the store. + const currentUser = + registry.select( coreStore ).getCurrentUser() || {}; + const userId = currentUser.id; + + if ( ! userId ) { + throw new Error( __( 'You must be logged in to react.' ) ); + } + + // Get current comment data. + const comment = registry + .select( coreStore ) + .getEntityRecord( 'root', 'comment', commentId ); + + if ( ! comment ) { + throw new Error( __( 'Comment not found.' ) ); + } + + // Check if user already reacted with this emoji. + const existingReactions = comment.meta?._wp_reactions || {}; + const emojiReactions = existingReactions[ emoji ] || []; + const alreadyReacted = emojiReactions.some( + ( reaction ) => reaction.userId === userId + ); + + if ( alreadyReacted ) { + await onRemoveReaction( { commentId, emoji } ); + } else { + await onAddReaction( { commentId, emoji } ); + } + } catch ( error ) { + reflowComments(); + onError( error ); + } + }; + + return { + onCreate, + onEdit, + onDelete, + onAddReaction, + onRemoveReaction, + onToggleReaction, + }; } export function useEnableFloatingSidebar( enabled = false ) { From bfc225197efed8cf04d4cd3cb0645f6a00159756 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 2 Feb 2026 10:23:20 -0800 Subject: [PATCH 111/362] Add styles for emoji reactions in Notes 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 --- .../src/components/collab-sidebar/style.scss | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 158763920a277d..02b42f059b1f66 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -227,3 +227,139 @@ mark.wp-note { border-radius: 1px; transition: background-color 0.1s ease-in-out; } + +// Emoji Reactions +.editor-collab-sidebar-panel__reactions { + display: flex; + flex-wrap: wrap; + gap: $grid-unit-05; + align-items: center; + margin-top: $grid-unit-10; +} + +.editor-collab-sidebar-panel__reaction-button { + display: inline-flex; + align-items: center; + gap: $grid-unit-05; + padding: $grid-unit-05 $grid-unit-10; + border-radius: $radius-round; + border: $border-width solid $gray-300; + background-color: $gray-100; + font-size: $default-font-size; + cursor: pointer; + transition: background-color 0.1s ease; + + &:hover { + background-color: $gray-200; + } + + &:focus { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } + + &.is-active { + background-color: var(--wp-admin-theme-color--rgb, rgba(56, 88, 233, 0.1)); + border-color: var(--wp-admin-theme-color); + } +} + +.editor-collab-sidebar-panel__reaction-emoji { + font-size: 14px; + line-height: 1; +} + +.editor-collab-sidebar-panel__reaction-count { + font-size: $default-font-size; + font-weight: $font-weight-medium; + color: $gray-700; +} + +.editor-collab-sidebar-panel__add-reaction-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border-radius: $radius-round; + border: $border-width dashed $gray-400; + background-color: transparent; + cursor: pointer; + transition: background-color 0.1s ease, border-color 0.1s ease; + + &:hover { + background-color: $gray-100; + border-color: $gray-600; + } + + &:focus { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } + + svg { + fill: $gray-600; + } +} + +.editor-collab-sidebar-panel__add-reaction-popover { + padding: $grid-unit-10; +} + +.editor-collab-sidebar-panel__emoji-picker { + display: flex; + gap: $grid-unit-05; + flex-wrap: wrap; +} + +.editor-collab-sidebar-panel__emoji-option { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: $radius-medium; + background-color: transparent; + font-size: 18px; + cursor: pointer; + transition: background-color 0.1s ease; + + &:hover { + background-color: $gray-200; + } + + &:focus { + outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } +} + +.editor-collab-sidebar-panel__reaction-details { + padding: $grid-unit-20; + min-width: 200px; + max-width: 300px; +} + +.editor-collab-sidebar-panel__reaction-details-content { + max-height: 300px; + overflow-y: auto; +} + +.editor-collab-sidebar-panel__reaction-details-emoji { + font-size: 20px; + line-height: 1; +} + +.editor-collab-sidebar-panel__reaction-details-user { + padding: $grid-unit-05 0; +} + +.editor-collab-sidebar-panel__reaction-details-avatar { + width: 24px; + height: 24px; + border-radius: $radius-round; + flex-shrink: 0; +} From 07fdd9d61ba88df90f3e4e274f9c26e1d5521e55 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 5 Feb 2026 14:28:29 -0800 Subject: [PATCH 112/362] Fix emoji reaction button: use smiley icon, fix focus, and improve styling 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 --- packages/editor/package.json | 1 + .../collab-sidebar/reaction-display.js | 39 +++++++++++++++++-- .../src/components/collab-sidebar/style.scss | 34 +++++++++++++--- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index 2b8bb076ca9e38..81e63bd9774646 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -93,6 +93,7 @@ "@wordpress/patterns": "file:../patterns", "@wordpress/plugins": "file:../plugins", "@wordpress/preferences": "file:../preferences", + "@wordpress/primitives": "file:../primitives", "@wordpress/private-apis": "file:../private-apis", "@wordpress/reusable-blocks": "file:../reusable-blocks", "@wordpress/rich-text": "file:../rich-text", diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 8dad8b44e3f779..3f001733e657a9 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -3,7 +3,7 @@ */ import { __, sprintf, _n } from '@wordpress/i18n'; import { Button, Dropdown } from '@wordpress/components'; -import { plus } from '@wordpress/icons'; +import { SVG, Circle, Path } from '@wordpress/primitives'; import clsx from 'clsx'; /** @@ -11,6 +11,31 @@ import clsx from 'clsx'; */ import ReactionEmojiPicker from './reaction-emoji-picker'; +/** + * Smiley face icon for the add-reaction button, inspired by Google Docs. + */ +const smileyIcon = ( + + + + + + +); + /** * Get the count of reactions for a specific emoji. * @@ -116,12 +141,18 @@ export default function ReactionDisplay( { ( ); } ) } - ( - +
) ) } - +
); } From 3863dc450bc12e030bb61c5a192c13df0627e501 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 11:17:43 -0800 Subject: [PATCH 119/362] Move _wp_reactions meta to wordpress-7.0 compat 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. --- lib/compat/wordpress-7.0/block-comments.php | 41 +++++++++++++++++++++ lib/load.php | 1 + 2 files changed, 42 insertions(+) create mode 100644 lib/compat/wordpress-7.0/block-comments.php diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php new file mode 100644 index 00000000000000..387d7a770b31dd --- /dev/null +++ b/lib/compat/wordpress-7.0/block-comments.php @@ -0,0 +1,41 @@ + 'object', + 'description' => __( 'Emoji reactions', 'gutenberg' ), + 'single' => true, + 'default' => array(), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'userId' => array( + 'type' => 'integer', + ), + 'timestamp' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + ), + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); +} +add_action( 'init', 'gutenberg_register_block_comment_reaction_metadata' ); diff --git a/lib/load.php b/lib/load.php index cf3df3faeb4108..75351d92bef0fc 100644 --- a/lib/load.php +++ b/lib/load.php @@ -66,6 +66,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-7.0/template-activate.php'; require __DIR__ . '/compat/wordpress-7.0/rest-api.php'; require __DIR__ . '/compat/wordpress-7.0/global-styles.php'; + require __DIR__ . '/compat/wordpress-7.0/block-comments.php'; // WordPress 7.1 compat. require __DIR__ . '/compat/wordpress-7.1/class-gutenberg-rest-attachments-controller-7-1.php'; From e2e7e0e02e03a897e485c88dfbabfb29f7943c18 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 11:18:56 -0800 Subject: [PATCH 120/362] Remove unnecessary login checks from reaction handlers The editor assumes users are logged in by default, so these guards are redundant. Matches the pattern used by other collab sidebar actions. --- .../src/components/collab-sidebar/hooks.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index bed76f3300e9a2..99bda5917fe6c5 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -499,12 +499,6 @@ export function useNoteActions( reflowComments = () => {} ) { registry.select( coreStore ).getCurrentUser() || {}; const userId = currentUser.id; - if ( ! userId ) { - throw new Error( - __( 'You must be logged in to add reactions.' ) - ); - } - // Get current comment data. const comment = registry .select( coreStore ) @@ -568,12 +562,6 @@ export function useNoteActions( reflowComments = () => {} ) { registry.select( coreStore ).getCurrentUser() || {}; const userId = currentUser.id; - if ( ! userId ) { - throw new Error( - __( 'You must be logged in to remove reactions.' ) - ); - } - // Get current comment data. const comment = registry .select( coreStore ) @@ -633,10 +621,6 @@ export function useNoteActions( reflowComments = () => {} ) { registry.select( coreStore ).getCurrentUser() || {}; const userId = currentUser.id; - if ( ! userId ) { - throw new Error( __( 'You must be logged in to react.' ) ); - } - // Get current comment data. const comment = registry .select( coreStore ) From ebbea880eae4fd18c1912729f5ff44b72b4d7e99 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 11:19:31 -0800 Subject: [PATCH 121/362] Remove reflowComments from reaction handlers Reactions update comment metadata without adding, removing, or resizing comments, so reflowing is unnecessary. --- packages/editor/src/components/collab-sidebar/hooks.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 99bda5917fe6c5..9e84167ad32c73 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -548,9 +548,7 @@ export function useNoteActions( reflowComments = () => {} ) { type: 'snackbar', isDismissible: true, } ); - reflowComments(); } catch ( error ) { - reflowComments(); onError( error ); } }; @@ -607,9 +605,7 @@ export function useNoteActions( reflowComments = () => {} ) { type: 'snackbar', isDismissible: true, } ); - reflowComments(); } catch ( error ) { - reflowComments(); onError( error ); } }; @@ -643,7 +639,6 @@ export function useNoteActions( reflowComments = () => {} ) { await onAddReaction( { commentId, emoji } ); } } catch ( error ) { - reflowComments(); onError( error ); } }; From 6bbf11901266251c577280954e52b86e089667a6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 11:22:21 -0800 Subject: [PATCH 122/362] Batch user fetching in reaction details popover Replace individual getUser() calls with a single getUsers() request using include, context: view, and _fields to reduce API calls and support low-capability users. --- .../reaction-details-popover.js | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js index 54ef5c7f949046..6fd3e4f2225ec5 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js +++ b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js @@ -69,16 +69,25 @@ export default function ReactionDetailsPopover( { return Array.from( userIdSet ).sort( ( a, b ) => a - b ); }, [ reactions ] ); - // Fetch user data for all users who reacted. + // Fetch user data for all users who reacted in a single request. const users = useSelect( ( select ) => { - const { getUser } = select( coreStore ); + if ( ! userIdArray.length ) { + return {}; + } + const { getUsers } = select( coreStore ); + const userList = getUsers( { + include: userIdArray, + context: 'view', + _fields: 'id,name,avatar_urls', + per_page: -1, + } ); + if ( ! userList ) { + return {}; + } const userData = {}; - userIdArray.forEach( ( userId ) => { - const user = getUser( userId ); - if ( user ) { - userData[ userId ] = user; - } + userList.forEach( ( user ) => { + userData[ user.id ] = user; } ); return userData; }, From c74ebcbc36d12df03ea72bef28afedec5e51f82d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 14:57:21 -0800 Subject: [PATCH 123/362] Update reaction button icon per design feedback --- .../collab-sidebar/reaction-display.js | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 88973a6cc02069..5b46eb68b1deff 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -3,7 +3,7 @@ */ import { __, sprintf, _n } from '@wordpress/i18n'; import { Button, Dropdown } from '@wordpress/components'; -import { SVG, Circle, Path } from '@wordpress/primitives'; +import { SVG, Path } from '@wordpress/primitives'; import clsx from 'clsx'; /** @@ -12,26 +12,19 @@ import clsx from 'clsx'; import ReactionEmojiPicker from './reaction-emoji-picker'; /** - * Smiley face icon for the add-reaction button, inspired by Google Docs. + * Smiley face icon for the add-reaction button. */ const smileyIcon = ( - - - ); From 5cc8faa335533fa58972b99017733675e718c010 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 15:12:20 -0800 Subject: [PATCH 124/362] Replace thumbs down reaction with thinking emoji 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. --- .../src/components/collab-sidebar/reaction-emoji-picker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index ff088dec4e3e58..07273a984b23e6 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -9,7 +9,7 @@ import { Composite } from '@wordpress/components'; */ export const REACTION_EMOJIS = [ { emoji: '👍', label: __( 'Thumbs up' ) }, - { emoji: '👎', label: __( 'Thumbs down' ) }, + { emoji: '🤔', label: __( 'Thinking' ) }, { emoji: '❤️', label: __( 'Heart' ) }, { emoji: '🎉', label: __( 'Celebration' ) }, { emoji: '😄', label: __( 'Smile' ) }, From 6d43109e35d9badf97e3c64bf138920e574da981 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 6 Feb 2026 08:51:02 -0800 Subject: [PATCH 125/362] Linter and styling fixes --- .../reaction-details-popover.js | 29 ++++++++++++------- .../src/components/collab-sidebar/style.scss | 11 ++++--- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js index 6fd3e4f2225ec5..c92de9678e7bff 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js +++ b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js @@ -108,6 +108,8 @@ export default function ReactionDetailsPopover( { placement="bottom-start" onClose={ onClose } anchor={ anchor } + focusOnMount + shift className="editor-collab-sidebar-panel__reaction-details" > ) } - - { getUserName( - users, - reaction.userId - ) } - - - { humanTimeDiff( - reaction.timestamp - ) } - + + + { getUserName( + users, + reaction.userId + ) } + + + { humanTimeDiff( + reaction.timestamp + ) } + + ) ) } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index f5af6039c9f4ec..9cbb3fc1207f39 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -368,13 +368,12 @@ mark.wp-note { } } -.editor-collab-sidebar-panel__reaction-details { - padding: $grid-unit-20; - min-width: 200px; - max-width: 300px; +.editor-collab-sidebar-panel__reaction-details .components-popover__content { + width: 260px; } .editor-collab-sidebar-panel__reaction-details-content { + padding: $grid-unit-30; max-height: 300px; overflow-y: auto; } @@ -394,3 +393,7 @@ mark.wp-note { border-radius: $radius-round; flex-shrink: 0; } + +.editor-collab-sidebar-panel__reaction-details-timestamp { + font-size: 12px; +} From fc7c864b96a56a6a7cf7f0f633331eda92db0379 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 10 Feb 2026 08:31:09 +0800 Subject: [PATCH 126/362] Store emoji slugs in database instead of emoji characters --- .../reaction-details-popover.js | 27 ++++++++++--------- .../collab-sidebar/reaction-emoji-picker.js | 18 +++++++------ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js index c92de9678e7bff..d99e01dbc7bd79 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-details-popover.js +++ b/packages/editor/src/components/collab-sidebar/reaction-details-popover.js @@ -16,7 +16,11 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import { REACTION_EMOJIS } from './reaction-emoji-picker'; +import { + REACTION_EMOJIS, + getEmojiBySlug, + getLabelBySlug, +} from './reaction-emoji-picker'; /** * Get user display name by ID. @@ -98,9 +102,9 @@ export default function ReactionDetailsPopover( { return null; } - // Get emojis that have reactions, in the order they appear in REACTION_EMOJIS. - const orderedEmojis = REACTION_EMOJIS.map( ( { emoji } ) => emoji ).filter( - ( emoji ) => reactions[ emoji ]?.length > 0 + // Get slugs that have reactions, in the order they appear in REACTION_EMOJIS. + const orderedSlugs = REACTION_EMOJIS.map( ( { value } ) => value ).filter( + ( slug ) => reactions[ slug ]?.length > 0 ); return ( @@ -116,19 +120,18 @@ export default function ReactionDetailsPopover( { spacing="3" className="editor-collab-sidebar-panel__reaction-details-content" > - { orderedEmojis.map( ( emoji ) => { - const emojiData = REACTION_EMOJIS.find( - ( e ) => e.emoji === emoji - ); - const reactionList = reactions[ emoji ]; + { orderedSlugs.map( ( slug ) => { + const reactionList = reactions[ slug ]; return ( - + - { emoji } + { getEmojiBySlug( slug ) } - { emojiData?.label } + + { getLabelBySlug( slug ) } + { reactionList.map( ( reaction, index ) => ( diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 07273a984b23e6..90d55991cce777 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -6,16 +6,18 @@ import { Composite } from '@wordpress/components'; /** * Curated emoji set for reactions. + * The `value` slug is used as the storage key in the database to avoid + * potential encoding issues with emoji characters. */ export const REACTION_EMOJIS = [ - { emoji: '👍', label: __( 'Thumbs up' ) }, - { emoji: '🤔', label: __( 'Thinking' ) }, - { emoji: '❤️', label: __( 'Heart' ) }, - { emoji: '🎉', label: __( 'Celebration' ) }, - { emoji: '😄', label: __( 'Smile' ) }, - { emoji: '😕', label: __( 'Confused' ) }, - { emoji: '👀', label: __( 'Eyes' ) }, - { emoji: '🚀', label: __( 'Rocket' ) }, + { emoji: '👍', label: __( 'Thumbs up' ), value: 'thumbs-up' }, + { emoji: '🤔', label: __( 'Thinking' ), value: 'thinking' }, + { emoji: '❤️', label: __( 'Heart' ), value: 'heart' }, + { emoji: '🎉', label: __( 'Celebration' ), value: 'celebration' }, + { emoji: '😄', label: __( 'Smile' ), value: 'smile' }, + { emoji: '😕', label: __( 'Confused' ), value: 'confused' }, + { emoji: '👀', label: __( 'Eyes' ), value: 'eyes' }, + { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; /** From 1eb84840b3e4042687b3f582adf7039a5d56153f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 10 Feb 2026 08:33:25 +0800 Subject: [PATCH 127/362] Use Button component in emoji picker / simplify CSS --- .../collab-sidebar/reaction-emoji-picker.js | 46 +++++++++--- .../src/components/collab-sidebar/style.scss | 75 +++---------------- 2 files changed, 45 insertions(+), 76 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 90d55991cce777..63877a2c2ac275 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Composite } from '@wordpress/components'; +import { Button, Composite } from '@wordpress/components'; /** * Curated emoji set for reactions. @@ -20,30 +20,52 @@ export const REACTION_EMOJIS = [ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; +/** + * Get the emoji character for a given reaction slug. + * + * @param {string} slug The reaction slug. + * @return {string} The emoji character, or the slug if not found. + */ +export function getEmojiBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug; +} + +/** + * Get the label for a given reaction slug. + * + * @param {string} slug The reaction slug. + * @return {string} The label, or the slug if not found. + */ +export function getLabelBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug; +} + /** * A selection of emoji buttons for adding reactions. * * @param {Object} props Component props. * @param {Function} props.onSelect Callback when an emoji is selected. - * @param {boolean} props.disabled Whether the picker is disabled. + * @return {WPElement} The ReactionEmojiPicker component. */ -export default function ReactionEmojiPicker( { onSelect, disabled = false } ) { +export default function ReactionEmojiPicker( { onSelect } ) { return ( - { REACTION_EMOJIS.map( ( { emoji, label } ) => ( + { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( onSelect( emoji ) } - disabled={ disabled } - accessibleWhenDisabled - aria-label={ label } - type="button" + key={ value } + render={ + ); } ) } @@ -148,34 +154,27 @@ export default function ReactionDisplay( { */ export function AddReactionButton( { onToggleReaction } ) { return ( - ( - ); } ) } - + ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 2ff757d103262d..e72bc95150b2e4 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -230,21 +230,6 @@ mark.wp-note { } // Emoji Reactions -.editor-collab-sidebar-panel__reactions-row { - display: flex; - flex-wrap: wrap; - gap: $grid-unit-05; - align-items: center; - margin-top: $grid-unit-10; -} - -.editor-collab-sidebar-panel__reactions { - display: flex; - flex-wrap: wrap; - gap: $grid-unit-05; - align-items: center; -} - .editor-collab-sidebar-panel__reaction-button { gap: $grid-unit-05; border: $border-width solid $gray-300; From ad622189de4e9ed5e264c0610128c7103f477768 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 08:00:44 +0700 Subject: [PATCH 153/362] Revert filterable emoji list for note reactions Remove the PHP filter (note_reaction_emojis) and editor settings injection for customizing the reaction emoji set. The JS components now use the hardcoded REACTION_EMOJIS list directly again. Co-Authored-By: Claude Opus 4.6 --- lib/block-editor-settings.php | 14 ----- lib/compat/wordpress-7.0/block-comments.php | 55 ------------------- .../collab-sidebar/reaction-display.js | 12 ++-- .../collab-sidebar/reaction-emoji-picker.js | 37 +++---------- 4 files changed, 12 insertions(+), 106 deletions(-) diff --git a/lib/block-editor-settings.php b/lib/block-editor-settings.php index c2d472540c8278..5ebf1ddd34499f 100644 --- a/lib/block-editor-settings.php +++ b/lib/block-editor-settings.php @@ -124,17 +124,3 @@ function gutenberg_get_block_editor_settings( $settings ) { return $settings; } add_filter( 'block_editor_settings_all', 'gutenberg_get_block_editor_settings', 0 ); - -/** - * Injects the filtered note reaction emoji list into block editor settings. - * - * @since 7.0.0 - * - * @param array $settings Existing block editor settings. - * @return array Updated block editor settings. - */ -function gutenberg_add_note_reaction_emojis_setting( $settings ) { - $settings['noteReactionEmojis'] = gutenberg_get_note_reaction_emojis(); - return $settings; -} -add_filter( 'block_editor_settings_all', 'gutenberg_add_note_reaction_emojis_setting' ); diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php index b6fbf7c61846d2..c00baee85a0151 100644 --- a/lib/compat/wordpress-7.0/block-comments.php +++ b/lib/compat/wordpress-7.0/block-comments.php @@ -4,58 +4,3 @@ * * @package gutenberg */ - -/** - * Returns the default emoji list for note reactions, filterable via the - * 'note_reaction_emojis' hook. - * - * Each emoji definition requires: - * - 'emoji' (string) The emoji character. - * - 'label' (string) Human-readable label. - * - 'value' (string) Storage slug used as the database key. - * - * @since 7.0.0 - * - * @return array[] Array of emoji definitions. - */ -function gutenberg_get_note_reaction_emojis() { - $default_emojis = array( - array( - 'emoji' => '❤️', - 'label' => __( 'Heart', 'gutenberg' ), - 'value' => 'heart', - ), - array( - 'emoji' => '🎉', - 'label' => __( 'Celebration', 'gutenberg' ), - 'value' => 'celebration', - ), - array( - 'emoji' => '😄', - 'label' => __( 'Smile', 'gutenberg' ), - 'value' => 'smile', - ), - array( - 'emoji' => '👀', - 'label' => __( 'Eyes', 'gutenberg' ), - 'value' => 'eyes', - ), - array( - 'emoji' => '🚀', - 'label' => __( 'Rocket', 'gutenberg' ), - 'value' => 'rocket', - ), - ); - - /** - * Filters the list of emoji available for note reactions. - * - * Each emoji requires an 'emoji' (character), 'label' (human-readable), - * and 'value' (storage slug). - * - * @since 7.0.0 - * - * @param array[] $emojis Array of emoji definitions. - */ -return apply_filters( 'note_reaction_emojis', $default_emojis ); -} diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 78f6b60786bc38..fe3243c4e96e1e 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -18,7 +18,6 @@ import { store as coreStore } from '@wordpress/core-data'; import ReactionEmojiPicker, { getEmojiBySlug, getLabelBySlug, - useReactionEmojis, } from './reaction-emoji-picker'; import { unlock } from '../../lock-unlock'; @@ -90,10 +89,9 @@ function getReactedSlugs( reactions ) { * @param {Object} users Map of user data by ID. * @param {Array} userIds Array of user IDs who reacted. * @param {string} slug The reaction slug. - * @param {Array} emojis The emoji list to look up labels. * @return {string} The tooltip text. */ -function getReactionTooltipText( users, userIds, slug, emojis ) { +function getReactionTooltipText( users, userIds, slug ) { const names = userIds .map( ( id ) => users?.[ id ]?.name ) .filter( Boolean ); @@ -102,7 +100,7 @@ function getReactionTooltipText( users, userIds, slug, emojis ) { return ''; } - const emojiLabel = getLabelBySlug( slug, emojis ); + const emojiLabel = getLabelBySlug( slug ); if ( names.length === 1 ) { return sprintf( @@ -146,7 +144,6 @@ function getReactionTooltipText( users, userIds, slug, emojis ) { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { reactions, onToggleReaction } ) { - const emojis = useReactionEmojis(); const currentUserId = useSelect( ( select ) => { const { getCurrentUser } = select( coreStore ); const user = getCurrentUser(); @@ -208,7 +205,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { slug, currentUserId ); - const emoji = getEmojiBySlug( slug, emojis ); + const emoji = getEmojiBySlug( slug ); const reactionUserIds = ( reactions?.[ slug ] || [] ).map( ( r ) => r.userId @@ -216,8 +213,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { const tooltipText = getReactionTooltipText( users, reactionUserIds, - slug, - emojis + slug ); // Use tooltip text when user data is loaded, otherwise fall back to basic label. diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index b92282336aec6a..0fc23a184bfa47 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -3,11 +3,9 @@ */ import { __ } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; -import { store as blockEditorStore } from '@wordpress/block-editor'; /** - * Default emoji set for reactions (JS-side fallback). + * Curated emoji set for reactions. * The `value` slug is used as the storage key in the database to avoid * potential encoding issues with emoji characters. */ @@ -19,41 +17,24 @@ export const REACTION_EMOJIS = [ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; -/** - * Hook that returns the reaction emoji list from editor settings, - * falling back to the hardcoded REACTION_EMOJIS default. - * - * @return {Array} The emoji list. - */ -export function useReactionEmojis() { - return useSelect( ( select ) => { - const { noteReactionEmojis } = select( blockEditorStore ).getSettings(); - return noteReactionEmojis?.length - ? noteReactionEmojis - : REACTION_EMOJIS; - }, [] ); -} - /** * Get the emoji character for a given reaction slug. * - * @param {string} slug The reaction slug. - * @param {Array} emojis Optional emoji list to search. + * @param {string} slug The reaction slug. * @return {string} The emoji character, or the slug if not found. */ -export function getEmojiBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.emoji ?? slug; +export function getEmojiBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug; } /** * Get the label for a given reaction slug. * - * @param {string} slug The reaction slug. - * @param {Array} emojis Optional emoji list to search. + * @param {string} slug The reaction slug. * @return {string} The label, or the slug if not found. */ -export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.label ?? slug; +export function getLabelBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug; } /** @@ -63,15 +44,13 @@ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { * @param {Function} props.onSelect Callback when an emoji is selected. */ export default function ReactionEmojiPicker( { onSelect } ) { - const emojis = useReactionEmojis(); - return ( - { emojis.map( ( { emoji, label, value } ) => ( + { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( Date: Wed, 18 Feb 2026 08:07:59 +0700 Subject: [PATCH 154/362] Rename note_reaction comment type to reaction Makes the reaction comment type more generic and future-proof so emoji reactions can eventually be used elsewhere in WordPress, not just on notes. Aligns with the approach used by the React plugin (wordpress.org/plugins/react). --- .../src/components/collab-sidebar/hooks.js | 4 +-- ...est-comments-controller-gutenberg-test.php | 32 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 075a4ba82b61af..ede807d6a0f349 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -61,7 +61,7 @@ export function useNoteThreads( postId ) { 'comment', { post: postId, - type: 'note_reaction', + type: 'reaction', status: 'all', per_page: -1, }, @@ -574,7 +574,7 @@ export function useNoteActions( reactionsMap = {} ) { 'comment', { post: getCurrentPostId(), - type: 'note_reaction', + type: 'reaction', parent: commentId, content: emoji, status: 'approve', diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 7d0725756df637..2be8e9082631e1 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -480,14 +480,14 @@ protected function create_note( $post_id, $user_id ) { return $data['id']; } - public function test_create_note_reaction() { + public function test_create_reaction() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'heart', 'author' => self::$editor_id, @@ -502,19 +502,19 @@ public function test_create_note_reaction() { $data = $response->get_data(); $new_comment = get_comment( $data['id'] ); - $this->assertSame( 'note_reaction', $new_comment->comment_type ); + $this->assertSame( 'reaction', $new_comment->comment_type ); $this->assertSame( 'heart', $new_comment->comment_content ); $this->assertSame( (string) $note_id, $new_comment->comment_parent ); $this->assertSame( '1', $new_comment->comment_approved ); } - public function test_cannot_create_note_reaction_without_parent() { + public function test_cannot_create_reaction_without_parent() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'content' => 'heart', 'author' => self::$editor_id, ); @@ -527,14 +527,14 @@ public function test_cannot_create_note_reaction_without_parent() { $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } - public function test_cannot_create_note_reaction_with_invalid_emoji() { + public function test_cannot_create_reaction_with_invalid_emoji() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'invalid_emoji', 'author' => self::$editor_id, @@ -548,14 +548,14 @@ public function test_cannot_create_note_reaction_with_invalid_emoji() { $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); } - public function test_cannot_create_duplicate_note_reaction() { + public function test_cannot_create_duplicate_reaction() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'rocket', 'author' => self::$editor_id, @@ -585,7 +585,7 @@ public function test_can_create_different_reactions_on_same_note() { foreach ( $emojis as $emoji ) { $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => $emoji, 'author' => self::$editor_id, @@ -598,7 +598,7 @@ public function test_can_create_different_reactions_on_same_note() { } } - public function test_cannot_create_note_reaction_on_regular_comment() { + public function test_cannot_create_reaction_on_regular_comment() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $comment_id = self::factory()->comment->create( @@ -610,7 +610,7 @@ public function test_cannot_create_note_reaction_on_regular_comment() { $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $comment_id, 'content' => 'heart', 'author' => self::$editor_id, @@ -624,13 +624,13 @@ public function test_cannot_create_note_reaction_on_regular_comment() { $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } - public function test_create_note_reaction_requires_login() { + public function test_create_reaction_requires_login() { wp_set_current_user( 0 ); $post_id = self::factory()->post->create(); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'content' => 'heart', ); @@ -645,14 +645,14 @@ public function test_create_note_reaction_requires_login() { /** * @dataProvider data_valid_reaction_emojis */ - public function test_create_note_reaction_valid_emojis( $emoji ) { + public function test_create_reaction_valid_emojis( $emoji ) { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => $emoji, 'author' => self::$editor_id, From 66968d0f80c0722c09e1b197484f0295e00037ab Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 22:32:03 +0700 Subject: [PATCH 155/362] Move reaction PHP code to lib/compat/wordpress-7.1 --- lib/compat/wordpress-7.1/block-comments.php | 223 ++++++- ...-gutenberg-rest-comment-controller-7-1.php | 574 ++++++++++++++++++ lib/load.php | 2 +- 3 files changed, 785 insertions(+), 14 deletions(-) create mode 100644 lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index acd77cc0a57b44..c2ffcb48596b40 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -1,24 +1,33 @@ ` - * marker alone: the `data-id` identifies the note and the marker's position - * follows edits, so no separate selection metadata is stored. The marker is - * kept in the raw `post_content` (and REST `raw` view) and only stripped from - * rendered front-end output by the filter below. + * Block comments compatibility for WordPress 7.1. + * + * Extends note-related block comment functions to support inline note markers + * and the 'reaction' comment type for emoji reactions on notes. + * + * Why a custom comment type (vs. comment meta on the parent note)? + * + * - Each reaction has a real author, date, and ID, so existing comment + * APIs handle authorship, timestamps, and deletion without bespoke code. + * This avoids client-side date math and timezone bugs. + * - The type is generic ('reaction', not 'note_reaction') so it can later + * attach to any commentable resource (blocks, posts, other comment types), + * not just block notes. + * - Race conditions when adding/removing reactions concurrently are handled + * by the comments table (one row per reaction) rather than by read-modify- + * write on a serialized meta value. + * + * Background discussion: https://github.com/WordPress/gutenberg/pull/75549 + * and https://github.com/WordPress/gutenberg/pull/75148. + * + * @package gutenberg */ /** * Strip inline note markers from rendered block output. * * Inline notes are anchored in raw block content with - * `…` so the marker survives edits, + * `...` so the marker survives edits, * but the public HTML should not expose note metadata. `render_block` unwraps * the marker entirely - dropping the `` open tag and its matching closer * while keeping the marked text - so nothing leaks to the front end. The raw @@ -101,3 +110,191 @@ public function remove_token() { return $processor->get_updated_html(); } add_filter( 'render_block', 'gutenberg_strip_inline_note_markers' ); + +/** + * Returns the list of internal comment types used by core features. + * + * Internal comment types (currently 'note' and 'reaction') back editor + * functionality such as block notes and emoji reactions, and should be + * excluded from front-end comment listings, counts, and similar contexts + * that target user discussion. Centralizing the list keeps every guard + * in sync when new internal types are added. + * + * Mirrors the planned `wp_get_internal_comment_types()` core helper + * (see https://github.com/WordPress/wordpress-develop/pull/10930). + * + * @since 7.1.0 + * + * @return string[] List of internal comment type slugs. + */ +function gutenberg_get_internal_comment_types() { + /** + * Filters the list of internal comment types. + * + * @since 7.1.0 + * + * @param string[] $types List of internal comment type slugs. + */ + return apply_filters( 'gutenberg_internal_comment_types', array( 'note', 'reaction' ) ); +} + +/** + * Updates the comment type for avatars to include internal comment types. + * + * Replaces the 6.9 implementation to also add the 'reaction' type + * to the list of comment types for which avatars should be retrieved. + * + * @param array $comment_type The array of comment types. + * @return array The updated array of comment types. + */ +function gutenberg_update_get_avatar_comment_type_7_1( $comment_type ) { + return array_values( array_unique( array_merge( $comment_type, gutenberg_get_internal_comment_types() ) ) ); +} +remove_filter( 'get_avatar_comment_types', 'update_get_avatar_comment_type' ); +add_filter( 'get_avatar_comment_types', 'gutenberg_update_get_avatar_comment_type_7_1' ); + +/** + * Excludes block comments and reactions from the admin comments query. + * + * Replaces the 6.9 implementation to also exclude 'reaction' type. + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string[] $clauses The current SQL clauses for the comments query. + * @param WP_Comment_Query $query The current comments query. + * + * @return string[] The modified SQL clauses for the comments query. + */ +function gutenberg_exclude_block_comments_from_admin_7_1( $clauses, $query ) { + if ( isset( $query->query_vars['type'] ) && '' === $query->query_vars['type'] ) { + $query->set( 'type', '' ); + + global $wpdb; + $internal_types = gutenberg_get_internal_comment_types(); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $clauses['where'] .= ' AND ' . $wpdb->prepare( "{$wpdb->comments}.comment_type NOT IN ( $type_placeholders )", $internal_types ); + } + + return $clauses; +} +remove_action( 'comments_clauses', 'exclude_block_comments_from_admin', 10 ); +add_action( 'comments_clauses', 'gutenberg_exclude_block_comments_from_admin_7_1', 10, 2 ); + +/** + * Filter the comment count query to exclude notes and reactions. + * + * Replaces the 6.9 implementation to also exclude 'reaction' type. + * + * @param string $query The SQL query string. + * @return string The modified SQL query string. + */ +function gutenberg_filter_comment_count_query_exclude_block_comments_7_1( $query ) { + if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { + if ( ! str_contains( $query, "comment_type != 'note'" ) ) { + $type_clauses = array(); + foreach ( gutenberg_get_internal_comment_types() as $internal_type ) { + $type_clauses[] = "comment_type != '" . esc_sql( $internal_type ) . "'"; + } + $query = str_replace( 'comment_approved', implode( ' AND ', $type_clauses ) . ' AND comment_approved', $query ); + } + } + return $query; +} +add_filter( 'query', 'gutenberg_filter_comment_count_query_exclude_block_comments_7_1' ); + +/** + * Adjusts the comments list table query so notes and reactions never display. + * + * Replaces the 6.9 implementation to also handle 'reaction' type. + * + * @param array $args An array of get_comments() arguments. + * @return array Possibly modified arguments for get_comments(). + */ +function gutenberg_hide_note_from_comment_list_table_7_1( $args ) { + if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], gutenberg_get_internal_comment_types(), true ) ) { + unset( $args['type'] ); + } + return $args; +} +add_filter( 'comments_list_table_query_args', 'gutenberg_hide_note_from_comment_list_table_7_1' ); + +/** + * Override comment_count to exclude notes and reactions from the comment count. + * + * Replaces the 6.9 implementation to also exclude 'reaction' type. + * + * @param int|null $new_count The new comment count. Default null. + * @param int $old_count The old comment count. + * @param int $post_id Post ID. + * @return int|null The modified comment count. + */ +function gutenberg_exclude_notes_from_comment_count_7_1( $new_count, $old_count, $post_id ) { + global $wpdb; + if ( null !== $new_count ) { + return $new_count; + } + $internal_types = gutenberg_get_internal_comment_types(); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + $new_count = (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ( $type_placeholders )", + array_merge( array( $post_id ), $internal_types ) + ) + ); + return $new_count; +} +add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_1', 10, 3 ); + +/** + * Returns the allowed emojis for note reactions. + * + * Each emoji is an associative array with: + * - `emoji` (string) The emoji character. + * - `label` (string) A translated human-readable label. + * - `value` (string) A slug used as the storage key. + * + * @since 7.1.0 + * + * @return array[] List of emoji definitions. + */ +function gutenberg_get_note_reaction_emojis() { + $default_emojis = array( + array( + 'emoji' => '❤️', + 'label' => __( 'Heart', 'gutenberg' ), + 'value' => 'heart', + ), + array( + 'emoji' => '🎉', + 'label' => __( 'Celebration', 'gutenberg' ), + 'value' => 'celebration', + ), + array( + 'emoji' => '😄', + 'label' => __( 'Smile', 'gutenberg' ), + 'value' => 'smile', + ), + array( + 'emoji' => '👀', + 'label' => __( 'Eyes', 'gutenberg' ), + 'value' => 'eyes', + ), + array( + 'emoji' => '🚀', + 'label' => __( 'Rocket', 'gutenberg' ), + 'value' => 'rocket', + ), + ); + + /** + * Filters the list of allowed emojis for note reactions. + * + * @since 7.1.0 + * + * @param array[] $emojis List of emoji definitions. Each item has + * `emoji`, `label`, and `value` keys. + */ + return apply_filters( 'gutenberg_note_reaction_emojis', $default_emojis ); +} diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php new file mode 100644 index 00000000000000..b8f13c0454d419 --- /dev/null +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -0,0 +1,574 @@ +is_note_or_reaction( $request['type'] ); + $is_edit_context = 'edit' === $request['context']; + + if ( ! empty( $request['post'] ) ) { + foreach ( (array) $request['post'] as $post_id ) { + $post = get_post( $post_id ); + + if ( $post && $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { + return new WP_Error( + 'rest_comment_not_supported_post_type', + __( 'Sorry, this post type does not support notes.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( ! empty( $post_id ) && $post && ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to read comments without a post.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + } + + if ( $is_edit_context && $is_note && ! empty( $request['post'] ) ) { + foreach ( (array) $request['post'] as $post_id ) { + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! current_user_can( 'edit_posts' ) ) { + $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); + $forbidden_params = array(); + + foreach ( $protected_params as $param ) { + if ( 'status' === $param ) { + if ( 'approve' !== $request[ $param ] ) { + $forbidden_params[] = $param; + } + } elseif ( 'type' === $param ) { + if ( 'comment' !== $request[ $param ] ) { + $forbidden_params[] = $param; + } + } elseif ( ! empty( $request[ $param ] ) ) { + $forbidden_params[] = $param; + } + } + + if ( ! empty( $forbidden_params ) ) { + return new WP_Error( + 'rest_forbidden_param', + /* translators: %s: List of forbidden parameters. */ + sprintf( __( 'Query parameter not permitted: %s', 'gutenberg' ), implode( ', ', $forbidden_params ) ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + return true; + } + + public function get_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + // Re-map edit context capabilities when requesting `note` or `reaction` type. + $edit_cap = $this->is_note_or_reaction( $comment->comment_type ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $post = get_post( $comment->comment_post_ID ); + + if ( ! $this->check_read_permission( $comment, $request ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to read this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( $post && ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + public function create_item_permissions_check( $request ) { + $is_note = ! empty( $request['type'] ) && $this->is_note_or_reaction( $request['type'] ); + + if ( ! is_user_logged_in() && $is_note ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + + if ( ! is_user_logged_in() ) { + if ( get_option( 'comment_registration' ) ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + $allow_anonymous = apply_filters( 'rest_allow_anonymous_comments', false, $request ); + + if ( ! $allow_anonymous ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + } + + if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_comment_invalid_author', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'author' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( isset( $request['author_ip'] ) && ! current_user_can( 'moderate_comments' ) ) { + if ( empty( $_SERVER['REMOTE_ADDR'] ) || $request['author_ip'] !== $_SERVER['REMOTE_ADDR'] ) { + return new WP_Error( + 'rest_comment_invalid_author_ip', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'author_ip' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + $edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' ); + if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) { + return new WP_Error( + 'rest_comment_invalid_status', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'status' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( empty( $request['post'] ) ) { + return new WP_Error( + 'rest_comment_invalid_post_id', + __( 'Sorry, you are not allowed to create this comment without a post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + $post = get_post( (int) $request['post'] ); + + if ( ! $post ) { + return new WP_Error( + 'rest_comment_invalid_post_id', + __( 'Sorry, you are not allowed to create this comment without a post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { + return new WP_Error( + 'rest_comment_not_supported_post_type', + __( 'Sorry, this post type does not support notes.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( 'draft' === $post->post_status && ! $is_note ) { + return new WP_Error( + 'rest_comment_draft_post', + __( 'Sorry, you are not allowed to create a comment on this post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( 'trash' === $post->post_status ) { + return new WP_Error( + 'rest_comment_trash_post', + __( 'Sorry, you are not allowed to create a comment on this post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! comments_open( $post->ID ) && ! $is_note ) { + return new WP_Error( + 'rest_comment_closed', + __( 'Sorry, comments are closed for this item.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Creates a comment. + * + * Extends the 6.9 implementation to support 'reaction' comment type + * with validation for parent note, valid emoji slugs, and uniqueness. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. + */ + public function create_item( $request ) { + if ( ! empty( $request['id'] ) ) { + return new WP_Error( + 'rest_comment_exists', + __( 'Cannot create existing comment.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Allow 'comment', 'note', and 'reaction' types. + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { + return new WP_Error( + 'rest_invalid_comment_type', + __( 'Cannot create a comment with that type.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Validate reaction-specific requirements. + if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { + // Validate parent is a note. + if ( empty( $request['parent'] ) ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must have a parent note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + $parent_comment = get_comment( $request['parent'] ); + if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Validate content is a valid emoji slug. + $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); + $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; + if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { + return new WP_Error( + 'rest_comment_invalid_reaction', + __( 'Invalid reaction emoji.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Enforce uniqueness: prevent duplicate emoji per user per note. + $existing = get_comments( + array( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'any', + ) + ); + + foreach ( $existing as $existing_reaction ) { + if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { + return new WP_Error( + 'rest_comment_duplicate_reaction', + __( 'You have already reacted with this emoji.', 'gutenberg' ), + array( 'status' => 409 ) + ); + } + } + } + + $prepared_comment = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $prepared_comment['comment_type'] = $request['type']; + + if ( ! isset( $prepared_comment['comment_content'] ) ) { + $prepared_comment['comment_content'] = ''; + } + + // Include note metadata into check_is_comment_content_allowed [backport]. + if ( isset( $request['meta']['_wp_note_status'] ) ) { + $prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status']; + } + + if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) { + return new WP_Error( + 'rest_comment_content_invalid', + __( 'Invalid comment content.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Setting remaining values before wp_insert_comment so we can use wp_allow_comment(). + if ( ! isset( $prepared_comment['comment_date_gmt'] ) ) { + $prepared_comment['comment_date_gmt'] = current_time( 'mysql', true ); + } + + // Set author data if the user's logged in. + $missing_author = empty( $prepared_comment['user_id'] ) + && empty( $prepared_comment['comment_author'] ) + && empty( $prepared_comment['comment_author_email'] ) + && empty( $prepared_comment['comment_author_url'] ); + + if ( is_user_logged_in() && $missing_author ) { + $user = wp_get_current_user(); + + $prepared_comment['user_id'] = $user->ID; + $prepared_comment['comment_author'] = $user->display_name; + $prepared_comment['comment_author_email'] = $user->user_email; + $prepared_comment['comment_author_url'] = $user->user_url; + } + + // Honor the discussion setting that requires a name and email address of the comment author. + if ( get_option( 'require_name_email' ) ) { + if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) { + return new WP_Error( + 'rest_comment_author_data_required', + __( 'Creating a comment requires valid author name and email values.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + } + + if ( ! isset( $prepared_comment['comment_author_email'] ) ) { + $prepared_comment['comment_author_email'] = ''; + } + + if ( ! isset( $prepared_comment['comment_author_url'] ) ) { + $prepared_comment['comment_author_url'] = ''; + } + + if ( ! isset( $prepared_comment['comment_agent'] ) ) { + $prepared_comment['comment_agent'] = ''; + } + + $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_comment ); + + if ( is_wp_error( $check_comment_lengths ) ) { + $error_code = $check_comment_lengths->get_error_code(); + return new WP_Error( + $error_code, + __( 'Comment field exceeds maximum length allowed.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Don't check for duplicates or flooding for notes and reactions. + $prepared_comment['comment_approved'] = + $this->is_note_or_reaction( $prepared_comment['comment_type'] ) ? + '1' : + wp_allow_comment( $prepared_comment, true ); + + if ( is_wp_error( $prepared_comment['comment_approved'] ) ) { + $error_code = $prepared_comment['comment_approved']->get_error_code(); + $error_message = $prepared_comment['comment_approved']->get_error_message(); + + if ( 'comment_duplicate' === $error_code ) { + return new WP_Error( + $error_code, + $error_message, + array( 'status' => 409 ) + ); + } + + if ( 'comment_flood' === $error_code ) { + return new WP_Error( + $error_code, + $error_message, + array( 'status' => 400 ) + ); + } + + return $prepared_comment['comment_approved']; + } + + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + $prepared_comment = apply_filters( 'rest_pre_insert_comment', $prepared_comment, $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $comment_id = wp_insert_comment( wp_filter_comment( wp_slash( (array) $prepared_comment ) ) ); + + if ( ! $comment_id ) { + return new WP_Error( + 'rest_comment_failed_create', + __( 'Creating comment failed.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + + if ( isset( $request['status'] ) ) { + $this->handle_status_param( $request['status'], $comment_id ); + } + + $comment = get_comment( $comment_id ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + do_action( 'rest_insert_comment', $comment, $request, true ); + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $comment_id ); + + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $comment, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $context = current_user_can( 'moderate_comments' ) ? 'edit' : 'view'; + $request->set_param( 'context', $context ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + do_action( 'rest_after_insert_comment', $comment, $request, true ); + + $response = $this->prepare_item_for_response( $comment, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) ); + + return $response; + } + + /** + * Prepares links for the request. + * + * Extends the 6.9 implementation to also handle 'reaction' type + * for children link embedding. + * + * @since 7.1.0 + * + * @param WP_Comment $comment Comment object. + * @return array Links for the given comment. + */ + protected function prepare_links( $comment ) { + $links = WP_REST_Comments_Controller::prepare_links( $comment ); + + // Embedding children for notes and reactions requires `type` and `status` inheritance. + if ( isset( $links['children'] ) && $this->is_note_or_reaction( $comment->comment_type ) ) { + $args = array( + 'parent' => $comment->comment_ID, + 'type' => $comment->comment_type, + 'status' => 'all', + ); + + $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) ); + + $links['children'] = array( + 'href' => $rest_url, + 'embeddable' => true, + ); + } + + return $links; + } + + /** + * Checks if comment content is allowed. + * + * Extends the 6.9 implementation to also allow reaction content + * (emoji slugs are always valid content). + * + * @since 7.1.0 + * + * @param array $prepared_comment The prepared comment data. + * @return bool True if the content is allowed, false otherwise. + */ + protected function check_is_comment_content_allowed( $prepared_comment ) { + // Note reactions always have content (the emoji slug). + if ( isset( $prepared_comment['comment_type'] ) && 'reaction' === $prepared_comment['comment_type'] ) { + return true; + } + + return parent::check_is_comment_content_allowed( $prepared_comment ); + } +} + +/** + * Registers the Gutenberg REST comment controller for WordPress 7.1 compatibility. + * + * Replaces the 6.9 controller registration to use the 7.1 controller + * which adds reaction support. + */ +function gutenberg_register_comment_controller_7_1() { + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $controller->register_routes(); +} +remove_action( 'rest_api_init', 'gutenberg_register_comment_controller_6_9' ); +add_action( 'rest_api_init', 'gutenberg_register_comment_controller_7_1' ); diff --git a/lib/load.php b/lib/load.php index 75351d92bef0fc..eec923ea742ed0 100644 --- a/lib/load.php +++ b/lib/load.php @@ -66,7 +66,6 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-7.0/template-activate.php'; require __DIR__ . '/compat/wordpress-7.0/rest-api.php'; require __DIR__ . '/compat/wordpress-7.0/global-styles.php'; - require __DIR__ . '/compat/wordpress-7.0/block-comments.php'; // WordPress 7.1 compat. require __DIR__ . '/compat/wordpress-7.1/class-gutenberg-rest-attachments-controller-7-1.php'; @@ -77,6 +76,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-7.1/collaboration.php'; require __DIR__ . '/compat/wordpress-7.1/block-bindings.php'; require __DIR__ . '/compat/wordpress-7.1/block-comments.php'; + require __DIR__ . '/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php'; // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; From e1aa34382837195d0423b579138a375b206a5941 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 23:10:55 +0700 Subject: [PATCH 156/362] Expose allowed reaction emojis via REST API schema Add a filterable gutenberg_get_note_reaction_emojis() helper and expose the emoji list as a read-only reaction_emojis property in the comments REST API schema. The client-side useReactionEmojis() hook reads this from the OPTIONS response so the emoji picker and display render what the server actually allows. Validation in create_item() now uses the same filtered list, so custom emojis added via the gutenberg_note_reaction_emojis filter are accepted end-to-end. Co-Authored-By: Claude Opus 4.6 --- ...-gutenberg-rest-comment-controller-7-1.php | 46 +++++++- .../collab-sidebar/reaction-display.js | 12 +- .../collab-sidebar/reaction-emoji-picker.js | 71 ++++++++++-- ...est-comments-controller-gutenberg-test.php | 103 +++++++++++++++++- 4 files changed, 213 insertions(+), 19 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index b8f13c0454d419..4bcdc2b641de59 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -11,6 +11,49 @@ class Gutenberg_REST_Comment_Controller_7_1 extends Gutenberg_REST_Comment_Controller_6_9 { + /** + * Retrieves the comment schema, adding reaction_emojis. + * + * Extends the parent schema with a read-only `reaction_emojis` + * property whose default value exposes the filtered emoji list. + * Clients can read this from the OPTIONS response to discover + * which reaction emojis the server accepts. + * + * @since 7.1.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = parent::get_item_schema(); + + $schema['properties']['reaction_emojis'] = array( + 'description' => __( 'Allowed emoji reactions for notes.', 'gutenberg' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'emoji' => array( + 'description' => __( 'The emoji character.', 'gutenberg' ), + 'type' => 'string', + ), + 'label' => array( + 'description' => __( 'A human-readable label for the emoji.', 'gutenberg' ), + 'type' => 'string', + ), + 'value' => array( + 'description' => __( 'The slug used as the storage key.', 'gutenberg' ), + 'type' => 'string', + ), + ), + ), + 'default' => gutenberg_get_note_reaction_emojis(), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ); + + return $schema; + } + /** * Checks whether the request type is a note or reaction. * @@ -312,7 +355,8 @@ public function create_item( $request ) { } // Validate content is a valid emoji slug. - $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); + $emojis = gutenberg_get_note_reaction_emojis(); + $valid_slugs = wp_list_pluck( $emojis, 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { return new WP_Error( diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index fe3243c4e96e1e..0b4410c13e3224 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -18,6 +18,7 @@ import { store as coreStore } from '@wordpress/core-data'; import ReactionEmojiPicker, { getEmojiBySlug, getLabelBySlug, + useReactionEmojis, } from './reaction-emoji-picker'; import { unlock } from '../../lock-unlock'; @@ -89,9 +90,10 @@ function getReactedSlugs( reactions ) { * @param {Object} users Map of user data by ID. * @param {Array} userIds Array of user IDs who reacted. * @param {string} slug The reaction slug. + * @param {Array} emojis The emoji list to look up labels from. * @return {string} The tooltip text. */ -function getReactionTooltipText( users, userIds, slug ) { +function getReactionTooltipText( users, userIds, slug, emojis ) { const names = userIds .map( ( id ) => users?.[ id ]?.name ) .filter( Boolean ); @@ -100,7 +102,7 @@ function getReactionTooltipText( users, userIds, slug ) { return ''; } - const emojiLabel = getLabelBySlug( slug ); + const emojiLabel = getLabelBySlug( slug, emojis ); if ( names.length === 1 ) { return sprintf( @@ -144,6 +146,7 @@ function getReactionTooltipText( users, userIds, slug ) { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { reactions, onToggleReaction } ) { + const emojis = useReactionEmojis(); const currentUserId = useSelect( ( select ) => { const { getCurrentUser } = select( coreStore ); const user = getCurrentUser(); @@ -205,7 +208,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { slug, currentUserId ); - const emoji = getEmojiBySlug( slug ); + const emoji = getEmojiBySlug( slug, emojis ); const reactionUserIds = ( reactions?.[ slug ] || [] ).map( ( r ) => r.userId @@ -213,7 +216,8 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { const tooltipText = getReactionTooltipText( users, reactionUserIds, - slug + slug, + emojis ); // Use tooltip text when user data is loaded, otherwise fall back to basic label. diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 0fc23a184bfa47..fbd4cec1a52127 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -3,9 +3,11 @@ */ import { __ } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; /** - * Curated emoji set for reactions. + * Curated emoji set for reactions (fallback). * The `value` slug is used as the storage key in the database to avoid * potential encoding issues with emoji characters. */ @@ -17,24 +19,75 @@ export const REACTION_EMOJIS = [ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; +// Module-level cache so the OPTIONS request is made only once. +let cachedEmojis = null; +let fetchPromise = null; + +/** + * Hook that returns the allowed reaction emojis from the REST API schema. + * + * Fetches the emoji list from the `OPTIONS /wp/v2/comments` response + * and caches the result at module level. Falls back to the hardcoded + * REACTION_EMOJIS if the request fails or the schema property is missing. + * + * @return {Array} The list of allowed reaction emoji objects. + */ +export function useReactionEmojis() { + const [ emojis, setEmojis ] = useState( cachedEmojis || REACTION_EMOJIS ); + + useEffect( () => { + if ( cachedEmojis ) { + return; + } + + if ( ! fetchPromise ) { + fetchPromise = apiFetch( { + path: '/wp/v2/comments', + method: 'OPTIONS', + } ) + .then( ( response ) => { + const schemaEmojis = + response?.schema?.properties?.reaction_emojis + ?.default; + if ( Array.isArray( schemaEmojis ) && schemaEmojis.length ) { + cachedEmojis = schemaEmojis; + } else { + cachedEmojis = REACTION_EMOJIS; + } + } ) + .catch( () => { + cachedEmojis = REACTION_EMOJIS; + } ); + } + + fetchPromise.then( () => { + setEmojis( cachedEmojis ); + } ); + }, [] ); + + return emojis; +} + /** * Get the emoji character for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug. + * @param {Array} emojis Optional emoji list to search. * @return {string} The emoji character, or the slug if not found. */ -export function getEmojiBySlug( slug ) { - return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug; +export function getEmojiBySlug( slug, emojis = REACTION_EMOJIS ) { + return emojis.find( ( r ) => r.value === slug )?.emoji ?? slug; } /** * Get the label for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug. + * @param {Array} emojis Optional emoji list to search. * @return {string} The label, or the slug if not found. */ -export function getLabelBySlug( slug ) { - return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug; +export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { + return emojis.find( ( r ) => r.value === slug )?.label ?? slug; } /** @@ -44,13 +97,15 @@ export function getLabelBySlug( slug ) { * @param {Function} props.onSelect Callback when an emoji is selected. */ export default function ReactionEmojiPicker( { onSelect } ) { + const emojis = useReactionEmojis(); + return ( - { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( + { emojis.map( ( { emoji, label, value } ) => ( array( 'heart' ), - 'celebration' => array( 'celebration' ), - 'smile' => array( 'smile' ), - 'eyes' => array( 'eyes' ), - 'rocket' => array( 'rocket' ), + $emojis = gutenberg_get_note_reaction_emojis(); + $data = array(); + foreach ( $emojis as $emoji ) { + $data[ $emoji['value'] ] = array( $emoji['value'] ); + } + return $data; + } + + public function test_schema_includes_reaction_emojis() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'reaction_emojis', $data['schema']['properties'] ); + + $reaction_emojis_schema = $data['schema']['properties']['reaction_emojis']; + $this->assertTrue( $reaction_emojis_schema['readonly'] ); + $this->assertSame( 'array', $reaction_emojis_schema['type'] ); + $this->assertContains( 'view', $reaction_emojis_schema['context'] ); + $this->assertContains( 'edit', $reaction_emojis_schema['context'] ); + + // Verify the default matches the helper function output. + $expected = gutenberg_get_note_reaction_emojis(); + $this->assertSame( $expected, $reaction_emojis_schema['default'] ); + } + + public function test_reaction_emojis_filter_modifies_schema() { + $custom_emoji = array( + array( + 'emoji' => '👍', + 'label' => 'Thumbs Up', + 'value' => 'thumbsup', + ), + ); + + $filter = function () use ( $custom_emoji ) { + return $custom_emoji; + }; + add_filter( 'gutenberg_note_reaction_emojis', $filter ); + + // Re-instantiate the controller so the schema picks up the filtered value. + $server = rest_get_server(); + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $controller->register_routes(); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); + $response = $server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( + $custom_emoji, + $data['schema']['properties']['reaction_emojis']['default'] + ); + + remove_filter( 'gutenberg_note_reaction_emojis', $filter ); + } + + public function test_reaction_emojis_filter_affects_validation() { + $custom_emoji = array( + array( + 'emoji' => '👍', + 'label' => 'Thumbs Up', + 'value' => 'thumbsup', + ), ); + + $filter = function () use ( $custom_emoji ) { + return $custom_emoji; + }; + add_filter( 'gutenberg_note_reaction_emojis', $filter ); + + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + // The custom emoji should be accepted. + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => 'thumbsup', + 'author' => self::$editor_id, + ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + // A previously-default emoji should now be rejected. + $params['content'] = 'heart'; + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + + remove_filter( 'gutenberg_note_reaction_emojis', $filter ); } } From b5acc0e6082a989390a9349a1f7cd916faf06c12 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 23 Mar 2026 11:49:00 -0700 Subject: [PATCH 157/362] Fix CI: add new PR to backport changelog and fix PHP alignment --- backport-changelog/7.0/10930.md | 1 + .../class-wp-rest-comments-controller-gutenberg-test.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backport-changelog/7.0/10930.md b/backport-changelog/7.0/10930.md index d2af26615dcc51..490e8f57459a85 100644 --- a/backport-changelog/7.0/10930.md +++ b/backport-changelog/7.0/10930.md @@ -2,3 +2,4 @@ https://github.com/WordPress/wordpress-develop/pull/10930 * https://github.com/WordPress/gutenberg/pull/75148 * https://github.com/WordPress/gutenberg/pull/75549 +* https://github.com/WordPress/gutenberg/pull/76767 diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 1448e8a7b823ba..fdcdb483b6cb1c 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -708,7 +708,7 @@ public function test_reaction_emojis_filter_modifies_schema() { add_filter( 'gutenberg_note_reaction_emojis', $filter ); // Re-instantiate the controller so the schema picks up the filtered value. - $server = rest_get_server(); + $server = rest_get_server(); $controller = new Gutenberg_REST_Comment_Controller_7_1(); $controller->register_routes(); From 224a4b6aae7c1398d7727f923fe5a6e458d7c8a0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 23 Mar 2026 13:02:04 -0700 Subject: [PATCH 158/362] Fix children link type for notes, unmount safety, and stale closure - Fix children link to query for 'reaction' type when parent is a note - Add unmount cleanup flag to useReactionEmojis to prevent state updates after component unmounts - Wrap onToggleReaction in useCallback with reactionsMap dependency to prevent stale closure --- ...-gutenberg-rest-comment-controller-7-1.php | 6 +- .../src/components/collab-sidebar/hooks.js | 106 ++++++++++-------- .../collab-sidebar/reaction-emoji-picker.js | 10 +- 3 files changed, 71 insertions(+), 51 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index 4bcdc2b641de59..bc7f387c9f8112 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -566,9 +566,11 @@ protected function prepare_links( $comment ) { // Embedding children for notes and reactions requires `type` and `status` inheritance. if ( isset( $links['children'] ) && $this->is_note_or_reaction( $comment->comment_type ) ) { - $args = array( + // Notes have reaction children; reactions don't have children. + $child_type = 'note' === $comment->comment_type ? 'reaction' : $comment->comment_type; + $args = array( 'parent' => $comment->comment_ID, - 'type' => $comment->comment_type, + 'type' => $child_type, 'status' => 'all', ); diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index ede807d6a0f349..9cfb6d52f3d13a 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -539,58 +539,68 @@ export function useNoteActions( reactionsMap = {} ) { } }; - const onToggleReaction = async ( { commentId, emoji } ) => { - try { - // Get current user from the store. - const currentUser = - registry.select( coreStore ).getCurrentUser() || {}; - const userId = currentUser.id; - - // Check if the user already reacted with this emoji. - const noteReactions = reactionsMap[ commentId ] || {}; - const emojiReactions = noteReactions[ emoji ] || []; - const existingReaction = emojiReactions.find( - ( reaction ) => reaction.userId === userId - ); - - if ( existingReaction ) { - // Remove the reaction by deleting the comment record. - await deleteEntityRecord( - 'root', - 'comment', - existingReaction.reactionId, - undefined, - { throwOnError: true } + const onToggleReaction = useCallback( + async ( { commentId, emoji } ) => { + try { + // Get current user from the store. + const currentUser = + registry.select( coreStore ).getCurrentUser() || {}; + const userId = currentUser.id; + + // Check if the user already reacted with this emoji. + const noteReactions = reactionsMap[ commentId ] || {}; + const emojiReactions = noteReactions[ emoji ] || []; + const existingReaction = emojiReactions.find( + ( reaction ) => reaction.userId === userId ); - createNotice( 'snackbar', __( 'Reaction removed.' ), { - type: 'snackbar', - isDismissible: true, - } ); - } else { - // Add a new reaction as a comment record. - await saveEntityRecord( - 'root', - 'comment', - { - post: getCurrentPostId(), - type: 'reaction', - parent: commentId, - content: emoji, - status: 'approve', - }, - { throwOnError: true } - ); + if ( existingReaction ) { + // Remove the reaction by deleting the comment record. + await deleteEntityRecord( + 'root', + 'comment', + existingReaction.reactionId, + undefined, + { throwOnError: true } + ); - createNotice( 'snackbar', __( 'Reaction added.' ), { - type: 'snackbar', - isDismissible: true, - } ); + createNotice( 'snackbar', __( 'Reaction removed.' ), { + type: 'snackbar', + isDismissible: true, + } ); + } else { + // Add a new reaction as a comment record. + await saveEntityRecord( + 'root', + 'comment', + { + post: getCurrentPostId(), + type: 'reaction', + parent: commentId, + content: emoji, + status: 'approve', + }, + { throwOnError: true } + ); + + createNotice( 'snackbar', __( 'Reaction added.' ), { + type: 'snackbar', + isDismissible: true, + } ); + } + } catch ( error ) { + onError( error ); } - } catch ( error ) { - onError( error ); - } - }; + }, + [ + reactionsMap, + registry, + deleteEntityRecord, + saveEntityRecord, + getCurrentPostId, + createNotice, + ] + ); return { onCreate, diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index fbd4cec1a52127..c11268f53d551f 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -40,6 +40,8 @@ export function useReactionEmojis() { return; } + let isMounted = true; + if ( ! fetchPromise ) { fetchPromise = apiFetch( { path: '/wp/v2/comments', @@ -61,8 +63,14 @@ export function useReactionEmojis() { } fetchPromise.then( () => { - setEmojis( cachedEmojis ); + if ( isMounted ) { + setEmojis( cachedEmojis ); + } } ); + + return () => { + isMounted = false; + }; }, [] ); return emojis; From f91034401946a574bf0a7e8c908f38e6f4428b3a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 23 Mar 2026 13:09:12 -0700 Subject: [PATCH 159/362] Fix schema tests to call get_item_schema() directly The OPTIONS dispatch on a collection route may not include the schema in the expected structure. Test the controller's schema directly. --- ...rest-comments-controller-gutenberg-test.php | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index fdcdb483b6cb1c..5cfa8e0a7063ab 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -676,13 +676,12 @@ public function data_valid_reaction_emojis() { } public function test_schema_includes_reaction_emojis() { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $schema = $controller->get_item_schema(); - $this->assertArrayHasKey( 'reaction_emojis', $data['schema']['properties'] ); + $this->assertArrayHasKey( 'reaction_emojis', $schema['properties'] ); - $reaction_emojis_schema = $data['schema']['properties']['reaction_emojis']; + $reaction_emojis_schema = $schema['properties']['reaction_emojis']; $this->assertTrue( $reaction_emojis_schema['readonly'] ); $this->assertSame( 'array', $reaction_emojis_schema['type'] ); $this->assertContains( 'view', $reaction_emojis_schema['context'] ); @@ -708,17 +707,12 @@ public function test_reaction_emojis_filter_modifies_schema() { add_filter( 'gutenberg_note_reaction_emojis', $filter ); // Re-instantiate the controller so the schema picks up the filtered value. - $server = rest_get_server(); $controller = new Gutenberg_REST_Comment_Controller_7_1(); - $controller->register_routes(); - - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); - $response = $server->dispatch( $request ); - $data = $response->get_data(); + $schema = $controller->get_item_schema(); $this->assertSame( $custom_emoji, - $data['schema']['properties']['reaction_emojis']['default'] + $schema['properties']['reaction_emojis']['default'] ); remove_filter( 'gutenberg_note_reaction_emojis', $filter ); From 13fd4d801387d2213c9beb1c3d08181a154d5c49 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 23 Mar 2026 13:35:47 -0700 Subject: [PATCH 160/362] Fix prettier formatting in reaction-emoji-picker --- .../components/collab-sidebar/reaction-emoji-picker.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index c11268f53d551f..9e26b39dbb5fde 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -49,9 +49,11 @@ export function useReactionEmojis() { } ) .then( ( response ) => { const schemaEmojis = - response?.schema?.properties?.reaction_emojis - ?.default; - if ( Array.isArray( schemaEmojis ) && schemaEmojis.length ) { + response?.schema?.properties?.reaction_emojis?.default; + if ( + Array.isArray( schemaEmojis ) && + schemaEmojis.length + ) { cachedEmojis = schemaEmojis; } else { cachedEmojis = REACTION_EMOJIS; From 5be22c83e58e9bdfb7f77c7b2e748c74c10cae1f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 3 Apr 2026 10:44:57 -0700 Subject: [PATCH 161/362] Add reaction_summary field to note REST responses Add a computed reaction_summary field to note comment REST responses that returns grouped reaction counts, whether the current user reacted, and the user's reaction ID for each emoji. This eliminates the need for clients to fetch all individual reaction records separately. Includes batch pre-fetching in get_items() to avoid N+1 queries when returning multiple notes. --- ...-gutenberg-rest-comment-controller-7-1.php | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index bc7f387c9f8112..1b9a8f36ec50c9 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -51,6 +51,30 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['reaction_summary'] = array( + 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji slug.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'count' => array( + 'description' => __( 'Total number of reactions with this emoji.', 'gutenberg' ), + 'type' => 'integer', + ), + 'reacted' => array( + 'description' => __( 'Whether the current user reacted with this emoji.', 'gutenberg' ), + 'type' => 'boolean', + ), + 'my_reaction_id' => array( + 'description' => __( 'The current user\'s reaction comment ID, or 0 if not reacted.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + ); + return $schema; } @@ -550,6 +574,183 @@ public function create_item( $request ) { return $response; } + /** + * Pre-fetched reaction summaries for batch queries. + * + * Populated by get_items() to avoid N+1 queries when returning + * multiple note comments. + * + * @since 7.1.0 + * @var array|null + */ + protected $reaction_summaries = null; + + /** + * Retrieves a collection of comments. + * + * Extends the parent to pre-fetch reaction summaries for all returned + * note comments in a single batch query, avoiding N+1 queries. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. + */ + public function get_items( $request ) { + $fields = $this->get_fields_for_response( $request ); + + // Pre-fetch reaction summaries when requesting notes with reaction_summary field. + if ( + ! empty( $request['type'] ) && + 'note' === $request['type'] && + rest_is_field_included( 'reaction_summary', $fields ) + ) { + // Run the same query logic as parent to get the comment IDs. + $registered = $this->get_collection_params(); + $parameter_mappings = array( + 'author' => 'author__in', + 'author_email' => 'author_email', + 'author_exclude' => 'author__not_in', + 'exclude' => 'comment__not_in', + 'include' => 'comment__in', + 'offset' => 'offset', + 'order' => 'order', + 'parent' => 'parent__in', + 'parent_exclude' => 'parent__not_in', + 'per_page' => 'number', + 'post' => 'post__in', + 'search' => 'search', + 'status' => 'status', + 'type' => 'type', + ); + + $prepared_args = array(); + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $prepared_args[ $wp_param ] = $request[ $api_param ]; + } + } + + // Only fetch IDs for the pre-fetch query. + $prepared_args['fields'] = 'ids'; + + $query = new WP_Comment_Query(); + $note_ids = $query->query( $prepared_args ); + + if ( ! empty( $note_ids ) ) { + $this->prefetch_reaction_summaries( array_map( 'intval', $note_ids ) ); + } + } + + $response = parent::get_items( $request ); + + $this->reaction_summaries = null; + + return $response; + } + + /** + * Pre-fetches reaction summaries for a set of note IDs. + * + * Runs a single aggregated query to build summaries for all notes, + * avoiding N+1 queries in prepare_item_for_response(). + * + * @since 7.1.0 + * + * @param int[] $note_ids Array of note comment IDs. + */ + protected function prefetch_reaction_summaries( $note_ids ) { + global $wpdb; + + $this->reaction_summaries = array(); + + if ( empty( $note_ids ) ) { + return; + } + + $current_user_id = get_current_user_id(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count, + GROUP_CONCAT(CONCAT(comment_ID, ':', user_id) SEPARATOR ',') AS details + FROM {$wpdb->comments} + WHERE comment_parent IN (" . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') + AND comment_type = %s + AND comment_approved = %s + GROUP BY comment_parent, comment_content', + ...array_merge( $note_ids, array( 'reaction', '1' ) ) + ) + ); + + // Initialize empty summaries for all note IDs. + foreach ( $note_ids as $note_id ) { + $this->reaction_summaries[ $note_id ] = array(); + } + + if ( ! $results ) { + return; + } + + foreach ( $results as $row ) { + $note_id = (int) $row->comment_parent; + $slug = wp_strip_all_tags( $row->comment_content ); + + $my_reaction_id = 0; + if ( $current_user_id && ! empty( $row->details ) ) { + $pairs = explode( ',', $row->details ); + foreach ( $pairs as $pair ) { + list( $comment_id, $user_id ) = explode( ':', $pair ); + if ( (int) $user_id === $current_user_id ) { + $my_reaction_id = (int) $comment_id; + break; + } + } + } + + $this->reaction_summaries[ $note_id ][ $slug ] = array( + 'count' => (int) $row->reaction_count, + 'reacted' => $my_reaction_id > 0, + 'my_reaction_id' => $my_reaction_id, + ); + } + } + + /** + * Prepares a single comment output for response. + * + * Extends the parent to include reaction_summary for note comments. + * + * @since 7.1.0 + * + * @param WP_Comment $item Comment object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $response = parent::prepare_item_for_response( $item, $request ); + $fields = $this->get_fields_for_response( $request ); + + if ( 'note' === $item->comment_type && rest_is_field_included( 'reaction_summary', $fields ) ) { + // Use pre-fetched data if available, otherwise query individually. + if ( null !== $this->reaction_summaries && isset( $this->reaction_summaries[ (int) $item->comment_ID ] ) ) { + $summary = $this->reaction_summaries[ (int) $item->comment_ID ]; + } else { + $this->prefetch_reaction_summaries( array( (int) $item->comment_ID ) ); + $summary = $this->reaction_summaries[ (int) $item->comment_ID ] ?? array(); + // Reset so the next call also queries individually. + $this->reaction_summaries = null; + } + + $data = $response->get_data(); + $data['reaction_summary'] = $summary; + $response->set_data( $data ); + } + + return $response; + } + /** * Prepares links for the request. * From 0845744e63c9da89df49741bc8fb282424e5879a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 3 Apr 2026 10:48:12 -0700 Subject: [PATCH 162/362] Use reaction_summary from REST API instead of fetching all reactions Replace the separate unbounded useEntityRecords call for reactions with the reaction_summary field returned on each note. This eliminates a separate API request and the per_page:-1 fetch of all individual reaction records, addressing unbounded request and performance concerns. Also removes the eager user data fetch for tooltips, using count-based labels instead. --- .../src/components/collab-sidebar/hooks.js | 63 ++----- .../collab-sidebar/reaction-display.js | 161 ++---------------- 2 files changed, 29 insertions(+), 195 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 9cfb6d52f3d13a..6bbd2f654a3cc7 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -3,6 +3,7 @@ */ import { __ } from '@wordpress/i18n'; import { + useCallback, useState, useEffect, useMemo, @@ -56,51 +57,21 @@ export function useNoteThreads( postId ) { { enabled: !! postId && typeof postId === 'number' } ); - const { records: reactionRecords } = useEntityRecords( - 'root', - 'comment', - { - post: postId, - type: 'reaction', - status: 'all', - per_page: -1, - }, - { enabled: !! postId && typeof postId === 'number' } - ); - - // Build a reactionsMap: { [noteId]: { [emojiSlug]: [{ userId, date, reactionId }] } } + // Build reactionsMap from the reaction_summary field on each note. + // Shape: { [noteId]: { [emojiSlug]: { count, reacted, my_reaction_id } } } const reactionsMap = useMemo( () => { - if ( ! reactionRecords || reactionRecords.length === 0 ) { + if ( ! threads || threads.length === 0 ) { return {}; } const map = {}; - reactionRecords.forEach( ( reaction ) => { - const noteId = reaction.parent; - const slug = - typeof reaction.content === 'object' - ? reaction.content?.raw || reaction.content?.rendered - : reaction.content; - // Strip HTML tags from rendered content. - const cleanSlug = slug?.replace?.( /<[^>]*>/g, '' )?.trim(); - if ( ! noteId || ! cleanSlug ) { - return; + threads.forEach( ( thread ) => { + if ( thread.reaction_summary ) { + map[ thread.id ] = thread.reaction_summary; } - - if ( ! map[ noteId ] ) { - map[ noteId ] = {}; - } - if ( ! map[ noteId ][ cleanSlug ] ) { - map[ noteId ][ cleanSlug ] = []; - } - map[ noteId ][ cleanSlug ].push( { - userId: reaction.author, - date: reaction.date, - reactionId: reaction.id, - } ); } ); return map; - }, [ reactionRecords ] ); + }, [ threads ] ); const { getBlockAttributes } = useSelect( blockEditorStore ); const { clientIds } = useSelect( ( select ) => { @@ -317,7 +288,6 @@ function clearInlineNoteMarker( } export function useNoteActions( reactionsMap = {} ) { - const registry = useRegistry(); const { createNotice } = useDispatch( noticesStore ); const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); @@ -542,24 +512,16 @@ export function useNoteActions( reactionsMap = {} ) { const onToggleReaction = useCallback( async ( { commentId, emoji } ) => { try { - // Get current user from the store. - const currentUser = - registry.select( coreStore ).getCurrentUser() || {}; - const userId = currentUser.id; - - // Check if the user already reacted with this emoji. + // Check if the user already reacted via reaction_summary. const noteReactions = reactionsMap[ commentId ] || {}; - const emojiReactions = noteReactions[ emoji ] || []; - const existingReaction = emojiReactions.find( - ( reaction ) => reaction.userId === userId - ); + const emojiData = noteReactions[ emoji ]; - if ( existingReaction ) { + if ( emojiData?.reacted && emojiData?.my_reaction_id ) { // Remove the reaction by deleting the comment record. await deleteEntityRecord( 'root', 'comment', - existingReaction.reactionId, + emojiData.my_reaction_id, undefined, { throwOnError: true } ); @@ -594,7 +556,6 @@ export function useNoteActions( reactionsMap = {} ) { }, [ reactionsMap, - registry, deleteEntityRecord, saveEntityRecord, getCurrentPostId, diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 0b4410c13e3224..b8960e88798c0d 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -8,9 +8,7 @@ import { privateApis as componentsPrivateApis, } from '@wordpress/components'; import { SVG, Path } from '@wordpress/primitives'; -import { useSelect } from '@wordpress/data'; -import { useMemo, useState } from '@wordpress/element'; -import { store as coreStore } from '@wordpress/core-data'; +import { useState } from '@wordpress/element'; /** * Internal dependencies @@ -45,34 +43,29 @@ const smileyIcon = ( /** * Get the count of reactions for a specific slug. * - * @param {Object} reactions The reactions object (keyed by slug). + * @param {Object} reactions The reactions summary (keyed by slug). * @param {string} slug The reaction slug to count. * @return {number} The count of reactions. */ function getReactionCount( reactions, slug ) { - return reactions?.[ slug ]?.length || 0; + return reactions?.[ slug ]?.count || 0; } /** * Check if the current user has reacted with a specific slug. * - * @param {Object} reactions The reactions object (keyed by slug). - * @param {string} slug The reaction slug to check. - * @param {number} currentUserId The current user's ID. + * @param {Object} reactions The reactions summary (keyed by slug). + * @param {string} slug The reaction slug to check. * @return {boolean} Whether the user has reacted. */ -function hasUserReacted( reactions, slug, currentUserId ) { - return ( - reactions?.[ slug ]?.some( - ( reaction ) => reaction.userId === currentUserId - ) || false - ); +function hasUserReacted( reactions, slug ) { + return reactions?.[ slug ]?.reacted || false; } /** * Get all reaction slugs that have reactions. * - * @param {Object} reactions The reactions object (keyed by slug). + * @param {Object} reactions The reactions summary (keyed by slug). * @return {string[]} Array of slugs with reactions. */ function getReactedSlugs( reactions ) { @@ -80,61 +73,7 @@ function getReactedSlugs( reactions ) { return []; } return Object.keys( reactions ).filter( - ( slug ) => reactions[ slug ]?.length > 0 - ); -} - -/** - * Generate GitHub-style tooltip text for a reaction. - * - * @param {Object} users Map of user data by ID. - * @param {Array} userIds Array of user IDs who reacted. - * @param {string} slug The reaction slug. - * @param {Array} emojis The emoji list to look up labels from. - * @return {string} The tooltip text. - */ -function getReactionTooltipText( users, userIds, slug, emojis ) { - const names = userIds - .map( ( id ) => users?.[ id ]?.name ) - .filter( Boolean ); - - if ( names.length === 0 ) { - return ''; - } - - const emojiLabel = getLabelBySlug( slug, emojis ); - - if ( names.length === 1 ) { - return sprintf( - /* translators: 1: user name, 2: emoji label. */ - __( '%1$s reacted with %2$s emoji' ), - names[ 0 ], - emojiLabel - ); - } - - if ( names.length === 2 ) { - return sprintf( - /* translators: 1: first user name, 2: second user name, 3: emoji label. */ - __( '%1$s and %2$s reacted with %3$s emoji' ), - names[ 0 ], - names[ 1 ], - emojiLabel - ); - } - - const othersCount = names.length - 2; - return sprintf( - /* translators: 1: first user name, 2: second user name, 3: number of other users, 4: emoji label. */ - _n( - '%1$s, %2$s, and %3$d other reacted with %4$s emoji', - '%1$s, %2$s, and %3$d others reacted with %4$s emoji', - othersCount - ), - names[ 0 ], - names[ 1 ], - othersCount, - emojiLabel + ( slug ) => reactions[ slug ]?.count > 0 ); } @@ -142,59 +81,13 @@ function getReactionTooltipText( users, userIds, slug, emojis ) { * Display current reactions with counts as pill-shaped buttons. * * @param {Object} props Component props. - * @param {Object} props.reactions The reactions object from comment meta. + * @param {Object} props.reactions The reaction summary (keyed by slug). * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { reactions, onToggleReaction } ) { const emojis = useReactionEmojis(); - const currentUserId = useSelect( ( select ) => { - const { getCurrentUser } = select( coreStore ); - const user = getCurrentUser(); - return user?.id; - }, [] ); const reactedSlugs = getReactedSlugs( reactions ); - // Collect all unique user IDs from reactions. - const userIdArray = useMemo( () => { - if ( ! reactions ) { - return []; - } - const userIdSet = new Set(); - Object.values( reactions ).forEach( ( reactionList ) => { - reactionList?.forEach( ( reaction ) => { - if ( reaction.userId ) { - userIdSet.add( reaction.userId ); - } - } ); - } ); - return Array.from( userIdSet ).sort( ( a, b ) => a - b ); - }, [ reactions ] ); - - // Fetch user data for all users who reacted. - const users = useSelect( - ( select ) => { - if ( ! userIdArray.length ) { - return {}; - } - const { getUsers } = select( coreStore ); - const userList = getUsers( { - include: userIdArray, - context: 'view', - _fields: 'id,name', - per_page: -1, - } ); - if ( ! userList ) { - return {}; - } - const userData = {}; - userList.forEach( ( user ) => { - userData[ user.id ] = user; - } ); - return userData; - }, - [ userIdArray ] - ); - if ( reactedSlugs.length === 0 ) { return null; } @@ -203,36 +96,16 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { { reactedSlugs.map( ( slug ) => { const count = getReactionCount( reactions, slug ); - const isActive = hasUserReacted( - reactions, - slug, - currentUserId - ); + const isActive = hasUserReacted( reactions, slug ); const emoji = getEmojiBySlug( slug, emojis ); + const emojiLabel = getLabelBySlug( slug, emojis ); - const reactionUserIds = ( reactions?.[ slug ] || [] ).map( - ( r ) => r.userId + const buttonLabel = sprintf( + /* translators: 1: emoji label, 2: count of reactions */ + _n( '%1$s, %2$d reaction', '%1$s, %2$d reactions', count ), + emojiLabel, + count ); - const tooltipText = getReactionTooltipText( - users, - reactionUserIds, - slug, - emojis - ); - - // Use tooltip text when user data is loaded, otherwise fall back to basic label. - const buttonLabel = tooltipText - ? tooltipText - : sprintf( - /* translators: 1: emoji, 2: count of reactions */ - _n( - '%1$s, %2$d reaction', - '%1$s, %2$d reactions', - count - ), - emoji, - count - ); return ( + ); +} + /** * Display current reactions with counts as pill-shaped buttons. * * @param {Object} props Component props. + * @param {number} props.noteId The parent note comment ID. * @param {Object} props.reactions The reaction summary (keyed by slug). * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ -export default function ReactionDisplay( { reactions, onToggleReaction } ) { +export default function ReactionDisplay( { + noteId, + reactions, + onToggleReaction, +} ) { const emojis = useReactionEmojis(); const reactedSlugs = getReactedSlugs( reactions ); @@ -79,42 +247,18 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { { reactedSlugs.map( ( slug ) => { const count = getReactionCount( reactions, slug ); const isActive = hasUserReacted( reactions, slug ); - const emoji = getEmojiBySlug( slug, emojis ); - const emojiLabel = getLabelBySlug( slug, emojis ); - - const buttonLabel = sprintf( - /* translators: 1: emoji label, 2: count of reactions */ - _n( '%1$s, %2$d reaction', '%1$s, %2$d reactions', count ), - emojiLabel, - count - ); return ( - + noteId={ noteId } + slug={ slug } + count={ count } + isActive={ isActive } + emoji={ getEmojiBySlug( slug, emojis ) } + emojiLabel={ getLabelBySlug( slug, emojis ) } + onToggleReaction={ onToggleReaction } + /> ); } ) } From ea4905ae804495cadbb4771b5b2a9406c0104dd7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 3 Apr 2026 17:29:01 -0700 Subject: [PATCH 168/362] Replace GROUP_CONCAT with separate user query for reaction summaries Split the single GROUP_CONCAT-based query into two simpler queries: one for aggregated counts and one for the current user's reaction IDs. This avoids MySQL's group_concat_max_len truncation on notes with many reactions. --- ...-gutenberg-rest-comment-controller-7-1.php | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index a4072bdb263e4f..8c08c6302bb90c 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -670,13 +670,13 @@ protected function prefetch_reaction_summaries( $note_ids ) { $current_user_id = get_current_user_id(); + // Query 1: Get aggregated counts per emoji per note. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $results = $wpdb->get_results( + $counts = $wpdb->get_results( $wpdb->prepare( - "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count, - GROUP_CONCAT(CONCAT(comment_ID, ':', user_id) SEPARATOR ',') AS details - FROM {$wpdb->comments} - WHERE comment_parent IN (" . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') + 'SELECT comment_parent, comment_content, COUNT(*) AS reaction_count + FROM ' . $wpdb->comments . ' + WHERE comment_parent IN (' . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') AND comment_type = %s AND comment_approved = %s GROUP BY comment_parent, comment_content', @@ -684,30 +684,44 @@ protected function prefetch_reaction_summaries( $note_ids ) { ) ); + // Query 2: Get the current user's own reaction IDs (if logged in). + $my_reactions = array(); + if ( $current_user_id ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $user_rows = $wpdb->get_results( + $wpdb->prepare( + 'SELECT comment_ID, comment_parent, comment_content + FROM ' . $wpdb->comments . ' + WHERE comment_parent IN (' . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') + AND comment_type = %s + AND comment_approved = %s + AND user_id = %d', + ...array_merge( $note_ids, array( 'reaction', '1', $current_user_id ) ) + ) + ); + + if ( $user_rows ) { + foreach ( $user_rows as $row ) { + $key = (int) $row->comment_parent . ':' . wp_strip_all_tags( $row->comment_content ); + $my_reactions[ $key ] = (int) $row->comment_ID; + } + } + } + // Initialize empty summaries for all note IDs. foreach ( $note_ids as $note_id ) { $this->reaction_summaries[ $note_id ] = array(); } - if ( ! $results ) { + if ( ! $counts ) { return; } - foreach ( $results as $row ) { - $note_id = (int) $row->comment_parent; - $slug = wp_strip_all_tags( $row->comment_content ); - - $my_reaction_id = 0; - if ( $current_user_id && ! empty( $row->details ) ) { - $pairs = explode( ',', $row->details ); - foreach ( $pairs as $pair ) { - list( $comment_id, $user_id ) = explode( ':', $pair ); - if ( (int) $user_id === $current_user_id ) { - $my_reaction_id = (int) $comment_id; - break; - } - } - } + foreach ( $counts as $row ) { + $note_id = (int) $row->comment_parent; + $slug = wp_strip_all_tags( $row->comment_content ); + $key = $note_id . ':' . $slug; + $my_reaction_id = $my_reactions[ $key ] ?? 0; $this->reaction_summaries[ $note_id ][ $slug ] = array( 'count' => (int) $row->reaction_count, From b1a7618ae51db5dee174d58e9eb5e6b702f81e9e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 19:55:29 -0700 Subject: [PATCH 169/362] Notes: Document rationale for storing reactions as a custom comment type Add a docblock to lib/compat/wordpress-7.1/block-comments.php explaining why reactions use a custom 'reaction' comment type instead of comment meta: free authorship/timestamps from comment APIs, generic type that can attach to other resources, and per-row writes to avoid serialized-meta races. Links to the discussion in PRs #75549 and #75148. --- lib/compat/wordpress-7.1/block-comments.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index c2ffcb48596b40..aba44b9c80243e 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -20,6 +20,21 @@ * Background discussion: https://github.com/WordPress/gutenberg/pull/75549 * and https://github.com/WordPress/gutenberg/pull/75148. * + * Why a custom comment type (vs. comment meta on the parent note)? + * + * - Each reaction has a real author, date, and ID, so existing comment + * APIs handle authorship, timestamps, and deletion without bespoke code. + * This avoids client-side date math and timezone bugs. + * - The type is generic ('reaction', not 'note_reaction') so it can later + * attach to any commentable resource (blocks, posts, other comment types), + * not just block notes. + * - Race conditions when adding/removing reactions concurrently are handled + * by the comments table (one row per reaction) rather than by read-modify- + * write on a serialized meta value. + * + * Background discussion: https://github.com/WordPress/gutenberg/pull/75549 + * and https://github.com/WordPress/gutenberg/pull/75148. + * * @package gutenberg */ From 4f45d7db90c4361f2ae98e7dde73ec65c9aa86d4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 19:58:52 -0700 Subject: [PATCH 170/362] Notes: Update reaction E2E tests to renamed fixture and method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trunk's collab-sidebar refactor renamed the test fixture and helper: - blockCommentUtils → blockNoteUtils - addBlockWithComment → addBlockWithNote Update the Emoji Reactions describe block to use the current names so Playwright stops failing with 'Test has unknown parameter blockCommentUtils'. --- .../specs/editor/various/block-notes.spec.js | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index b4d657f10245fb..37efe325350266 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -877,6 +877,280 @@ test.describe( 'Block Notes', () => { } ); } ); + test.describe( 'Emoji Reactions', () => { + test( 'can add an emoji reaction to a note', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing emoji reactions' }, + comment: 'Test comment for reactions', + } ); + + await blockNoteUtils.addReactionToComment( 'Heart' ); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Verify the reaction button appears with count. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '1' ); + } ); + + test( 'can re-add the same reaction after removing it', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing re-add reaction' }, + comment: 'Re-add reaction', + } ); + + await blockNoteUtils.addReactionToComment( 'Heart' ); + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '1' ); + + // Remove the reaction. + await reactionButton.click(); + await expect( reactionButton ).toBeHidden(); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction removed.' } ) + ).toBeVisible(); + + // Add the same reaction again. This used to fail two ways: + // 1) the parent note's cached `reaction_summary` still + // reported the removed heart as `reacted`, so the toggle + // attempted to delete a now-missing comment record + // instead of routing to add; and 2) the server's + // duplicate-reaction guard included trashed comments, + // so the just-removed reaction blocked the re-add with + // `rest_comment_duplicate_reaction` ("You have already + // reacted with this emoji"). + await blockNoteUtils.addReactionToComment( 'Heart' ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '❤' ); + await expect( reactionButton ).toContainText( '1' ); + + // Both adds must have surfaced a success snackbar (initial + // add + re-add), and the duplicate-reaction error must + // never appear — pins both fixes (client refetch + server + // status='approve' query) against regression. + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toHaveCount( 2 ); + await expect( + page.locator( '.components-snackbar__content', { + hasText: /already reacted/i, + } ) + ).toHaveCount( 0 ); + } ); + + test( 'can remove own emoji reaction by clicking it', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing reaction removal' }, + comment: 'Test comment for removing reactions', + } ); + + // Add a reaction. + await blockNoteUtils.addReactionToComment( 'Heart' ); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Click the reaction to remove it. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await reactionButton.click(); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction removed.' } ) + ).toBeVisible(); + + // Verify the reaction button is no longer visible. + await expect( reactionButton ).toBeHidden(); + } ); + + test( 'can see reaction tooltip on hover', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing reaction tooltip' }, + comment: 'Test comment for reaction tooltip', + } ); + + // Add a reaction. + await blockNoteUtils.addReactionToComment( 'Celebration' ); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Hover over the reaction button to trigger tooltip. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await reactionButton.hover(); + + // Verify the tooltip is visible and contains expected text. + const tooltip = page.getByRole( 'tooltip' ); + await expect( tooltip ).toBeVisible(); + await expect( tooltip ).toContainText( 'reacted with' ); + await expect( tooltip ).toContainText( 'Celebration' ); + } ); + + test( 'reaction buttons are keyboard accessible', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing keyboard accessibility' }, + comment: 'Test comment for keyboard access', + } ); + + // Open the emoji picker with keyboard. + const addReactionButton = page.getByRole( 'button', { + name: 'Add reaction', + } ); + await addReactionButton.focus(); + await page.keyboard.press( 'Enter' ); + + // Verify the picker is visible. + const emojiPicker = page.locator( + '.editor-collab-sidebar-panel__emoji-picker' + ); + await expect( emojiPicker ).toBeVisible(); + + // Navigate with arrow keys and select. The picker is a horizontal + // listbox, so ArrowRight moves to the next option. + const firstEmoji = emojiPicker.getByRole( 'option' ).first(); + await firstEmoji.focus(); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + } ); + + test( 'can add multiple different reactions to same note', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing multiple reactions' }, + comment: 'Test comment for multiple reactions', + } ); + + // Add first reaction. + await blockNoteUtils.addReactionToComment( 'Smile' ); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Add second reaction. + await blockNoteUtils.addReactionToComment( 'Rocket' ); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // Verify both reactions are visible. + const reactionButtons = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButtons ).toHaveCount( 2 ); + } ); + + test( 'reaction picker portals outside the collab sidebar', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing popover portal' }, + comment: 'Popover portal', + } ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + + const popover = page.locator( + '.editor-collab-sidebar-panel__add-reaction-popover' + ); + await expect( popover ).toBeVisible(); + + // The popover must portal out of the sidebar; otherwise the + // `overflow: hidden` chain on `.editor-collab-sidebar-panel` + // (and the framework `.interface-interface-skeleton__sidebar`) + // would clip the picker. Pin the contract by asserting the + // popover has no sidebar-panel ancestor. + await expect( popover ).toHaveCount( 1 ); + const isPortaled = await popover.evaluate( + ( el ) => ! el.closest( '.editor-collab-sidebar-panel' ) + ); + expect( isPortaled ).toBe( true ); + } ); + + test( 'note remains selected while reaction picker is open', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing selection persistence' }, + comment: 'Selection persistence', + } ); + + const thread = page.getByRole( 'treeitem', { + name: /Note: Selection persistence/, + } ); + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await expect( + page.locator( '.editor-collab-sidebar-panel__emoji-picker' ) + ).toBeVisible(); + + // Focus has moved into the portaled popover, but the note's + // onBlur handler exempts `.components-popover` so the thread + // stays selected and the trigger stays mounted. + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + } ); + } ); + test.describe( 'Multiple notes per block', () => { test( 'can add multiple notes to the same block', async ( { editor, From 0f0f81233db5dd4e1cb3ad2eac792ea51c3fdcf6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 20:26:16 -0700 Subject: [PATCH 171/362] Notes: Use --wpds-cursor-control token on add-reaction button The Lint Styles check enforces using the design system cursor token for interactive non-link controls (rule added in trunk). Replace 'cursor: pointer' on .editor-collab-sidebar-panel__add-reaction-button to satisfy the rule. --- packages/editor/src/components/collab-sidebar/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index e72bc95150b2e4..c72740b2ea9980 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -268,7 +268,7 @@ mark.wp-note { border-radius: $radius-round; border: $border-width solid $gray-300; background-color: $white; - cursor: pointer; + cursor: var(--wpds-cursor-control); &:hover { background-color: $gray-100; From 4f782d27748f0b411980769456a07cb4d916de35 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 20:48:58 -0700 Subject: [PATCH 172/362] Notes: Centralize internal comment types via gutenberg_get_internal_comment_types(). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the change being proposed for core in https://github.com/WordPress/wordpress-develop/pull/10930. Introduces a single helper that returns the list of internal comment types ('note', 'reaction'), filterable so future additions only need to update one place. Apply it across all the existing 'note'/'reaction' guards in the 7.1 compat layer: * gutenberg_update_get_avatar_comment_type_7_1() — avatar-eligible types. * gutenberg_exclude_block_comments_from_admin_7_1() — admin query exclude. * gutenberg_filter_comment_count_query_exclude_block_comments_7_1() — pending-count SQL guard. * gutenberg_hide_note_from_comment_list_table_7_1() — list table args. * gutenberg_exclude_notes_from_comment_count_7_1() — approved-count SQL. * Gutenberg_REST_Comment_Controller_7_1::is_note_or_reaction() — REST controller's per-request type check (used by permissions, prepare, validation, and links). Props westonruter for the suggestion to centralize this list. --- lib/compat/wordpress-7.1/block-comments.php | 15 --------------- ...lass-gutenberg-rest-comment-controller-7-1.php | 10 +++++----- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index aba44b9c80243e..c2ffcb48596b40 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -20,21 +20,6 @@ * Background discussion: https://github.com/WordPress/gutenberg/pull/75549 * and https://github.com/WordPress/gutenberg/pull/75148. * - * Why a custom comment type (vs. comment meta on the parent note)? - * - * - Each reaction has a real author, date, and ID, so existing comment - * APIs handle authorship, timestamps, and deletion without bespoke code. - * This avoids client-side date math and timezone bugs. - * - The type is generic ('reaction', not 'note_reaction') so it can later - * attach to any commentable resource (blocks, posts, other comment types), - * not just block notes. - * - Race conditions when adding/removing reactions concurrently are handled - * by the comments table (one row per reaction) rather than by read-modify- - * write on a serialized meta value. - * - * Background discussion: https://github.com/WordPress/gutenberg/pull/75549 - * and https://github.com/WordPress/gutenberg/pull/75148. - * * @package gutenberg */ diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index 8c08c6302bb90c..b0702461c87048 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -79,13 +79,13 @@ public function get_item_schema() { } /** - * Checks whether the request type is a note or reaction. + * Checks whether the request type is an internal comment type (note or reaction). * * @param string $type The comment type from the request. - * @return bool True if the type is 'note' or 'reaction'. + * @return bool True if the type is one of the internal comment types. */ protected function is_note_or_reaction( $type ) { - return in_array( $type, array( 'note', 'reaction' ), true ); + return in_array( $type, gutenberg_get_internal_comment_types(), true ); } public function get_items_permissions_check( $request ) { @@ -349,8 +349,8 @@ public function create_item( $request ) { ); } - // Allow 'comment', 'note', and 'reaction' types. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { + // Allow 'comment' plus internal comment types (note, reaction). + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array_merge( array( 'comment' ), gutenberg_get_internal_comment_types() ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.', 'gutenberg' ), From d000a4dd65b887e23728e68b0ca6c9efca194793 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 08:10:28 -0700 Subject: [PATCH 173/362] Notes: Add Frimousse-based full emoji picker behind a + button Preserves the existing curated 5-emoji quick row and adds a `+` button at the end that opens a Frimousse-based full emoji picker in a Popover. Frimousse is a lightweight (MIT-licensed, GPLv2-compatible), headless, composable emoji picker built for collaborative tools. It is rendered through compound components, allowing it to be styled with WPDS tokens (surface, dimension, border-radius, focus stroke, font, cursor) so the picker visually matches surrounding WPDS UI without forking the library. The picker is loaded via React.lazy + Suspense so its code and its emoji-data fetch are only paid for when a user actually opens it. Reactions chosen from the full picker are stored as the raw emoji character (e.g. heart) rather than a curated slug (e.g. heart). Server-side validation is relaxed to accept either a curated slug or a short (<= 16 chars) string, capping length to keep the column from storing anything resembling text content. Note: by default Frimousse fetches emoji data from the jsdelivr CDN. For production we should point emojibaseUrl at a self-hosted bundle. --- ...-gutenberg-rest-comment-controller-7-1.php | 11 +- package-lock.json | 20 +++ packages/editor/package.json | 1 + .../collab-sidebar/frimousse-picker.js | 72 +++++++++++ .../collab-sidebar/reaction-emoji-picker.js | 93 +++++++++++--- .../src/components/collab-sidebar/style.scss | 119 ++++++++++++++++-- 6 files changed, 288 insertions(+), 28 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/frimousse-picker.js diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index b0702461c87048..5ca2023411505e 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -378,11 +378,18 @@ public function create_item( $request ) { ); } - // Validate content is a valid emoji slug. + // Validate the reaction content. We accept either a curated emoji + // slug (e.g. "heart") or a short raw emoji character chosen from + // the full picker (e.g. "👍"). Length is capped so this column + // can never store anything resembling text content. $emojis = gutenberg_get_note_reaction_emojis(); $valid_slugs = wp_list_pluck( $emojis, 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; - if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { + + $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); + $is_short_emoji = '' !== $emoji_slug && mb_strlen( $emoji_slug ) <= 16; + + if ( ! $is_curated_slug && ! $is_short_emoji ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.', 'gutenberg' ), diff --git a/package-lock.json b/package-lock.json index ac44346b4473dc..248c3f0db05739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24796,6 +24796,25 @@ "node": ">= 0.6" } }, + "node_modules/frimousse": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.3.0.tgz", + "integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==", + "license": "MIT", + "workspaces": [ + ".", + "site" + ], + "peerDependencies": { + "react": "^18 || ^19", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -46965,6 +46984,7 @@ "date-fns": "^4.1.0", "diff": "^8.0.3", "fast-deep-equal": "^3.1.3", + "frimousse": "^0.3.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "redux": "^5.0.1", diff --git a/packages/editor/package.json b/packages/editor/package.json index 81e63bd9774646..e731a00eeb300d 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -111,6 +111,7 @@ "date-fns": "^4.1.0", "diff": "^8.0.3", "fast-deep-equal": "^3.1.3", + "frimousse": "^0.3.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "redux": "^5.0.1", diff --git a/packages/editor/src/components/collab-sidebar/frimousse-picker.js b/packages/editor/src/components/collab-sidebar/frimousse-picker.js new file mode 100644 index 00000000000000..62d895c6368ba8 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/frimousse-picker.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import { EmojiPicker } from 'frimousse'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Frimousse-based full emoji picker, styled with WPDS tokens. + * + * Emoji data is fetched from the default Emojibase CDN + * (https://cdn.jsdelivr.net/npm/emojibase-data) on first open. For a + * production rollout we'd point `emojibaseUrl` at a self-hosted bundle. + * + * @param {Object} props Component props. + * @param {Function} props.onSelect Called with the selected emoji character. + */ +export default function FrimoussePicker( { onSelect } ) { + return ( + onSelect( emoji ) } + columns={ 8 } + > + + + + { __( 'Loading…' ) } + + + { __( 'No emoji found.' ) } + + ( +
+ { category.label } +
+ ), + Row: ( { children, ...rest } ) => ( +
+ { children } +
+ ), + Emoji: ( { emoji, ...rest } ) => ( + + ), + } } + /> +
+
+ ); +} diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 9e26b39dbb5fde..6d003dddea10b0 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -2,9 +2,22 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Button, Composite } from '@wordpress/components'; -import { useState, useEffect } from '@wordpress/element'; +import { Button, Composite, Popover } from '@wordpress/components'; +import { + useState, + useEffect, + useRef, + lazy, + Suspense, +} from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; +import { plus as plusIcon } from '@wordpress/icons'; + +/** + * Lazy-load the Frimousse picker so its emoji data fetch and ~30KB of + * picker code are only paid for when a user actually opens it. + */ +const FrimoussePicker = lazy( () => import( './frimousse-picker' ) ); /** * Curated emoji set for reactions (fallback). @@ -101,36 +114,78 @@ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { } /** - * A selection of emoji buttons for adding reactions. + * A row of curated emoji buttons plus a `+` button that opens a full + * Frimousse-based picker in a Popover. * * @param {Object} props Component props. - * @param {Function} props.onSelect Callback when an emoji is selected. + * @param {Function} props.onSelect Callback invoked with the chosen emoji. + * For curated picks this is the slug + * (e.g. `heart`); for picks from the full + * picker it is the emoji character itself. */ export default function ReactionEmojiPicker( { onSelect } ) { const emojis = useReactionEmojis(); + const [ isFullPickerOpen, setIsFullPickerOpen ] = useState( false ); + const moreButtonRef = useRef( null ); return ( - - { emojis.map( ( { emoji, label, value } ) => ( + <> + + { emojis.map( ( { emoji, label, value } ) => ( + onSelect( value ) } + aria-label={ label } + className="editor-collab-sidebar-panel__emoji-option" + /> + } + > + { emoji } + + ) ) } onSelect( value ) } - aria-label={ label } - className="editor-collab-sidebar-panel__emoji-option" + icon={ plusIcon } + onClick={ () => + setIsFullPickerOpen( ( open ) => ! open ) + } + aria-expanded={ isFullPickerOpen } + aria-haspopup="dialog" + label={ __( 'More emojis' ) } + className="editor-collab-sidebar-panel__emoji-more" /> } + /> + + { isFullPickerOpen && ( + setIsFullPickerOpen( false ) } + onFocusOutside={ () => setIsFullPickerOpen( false ) } + className="editor-collab-sidebar-panel__frimousse-popover" > - { emoji } -
- ) ) } -
+ + { + setIsFullPickerOpen( false ); + onSelect( emoji ); + } } + /> + + + ) } + ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index c72740b2ea9980..338c12bc381da2 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -39,8 +39,10 @@ } &:focus { - outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); - outline-offset: calc((-1 * var(--wp-admin-border-width-focus))); + outline: + var(--wp-admin-border-width-focus) solid + var(--wp-admin-theme-color); + outline-offset: calc(( -1 * var(--wp-admin-border-width-focus) )); } &.is-floating { @@ -275,7 +277,9 @@ mark.wp-note { } &:focus { - outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); + outline: + var(--wp-admin-border-width-focus) solid + var(--wp-admin-theme-color); outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); } @@ -287,19 +291,21 @@ mark.wp-note { } } - .editor-collab-sidebar-panel__add-reaction-popover { padding: $grid-unit-10; } -.editor-collab-sidebar-panel .editor-collab-sidebar-panel__add-reaction-popover { +.editor-collab-sidebar-panel +.editor-collab-sidebar-panel__add-reaction-popover { min-width: 40px; } .editor-collab-sidebar-panel__emoji-picker { display: flex; - flex-direction: column; - gap: 0; + flex-direction: row; + align-items: center; + gap: var(--wpds-dimension-gap-xs); + padding: var(--wpds-dimension-padding-xs); } .editor-collab-sidebar-panel__emoji-option { @@ -308,3 +314,102 @@ mark.wp-note { justify-content: center; } +.editor-collab-sidebar-panel__emoji-more { + cursor: var(--wpds-cursor-control); +} + +// Frimousse popover: anchor a fixed-size picker surface with WPDS surface +// tokens so it visually matches the WPDS toolbar/menu surfaces nearby. +.editor-collab-sidebar-panel__frimousse-popover { + .components-popover__content { + width: var(--wpds-dimension-surface-width-md); + padding: 0; + border-radius: var(--wpds-border-radius-md); + box-shadow: var(--wpds-elevation-md); + background-color: var(--wpds-color-bg-surface-neutral-weak); + } +} + +.editor-collab-sidebar-panel__frimousse { + display: flex; + flex-direction: column; + height: 320px; + font-family: var(--wpds-typography-font-family-body); + font-size: var(--wpds-typography-font-size-sm); + color: var(--wpds-color-fg-content-neutral); + isolation: isolate; +} + +.editor-collab-sidebar-panel__frimousse-search { + margin: var(--wpds-dimension-padding-sm); + padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-interactive-neutral); + border-radius: var(--wpds-border-radius-sm); + background-color: var(--wpds-color-bg-surface-neutral-weak); + color: inherit; + font: inherit; + + &:focus { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline-offset: -1px; + } +} + +.editor-collab-sidebar-panel__frimousse-viewport { + flex: 1; + overflow: auto; + position: relative; + padding-inline: var(--wpds-dimension-padding-xs); +} + +.editor-collab-sidebar-panel__frimousse-loading, +.editor-collab-sidebar-panel__frimousse-empty { + display: grid; + place-items: center; + height: 100%; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.editor-collab-sidebar-panel__frimousse-list { + padding-bottom: var(--wpds-dimension-padding-sm); +} + +.editor-collab-sidebar-panel__frimousse-category { + position: sticky; + top: 0; + padding: var(--wpds-dimension-padding-sm); + background-color: var(--wpds-color-bg-surface-neutral-weak); + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + font-weight: var(--wpds-typography-font-weight-medium); + z-index: 1; +} + +.editor-collab-sidebar-panel__frimousse-row { + display: flex; + gap: var(--wpds-dimension-gap-xs); +} + +.editor-collab-sidebar-panel__frimousse-emoji { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--wpds-border-radius-sm); + background: transparent; + cursor: var(--wpds-cursor-control); + font-size: 18px; + line-height: 1; + + &[data-active="true"] { + background-color: var(--wpds-color-bg-interactive-neutral-weak-active); + } + + &:focus-visible { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline-offset: -1px; + } +} From d9f1ac85f8f156d81cb4caedf6a0d9ae45d0b0f5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 09:09:15 -0700 Subject: [PATCH 174/362] Notes: Bundle Frimousse emoji data, normalize reaction storage Two related changes that drop the CDN runtime dependency and harden storage of reactions across WordPress installs. Bundle Frimousse data: - Add emojibase-data dependency - frimousse-picker imports en/data.json + en/messages.json directly and installs a one-time window.fetch interceptor that returns the bundled JSON for any URL prefixed with a sentinel host. The picker passes that sentinel as emojibaseUrl, so all of Frimousse's data loading is satisfied locally and no jsdelivr CDN call is ever made - Trade-off: the editor bundle inflates by ~100KB gzipped because esbuild does not currently chunk-split lazy() imports. We accept this for the prototype; a follow-up should either enable splitting or ship the data as a static asset and resolve its URL via PHP Normalize reaction storage: - Curated picks continue to store as their slug (e.g. "heart") - Frimousse picks normalize to a lowercase hex-codepoint sequence joined by "-" (e.g. "1f44d" for thumbs up; "1f468-200d-1f4bb" for technologist). Variation selector U+FE0F is stripped so a Frimousse pick of the heart emoji collapses into the curated "heart" slug - Picks are mapped through emojiToStorageKey before submission - getEmojiBySlug / getLabelBySlug now fall back through hexKeyToEmoji for non-curated keys so display works without server-side help - PHP validation accepts either a curated slug or the hex pattern (^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$) and rejects raw emoji bytes. The comments table is not guaranteed to be utf8mb4 across all WordPress installs; ASCII storage keys sidestep that risk and keep reaction_summary grouping stable --- ...-gutenberg-rest-comment-controller-7-1.php | 18 ++-- package-lock.json | 14 +++ packages/editor/package.json | 1 + .../collab-sidebar/frimousse-picker.js | 64 ++++++++++++- .../collab-sidebar/reaction-emoji-picker.js | 90 +++++++++++++++++-- 5 files changed, 172 insertions(+), 15 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index 5ca2023411505e..a86b4f723b4b57 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -378,18 +378,24 @@ public function create_item( $request ) { ); } - // Validate the reaction content. We accept either a curated emoji - // slug (e.g. "heart") or a short raw emoji character chosen from - // the full picker (e.g. "👍"). Length is capped so this column - // can never store anything resembling text content. + // Validate the reaction content. We accept either: + // - a curated slug (e.g. "heart"), or + // - a lowercase hex-codepoint sequence joined by `-` (e.g. + // "1f44d" for 👍 or "1f468-200d-1f4bb" for 👨‍💻). + // Raw emoji bytes are rejected because the comments table is + // not guaranteed to be utf8mb4 across all WordPress installs; + // clients normalize before submitting. $emojis = gutenberg_get_note_reaction_emojis(); $valid_slugs = wp_list_pluck( $emojis, 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); - $is_short_emoji = '' !== $emoji_slug && mb_strlen( $emoji_slug ) <= 16; + $is_hex_key = (bool) preg_match( + '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', + $emoji_slug + ); - if ( ! $is_curated_slug && ! $is_short_emoji ) { + if ( ! $is_curated_slug && ! $is_hex_key ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.', 'gutenberg' ), diff --git a/package-lock.json b/package-lock.json index 248c3f0db05739..e4def1755cdd79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22336,6 +22336,19 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/emojibase-data": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-17.0.0.tgz", + "integrity": "sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==", + "license": "MIT", + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + }, + "peerDependencies": { + "emojibase": "*" + } + }, "node_modules/emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", @@ -46983,6 +46996,7 @@ "colord": "^2.9.3", "date-fns": "^4.1.0", "diff": "^8.0.3", + "emojibase-data": "17.0.0", "fast-deep-equal": "^3.1.3", "frimousse": "^0.3.0", "memize": "^2.1.0", diff --git a/packages/editor/package.json b/packages/editor/package.json index e731a00eeb300d..3cba493c5ad9cf 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -110,6 +110,7 @@ "colord": "^2.9.3", "date-fns": "^4.1.0", "diff": "^8.0.3", + "emojibase-data": "^17.0.0", "fast-deep-equal": "^3.1.3", "frimousse": "^0.3.0", "memize": "^2.1.0", diff --git a/packages/editor/src/components/collab-sidebar/frimousse-picker.js b/packages/editor/src/components/collab-sidebar/frimousse-picker.js index 62d895c6368ba8..38753802021c0c 100644 --- a/packages/editor/src/components/collab-sidebar/frimousse-picker.js +++ b/packages/editor/src/components/collab-sidebar/frimousse-picker.js @@ -2,6 +2,8 @@ * External dependencies */ import { EmojiPicker } from 'frimousse'; +import emojibaseData from 'emojibase-data/en/data.json'; +import emojibaseMessages from 'emojibase-data/en/messages.json'; /** * WordPress dependencies @@ -11,18 +13,74 @@ import { __ } from '@wordpress/i18n'; /** * Frimousse-based full emoji picker, styled with WPDS tokens. * - * Emoji data is fetched from the default Emojibase CDN - * (https://cdn.jsdelivr.net/npm/emojibase-data) on first open. For a - * production rollout we'd point `emojibaseUrl` at a self-hosted bundle. + * Frimousse normally fetches Emojibase data from the jsdelivr CDN. We + * cannot ship a CDN dependency in WordPress Core, so we bundle the + * `emojibase-data/en/{data,messages}.json` files via esbuild and serve + * them in-process by intercepting the picker's two known fetch URLs. + * + * The picker is loaded lazily by reaction-emoji-picker.js, so the + * additional bundle (~100KB gzipped for the JSON + the picker code) + * is only paid once a user opens the more-emojis popover. * * @param {Object} props Component props. * @param {Function} props.onSelect Called with the selected emoji character. */ + +// Sentinel base URL: anything that won't ever resolve to a real network +// host. The interceptor below matches on this prefix. +const EMOJIBASE_LOCAL_URL = 'https://wordpress.local/__emojibase'; + +let interceptorInstalled = false; + +/** + * Install a one-time `window.fetch` interceptor that returns the bundled + * Emojibase JSON for any URL prefixed with EMOJIBASE_LOCAL_URL. All other + * URLs pass through unchanged. Idempotent so repeat picker mounts are + * safe. + */ +function installEmojibaseFetchInterceptor() { + if ( + interceptorInstalled || + typeof window === 'undefined' || + typeof window.fetch !== 'function' + ) { + return; + } + const originalFetch = window.fetch.bind( window ); + window.fetch = ( input, init ) => { + const url = typeof input === 'string' ? input : input?.url ?? ''; + if ( url.startsWith( EMOJIBASE_LOCAL_URL ) ) { + let payload; + if ( url.endsWith( '/data.json' ) ) { + payload = emojibaseData; + } else if ( url.endsWith( '/messages.json' ) ) { + payload = emojibaseMessages; + } + if ( payload ) { + return Promise.resolve( + new Response( JSON.stringify( payload ), { + status: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'public, max-age=31536000', + }, + } ) + ); + } + } + return originalFetch( input, init ); + }; + interceptorInstalled = true; +} + +installEmojibaseFetchInterceptor(); + export default function FrimoussePicker( { onSelect } ) { return ( onSelect( emoji ) } + emojibaseUrl={ EMOJIBASE_LOCAL_URL } columns={ 8 } > c.codePointAt( 0 ).toString( 16 ) ) + .join( '-' ); +} + +/** + * Convert a hex-codepoint sequence back to its emoji character. + * + * @param {string} hexKey Lowercase hex codepoints joined by `-`. + * @return {string} The emoji character, or the input on parse failure. + */ +export function hexKeyToEmoji( hexKey ) { + if ( typeof hexKey !== 'string' || ! HEX_KEY_RE.test( hexKey ) ) { + return hexKey; + } + try { + return String.fromCodePoint( + ...hexKey.split( '-' ).map( ( p ) => parseInt( p, 16 ) ) + ); + } catch { + return hexKey; + } +} + +/** + * Map a chosen emoji character to its storage key. If the emoji matches + * a curated reaction (after stripping VS-16) returns its slug, otherwise + * returns the hex-codepoint key. + * + * @param {string} emoji The emoji character. + * @param {Array} emojis Curated emoji list to match against. + * @return {string} The storage key (slug or hex codepoints). + */ +export function emojiToStorageKey( emoji, emojis = REACTION_EMOJIS ) { + const hex = emojiToHexKey( emoji ); + const curated = emojis.find( ( r ) => emojiToHexKey( r.emoji ) === hex ); + return curated ? curated.value : hex; +} + /** * Get the emoji character for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug or hex-codepoint key. * @param {Array} emojis Optional emoji list to search. * @return {string} The emoji character, or the slug if not found. */ export function getEmojiBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.emoji ?? slug; + const curated = emojis.find( ( r ) => r.value === slug ); + if ( curated ) { + return curated.emoji; + } + if ( HEX_KEY_RE.test( slug ) ) { + return hexKeyToEmoji( slug ); + } + return slug; } /** * Get the label for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug or hex-codepoint key. * @param {Array} emojis Optional emoji list to search. - * @return {string} The label, or the slug if not found. + * @return {string} The label, or the emoji character if non-curated. */ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.label ?? slug; + const curated = emojis.find( ( r ) => r.value === slug ); + if ( curated ) { + return curated.label; + } + return getEmojiBySlug( slug, emojis ); } /** @@ -180,7 +258,7 @@ export default function ReactionEmojiPicker( { onSelect } ) { { setIsFullPickerOpen( false ); - onSelect( emoji ); + onSelect( emojiToStorageKey( emoji, emojis ) ); } } /> From 1124da24bdd3e7cde057ea18121641a0aee1ecdb Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 09:29:24 -0700 Subject: [PATCH 175/362] Notes: Move full picker to a sibling button + add E2E tests The first prototype put the "+" trigger inside the curated picker's popover, which forced a nested Popover to open the Frimousse picker. Nested popovers conflicted with the parent Menu's focus and dismissal handling, and the Composite role mixed listbox semantics with a button that opened a dialog. Restructure into two sibling Menu components living next to each other: - AddReactionButton (smiley) opens the curated 5-emoji popover, which is reverted to its original vertical column layout - MoreEmojiButton (plus) opens a Popover containing the lazy-loaded Frimousse picker Both menus share the same onToggleReaction prop, so picks from either flow through the same emojiToStorageKey normalization pipeline. E2E tests: add four new tests covering - the + button opens the Frimousse picker - a Frimousse pick that matches a curated emoji collapses to the curated slug (verifies storage normalization end-to-end) - a non-curated Frimousse pick renders the chosen emoji from its hex-codepoint storage key - Escape closes the Frimousse popover Selectors use Frimousse's stable `grid` / `gridcell` ARIA roles instead of internal class names. The keyboard-accessibility test now uses ArrowDown rather than ArrowRight inside the vertical curated listbox; ArrowRight was previously a no-op but now escapes to the adjacent More-emojis menu trigger. --- .../src/components/collab-sidebar/note.js | 13 +- .../collab-sidebar/reaction-display.js | 63 +++++++- .../collab-sidebar/reaction-emoji-picker.js | 96 +++-------- .../src/components/collab-sidebar/style.scss | 10 +- .../specs/editor/various/block-notes.spec.js | 151 +++++++++++++++++- 5 files changed, 241 insertions(+), 92 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/note.js b/packages/editor/src/components/collab-sidebar/note.js index a33419872e836c..f6fb61eddf19be 100644 --- a/packages/editor/src/components/collab-sidebar/note.js +++ b/packages/editor/src/components/collab-sidebar/note.js @@ -22,7 +22,10 @@ import { moreVertical, published } from '@wordpress/icons'; */ import { NoteCard } from './note-card'; import { NoteForm } from './note-form'; -import ReactionDisplay, { AddReactionButton } from './reaction-display'; +import ReactionDisplay, { + AddReactionButton, + MoreEmojiButton, +} from './reaction-display'; import { unlock } from '../../lock-unlock'; const { Menu } = unlock( componentsPrivateApis ); @@ -230,6 +233,14 @@ export function Note( { } ) } /> + + onToggleReaction?.( { + commentId: note.id, + emoji, + } ) + } + /> import( './frimousse-picker' ) ); + const { Menu } = unlock( componentsPrivateApis ); /** @@ -243,7 +251,7 @@ export default function ReactionDisplay( { } return ( - + { reactedSlugs.map( ( slug ) => { const count = getReactionCount( reactions, slug ); const isActive = hasUserReacted( reactions, slug ); @@ -261,12 +269,13 @@ export default function ReactionDisplay( { /> ); } ) } - + ); } /** - * Standalone add-reaction button with emoji picker dropdown. + * Standalone add-reaction button with the curated emoji picker + * dropdown (the 5-emoji quick row). * * @param {Object} props Component props. * @param {boolean} props.disabled Whether the button is disabled. @@ -302,3 +311,45 @@ export function AddReactionButton( { disabled = false, onToggleReaction } ) { ); } + +/** + * Standalone "+" button that opens a full Frimousse-based emoji picker. + * Sibling of AddReactionButton; not nested inside it so the curated and + * full picker popovers never conflict. + * + * @param {Object} props Component props. + * @param {Function} props.onToggleReaction Callback to toggle a reaction. + */ +export function MoreEmojiButton( { onToggleReaction } ) { + const [ isOpen, setIsOpen ] = useState( false ); + const emojis = useReactionEmojis(); + return ( + + + } + /> + + + { + setIsOpen( false ); + onToggleReaction( + emojiToStorageKey( emoji, emojis ) + ); + } } + /> + + + + ); +} diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 21dad6a09f28de..970c95abf0760d 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -2,22 +2,9 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Button, Composite, Popover } from '@wordpress/components'; -import { - useState, - useEffect, - useRef, - lazy, - Suspense, -} from '@wordpress/element'; +import { Button, Composite } from '@wordpress/components'; +import { useState, useEffect } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; -import { plus as plusIcon } from '@wordpress/icons'; - -/** - * Lazy-load the Frimousse picker so its emoji data fetch and ~30KB of - * picker code are only paid for when a user actually opens it. - */ -const FrimoussePicker = lazy( () => import( './frimousse-picker' ) ); /** * Curated emoji set for reactions (fallback). @@ -192,78 +179,39 @@ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { } /** - * A row of curated emoji buttons plus a `+` button that opens a full - * Frimousse-based picker in a Popover. + * A row of curated emoji buttons. The `+` "More emojis" trigger lives + * outside this picker (as a sibling of the smiley trigger) so the + * full-picker popover never has to nest inside the curated popover. * * @param {Object} props Component props. - * @param {Function} props.onSelect Callback invoked with the chosen emoji. - * For curated picks this is the slug - * (e.g. `heart`); for picks from the full - * picker it is the emoji character itself. + * @param {Function} props.onSelect Called with the chosen slug when the + * user picks a curated emoji. */ export default function ReactionEmojiPicker( { onSelect } ) { const emojis = useReactionEmojis(); - const [ isFullPickerOpen, setIsFullPickerOpen ] = useState( false ); - const moreButtonRef = useRef( null ); return ( - <> - - { emojis.map( ( { emoji, label, value } ) => ( - onSelect( value ) } - aria-label={ label } - className="editor-collab-sidebar-panel__emoji-option" - /> - } - > - { emoji } - - ) ) } + + { emojis.map( ( { emoji, label, value } ) => ( - setIsFullPickerOpen( ( open ) => ! open ) - } - aria-expanded={ isFullPickerOpen } - aria-haspopup="dialog" - label={ __( 'More emojis' ) } - className="editor-collab-sidebar-panel__emoji-more" + onClick={ () => onSelect( value ) } + aria-label={ label } + className="editor-collab-sidebar-panel__emoji-option" /> } - /> - - { isFullPickerOpen && ( - setIsFullPickerOpen( false ) } - onFocusOutside={ () => setIsFullPickerOpen( false ) } - className="editor-collab-sidebar-panel__frimousse-popover" > - - { - setIsFullPickerOpen( false ); - onSelect( emojiToStorageKey( emoji, emojis ) ); - } } - /> - - - ) } - + { emoji } +
+ ) ) } +
); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 338c12bc381da2..d664e79a4e7404 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -302,10 +302,8 @@ mark.wp-note { .editor-collab-sidebar-panel__emoji-picker { display: flex; - flex-direction: row; - align-items: center; - gap: var(--wpds-dimension-gap-xs); - padding: var(--wpds-dimension-padding-xs); + flex-direction: column; + gap: 0; } .editor-collab-sidebar-panel__emoji-option { @@ -314,10 +312,6 @@ mark.wp-note { justify-content: center; } -.editor-collab-sidebar-panel__emoji-more { - cursor: var(--wpds-cursor-control); -} - // Frimousse popover: anchor a fixed-size picker surface with WPDS surface // tokens so it visually matches the WPDS toolbar/menu surfaces nearby. .editor-collab-sidebar-panel__frimousse-popover { diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 37efe325350266..eb8c258efdce32 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1048,11 +1048,12 @@ test.describe( 'Block Notes', () => { ); await expect( emojiPicker ).toBeVisible(); - // Navigate with arrow keys and select. The picker is a horizontal - // listbox, so ArrowRight moves to the next option. + // Navigate with arrow keys and select. The picker is a vertical + // listbox, so ArrowDown moves to the next option. ArrowRight + // would now escape to the adjacent "More emojis" menu trigger. const firstEmoji = emojiPicker.getByRole( 'option' ).first(); await firstEmoji.focus(); - await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowDown' ); await page.keyboard.press( 'Enter' ); await expect( @@ -1095,6 +1096,96 @@ test.describe( 'Block Notes', () => { await expect( reactionButtons ).toHaveCount( 2 ); } ); + test( 'can open the full emoji picker via the + button', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing full emoji picker' }, + comment: 'Open the full picker', + } ); + + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + + await blockNoteUtils.waitForFrimoussePicker(); + + // The search field is present. + await expect( page.getByPlaceholder( 'Search emoji' ) ).toBeVisible(); + } ); + + test( 'a Frimousse pick that matches a curated emoji stores as the curated slug', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Curated normalization' }, + comment: 'Pick heart from full picker', + } ); + + await blockNoteUtils.pickFrimousseEmojiBySearch( 'red heart' ); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // The same reaction button shape that the curated row produces + // — contains the heart emoji and a count of 1. If storage + // normalization were broken we'd get a stray hex-key button. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( '❤' ); + await expect( reactionButton ).toContainText( '1' ); + } ); + + test( 'a Frimousse pick that is not curated renders the chosen emoji', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Non-curated emoji' }, + comment: 'Pick thumbs up from full picker', + } ); + + await blockNoteUtils.pickFrimousseEmojiBySearch( 'thumbs up' ); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( '👍' ); + } ); + + test( 'pressing Escape closes the Frimousse popover', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Escape closes picker' }, + comment: 'Close picker with Escape', + } ); + + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFrimoussePicker(); + + await page.keyboard.press( 'Escape' ); + + await expect( page.getByPlaceholder( 'Search emoji' ) ).toBeHidden(); + } ); + test( 'reaction picker portals outside the collab sidebar', async ( { page, blockNoteUtils, @@ -1821,4 +1912,58 @@ class BlockNoteUtils { .click(); await this.#page.getByRole( 'menuitem', { name: actionName } ).click(); } + + async addReactionToComment( emoji ) { + await this.#page + .getByRole( 'button', { name: 'Add reaction' } ) + .click(); + + // Wait for the emoji picker popover to appear. + const emojiPicker = this.#page.locator( + '.editor-collab-sidebar-panel__emoji-picker' + ); + await expect( emojiPicker ).toBeVisible(); + + // Click the specific emoji within the picker. + await emojiPicker + .getByRole( 'option', { name: new RegExp( emoji, 'i' ) } ) + .click(); + } + + /** + * Wait for the Frimousse picker to finish loading its bundled + * Emojibase data and render at least one emoji button. The grid / + * gridcell roles come from Frimousse itself and are stable across + * className changes. + */ + async waitForFrimoussePicker() { + await expect( this.#page.getByPlaceholder( 'Search emoji' ) ).toBeVisible(); + await expect( + this.#page.getByRole( 'grid' ).getByRole( 'gridcell' ).first() + ).toBeVisible(); + } + + /** + * Click the + button to open Frimousse, search by name, and click + * the first matching emoji. + * + * @param {string} search Search term (matched against Emojibase + * labels, e.g. "red heart" or "thumbs up"). + */ + async pickFrimousseEmojiBySearch( search ) { + await this.#page.getByRole( 'button', { name: 'More emojis' } ).click(); + await this.waitForFrimoussePicker(); + + await this.#page.getByPlaceholder( 'Search emoji' ).fill( search ); + + // Wait for the search to actually filter. Frimousse's gridcells + // expose the emoji label as their accessible name, so once the + // first cell carries a name matching `search` we know the grid + // has finished re-laying-out. + const match = this.#page + .getByRole( 'gridcell', { name: new RegExp( search, 'i' ) } ) + .first(); + await expect( match ).toBeVisible(); + await match.click(); + } } From e9e43ea418c37eb7bc273dc5a0892c64aa93621f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 10:11:38 -0700 Subject: [PATCH 176/362] Notes: Serve emojibase data from plugins_url, drop bundle inflation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut imported emoji-data JSON directly into the picker source and used a window.fetch interceptor to satisfy Frimousse's data load. That worked but inflated the editor bundle by ~770KB raw / ~100KB gzipped because esbuild does not currently chunk-split lazy() imports. Move the data out of the JS bundle and serve it as static files: - New build step bin/copy-emojibase-data.mjs copies en/data.json and en/messages.json out of node_modules/emojibase-data into build/emojibase-data/. The other ~50MB of unused emojibase-data is left behind. Wired into bin/build.mjs after wp-build runs. - emojibase-data is moved from packages/editor's deps to the root devDependencies; npm consumers of @wordpress/editor no longer pull the 50MB unpacked package. - New PHP file lib/compat/wordpress-7.1/emojibase-data.php uses wp_add_inline_script('wp-editor', ..., 'before') to set window.gutenbergEmojibaseUrl to plugins_url('build/emojibase-data'). - frimousse-picker.js drops the JSON imports and the fetch interceptor and reads emojibaseUrl from the global. Frimousse fetches its data same-origin from plugins_url() and the editor bundle drops back to pre-bundling weight. - MoreEmojiButton renders nothing when window.gutenbergEmojibaseUrl is unset, so npm consumers of @wordpress/editor without their own hosted dataset don't accidentally fall through to the jsdelivr CDN. Bundle size for build/scripts/editor/index.min.js drops from ~520KB gzipped (with the JSON inlined) back to ~422KB gzipped — i.e. ~100KB gzipped reclaimed. Drive-by: the plus icon in the new MoreEmojiButton was invisible because it inherited the smiley trigger's `svg { fill: none }` rule (the smiley uses path-level `fill="currentColor"` to override that; the plus path has no attribute). Give the More button its own class that sets `fill: currentColor`. The pinned "All notes" sidebar still clips the popover since Menu.Popover does not portal here. That clipping pre-dates this work and applies to the curated picker too; tracking as a follow-up. --- bin/copy-emojibase-data.mjs | 65 ++++++++++++++++ lib/compat/wordpress-7.1/emojibase-data.php | 28 +++++++ lib/load.php | 1 + package-lock.json | 3 +- package.json | 1 + packages/editor/package.json | 1 - .../collab-sidebar/frimousse-picker.js | 77 ++++--------------- .../collab-sidebar/reaction-display.js | 11 ++- .../src/components/collab-sidebar/style.scss | 37 +++++++++ tools/build-scripts/build.mjs | 7 ++ 10 files changed, 168 insertions(+), 63 deletions(-) create mode 100644 bin/copy-emojibase-data.mjs create mode 100644 lib/compat/wordpress-7.1/emojibase-data.php diff --git a/bin/copy-emojibase-data.mjs b/bin/copy-emojibase-data.mjs new file mode 100644 index 00000000000000..19737b29864f96 --- /dev/null +++ b/bin/copy-emojibase-data.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Copy the locale data files Frimousse expects from the bundled + * emojibase-data package into Gutenberg's `build/emojibase-data/` + * directory so the plugin can serve them via plugins_url() instead of + * inlining ~770KB of JSON into the editor JS bundle. + * + * Runs as a step in `bin/build.mjs`, after wp-build has populated + * `build/`. Exits 0 even when emojibase-data is missing — the editor + * gracefully degrades by hiding the "More emojis" trigger. + */ + +import { mkdir, copyFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); +const ROOT_DIR = path.resolve( __dirname, '..' ); +const SRC_DIR = path.join( ROOT_DIR, 'node_modules', 'emojibase-data' ); +const DEST_DIR = path.join( ROOT_DIR, 'build', 'emojibase-data' ); + +// Frimousse only ever fetches data.json and messages.json. We ship just +// those two files per locale; the rest of the package (~50MB unpacked) +// stays out of the plugin distribution. +const FILES = [ 'data.json', 'messages.json' ]; + +// Locales the plugin ships. Keep this small for now — adding a locale +// is ~85KB gzipped. English covers the strings shown to users today. +const LOCALES = [ 'en' ]; + +async function copyEmojibaseData() { + if ( ! existsSync( SRC_DIR ) ) { + console.warn( + '⚠️ emojibase-data not found at', + SRC_DIR, + '— skipping. Run `npm install` to fetch it.' + ); + return; + } + + for ( const locale of LOCALES ) { + const localeDest = path.join( DEST_DIR, locale ); + await mkdir( localeDest, { recursive: true } ); + for ( const file of FILES ) { + const from = path.join( SRC_DIR, locale, file ); + const to = path.join( localeDest, file ); + if ( ! existsSync( from ) ) { + console.warn( `⚠️ emojibase-data missing ${ from }` ); + continue; + } + await copyFile( from, to ); + } + } + console.log( + ` ✔ Copied emojibase data for ${ LOCALES.join( + ', ' + ) } to build/emojibase-data/` + ); +} + +copyEmojibaseData().catch( ( error ) => { + console.error( '❌ Failed to copy emojibase data:', error ); + process.exit( 1 ); +} ); diff --git a/lib/compat/wordpress-7.1/emojibase-data.php b/lib/compat/wordpress-7.1/emojibase-data.php new file mode 100644 index 00000000000000..312076672e81ee --- /dev/null +++ b/lib/compat/wordpress-7.1/emojibase-data.php @@ -0,0 +1,28 @@ + { - const url = typeof input === 'string' ? input : input?.url ?? ''; - if ( url.startsWith( EMOJIBASE_LOCAL_URL ) ) { - let payload; - if ( url.endsWith( '/data.json' ) ) { - payload = emojibaseData; - } else if ( url.endsWith( '/messages.json' ) ) { - payload = emojibaseMessages; - } - if ( payload ) { - return Promise.resolve( - new Response( JSON.stringify( payload ), { - status: 200, - headers: { - 'content-type': 'application/json', - 'cache-control': 'public, max-age=31536000', - }, - } ) - ); - } - } - return originalFetch( input, init ); - }; - interceptorInstalled = true; -} - -installEmojibaseFetchInterceptor(); - export default function FrimoussePicker( { onSelect } ) { + const emojibaseUrl = + typeof window !== 'undefined' && window.gutenbergEmojibaseUrl + ? window.gutenbergEmojibaseUrl + : undefined; + return ( onSelect( emoji ) } - emojibaseUrl={ EMOJIBASE_LOCAL_URL } + emojibaseUrl={ emojibaseUrl } columns={ 8 } > diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index d664e79a4e7404..7314320605b7ff 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -291,6 +291,43 @@ mark.wp-note { } } +// Mirror the smiley trigger but render the plus icon with a filled +// path. The shared add-reaction-button rule sets `fill: none` on its +// SVG (the smiley uses path-level `fill="currentColor"` which beats +// it); the plus icon's single path has no attribute and was hidden. +.editor-collab-sidebar-panel__more-reaction-button { + display: inline-flex; + margin: 0; + align-items: center; + justify-content: center; + width: 28px; + height: 28px !important; + 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 { + background-color: $gray-100; + } + + &:focus { + outline: + var(--wp-admin-border-width-focus) solid + var(--wp-admin-theme-color); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } + + svg { + fill: currentColor; + color: $gray-700; + width: 20px; + height: 20px; + } +} + .editor-collab-sidebar-panel__add-reaction-popover { padding: $grid-unit-10; } diff --git a/tools/build-scripts/build.mjs b/tools/build-scripts/build.mjs index fc2f6aaca27efd..c4ffe1dbcd18a1 100644 --- a/tools/build-scripts/build.mjs +++ b/tools/build-scripts/build.mjs @@ -159,6 +159,13 @@ async function build() { env: { ...process.env, NODE_ENV: 'production' }, } ); + // Step 7.4: Copy emojibase locale data into build/ so the editor + // can fetch it from plugins_url() instead of bundling ~770KB of + // JSON into the main JS bundle. + console.log( '\n📦 Copying emojibase data...' ); + await exec( 'node', [ './bin/copy-emojibase-data.mjs' ] ); + + // Step 7.5: Build blocks manifests console.log( '\n📦 Building blocks manifests...' ); const blocksDirs = [ { From c120aac4bb08f6fb3f14d4c8bbdf508fce4cdb49 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 11:50:07 -0700 Subject: [PATCH 177/362] Notes: Replace Menu.Popover with Dropdown so reaction popovers escape sidebar clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned "All notes" sidebar (and the framework-level .interface-interface-skeleton__sidebar) sets overflow: hidden, which clipped both the curated and Frimousse pickers when they extended past the sidebar's edges. Switching the smiley and "+" triggers from Ariakit-backed `Menu.Popover` to `@wordpress/components/Dropdown` portals the popover to , so its content is no longer constrained by ancestor overflow. Dropdown also owns its own open/close state, so the manual `useState` and `Menu.TriggerButton` plumbing goes away. The note thread's onBlur handler deselects when focus leaves the thread DOM. With the popover now portaled, focus moving into the picker's first option looked like a leave; extend the existing `[role="dialog"]` exemption to also cover `.components-popover`, so focus moving into a popover anchored from inside the note keeps the note selected (and the trigger rendered). Style update mirrors the markup change: `contentClassName` lands on `.components-popover__content` directly, so the previously nested `.editor-collab-sidebar-panel__frimousse-popover .components-popover__content` selector flattens. Drops the `.editor-collab-sidebar-panel .editor-collab-sidebar-panel__add-reaction-popover` descendant rule — no longer matches once the popover is portaled out of the panel. All 33 block-notes E2E tests pass, including the 9 reaction tests. --- .../components/collab-sidebar/note-thread.js | 9 +- .../collab-sidebar/reaction-display.js | 88 +++++++++---------- .../src/components/collab-sidebar/style.scss | 19 ++-- 3 files changed, 56 insertions(+), 60 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/note-thread.js b/packages/editor/src/components/collab-sidebar/note-thread.js index 4c885d0154e399..2e5ddc5b50c7e6 100644 --- a/packages/editor/src/components/collab-sidebar/note-thread.js +++ b/packages/editor/src/components/collab-sidebar/note-thread.js @@ -106,9 +106,9 @@ export function NoteThread( { ); const isDialogFocused = event.relatedTarget?.closest( '[role="dialog"]' ); - // Popovers anchored from inside the note (e.g. emoji reaction - // pickers) portal their content to , so focus moving into them - // looks like focus leaving the thread. + // Popovers anchored from inside the note (e.g. the emoji + // reaction pickers) portal their content to , so focus + // moving into them looks like focus leaving the thread. const isPopoverFocused = event.relatedTarget?.closest( '.components-popover' ); @@ -122,6 +122,9 @@ export function NoteThread( { if ( isDialogFocused ) { return; } + // When opening a popover from inside the note, the popover + // content is portaled outside the thread DOM but the note + // should remain selected for the trigger to stay rendered. if ( isPopoverFocused ) { return; } diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index fb7a2c08df6303..94022677b8a8cc 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -2,10 +2,7 @@ * WordPress dependencies */ import { __, sprintf, _n } from '@wordpress/i18n'; -import { - Button, - privateApis as componentsPrivateApis, -} from '@wordpress/components'; +import { Button, Dropdown } from '@wordpress/components'; import { Stack } from '@wordpress/ui'; import { smiley as smileyIcon, plus as plusIcon } from '@wordpress/icons'; import { useState, useCallback, lazy, Suspense } from '@wordpress/element'; @@ -21,7 +18,6 @@ import ReactionEmojiPicker, { getLabelBySlug, useReactionEmojis, } from './reaction-emoji-picker'; -import { unlock } from '../../lock-unlock'; /** * Lazy-load the Frimousse-based full picker. Its bundle (Frimousse + @@ -30,7 +26,11 @@ import { unlock } from '../../lock-unlock'; */ const FrimoussePicker = lazy( () => import( './frimousse-picker' ) ); -const { Menu } = unlock( componentsPrivateApis ); +// `Dropdown`'s popover is rendered in a portal anchored to , +// so it escapes the `overflow: hidden` chain on the collab sidebar +// (`.interface-interface-skeleton__sidebar`, +// `.editor-collab-sidebar`, `.editor-collab-sidebar-panel`). +const POPOVER_PROPS = { placement: 'bottom-end' }; /** * Get the count of reactions for a specific slug. @@ -282,33 +282,31 @@ export default function ReactionDisplay( { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export function AddReactionButton( { disabled = false, onToggleReaction } ) { - const [ isOpen, setIsOpen ] = useState( false ); return ( - - - } - /> - + ( + + ) } + /> ); } @@ -327,38 +325,36 @@ export function AddReactionButton( { disabled = false, onToggleReaction } ) { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export function MoreEmojiButton( { onToggleReaction } ) { - const [ isOpen, setIsOpen ] = useState( false ); const emojis = useReactionEmojis(); if ( typeof window === 'undefined' || ! window.gutenbergEmojibaseUrl ) { return null; } return ( - - - } - /> - + ( + + ) } + /> ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 7314320605b7ff..c90c32de372819 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -328,12 +328,11 @@ mark.wp-note { } } +// Class is applied via `Dropdown`'s `contentClassName`, which lands +// on `.components-popover__content` itself — so style the class +// directly rather than nesting through `.components-popover__content`. .editor-collab-sidebar-panel__add-reaction-popover { padding: $grid-unit-10; -} - -.editor-collab-sidebar-panel -.editor-collab-sidebar-panel__add-reaction-popover { min-width: 40px; } @@ -352,13 +351,11 @@ mark.wp-note { // Frimousse popover: anchor a fixed-size picker surface with WPDS surface // tokens so it visually matches the WPDS toolbar/menu surfaces nearby. .editor-collab-sidebar-panel__frimousse-popover { - .components-popover__content { - width: var(--wpds-dimension-surface-width-md); - padding: 0; - border-radius: var(--wpds-border-radius-md); - box-shadow: var(--wpds-elevation-md); - background-color: var(--wpds-color-bg-surface-neutral-weak); - } + width: var(--wpds-dimension-surface-width-md); + padding: 0; + border-radius: var(--wpds-border-radius-md); + box-shadow: var(--wpds-elevation-md); + background-color: var(--wpds-color-bg-surface-neutral-weak); } .editor-collab-sidebar-panel__frimousse { From ab18eaa25cea6d819cfc234ca3eeaa7744fb9e3b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 12:23:53 -0700 Subject: [PATCH 178/362] Notes: Add E2E tests pinning the popover-portal and selection-persistence contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regression guards specific to the Dropdown migration: 1. `reaction picker portals outside the collab sidebar` — asserts the popover content has no `.editor-collab-sidebar-panel` ancestor, so that reverting the portal (e.g. by switching back to a non- portaling primitive) fails loudly instead of silently re-clipping. 2. `note remains selected while reaction picker is open` — asserts the thread keeps `aria-expanded="true"` after the picker opens, pinning the `.components-popover` exemption in `note-thread.js`'s onBlur handler. Without this, removing that exemption would unmount the trigger and close the picker mid-click — but the existing 9 tests would still pass because they don't observe selection state between open and close. --- .../specs/editor/various/block-notes.spec.js | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index eb8c258efdce32..c3b24238a17c74 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1840,6 +1840,61 @@ test.describe( 'Block Notes', () => { await expect.poll( alphaOf ).toBeGreaterThan( 0.4 ); } ); + + test( 'reaction picker portals outside the collab sidebar', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing popover portal' }, + comment: 'Popover portal', + } ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + + const popover = page.locator( + '.editor-collab-sidebar-panel__add-reaction-popover' + ); + await expect( popover ).toBeVisible(); + + // The popover must portal out of the sidebar; otherwise the + // `overflow: hidden` chain on `.editor-collab-sidebar-panel` + // (and the framework `.interface-interface-skeleton__sidebar`) + // would clip the picker. Pin the contract by asserting the + // popover has no sidebar-panel ancestor. + await expect( popover ).toHaveCount( 1 ); + const isPortaled = await popover.evaluate( + ( el ) => ! el.closest( '.editor-collab-sidebar-panel' ) + ); + expect( isPortaled ).toBe( true ); + } ); + + test( 'note remains selected while reaction picker is open', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing selection persistence' }, + comment: 'Selection persistence', + } ); + + const thread = page.getByRole( 'treeitem', { + name: /Note: Selection persistence/, + } ); + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await expect( + page.locator( '.editor-collab-sidebar-panel__emoji-picker' ) + ).toBeVisible(); + + // Focus has moved into the portaled popover, but the note's + // onBlur handler exempts `.components-popover` so the thread + // stays selected and the trigger stays mounted. + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + } ); } ); } ); From 85766479a3cd07796081234786a5d8b8315b5ffe Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 12:40:04 -0700 Subject: [PATCH 179/362] Notes: Wrap Frimousse popover tightly to its picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Dropdown` applies `padding: 8px` to the inner `.components-popover__content` by default. Combined with the `.components-popover__content { width: min-content }` default and a fixed `width: var(--wpds-dimension-surface-width-md)` on the inner Frimousse picker, the popover surface ended up 16px wider than the picker — visible as a band of empty surface to the right of the grid. `contentClassName` from `Dropdown` lands on the outer `.components-popover` wrapper, not on `.components-popover__content`, so the previous `padding: 0` rule didn't override Dropdown's default. Nest the override through `.components-popover__content` to bump specificity above `.components-dropdown__content .components-popover__content`. Drops the dead `.editor-collab-sidebar-panel__add-reaction-popover` rule (also targeting the wrong layer) — Dropdown's default 8px padding is what the curated picker wants anyway. Adds an E2E regression guard `Frimousse popover wraps tightly to the picker width` that compares bounding boxes; preferred over a pixel screenshot since emoji rendering differs across OSes. --- .../src/components/collab-sidebar/style.scss | 36 +++++++++++-------- .../specs/editor/various/block-notes.spec.js | 31 ++++++++++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index c90c32de372819..85611d0070283f 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -328,13 +328,9 @@ mark.wp-note { } } -// Class is applied via `Dropdown`'s `contentClassName`, which lands -// on `.components-popover__content` itself — so style the class -// directly rather than nesting through `.components-popover__content`. -.editor-collab-sidebar-panel__add-reaction-popover { - padding: $grid-unit-10; - min-width: 40px; -} +// Curated picker uses Dropdown's default `.components-popover__content` +// padding (8px) for breathing room around the listbox; no extra rule +// needed. .editor-collab-sidebar-panel__emoji-picker { display: flex; @@ -348,19 +344,31 @@ mark.wp-note { justify-content: center; } -// Frimousse popover: anchor a fixed-size picker surface with WPDS surface -// tokens so it visually matches the WPDS toolbar/menu surfaces nearby. +// Frimousse popover: width is driven by the picker itself (below) so the +// popover wrapper fits its content via Popover's default +// `width: min-content`. Setting width on the wrapper instead leaves +// horizontal slack between the picker and the popover edge. +// +// `contentClassName` from `Dropdown` lands on the outer +// `.components-popover` wrapper. The inner `.components-popover__content` +// is where the visible surface and Dropdown's default 8px padding live — +// override that here with a higher-specificity selector so the Frimousse +// picker can manage its own internal padding (search margin, viewport +// padding-inline, list padding-bottom) without an extra ring of empty +// surface around it. .editor-collab-sidebar-panel__frimousse-popover { - width: var(--wpds-dimension-surface-width-md); - padding: 0; - border-radius: var(--wpds-border-radius-md); - box-shadow: var(--wpds-elevation-md); - background-color: var(--wpds-color-bg-surface-neutral-weak); + .components-popover__content { + padding: 0; + border-radius: var(--wpds-border-radius-md); + box-shadow: var(--wpds-elevation-md); + background-color: var(--wpds-color-bg-surface-neutral-weak); + } } .editor-collab-sidebar-panel__frimousse { display: flex; flex-direction: column; + width: var(--wpds-dimension-surface-width-md); height: 320px; font-family: var(--wpds-typography-font-family-body); font-size: var(--wpds-typography-font-size-sm); diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index c3b24238a17c74..27dce41f0031cf 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1895,6 +1895,37 @@ test.describe( 'Block Notes', () => { // stays selected and the trigger stays mounted. await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); } ); + + test( 'Frimousse popover wraps tightly to the picker width', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing tight popover layout' }, + comment: 'Tight layout', + } ); + + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFrimoussePicker(); + + const popover = page.locator( + '.editor-collab-sidebar-panel__frimousse-popover' + ); + const picker = page.locator( + '.editor-collab-sidebar-panel__frimousse' + ); + + const popoverBox = await popover.boundingBox(); + const pickerBox = await picker.boundingBox(); + + // Popover wrapper must wrap tightly to the picker. If the + // popover is wider, the surface background renders as a + // visible band beside the picker. + expect( + Math.abs( popoverBox.width - pickerBox.width ) + ).toBeLessThanOrEqual( 1 ); + } ); } ); } ); From 6021c5a9a33fa6eb9264eaf36bfb2dac4bb88174 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 16:52:54 -0700 Subject: [PATCH 180/362] Notes: Size Frimousse picker to its 8-column grid, not an arbitrary surface token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker was set to var(--wpds-dimension-surface-width-md) (~400px), but the 8-column emoji grid only consumes ~252px. The visible result was emojis filling the left two-thirds of the popover with a wide empty band on the right. Width now derives from the actual grid: (8 × 32px button) + (7 × 4px gap) + (2 × 4px viewport padding) + a 17px scrollbar gutter, so the popover wraps the visible emoji area and the buttons don't shrink under flex pressure when the scrollbar is present. Two related changes: - Bumps the emoji button from 28px to 32px (better tap target, matches GitHub/Slack-style pickers). - Sets `scrollbar-gutter: stable` on the viewport so the gutter is always reserved — without it, the layout would shift when the picker scrolls. Adds an E2E regression guard `emoji grid fills the picker width` that asserts the last emoji's right edge sits within ~24px of the picker's right edge (covering only viewport padding + scrollbar gutter). The original bug had ~145px of slack; this catches anything remotely close to that. --- .../src/components/collab-sidebar/style.scss | 32 ++++++++++++---- .../specs/editor/various/block-notes.spec.js | 37 +++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 85611d0070283f..98b5b96a248e77 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -368,7 +368,14 @@ mark.wp-note { .editor-collab-sidebar-panel__frimousse { display: flex; flex-direction: column; - width: var(--wpds-dimension-surface-width-md); + // Width sized to the 8-column emoji grid plus the viewport's + // reserved scrollbar gutter, so the popover wraps the visible + // emoji area without buttons shrinking under flex pressure when + // the scrollbar is present. + // (8 buttons × 32px) + (7 gaps × 4px) + (2 viewport padding × 4px) + // + scrollbar gutter (reserved via `scrollbar-gutter: stable` on + // the viewport below; ~17px on most platforms). + width: calc(8 * 32px + 7 * 4px + 2 * 4px + 17px); height: 320px; font-family: var(--wpds-typography-font-family-body); font-size: var(--wpds-typography-font-size-sm); @@ -378,15 +385,21 @@ mark.wp-note { .editor-collab-sidebar-panel__frimousse-search { margin: var(--wpds-dimension-padding-sm); - padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); - border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-interactive-neutral); + padding: + var(--wpds-dimension-padding-sm) + var(--wpds-dimension-padding-md); + border: + var(--wpds-border-width-xs) solid + var(--wpds-color-stroke-interactive-neutral); border-radius: var(--wpds-border-radius-sm); background-color: var(--wpds-color-bg-surface-neutral-weak); color: inherit; font: inherit; &:focus { - outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline: + var(--wpds-border-width-focus) solid + var(--wpds-color-stroke-focus-brand); outline-offset: -1px; } } @@ -394,6 +407,9 @@ mark.wp-note { .editor-collab-sidebar-panel__frimousse-viewport { flex: 1; overflow: auto; + // Always reserve scrollbar space so the emoji grid doesn't shift + // or shrink when content height crosses the scroll threshold. + scrollbar-gutter: stable; position: relative; padding-inline: var(--wpds-dimension-padding-xs); } @@ -430,8 +446,8 @@ mark.wp-note { display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 32px; + height: 32px; padding: 0; border: 0; border-radius: var(--wpds-border-radius-sm); @@ -445,7 +461,9 @@ mark.wp-note { } &:focus-visible { - outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline: + var(--wpds-border-width-focus) solid + var(--wpds-color-stroke-focus-brand); outline-offset: -1px; } } diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 27dce41f0031cf..a1af62870b8b0f 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1926,6 +1926,43 @@ test.describe( 'Block Notes', () => { Math.abs( popoverBox.width - pickerBox.width ) ).toBeLessThanOrEqual( 1 ); } ); + + test( 'emoji grid fills the picker width', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing grid fills picker' }, + comment: 'Grid fill', + } ); + + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFrimoussePicker(); + + const picker = page.locator( + '.editor-collab-sidebar-panel__frimousse' + ); + const lastEmojiInFirstRow = page + .locator( '.editor-collab-sidebar-panel__frimousse-row' ) + .first() + .locator( '.editor-collab-sidebar-panel__frimousse-emoji' ) + .last(); + + const pickerBox = await picker.boundingBox(); + const emojiBox = await lastEmojiInFirstRow.boundingBox(); + const horizontalSlack = + pickerBox.x + pickerBox.width - ( emojiBox.x + emojiBox.width ); + + // Last emoji in a full row should sit close to the picker's + // right edge. Tolerance allows for viewport padding (4px) + // plus a reserved scrollbar gutter (~17px on most + // platforms). Catches regressions where the picker is sized + // wider than the emoji grid (buttons fill only the left + // fraction, with a large empty band to the right — the + // original bug had ~145px of slack). + expect( horizontalSlack ).toBeLessThanOrEqual( 24 ); + } ); } ); } ); From 459d7c62ff2bb02fcb8a27dde3e9df79ed3cc255 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 17:06:26 -0700 Subject: [PATCH 181/362] Notes: Allow re-adding a reaction after removing it on the same note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues conspired to make the same emoji reaction unre-addable on the note (or block) it had just been removed from. Server: the duplicate-reaction guard on POST /comments queried existing reactions with `status => 'any'`, which includes trashed comments. Since WP REST DELETE trashes comments by default, the just-removed reaction was still present in trash and matched the duplicate check, returning `rest_comment_duplicate_reaction`. Limit the lookup to `status => 'approve'` — only active reactions can legitimately block a re-add. Client: `reactionsMap` is derived from each note's server-computed `reaction_summary` field, cached on the note's entity record. Mutating a reaction comment doesn't invalidate that field, so a subsequent toggle on the same emoji read stale `reacted` / `my_reaction_id` values and tried to delete a now-missing record instead of routing into the add path. Invalidate the notes list resolution after each toggle so each parent's `reaction_summary` refetches. Adds `can re-add the same reaction after removing it` E2E test. Verified to fail without either fix and pass with both. --- ...-gutenberg-rest-comment-controller-7-1.php | 4 +++- .../src/components/collab-sidebar/hooks.js | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index a86b4f723b4b57..8303b32eae13b5 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -404,12 +404,14 @@ public function create_item( $request ) { } // Enforce uniqueness: prevent duplicate emoji per user per note. + // Limit to active reactions — trashed reactions are invisible and + // must not block the user from re-adding the same emoji. $existing = get_comments( array( 'parent' => $request['parent'], 'user_id' => get_current_user_id(), 'type' => 'reaction', - 'status' => 'any', + 'status' => 'approve', ) ); diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 6bbd2f654a3cc7..a1cf2b414167ec 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -289,7 +289,8 @@ function clearInlineNoteMarker( export function useNoteActions( reactionsMap = {} ) { const { createNotice } = useDispatch( noticesStore ); - const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); + const { saveEntityRecord, deleteEntityRecord, invalidateResolution } = + useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); const { getBlockAttributes, @@ -550,6 +551,26 @@ export function useNoteActions( reactionsMap = {} ) { isDismissible: true, } ); } + + // `reaction_summary` is computed server-side and cached + // on each parent note's entity record. Mutating a + // reaction comment doesn't invalidate that field, so a + // subsequent toggle for the same emoji on the same note + // reads stale `reacted` / `my_reaction_id` data — a + // removed reaction can't be re-added because the toggle + // still tries to delete a now-missing comment record. + // Refetch the notes list so each parent's + // `reaction_summary` is fresh. + invalidateResolution( 'getEntityRecords', [ + 'root', + 'comment', + { + post: getCurrentPostId(), + type: 'note', + status: 'all', + per_page: -1, + }, + ] ); } catch ( error ) { onError( error ); } @@ -560,6 +581,7 @@ export function useNoteActions( reactionsMap = {} ) { saveEntityRecord, getCurrentPostId, createNotice, + invalidateResolution, ] ); From 45be10c34af4f4171a6302ac0b5bd3a1f2ffe3f3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 7 May 2026 17:15:24 -0700 Subject: [PATCH 182/362] Notes: Drop empty wordpress-7.0/block-comments.php compat shim 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. --- lib/compat/wordpress-7.0/block-comments.php | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 lib/compat/wordpress-7.0/block-comments.php diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php deleted file mode 100644 index c00baee85a0151..00000000000000 --- a/lib/compat/wordpress-7.0/block-comments.php +++ /dev/null @@ -1,6 +0,0 @@ - Date: Thu, 7 May 2026 20:29:00 -0700 Subject: [PATCH 183/362] Notes: Force-delete reactions and refetch only the toggled note's record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/collab-sidebar/hooks.js | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index a1cf2b414167ec..b11d83cdb461b7 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -16,6 +16,7 @@ import { privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { store as noticesStore } from '@wordpress/notices'; +import apiFetch from '@wordpress/api-fetch'; import { getScrollContainer } from '@wordpress/dom'; import { decodeEntities } from '@wordpress/html-entities'; import { store as interfaceStore } from '@wordpress/interface'; @@ -289,7 +290,7 @@ function clearInlineNoteMarker( export function useNoteActions( reactionsMap = {} ) { const { createNotice } = useDispatch( noticesStore ); - const { saveEntityRecord, deleteEntityRecord, invalidateResolution } = + const { saveEntityRecord, deleteEntityRecord, receiveEntityRecords } = useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); const { @@ -518,12 +519,16 @@ export function useNoteActions( reactionsMap = {} ) { const emojiData = noteReactions[ emoji ]; if ( emojiData?.reacted && emojiData?.my_reaction_id ) { - // Remove the reaction by deleting the comment record. + // Force-delete the reaction comment rather than + // trashing it (the WP REST default). Reactions + // don't have a trash workflow, and a trashed + // reaction would otherwise linger in `wp_comments` + // indefinitely each time the user toggles it off. await deleteEntityRecord( 'root', 'comment', emojiData.my_reaction_id, - undefined, + { force: true }, { throwOnError: true } ); @@ -552,25 +557,23 @@ export function useNoteActions( reactionsMap = {} ) { } ); } - // `reaction_summary` is computed server-side and cached - // on each parent note's entity record. Mutating a - // reaction comment doesn't invalidate that field, so a - // subsequent toggle for the same emoji on the same note - // reads stale `reacted` / `my_reaction_id` data — a - // removed reaction can't be re-added because the toggle - // still tries to delete a now-missing comment record. - // Refetch the notes list so each parent's - // `reaction_summary` is fresh. - invalidateResolution( 'getEntityRecords', [ - 'root', - 'comment', - { - post: getCurrentPostId(), - type: 'note', - status: 'all', - per_page: -1, - }, - ] ); + // `reaction_summary` is computed server-side and + // cached on the parent note's entity record. Mutating + // a reaction comment doesn't invalidate that field, so + // a subsequent toggle would read stale `reacted` / + // `my_reaction_id` data and route into the wrong + // branch (deleting an already-removed comment). + // + // Refetch only the parent note (1 record) and merge it + // into the entity store. `receiveEntityRecords` with + // no `query` arg updates the per-record cache, which + // the list selector reads through by ID — so the LIST + // view picks up the fresh `reaction_summary` without + // re-fetching every other note on the post. + const refreshed = await apiFetch( { + path: `/wp/v2/comments/${ commentId }`, + } ); + receiveEntityRecords( 'root', 'comment', [ refreshed ] ); } catch ( error ) { onError( error ); } @@ -581,7 +584,7 @@ export function useNoteActions( reactionsMap = {} ) { saveEntityRecord, getCurrentPostId, createNotice, - invalidateResolution, + receiveEntityRecords, ] ); From ac175cc5b91a7a2cab547aca3718b6eb1d8367b4 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Sun, 10 May 2026 17:30:44 -0700 Subject: [PATCH 184/362] Notes: Replace Frimousse picker with native SearchControl + Composite 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. --- bin/copy-emojibase-data.mjs | 53 +- lib/compat/wordpress-7.1/emojibase-data.php | 101 +++- package-lock.json | 20 - packages/editor/package.json | 1 - .../components/collab-sidebar/emoji-picker.js | 472 ++++++++++++++++++ .../collab-sidebar/frimousse-picker.js | 87 ---- .../collab-sidebar/reaction-display.js | 21 +- .../collab-sidebar/reaction-emoji-picker.js | 2 +- .../src/components/collab-sidebar/style.scss | 53 +- .../collab-sidebar/test/emoji-picker.js | 202 ++++++++ phpunit/emoji-picker-data-test.php | 181 +++++++ .../specs/editor/various/block-notes.spec.js | 272 +++++----- 12 files changed, 1155 insertions(+), 310 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/emoji-picker.js delete mode 100644 packages/editor/src/components/collab-sidebar/frimousse-picker.js create mode 100644 packages/editor/src/components/collab-sidebar/test/emoji-picker.js create mode 100644 phpunit/emoji-picker-data-test.php diff --git a/bin/copy-emojibase-data.mjs b/bin/copy-emojibase-data.mjs index 19737b29864f96..434f62bc3115b8 100644 --- a/bin/copy-emojibase-data.mjs +++ b/bin/copy-emojibase-data.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node /** - * Copy the locale data files Frimousse expects from the bundled - * emojibase-data package into Gutenberg's `build/emojibase-data/` - * directory so the plugin can serve them via plugins_url() instead of - * inlining ~770KB of JSON into the editor JS bundle. + * Copy Emojibase locale data into Gutenberg's `build/emojibase-data/` + * directory so the editor's emoji picker can fetch translated emoji + * labels and category names same-origin without inlining the data into + * the JS bundle. Files are loaded per-locale at runtime (one locale per + * editor session), so the disk cost on the build artifact does not + * translate into a network cost for users. * * Runs as a step in `bin/build.mjs`, after wp-build has populated * `build/`. Exits 0 even when emojibase-data is missing — the editor @@ -20,14 +22,45 @@ const ROOT_DIR = path.resolve( __dirname, '..' ); const SRC_DIR = path.join( ROOT_DIR, 'node_modules', 'emojibase-data' ); const DEST_DIR = path.join( ROOT_DIR, 'build', 'emojibase-data' ); -// Frimousse only ever fetches data.json and messages.json. We ship just -// those two files per locale; the rest of the package (~50MB unpacked) -// stays out of the plugin distribution. +// We only ever fetch data.json and messages.json. Shipping just those +// two files per locale keeps the rest of the upstream package +// (~50MB unpacked) out of the plugin distribution. const FILES = [ 'data.json', 'messages.json' ]; -// Locales the plugin ships. Keep this small for now — adding a locale -// is ~85KB gzipped. English covers the strings shown to users today. -const LOCALES = [ 'en' ]; +// All locales Emojibase ships translated data for. Keep in sync with +// `EMOJIBASE_LOCALES` in `packages/editor/src/components/collab-sidebar/ +// emoji-picker.js`. Each locale adds ~85KB gzipped on disk; only the +// active locale is fetched per editor session. +const LOCALES = [ + 'bn', + 'da', + 'de', + 'en', + 'en-gb', + 'es', + 'es-mx', + 'et', + 'fi', + 'fr', + 'hi', + 'hu', + 'it', + 'ja', + 'ko', + 'lt', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'ru', + 'sv', + 'th', + 'uk', + 'vi', + 'zh', + 'zh-hant', +]; async function copyEmojibaseData() { if ( ! existsSync( SRC_DIR ) ) { diff --git a/lib/compat/wordpress-7.1/emojibase-data.php b/lib/compat/wordpress-7.1/emojibase-data.php index 312076672e81ee..6b4711727394cd 100644 --- a/lib/compat/wordpress-7.1/emojibase-data.php +++ b/lib/compat/wordpress-7.1/emojibase-data.php @@ -1,9 +1,9 @@ translated label`. + */ + $overrides = apply_filters( + 'gutenberg_emoji_picker_label_overrides', + $defaults + ); + + // Coerce defensively: a misbehaving filter callback that returns + // non-string values would otherwise crash the picker's JS-side + // label handling (`label.toLowerCase()` in searchEmojis()). + $overrides = is_array( $overrides ) ? $overrides : array(); + $overrides = array_filter( $overrides, 'is_string' ); + + wp_add_inline_script( + 'wp-editor', + 'window.gutenbergEmojiLabelOverrides = ' . wp_json_encode( (object) $overrides ) . ';', + 'before' + ); +} +add_action( 'init', 'gutenberg_emoji_picker_label_overrides_register_inline_script' ); diff --git a/package-lock.json b/package-lock.json index 03b4387c2cb93d..4073e74a014a16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24811,25 +24811,6 @@ "node": ">= 0.6" } }, - "node_modules/frimousse": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.3.0.tgz", - "integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==", - "license": "MIT", - "workspaces": [ - ".", - "site" - ], - "peerDependencies": { - "react": "^18 || ^19", - "typescript": ">=5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -46999,7 +46980,6 @@ "date-fns": "^4.1.0", "diff": "^8.0.3", "fast-deep-equal": "^3.1.3", - "frimousse": "^0.3.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "redux": "^5.0.1", diff --git a/packages/editor/package.json b/packages/editor/package.json index e731a00eeb300d..81e63bd9774646 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -111,7 +111,6 @@ "date-fns": "^4.1.0", "diff": "^8.0.3", "fast-deep-equal": "^3.1.3", - "frimousse": "^0.3.0", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", "redux": "^5.0.1", diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.js b/packages/editor/src/components/collab-sidebar/emoji-picker.js new file mode 100644 index 00000000000000..3cb2e6196997e9 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.js @@ -0,0 +1,472 @@ +/** + * WordPress dependencies + */ +import { __, _x } from '@wordpress/i18n'; +import { Composite, SearchControl } from '@wordpress/components'; +import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; + +/** + * Locales for which Emojibase ships translated labels and tags. Built + * from the directory listing of the `emojibase-data` npm package, kept + * in sync with the `LOCALES` set copied by `bin/copy-emojibase-data.mjs`. + */ +export const EMOJIBASE_LOCALES = new Set( [ + 'bn', + 'da', + 'de', + 'en', + 'en-gb', + 'es', + 'es-mx', + 'et', + 'fi', + 'fr', + 'hi', + 'hu', + 'it', + 'ja', + 'ko', + 'lt', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'ru', + 'sv', + 'th', + 'uk', + 'vi', + 'zh', + 'zh-hant', +] ); + +/** + * Map a BCP-47 / WordPress locale string to the closest Emojibase locale, + * falling back to English. Tries the full tag (e.g. `pt-br`) first, then + * the language portion (`pt`), then `en`. + * + * @param {string} raw The user-facing locale, e.g. `pt-BR` or `fr_FR`. + * @return {string} An Emojibase locale key. + */ +export function resolveEmojibaseLocale( raw ) { + if ( typeof raw !== 'string' || ! raw ) { + return 'en'; + } + const normalized = raw.toLowerCase().replace( '_', '-' ); + if ( EMOJIBASE_LOCALES.has( normalized ) ) { + return normalized; + } + // Special-case Traditional Chinese variants. Must run before the + // language-portion fallback, since `zh` is itself a supported locale + // and would otherwise swallow `zh-tw`/`zh-hk`/`zh-mo`. + if ( [ 'zh-tw', 'zh-hk', 'zh-mo' ].includes( normalized ) ) { + return 'zh-hant'; + } + const lang = normalized.split( '-' )[ 0 ]; + if ( EMOJIBASE_LOCALES.has( lang ) ) { + return lang; + } + return 'en'; +} + +/** + * Detect the active locale for the picker. Prefers `` (set by + * WordPress to the site language) and falls back to `navigator.language`. + * + * @return {string} A locale key Emojibase ships data for. + */ +function detectLocale() { + if ( typeof document !== 'undefined' && document.documentElement?.lang ) { + return resolveEmojibaseLocale( document.documentElement.lang ); + } + if ( typeof navigator !== 'undefined' && navigator.language ) { + return resolveEmojibaseLocale( navigator.language ); + } + return 'en'; +} + +// Module-level cache so opening the picker twice doesn't refetch. +const dataCache = new Map(); +const inflight = new Map(); + +/** + * Fetch and cache Emojibase `data.json` + `messages.json` for a given + * locale. Resolves with `{ data, messages }` or rejects on a network + * error so callers can render an error/empty state. + * + * @param {string} baseUrl Same-origin base URL for the emojibase-data + * directory (e.g. plugin's + * `build/emojibase-data`). + * @param {string} locale Emojibase locale key. + * @return {Promise<{data: Array, messages: Object}>} Loaded dataset. + */ +function loadEmojibaseData( baseUrl, locale ) { + const cacheKey = `${ baseUrl }|${ locale }`; + if ( dataCache.has( cacheKey ) ) { + return Promise.resolve( dataCache.get( cacheKey ) ); + } + if ( inflight.has( cacheKey ) ) { + return inflight.get( cacheKey ); + } + const promise = Promise.all( [ + fetch( `${ baseUrl }/${ locale }/data.json` ).then( ( r ) => { + if ( ! r.ok ) { + throw new Error( `Failed to load ${ locale }/data.json` ); + } + return r.json(); + } ), + fetch( `${ baseUrl }/${ locale }/messages.json` ).then( ( r ) => { + if ( ! r.ok ) { + throw new Error( `Failed to load ${ locale }/messages.json` ); + } + return r.json(); + } ), + ] ) + .then( ( [ data, messages ] ) => { + const value = { data, messages }; + dataCache.set( cacheKey, value ); + inflight.delete( cacheKey ); + return value; + } ) + .catch( ( error ) => { + inflight.delete( cacheKey ); + throw error; + } ); + inflight.set( cacheKey, promise ); + return promise; +} + +/** + * React hook that fetches Emojibase data for a given base URL and locale. + * + * @param {string} baseUrl Same-origin URL pointing at the emojibase data dir. + * @param {string} locale Emojibase locale key. + * @return {{ data: Array|null, messages: Object|null, isLoading: boolean, error: Error|null }} + * Loading state for the dataset. + */ +function useEmojibaseData( baseUrl, locale ) { + const [ state, setState ] = useState( () => { + const cached = dataCache.get( `${ baseUrl }|${ locale }` ); + return { + data: cached?.data || null, + messages: cached?.messages || null, + isLoading: ! cached, + error: null, + }; + } ); + + useEffect( () => { + let cancelled = false; + const cached = dataCache.get( `${ baseUrl }|${ locale }` ); + if ( cached ) { + setState( { + data: cached.data, + messages: cached.messages, + isLoading: false, + error: null, + } ); + return; + } + setState( ( prev ) => ( { ...prev, isLoading: true, error: null } ) ); + loadEmojibaseData( baseUrl, locale ) + .then( ( { data, messages } ) => { + if ( cancelled ) { + return; + } + setState( { + data, + messages, + isLoading: false, + error: null, + } ); + } ) + .catch( ( error ) => { + if ( cancelled ) { + return; + } + setState( { + data: null, + messages: null, + isLoading: false, + error, + } ); + } ); + return () => { + cancelled = true; + }; + }, [ baseUrl, locale ] ); + + return state; +} + +const COLUMNS = 8; + +/** + * Group emoji records by their Emojibase `group` key, preserving + * Emojibase's natural ordering (which follows Unicode CLDR). Entries + * with no `group` (e.g. component code points) are skipped. + * + * @param {Array} data Emoji records from `data.json`. + * @return {Array<{ key: number, emojis: Array }>} Ordered category buckets. + */ +export function groupEmojis( data ) { + const buckets = new Map(); + for ( const entry of data ) { + if ( typeof entry.group !== 'number' ) { + continue; + } + if ( ! buckets.has( entry.group ) ) { + buckets.set( entry.group, [] ); + } + buckets.get( entry.group ).push( entry ); + } + return Array.from( buckets.entries() ) + .sort( ( a, b ) => a[ 0 ] - b[ 0 ] ) + .map( ( [ key, emojis ] ) => ( { key, emojis } ) ); +} + +/** + * Slice a flat list of emoji into rows of `COLUMNS` items so the grid + * keeps a stable column count even as the visible list shrinks during + * search. + * + * @param {Array} emojis Emoji records. + * @return {Array} Rows of up to `COLUMNS` emoji each. + */ +export function chunkRows( emojis ) { + const rows = []; + for ( let i = 0; i < emojis.length; i += COLUMNS ) { + rows.push( emojis.slice( i, i + COLUMNS ) ); + } + return rows; +} + +/** + * Run a case-insensitive search over the emoji label and Emojibase + * tags, applying any per-emoji label override before matching so users + * searching for an overridden label still get the expected hit. + * Returns the unfiltered list when the query is empty. + * + * @param {Array} emojis Emoji records. + * @param {string} query Search query. + * @param {Object|null} overrides Map of `hexcode => translated label`. + * @return {Array} Matching emoji records. + */ +export function searchEmojis( emojis, query, overrides ) { + const trimmed = query.trim().toLowerCase(); + if ( ! trimmed ) { + return emojis; + } + return emojis.filter( ( entry ) => { + // Match against both the overridden label (if any) and the + // original Emojibase label so a user searching in either name + // still finds the emoji — useful when an English-language user + // types "red heart" against a "Heart" override, or a translator + // types the local-language label against the English fallback. + const override = overrides?.[ entry.hexcode ]; + if ( override && override.toLowerCase().includes( trimmed ) ) { + return true; + } + if ( entry.label && entry.label.toLowerCase().includes( trimmed ) ) { + return true; + } + if ( Array.isArray( entry.tags ) ) { + for ( const tag of entry.tags ) { + if ( tag.toLowerCase().includes( trimmed ) ) { + return true; + } + } + } + return false; + } ); +} + +/** + * Full searchable emoji picker built from WPDS components + * (`SearchControl`, `Composite`) and styled with WPDS tokens. Emoji + * data and labels come from Emojibase per-locale files served + * same-origin from `window.gutenbergEmojibaseUrl`; UI chrome strings + * go through `@wordpress/i18n` so GlotPress can translate them. + * + * @param {Object} props Component props. + * @param {Function} props.onSelect Called with the selected emoji character. + */ +export default function EmojiPicker( { onSelect } ) { + const baseUrl = + typeof window !== 'undefined' ? window.gutenbergEmojibaseUrl : null; + const labelOverrides = + typeof window !== 'undefined' && window.gutenbergEmojiLabelOverrides + ? window.gutenbergEmojiLabelOverrides + : null; + const [ locale ] = useState( detectLocale ); + const { data, messages, isLoading, error } = useEmojibaseData( + baseUrl, + locale + ); + const [ query, setQuery ] = useState( '' ); + const viewportRef = useRef( null ); + + /** + * Resolve the user-facing label for an emoji record. Prefers the + * server-supplied override (typically a `__()`-translated string for + * locales Emojibase doesn't cover) over the Emojibase data label. + * + * @param {Object} entry Emojibase emoji record. + * @return {string} The label to render and use as the accessible name. + */ + const labelFor = ( entry ) => + labelOverrides?.[ entry.hexcode ] || entry.label || ''; + + const groups = useMemo( + () => ( data ? groupEmojis( data ) : [] ), + [ data ] + ); + + const groupLabelByKey = useMemo( () => { + const map = new Map(); + if ( messages?.groups ) { + for ( const g of messages.groups ) { + map.set( g.order, g.message ); + } + } + return map; + }, [ messages ] ); + + const visibleGroups = useMemo( () => { + if ( ! groups.length ) { + return []; + } + if ( ! query.trim() ) { + return groups.map( ( g ) => ( { + ...g, + rows: chunkRows( g.emojis ), + } ) ); + } + return groups + .map( ( g ) => { + const filtered = searchEmojis( + g.emojis, + query, + labelOverrides + ); + return { + ...g, + emojis: filtered, + rows: chunkRows( filtered ), + }; + } ) + .filter( ( g ) => g.emojis.length > 0 ); + }, [ groups, query, labelOverrides ] ); + + const matchCount = useMemo( + () => visibleGroups.reduce( ( n, g ) => n + g.emojis.length, 0 ), + [ visibleGroups ] + ); + + // Announce result counts during search so screen readers stay in sync + // with the visible grid as the user types. + useEffect( () => { + if ( ! query.trim() || isLoading ) { + return; + } + if ( matchCount === 0 ) { + speak( __( 'No emoji found.' ) ); + } + }, [ query, matchCount, isLoading ] ); + + // Reset scroll position when the search query changes so the user + // always sees the top match instead of a stale scroll offset. + useEffect( () => { + if ( viewportRef.current ) { + viewportRef.current.scrollTop = 0; + } + }, [ query ] ); + + if ( ! baseUrl ) { + return null; + } + + return ( +
+
+ +
+
+ { isLoading && ( +
+ { __( 'Loading…' ) } +
+ ) } + { error && ! isLoading && ( +
+ { __( 'Couldn’t load emojis.' ) } +
+ ) } + { ! isLoading && ! error && matchCount === 0 && ( +
+ { __( 'No emoji found.' ) } +
+ ) } + { ! isLoading && ! error && matchCount > 0 && ( + + { visibleGroups.map( ( group ) => ( +
+
+ { groupLabelByKey.get( group.key ) || '' } +
+ { group.rows.map( ( row, rowIndex ) => ( + + { row.map( ( emoji ) => ( + + onSelect( emoji.emoji ) + } + > + { emoji.emoji } + + ) ) } + + ) ) } +
+ ) ) } +
+ ) } +
+
+ ); +} diff --git a/packages/editor/src/components/collab-sidebar/frimousse-picker.js b/packages/editor/src/components/collab-sidebar/frimousse-picker.js deleted file mode 100644 index 33c87356a7640c..00000000000000 --- a/packages/editor/src/components/collab-sidebar/frimousse-picker.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * External dependencies - */ -import { EmojiPicker } from 'frimousse'; - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Frimousse-based full emoji picker, styled with WPDS tokens. - * - * Frimousse fetches its dataset (~770KB raw, ~85KB gzipped) from - * `${emojibaseUrl}/${locale}/{data,messages}.json`. We do not want a - * runtime CDN dependency, so the Gutenberg plugin ships those files in - * `build/emojibase-data/` and exposes the directory URL via PHP as - * `window.gutenbergEmojibaseUrl`. When this picker is consumed outside - * the plugin (e.g. an npm consumer of @wordpress/editor), the consumer - * can set the same global to a URL of their choice — the bundle does - * not embed the data, so a URL must be provided one way or another. - * - * The picker is loaded lazily by reaction-display.js, so the Frimousse - * code itself only ships in the editor bundle when this file is - * imported. - * - * @param {Object} props Component props. - * @param {Function} props.onSelect Called with the selected emoji character. - */ -export default function FrimoussePicker( { onSelect } ) { - const emojibaseUrl = - typeof window !== 'undefined' && window.gutenbergEmojibaseUrl - ? window.gutenbergEmojibaseUrl - : undefined; - - return ( - onSelect( emoji ) } - emojibaseUrl={ emojibaseUrl } - columns={ 8 } - > - - - - { __( 'Loading…' ) } - - - { __( 'No emoji found.' ) } - - ( -
- { category.label } -
- ), - Row: ( { children, ...rest } ) => ( -
- { children } -
- ), - Emoji: ( { emoji, ...rest } ) => ( - - ), - } } - /> -
-
- ); -} diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 94022677b8a8cc..07364c96b2f27c 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -20,11 +20,11 @@ import ReactionEmojiPicker, { } from './reaction-emoji-picker'; /** - * Lazy-load the Frimousse-based full picker. Its bundle (Frimousse + - * the bundled Emojibase JSON) is only fetched when a user opens the - * "More emojis" popover for the first time in a session. + * Lazy-load the full emoji picker. Its bundle is only fetched when a + * user opens the "More emojis" popover for the first time in a session; + * the Emojibase JSON dataset is fetched separately on first open. */ -const FrimoussePicker = lazy( () => import( './frimousse-picker' ) ); +const FullEmojiPicker = lazy( () => import( './emoji-picker' ) ); // `Dropdown`'s popover is rendered in a portal anchored to , // so it escapes the `overflow: hidden` chain on the collab sidebar @@ -311,15 +311,14 @@ export function AddReactionButton( { disabled = false, onToggleReaction } ) { } /** - * Standalone "+" button that opens a full Frimousse-based emoji picker. - * Sibling of AddReactionButton; not nested inside it so the curated and - * full picker popovers never conflict. + * Standalone "+" button that opens the full emoji picker. Sibling of + * AddReactionButton; not nested inside it so the curated and full picker + * popovers never conflict. * * Renders nothing when `window.gutenbergEmojibaseUrl` is unset — the * Gutenberg plugin sets it via PHP, but npm consumers of @wordpress/editor * must opt in by providing a URL pointing at a self-hosted emojibase - * dataset. We hide the trigger rather than fall through to Frimousse's - * default jsdelivr CDN. + * dataset. * * @param {Object} props Component props. * @param {Function} props.onToggleReaction Callback to toggle a reaction. @@ -332,7 +331,7 @@ export function MoreEmojiButton( { onToggleReaction } ) { return ( ( ); @@ -307,7 +310,6 @@ export function AddReactionButton( { size="small" className="editor-collab-sidebar-panel__add-reaction-button" icon={ smileyIcon } - iconSize={ 20 } label={ __( 'Add reaction' ) } aria-expanded={ isOpen } disabled={ disabled } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index c4cd4f41e42a35..aae150089f37d9 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -1,7 +1,6 @@ @use "@wordpress/base-styles/colors" as *; @use "@wordpress/base-styles/mixins" as *; @use "@wordpress/base-styles/variables" as *; -@use "sass:math"; .interface-interface-skeleton__sidebar:has(.editor-collab-sidebar) { box-shadow: none; @@ -232,51 +231,20 @@ mark.wp-note { } // Emoji Reactions +// +// The add-reaction trigger is a standard small icon Button and the +// reaction pills are standard small secondary Buttons, so sizing, +// hover, pressed, and focus styles all come from the component. +// Only emoji-specific adjustments live here. .editor-collab-sidebar-panel__reaction-button { gap: $grid-unit-05; - border: $border-width solid $gray-300; - background-color: $gray-100; - font-weight: $font-weight-medium; - color: $gray-700; - border-radius: math.div($button-size-compact, 2); - font-size: $font-size-small; - - &.editor-collab-sidebar-panel__reaction-button[aria-pressed="true"] { - background-color: $gray-100; - border-color: var(--wp-admin-theme-color); - color: inherit; - - &:hover { - color: var(--wp-admin-theme-color); - background-color: $gray-100; - } - } - - &:hover { - color: var(--wp-admin-theme-color); - background-color: $gray-100; - } } -.editor-collab-sidebar-panel__add-reaction-button { - border-radius: $radius-round; - border: $border-width solid $gray-300; - cursor: var(--wpds-cursor-control); - - &:hover { - background-color: $gray-100; - } - - &:focus { - outline: - var(--wp-admin-border-width-focus) solid - var(--wp-admin-theme-color); - outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); - } - - svg { - color: $gray-700; - } +.editor-collab-sidebar-panel__reaction-button-emoji { + // Render the emoji glyph larger than the button's text size so it + // visually balances with the 24px reaction icon next to it. + font-size: 16px; + line-height: 1; } // Curated picker uses Dropdown's default `.components-popover__content` From 8cf346a79dadcbbefb82024799e374e21da4be0a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 8 Jul 2026 12:45:32 -0700 Subject: [PATCH 212/362] Notes full picker: address design review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make the '+' More emojis trigger a standard small icon Button like the smiley trigger, dropping the custom 28px circle and thick focus outline so both triggers match other editor icon buttons. - Resolve emoji names for full-picker (hex-key) reactions from the Emojibase dataset so pill tooltips and accessible names read 'thumbs up' instead of echoing the emoji character, matching the curated reactions. - Prefetch the lazy picker chunk and the Emojibase dataset on hover/focus of the trigger, and show a sized 'Loading…' state while the popover populates, so opening the picker no longer feels slow. - Extract Emojibase data loading into emojibase-data.js, shared by the lazy picker and the (eager) reaction pills without pulling the picker UI into the main bundle. --- .../components/collab-sidebar/emoji-picker.js | 195 +---------- .../collab-sidebar/emojibase-data.js | 328 ++++++++++++++++++ .../collab-sidebar/reaction-display.js | 65 +++- .../src/components/collab-sidebar/style.scss | 37 -- .../collab-sidebar/test/emoji-picker.js | 9 +- .../collab-sidebar/test/reaction-display.js | 51 +++ tools/build-scripts/copy-emojibase-data.mjs | 2 +- 7 files changed, 436 insertions(+), 251 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/emojibase-data.js diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.js b/packages/editor/src/components/collab-sidebar/emoji-picker.js index 58a64cb0ef8c85..2368c6ffcffba0 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.js @@ -7,200 +7,9 @@ import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { speak } from '@wordpress/a11y'; /** - * Locales for which Emojibase ships translated labels and tags. Built - * from the directory listing of the `emojibase-data` npm package, kept - * in sync with the `LOCALES` set copied by - * `tools/build-scripts/copy-emojibase-data.mjs`. + * Internal dependencies */ -export const EMOJIBASE_LOCALES = new Set( [ - 'bn', - 'da', - 'de', - 'en', - 'en-gb', - 'es', - 'es-mx', - 'et', - 'fi', - 'fr', - 'hi', - 'hu', - 'it', - 'ja', - 'ko', - 'lt', - 'ms', - 'nb', - 'nl', - 'pl', - 'pt', - 'ru', - 'sv', - 'th', - 'uk', - 'vi', - 'zh', - 'zh-hant', -] ); - -/** - * Map a BCP-47 / WordPress locale string to the closest Emojibase locale, - * falling back to English. Tries the full tag (e.g. `pt-br`) first, then - * the language portion (`pt`), then `en`. - * - * @param {string} raw The user-facing locale, e.g. `pt-BR` or `fr_FR`. - * @return {string} An Emojibase locale key. - */ -export function resolveEmojibaseLocale( raw ) { - if ( typeof raw !== 'string' || ! raw ) { - return 'en'; - } - const normalized = raw.toLowerCase().replace( '_', '-' ); - if ( EMOJIBASE_LOCALES.has( normalized ) ) { - return normalized; - } - // Special-case Traditional Chinese variants. Must run before the - // language-portion fallback, since `zh` is itself a supported locale - // and would otherwise swallow `zh-tw`/`zh-hk`/`zh-mo`. - if ( [ 'zh-tw', 'zh-hk', 'zh-mo' ].includes( normalized ) ) { - return 'zh-hant'; - } - const lang = normalized.split( '-' )[ 0 ]; - if ( EMOJIBASE_LOCALES.has( lang ) ) { - return lang; - } - return 'en'; -} - -/** - * Detect the active locale for the picker. Prefers `` (set by - * WordPress to the site language) and falls back to `navigator.language`. - * - * @return {string} A locale key Emojibase ships data for. - */ -function detectLocale() { - if ( typeof document !== 'undefined' && document.documentElement?.lang ) { - return resolveEmojibaseLocale( document.documentElement.lang ); - } - if ( typeof navigator !== 'undefined' && navigator.language ) { - return resolveEmojibaseLocale( navigator.language ); - } - return 'en'; -} - -// Module-level cache so opening the picker twice doesn't refetch. -const dataCache = new Map(); -const inflight = new Map(); - -/** - * Fetch and cache Emojibase `data.json` + `messages.json` for a given - * locale. Resolves with `{ data, messages }` or rejects on a network - * error so callers can render an error/empty state. - * - * @param {string} baseUrl Same-origin base URL for the emojibase-data - * directory (e.g. plugin's - * `build/emojibase-data`). - * @param {string} locale Emojibase locale key. - * @return {Promise<{data: Array, messages: Object}>} Loaded dataset. - */ -function loadEmojibaseData( baseUrl, locale ) { - const cacheKey = `${ baseUrl }|${ locale }`; - if ( dataCache.has( cacheKey ) ) { - return Promise.resolve( dataCache.get( cacheKey ) ); - } - if ( inflight.has( cacheKey ) ) { - return inflight.get( cacheKey ); - } - const promise = Promise.all( [ - fetch( `${ baseUrl }/${ locale }/data.json` ).then( ( r ) => { - if ( ! r.ok ) { - throw new Error( `Failed to load ${ locale }/data.json` ); - } - return r.json(); - } ), - fetch( `${ baseUrl }/${ locale }/messages.json` ).then( ( r ) => { - if ( ! r.ok ) { - throw new Error( `Failed to load ${ locale }/messages.json` ); - } - return r.json(); - } ), - ] ) - .then( ( [ data, messages ] ) => { - const value = { data, messages }; - dataCache.set( cacheKey, value ); - inflight.delete( cacheKey ); - return value; - } ) - .catch( ( error ) => { - inflight.delete( cacheKey ); - throw error; - } ); - inflight.set( cacheKey, promise ); - return promise; -} - -/** - * React hook that fetches Emojibase data for a given base URL and locale. - * - * @param {string} baseUrl Same-origin URL pointing at the emojibase data dir. - * @param {string} locale Emojibase locale key. - * @return {{ data: Array|null, messages: Object|null, isLoading: boolean, error: Error|null }} - * Loading state for the dataset. - */ -function useEmojibaseData( baseUrl, locale ) { - const [ state, setState ] = useState( () => { - const cached = dataCache.get( `${ baseUrl }|${ locale }` ); - return { - data: cached?.data || null, - messages: cached?.messages || null, - isLoading: ! cached, - error: null, - }; - } ); - - useEffect( () => { - let cancelled = false; - const cached = dataCache.get( `${ baseUrl }|${ locale }` ); - if ( cached ) { - setState( { - data: cached.data, - messages: cached.messages, - isLoading: false, - error: null, - } ); - return; - } - setState( ( prev ) => ( { ...prev, isLoading: true, error: null } ) ); - loadEmojibaseData( baseUrl, locale ) - .then( ( { data, messages } ) => { - if ( cancelled ) { - return; - } - setState( { - data, - messages, - isLoading: false, - error: null, - } ); - } ) - .catch( ( error ) => { - if ( cancelled ) { - return; - } - setState( { - data: null, - messages: null, - isLoading: false, - error, - } ); - } ); - return () => { - cancelled = true; - }; - }, [ baseUrl, locale ] ); - - return state; -} +import { detectLocale, useEmojibaseData } from './emojibase-data'; const COLUMNS = 8; diff --git a/packages/editor/src/components/collab-sidebar/emojibase-data.js b/packages/editor/src/components/collab-sidebar/emojibase-data.js new file mode 100644 index 00000000000000..7bdbf7890dfc84 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/emojibase-data.js @@ -0,0 +1,328 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; + +/** + * Emojibase data loading shared between the full emoji picker (lazy + * loaded) and the reaction pills (eager, label lookups only). This + * module stays dependency-light so importing it eagerly doesn't pull + * the picker UI into the main bundle; the heavy part — the per-locale + * JSON dataset — is only ever fetched on demand. + */ + +/** + * Locales for which Emojibase ships translated labels and tags. Built + * from the directory listing of the `emojibase-data` npm package, kept + * in sync with the `LOCALES` set copied by + * `tools/build-scripts/copy-emojibase-data.mjs`. + */ +export const EMOJIBASE_LOCALES = new Set( [ + 'bn', + 'da', + 'de', + 'en', + 'en-gb', + 'es', + 'es-mx', + 'et', + 'fi', + 'fr', + 'hi', + 'hu', + 'it', + 'ja', + 'ko', + 'lt', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'ru', + 'sv', + 'th', + 'uk', + 'vi', + 'zh', + 'zh-hant', +] ); + +/** + * Map a BCP-47 / WordPress locale string to the closest Emojibase locale, + * falling back to English. Tries the full tag (e.g. `pt-br`) first, then + * the language portion (`pt`), then `en`. + * + * @param {string} raw The user-facing locale, e.g. `pt-BR` or `fr_FR`. + * @return {string} An Emojibase locale key. + */ +export function resolveEmojibaseLocale( raw ) { + if ( typeof raw !== 'string' || ! raw ) { + return 'en'; + } + const normalized = raw.toLowerCase().replace( '_', '-' ); + if ( EMOJIBASE_LOCALES.has( normalized ) ) { + return normalized; + } + // Special-case Traditional Chinese variants. Must run before the + // language-portion fallback, since `zh` is itself a supported locale + // and would otherwise swallow `zh-tw`/`zh-hk`/`zh-mo`. + if ( [ 'zh-tw', 'zh-hk', 'zh-mo' ].includes( normalized ) ) { + return 'zh-hant'; + } + const lang = normalized.split( '-' )[ 0 ]; + if ( EMOJIBASE_LOCALES.has( lang ) ) { + return lang; + } + return 'en'; +} + +/** + * Detect the active locale for the picker. Prefers `` (set by + * WordPress to the site language) and falls back to `navigator.language`. + * + * @return {string} A locale key Emojibase ships data for. + */ +export function detectLocale() { + if ( typeof document !== 'undefined' && document.documentElement?.lang ) { + return resolveEmojibaseLocale( document.documentElement.lang ); + } + if ( typeof navigator !== 'undefined' && navigator.language ) { + return resolveEmojibaseLocale( navigator.language ); + } + return 'en'; +} + +// Module-level cache so opening the picker twice doesn't refetch. +const dataCache = new Map(); +const inflight = new Map(); + +/** + * Fetch and cache Emojibase `data.json` + `messages.json` for a given + * locale. Resolves with `{ data, messages }` or rejects on a network + * error so callers can render an error/empty state. + * + * @param {string} baseUrl Same-origin base URL for the emojibase-data + * directory (e.g. plugin's + * `build/emojibase-data`). + * @param {string} locale Emojibase locale key. + * @return {Promise<{data: Array, messages: Object}>} Loaded dataset. + */ +export function loadEmojibaseData( baseUrl, locale ) { + const cacheKey = `${ baseUrl }|${ locale }`; + if ( dataCache.has( cacheKey ) ) { + return Promise.resolve( dataCache.get( cacheKey ) ); + } + if ( inflight.has( cacheKey ) ) { + return inflight.get( cacheKey ); + } + const promise = Promise.all( [ + fetch( `${ baseUrl }/${ locale }/data.json` ).then( ( r ) => { + if ( ! r.ok ) { + throw new Error( `Failed to load ${ locale }/data.json` ); + } + return r.json(); + } ), + fetch( `${ baseUrl }/${ locale }/messages.json` ).then( ( r ) => { + if ( ! r.ok ) { + throw new Error( `Failed to load ${ locale }/messages.json` ); + } + return r.json(); + } ), + ] ) + .then( ( [ data, messages ] ) => { + const value = { data, messages }; + dataCache.set( cacheKey, value ); + inflight.delete( cacheKey ); + return value; + } ) + .catch( ( error ) => { + inflight.delete( cacheKey ); + throw error; + } ); + inflight.set( cacheKey, promise ); + return promise; +} + +/** + * React hook that fetches Emojibase data for a given base URL and locale. + * + * @param {string} baseUrl Same-origin URL pointing at the emojibase data dir. + * @param {string} locale Emojibase locale key. + * @return {{ data: Array|null, messages: Object|null, isLoading: boolean, error: Error|null }} + * Loading state for the dataset. + */ +export function useEmojibaseData( baseUrl, locale ) { + const [ state, setState ] = useState( () => { + const cached = dataCache.get( `${ baseUrl }|${ locale }` ); + return { + data: cached?.data || null, + messages: cached?.messages || null, + isLoading: ! cached, + error: null, + }; + } ); + + useEffect( () => { + let cancelled = false; + const cached = dataCache.get( `${ baseUrl }|${ locale }` ); + if ( cached ) { + setState( { + data: cached.data, + messages: cached.messages, + isLoading: false, + error: null, + } ); + return; + } + setState( ( prev ) => ( { ...prev, isLoading: true, error: null } ) ); + loadEmojibaseData( baseUrl, locale ) + .then( ( { data, messages } ) => { + if ( cancelled ) { + return; + } + setState( { + data, + messages, + isLoading: false, + error: null, + } ); + } ) + .catch( ( error ) => { + if ( cancelled ) { + return; + } + setState( { + data: null, + messages: null, + isLoading: false, + error, + } ); + } ); + return () => { + cancelled = true; + }; + }, [ baseUrl, locale ] ); + + return state; +} + +/** + * Normalize an Emojibase hexcode (`2764-FE0F`) to the reaction storage + * key form: lowercase with variation selector U+FE0F segments removed + * (`2764`), matching `emojiToHexKey()` in `reaction-emoji-picker.js`. + * + * @param {string} hexcode Emojibase hexcode. + * @return {string} Normalized hex key. + */ +function normalizeHexcode( hexcode ) { + return hexcode + .split( '-' ) + .filter( ( part ) => part.toLowerCase() !== 'fe0f' ) + .join( '-' ) + .toLowerCase(); +} + +// One label map per `baseUrl|locale`, built lazily from the dataset. +const labelMapCache = new Map(); + +/** + * Build (and cache) a Map from normalized hex key to user-facing emoji + * label for a loaded dataset, applying any per-site label overrides + * from `window.gutenbergEmojiLabelOverrides`. + * + * @param {string} cacheKey `baseUrl|locale` cache key. + * @param {Array} data Emojibase emoji records. + * @return {Map} Map from hex key to label. + */ +function buildLabelMap( cacheKey, data ) { + if ( labelMapCache.has( cacheKey ) ) { + return labelMapCache.get( cacheKey ); + } + const overrides = + typeof window !== 'undefined' && window.gutenbergEmojiLabelOverrides + ? window.gutenbergEmojiLabelOverrides + : null; + const map = new Map(); + for ( const entry of data ) { + if ( ! entry.hexcode || ! entry.label ) { + continue; + } + map.set( + normalizeHexcode( entry.hexcode ), + overrides?.[ entry.hexcode ] || entry.label + ); + } + labelMapCache.set( cacheKey, map ); + return map; +} + +/** + * Look up the label for a stored hex-key reaction from the already + * loaded dataset, without triggering a fetch. + * + * @param {string} hexKey Normalized reaction hex key, e.g. `1f44d`. + * @return {string|null} The label, or null when unknown/not loaded. + */ +export function getCachedEmojiLabel( hexKey ) { + if ( typeof window === 'undefined' || ! window.gutenbergEmojibaseUrl ) { + return null; + } + const cacheKey = `${ window.gutenbergEmojibaseUrl }|${ detectLocale() }`; + const cached = dataCache.get( cacheKey ); + if ( ! cached ) { + return null; + } + return buildLabelMap( cacheKey, cached.data ).get( hexKey ) || null; +} + +/** + * React hook resolving the user-facing label for a hex-key reaction + * (a pick from the full emoji picker). Loads the Emojibase dataset on + * demand — the fetch is shared with the full picker via the module + * cache — so reaction pill tooltips can show the emoji name just like + * curated reactions do. + * + * @param {string} hexKey Normalized reaction hex key, e.g. `1f44d`. + * @param {boolean} enabled Whether resolution should run (false for + * curated slugs, which have their own labels). + * @return {string|null} The resolved label, or null while unresolved. + */ +export function useEmojiLabel( hexKey, enabled ) { + const [ label, setLabel ] = useState( () => + enabled ? getCachedEmojiLabel( hexKey ) : null + ); + + useEffect( () => { + if ( ! enabled || label ) { + return; + } + if ( typeof window === 'undefined' || ! window.gutenbergEmojibaseUrl ) { + return; + } + const baseUrl = window.gutenbergEmojibaseUrl; + const locale = detectLocale(); + let cancelled = false; + loadEmojibaseData( baseUrl, locale ) + .then( ( { data } ) => { + if ( cancelled ) { + return; + } + const resolved = buildLabelMap( + `${ baseUrl }|${ locale }`, + data + ).get( hexKey ); + if ( resolved ) { + setLabel( resolved ); + } + } ) + .catch( () => { + // Leave the emoji character as the fallback label. + } ); + return () => { + cancelled = true; + }; + }, [ hexKey, enabled, label ] ); + + return label; +} diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 93838c0261fbf7..4ce49d00f1fac2 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -35,6 +35,11 @@ import ReactionEmojiPicker, { hexKeyToEmoji, buildEmojiBySlugMap, } from './reaction-emoji-picker'; +import { + detectLocale, + loadEmojibaseData, + useEmojiLabel, +} from './emojibase-data'; /** * Lazy-load the full emoji picker. Its bundle is only fetched when a @@ -43,6 +48,21 @@ import ReactionEmojiPicker, { */ const FullEmojiPicker = lazy( () => import( './emoji-picker' ) ); +/** + * Warm the full picker before it opens: start fetching the lazy picker + * chunk and the Emojibase dataset for the active locale. Both loaders + * cache, so calling this repeatedly (every hover) is free after the + * first invocation. + */ +function prefetchFullPicker() { + import( './emoji-picker' ).catch( () => {} ); + if ( typeof window !== 'undefined' && window.gutenbergEmojibaseUrl ) { + loadEmojibaseData( window.gutenbergEmojibaseUrl, detectLocale() ).catch( + () => {} + ); + } +} + // The curated emoji set is static, so index it once at module load. const emojiBySlug = buildEmojiBySlugMap(); @@ -143,7 +163,8 @@ const reactionNamesCache = {}; * @param {number} props.count The reaction count. * @param {boolean} props.isActive Whether the current user reacted. * @param {string} props.emoji The emoji character. - * @param {string} props.emojiLabel The emoji label. + * @param {string} props.emojiLabel The emoji label, if known + * (curated reactions only). * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ function ReactionButton( { @@ -157,15 +178,18 @@ function ReactionButton( { } ) { const [ tooltipText, setTooltipText ] = useState( '' ); const [ isFetching, setIsFetching ] = useState( false ); + // Reactions picked from the full picker are stored as hex keys and + // carry no curated label; resolve their name from the Emojibase + // dataset so tooltips read "thumbs up" rather than echoing the "👍" + // character. Falls back to the emoji character until resolved. + const resolvedLabel = useEmojiLabel( slug, ! emojiLabel ); + const label = emojiLabel || resolvedLabel || emoji; const fetchReactionNames = useCallback( () => { const cacheKey = `${ noteId }:${ slug }`; if ( reactionNamesCache[ cacheKey ] ) { setTooltipText( - formatReactionTooltip( - reactionNamesCache[ cacheKey ], - emojiLabel - ) + formatReactionTooltip( reactionNamesCache[ cacheKey ], label ) ); return; } @@ -200,9 +224,7 @@ function ReactionButton( { reactionNamesCache[ cacheKey ] = names; if ( names.length > 0 ) { - setTooltipText( - formatReactionTooltip( names, emojiLabel ) - ); + setTooltipText( formatReactionTooltip( names, label ) ); } } ) .catch( () => { @@ -211,12 +233,12 @@ function ReactionButton( { .finally( () => { setIsFetching( false ); } ); - }, [ noteId, slug, emojiLabel, isFetching ] ); + }, [ noteId, slug, label, isFetching ] ); const defaultLabel = sprintf( /* translators: 1: emoji label, 2: count of reactions */ _n( '%1$s, %2$d reaction', '%1$s, %2$d reactions', count ), - emojiLabel, + label, count ); @@ -287,7 +309,7 @@ export default function ReactionDisplay( { count={ count } isActive={ isActive } emoji={ entry?.emoji ?? hexKeyToEmoji( slug ) } - emojiLabel={ entry?.label ?? hexKeyToEmoji( slug ) } + emojiLabel={ entry?.label } onToggleReaction={ onToggleReaction } /> ); @@ -365,16 +387,33 @@ export function MoreEmojiButton( { onToggleReaction } ) { contentClassName="editor-collab-sidebar-panel__picker-popover" renderToggle={ ( { isOpen, onToggle } ) => ( + ) } + renderContent={ ( { onClose } ) => ( +
+
+ { __( 'Choose your default skin tone' ) } +
+ + { SKIN_TONES.map( ( { tone, emoji, label } ) => ( + { + onChange( tone ); + onClose(); + } } + /> + } + > + { emoji } + + ) ) } + +
+ ) } + /> + ); +} diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 4455b052dfe389..025bee66370a53 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -321,14 +321,53 @@ mark.wp-note { isolation: isolate; } +// Search field with the persistent skin-tone toggle beside it, so the +// current tone is always visible while the picker is open. .editor-collab-sidebar-panel__picker-search { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-xs); padding: var(--wpds-dimension-padding-sm); .components-search-control { + flex: 1; margin: 0; } } +.editor-collab-sidebar-panel__skin-tone-toggle { + flex-shrink: 0; + font-size: 18px; + justify-content: center; +} + +.editor-collab-sidebar-panel__skin-tone-menu { + padding: var(--wpds-dimension-padding-sm); + font-family: var(--wpds-typography-font-family-body); +} + +.editor-collab-sidebar-panel__skin-tone-heading { + padding-bottom: var(--wpds-dimension-padding-sm); + color: var(--wpds-color-foreground-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); + font-weight: var(--wpds-typography-font-weight-medium); +} + +.editor-collab-sidebar-panel__skin-tone-options { + display: flex; + gap: var(--wpds-dimension-gap-xs); +} + +.editor-collab-sidebar-panel__skin-tone-option { + width: 32px; + font-size: 18px; + justify-content: center; + + &[aria-selected="true"] { + background-color: var(--wpds-color-background-interactive-neutral-weak-active); + } +} + .editor-collab-sidebar-panel__picker-viewport { flex: 1; overflow: auto; diff --git a/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js b/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js new file mode 100644 index 00000000000000..ce9d7495e33b06 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import SkinTonePicker, { SKIN_TONES, applySkinTone } from '../skin-tone-picker'; + +const RAISED_HAND = { + label: 'raised hand', + hexcode: '270B', + emoji: '✋️', + skins: [ + { label: 'raised hand: light skin tone', emoji: '✋🏻', tone: 1 }, + { label: 'raised hand: medium-light skin tone', emoji: '✋🏼', tone: 2 }, + { label: 'raised hand: medium skin tone', emoji: '✋🏽', tone: 3 }, + { label: 'raised hand: medium-dark skin tone', emoji: '✋🏾', tone: 4 }, + { label: 'raised hand: dark skin tone', emoji: '✋🏿', tone: 5 }, + ], +}; + +describe( 'SKIN_TONES', () => { + it( 'lists the default tone first, then tones 1-5', () => { + expect( SKIN_TONES.map( ( t ) => t.tone ) ).toEqual( [ + 0, 1, 2, 3, 4, 5, + ] ); + } ); + + it( 'uses the raised hand as the exemplar for every swatch', () => { + for ( const { emoji } of SKIN_TONES ) { + expect( emoji.codePointAt( 0 ) ).toBe( 0x270b ); + } + } ); +} ); + +describe( 'applySkinTone', () => { + it( 'returns the base entry for the default tone', () => { + expect( applySkinTone( RAISED_HAND, 0 ) ).toBe( RAISED_HAND ); + } ); + + it( 'returns the matching skin variant for tones 1-5', () => { + expect( applySkinTone( RAISED_HAND, 1 ).emoji ).toBe( '✋🏻' ); + expect( applySkinTone( RAISED_HAND, 3 ).emoji ).toBe( '✋🏽' ); + expect( applySkinTone( RAISED_HAND, 5 ).emoji ).toBe( '✋🏿' ); + } ); + + it( 'returns the base entry for emoji without skin variants', () => { + const heart = { label: 'red heart', hexcode: '2764-FE0F', emoji: '❤️' }; + expect( applySkinTone( heart, 3 ) ).toBe( heart ); + } ); + + it( 'ignores mixed-tone combination variants', () => { + const handshake = { + label: 'handshake', + hexcode: '1F91D', + emoji: '🤝', + skins: [ + { + label: 'handshake: light, dark', + emoji: '🫱🏻‍🫲🏿', + tone: [ 1, 5 ], + }, + ], + }; + expect( applySkinTone( handshake, 1 ) ).toBe( handshake ); + } ); +} ); + +describe( 'SkinTonePicker', () => { + it( 'shows the current tone on the persistent toggle', () => { + render( {} } /> ); + + const toggle = screen.getByRole( 'button', { + name: 'Skin tone: Medium skin tone', + } ); + expect( toggle ).toHaveTextContent( '✋🏽' ); + } ); + + it( 'opens a flyout with a heading and six selectable swatches', async () => { + const user = userEvent.setup(); + render( {} } /> ); + + await user.click( + screen.getByRole( 'button', { + name: 'Skin tone: Default skin tone', + } ) + ); + + expect( + screen.getByText( 'Choose your default skin tone' ) + ).toBeVisible(); + const options = screen.getAllByRole( 'option' ); + expect( options ).toHaveLength( 6 ); + expect( options[ 0 ] ).toHaveAccessibleName( 'Default skin tone' ); + expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); + expect( options[ 5 ] ).toHaveAttribute( 'aria-selected', 'false' ); + } ); + + it( 'reports the chosen tone and closes the flyout', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( ); + + await user.click( + screen.getByRole( 'button', { + name: 'Skin tone: Default skin tone', + } ) + ); + await user.click( + screen.getByRole( 'option', { name: 'Dark skin tone' } ) + ); + + expect( onChange ).toHaveBeenCalledWith( 5 ); + expect( + screen.queryByText( 'Choose your default skin tone' ) + ).not.toBeInTheDocument(); + } ); +} ); From b4a2ee4f44fd399d7f9c0bc69a855830e0e6d641 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 9 Jul 2026 15:42:44 -0700 Subject: [PATCH 218/362] Add e2e coverage for the emoji skin tone preference Covers the whole flow: default-tone toggle visible next to search, flyout heading and six swatches with the default explicitly selected, choosing a tone updates the persistent toggle, and a subsequent pick stores and renders the toned emoji on the reaction pill. --- .../specs/editor/various/block-notes.spec.js | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 0ad14b797f95a0..2fc5d011cca951 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1252,6 +1252,74 @@ test.describe( 'Block Notes', () => { ).toHaveCount( 0 ); } ); + test( 'can set a default skin tone that applies to picked emoji', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Skin tone preference' }, + comment: 'Pick a toned thumbs up', + } ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFullPicker(); + + // The persistent toggle next to the search field shows the + // current (default) tone. + await page + .getByRole( 'button', { name: 'Skin tone: Default skin tone' } ) + .click(); + + // The flyout has an explicit heading and six swatches, with + // the default tone selected. + await expect( + page.getByText( 'Choose your default skin tone' ) + ).toBeVisible(); + const swatches = page.getByRole( 'option' ); + await expect( swatches ).toHaveCount( 6 ); + await expect( + page.getByRole( 'option', { name: 'Default skin tone' } ) + ).toHaveAttribute( 'aria-selected', 'true' ); + + await page + .getByRole( 'option', { name: 'Dark skin tone', exact: true } ) + .click(); + + // The toggle reflects the new tone and the flyout closes. + await expect( + page.getByRole( 'button', { + name: 'Skin tone: Dark skin tone', + } ) + ).toBeVisible(); + await expect( + page.getByText( 'Choose your default skin tone' ) + ).toBeHidden(); + + // Tone-capable emoji in the grid now carry the chosen tone. + await page.getByPlaceholder( 'Search emoji' ).fill( 'thumbs up' ); + await page + .getByRole( 'gridcell', { + name: 'thumbs up: dark skin tone', + exact: true, + } ) + .click(); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // The stored reaction renders the toned emoji. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( '👍🏿' ); + } ); + test( 'reaction picker portals outside the collab sidebar', async ( { page, blockNoteUtils, From 50aacc5c04d01e68a5a72b03389f2c071e48e76a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 9 Jul 2026 21:22:38 -0700 Subject: [PATCH 219/362] Add a Frequently used section to the full emoji picker Show a frequency-ordered emoji section at the top of the full picker, seeded with the five curated reaction emojis and updated as the user picks reactions from either the quick row or the full picker. Usage is persisted per user via the preferences store (core scope), capped at four grid rows (32 emojis) with the least used entry discarded when a new emoji is picked. --- .../components/collab-sidebar/emoji-picker.js | 125 ++++++++++---- .../collab-sidebar/emojibase-data.js | 2 +- .../collab-sidebar/frequent-emojis.js | 155 ++++++++++++++++++ .../collab-sidebar/reaction-display.js | 13 +- .../collab-sidebar/test/frequent-emojis.js | 154 +++++++++++++++++ 5 files changed, 411 insertions(+), 38 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/frequent-emojis.js create mode 100644 packages/editor/src/components/collab-sidebar/test/frequent-emojis.js diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.js b/packages/editor/src/components/collab-sidebar/emoji-picker.js index 1d0fe857035cd9..d6442ea90defad 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.js @@ -11,7 +11,12 @@ import { speak } from '@wordpress/a11y'; /** * Internal dependencies */ -import { detectLocale, useEmojibaseData } from './emojibase-data'; +import { + detectLocale, + normalizeHexcode, + useEmojibaseData, +} from './emojibase-data'; +import { useFrequentEmojis } from './frequent-emojis'; import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; /** @@ -129,6 +134,7 @@ export default function EmojiPicker( { onSelect } ) { const viewportRef = useRef( null ); const searchRef = useRef( null ); + const { frequentKeys, recordUse } = useFrequentEmojis(); const skinTone = useSelect( ( select ) => select( preferencesStore ).get( @@ -204,6 +210,31 @@ export default function EmojiPicker( { onSelect } ) { [ visibleGroups ] ); + // Index base records by their normalized hex key so the stored + // frequently-used keys can be resolved back to full emoji records. + const recordByHexKey = useMemo( () => { + const map = new Map(); + for ( const entry of data || [] ) { + if ( typeof entry.group === 'number' ) { + map.set( normalizeHexcode( entry.hexcode ), entry ); + } + } + return map; + }, [ data ] ); + + // The "Frequently used" section only shows while browsing; during a + // search it would just duplicate hits from the category results. + const frequentRows = useMemo( () => { + if ( query.trim() ) { + return []; + } + return chunkRows( + frequentKeys + .map( ( key ) => recordByHexKey.get( key ) ) + .filter( Boolean ) + ); + }, [ frequentKeys, recordByHexKey, query ] ); + // Announce result counts during search so screen readers stay in sync // with the visible grid as the user types. useEffect( () => { @@ -227,6 +258,44 @@ export default function EmojiPicker( { onSelect } ) { return null; } + /** + * Render one grid row of emoji, applying the user's skin tone + * preference and recording usage on selection. Shared between the + * "Frequently used" section and the category sections. + * + * @param {Array} row Emoji records for the row. + * @param {string} rowKey React key for the row. + * @return {JSX.Element} The rendered row. + */ + const renderRow = ( row, rowKey ) => ( + + { row.map( ( emoji ) => { + // Swap in the skin-tone variant for display and + // selection; the base record still drives search + // matching, usage tracking, and the grid key. + const display = applySkinTone( emoji, skinTone ); + return ( + { + recordUse( normalizeHexcode( emoji.hexcode ) ); + onSelect( display.emoji ); + } } + > + { display.emoji } + + ); + } ) } + + ); + return (
@@ -278,6 +347,19 @@ export default function EmojiPicker( { onSelect } ) { aria-label={ _x( 'Emoji', 'emoji picker grid label' ) } className="editor-collab-sidebar-panel__picker-list" > + { frequentRows.length > 0 && ( +
+
+ { __( 'Frequently used' ) } +
+ { frequentRows.map( ( row, rowIndex ) => + renderRow( row, `frequent-${ rowIndex }` ) + ) } +
+ ) } { visibleGroups.map( ( group ) => (
{ groupLabelByKey.get( group.key ) || '' }
- { group.rows.map( ( row, rowIndex ) => ( - - { row.map( ( emoji ) => { - // Swap in the skin-tone variant - // for display and selection; the - // base record still drives search - // matching and the grid key. - const display = applySkinTone( - emoji, - skinTone - ); - return ( - - onSelect( - display.emoji - ) - } - > - { display.emoji } - - ); - } ) } - - ) ) } + { group.rows.map( ( row, rowIndex ) => + renderRow( + row, + `${ group.key }-${ rowIndex }` + ) + ) }
) ) } diff --git a/packages/editor/src/components/collab-sidebar/emojibase-data.js b/packages/editor/src/components/collab-sidebar/emojibase-data.js index 36b9c30099cecc..b9b72a124827ba 100644 --- a/packages/editor/src/components/collab-sidebar/emojibase-data.js +++ b/packages/editor/src/components/collab-sidebar/emojibase-data.js @@ -215,7 +215,7 @@ export function useEmojibaseData( baseUrl, locale ) { * @param {string} hexcode Emojibase hexcode. * @return {string} Normalized hex key. */ -function normalizeHexcode( hexcode ) { +export function normalizeHexcode( hexcode ) { return hexcode .split( '-' ) .filter( ( part ) => part.toLowerCase() !== 'fe0f' ) diff --git a/packages/editor/src/components/collab-sidebar/frequent-emojis.js b/packages/editor/src/components/collab-sidebar/frequent-emojis.js new file mode 100644 index 00000000000000..5c3a31d6996430 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/frequent-emojis.js @@ -0,0 +1,155 @@ +/** + * WordPress dependencies + */ +import { useCallback } from '@wordpress/element'; +import { useSelect, useRegistry } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import { REACTION_EMOJIS, emojiToHexKey } from './reaction-emoji-picker'; + +/** + * Preference key (in the `core` scope) storing the user's frequently + * used emoji as an ordered array of `{ key, count }` entries, where + * `key` is the normalized hex key of the base (untoned) emoji and + * `count` is how many times it has been picked. The array is kept + * sorted by count (descending) with ties broken by recency, and capped + * at `MAX_FREQUENT_EMOJIS`. + */ +export const FREQUENT_EMOJIS_PREFERENCE_KEY = 'emojiPickerFrequentEmojis'; + +/** + * Cap on stored frequently-used emoji: four rows of the picker's + * 8-column grid. Once full, the least used entry is discarded when a + * new emoji is picked. + */ +export const MAX_FREQUENT_EMOJIS = 32; + +/** + * Hex keys of the curated reaction set, used to seed the + * frequently-used section before the user has picked anything. The + * seeds are never persisted — they are appended at display time and + * fall away as real usage fills the list. + */ +export const DEFAULT_FREQUENT_EMOJI_KEYS = REACTION_EMOJIS.map( ( entry ) => + emojiToHexKey( entry.emoji ) +); + +/** + * Drop malformed entries from a stored preference value so a corrupt + * or legacy value can never break the picker. + * + * @param {*} entries The raw preference value. + * @return {Array<{ key: string, count: number }>} Sanitized entries. + */ +function sanitizeEntries( entries ) { + if ( ! Array.isArray( entries ) ) { + return []; + } + return entries.filter( + ( entry ) => + entry && + typeof entry.key === 'string' && + entry.key && + typeof entry.count === 'number' && + Number.isFinite( entry.count ) && + entry.count > 0 + ); +} + +/** + * Return a new usage list with one more use of `key` recorded. The + * bumped entry is placed ahead of entries with the same or a lower + * count (frequency first, recency as the tie-break). When the list + * exceeds `MAX_FREQUENT_EMOJIS`, the least used entry — other than the + * one just recorded — is discarded, so a newly picked emoji always + * makes it into the list. + * + * @param {Array} entries Current `{ key, count }` entries. + * @param {string} key Normalized hex key of the picked emoji. + * @return {Array} The updated entries. + */ +export function recordEmojiUse( entries, key ) { + const list = sanitizeEntries( entries ); + if ( ! key || typeof key !== 'string' ) { + return list; + } + const count = ( list.find( ( e ) => e.key === key )?.count || 0 ) + 1; + const next = list.filter( ( e ) => e.key !== key ); + const index = next.findIndex( ( e ) => e.count <= count ); + next.splice( index === -1 ? next.length : index, 0, { key, count } ); + while ( next.length > MAX_FREQUENT_EMOJIS ) { + // Evict from the tail (lowest count, least recent among ties), + // skipping the just-recorded key. + let evictIndex = next.length - 1; + if ( next[ evictIndex ].key === key ) { + evictIndex -= 1; + } + next.splice( evictIndex, 1 ); + } + return next; +} + +/** + * Resolve the ordered list of hex keys to show in the "Frequently + * used" section: recorded usage first (already sorted by frequency), + * padded with the curated default reactions the user hasn't recorded + * yet, capped at `MAX_FREQUENT_EMOJIS`. + * + * @param {Array} entries Stored `{ key, count }` entries. + * @return {string[]} Ordered hex keys. + */ +export function getFrequentEmojiKeys( entries ) { + const keys = sanitizeEntries( entries ).map( ( entry ) => entry.key ); + const seen = new Set( keys ); + for ( const key of DEFAULT_FREQUENT_EMOJI_KEYS ) { + if ( ! seen.has( key ) ) { + keys.push( key ); + seen.add( key ); + } + } + return keys.slice( 0, MAX_FREQUENT_EMOJIS ); +} + +/** + * Hook exposing the persisted frequently-used emoji list and a + * `recordUse` callback. Usage is stored per user through the + * preferences store (`core` scope), the same channel as the skin tone + * preference, so it follows the user across sessions and browsers. + * + * @return {{ frequentKeys: string[], recordUse: Function }} The + * display-ready hex keys and the usage recorder. + */ +export function useFrequentEmojis() { + const registry = useRegistry(); + const frequentKeys = useSelect( + ( select ) => + getFrequentEmojiKeys( + select( preferencesStore ).get( + 'core', + FREQUENT_EMOJIS_PREFERENCE_KEY + ) + ), + [] + ); + const recordUse = useCallback( + ( key ) => { + // Read the latest stored value at call time so back-to-back + // picks never clobber each other through a stale closure. + const entries = registry + .select( preferencesStore ) + .get( 'core', FREQUENT_EMOJIS_PREFERENCE_KEY ); + registry + .dispatch( preferencesStore ) + .set( + 'core', + FREQUENT_EMOJIS_PREFERENCE_KEY, + recordEmojiUse( entries, key ) + ); + }, + [ registry ] + ); + return { frequentKeys, recordUse }; +} diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 418b5a7b54b0fe..ca06dde05d0cc6 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -30,6 +30,7 @@ const smileyIcon = ( * Internal dependencies */ import ReactionEmojiPicker, { + emojiToHexKey, emojiToStorageKey, hexKeyToEmoji, buildEmojiBySlugMap, @@ -39,6 +40,7 @@ import { loadEmojibaseData, useEmojiLabel, } from './emojibase-data'; +import { useFrequentEmojis } from './frequent-emojis'; /** * Lazy-load the full emoji picker. Its bundle is only fetched when a @@ -339,6 +341,7 @@ export function AddReactionButton( { onToggleReaction, } ) { const [ isFullPicker, setIsFullPicker ] = useState( false ); + const { recordUse } = useFrequentEmojis(); const hasFullPicker = typeof window !== 'undefined' && !! window.gutenbergEmojibaseUrl; @@ -400,7 +403,15 @@ export function AddReactionButton( { return ( { + // Count the pick toward the full picker's + // "Frequently used" section. (The full picker + // records its own picks.) + recordUse( + emojiToHexKey( emojiBySlug.get( slug )?.emoji ) + ); + pickReaction( slug ); + } } onMore={ hasFullPicker ? () => setIsFullPicker( true ) diff --git a/packages/editor/src/components/collab-sidebar/test/frequent-emojis.js b/packages/editor/src/components/collab-sidebar/test/frequent-emojis.js new file mode 100644 index 00000000000000..bdfa4c5b7de72e --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/test/frequent-emojis.js @@ -0,0 +1,154 @@ +/** + * Internal dependencies + */ +import { + DEFAULT_FREQUENT_EMOJI_KEYS, + MAX_FREQUENT_EMOJIS, + getFrequentEmojiKeys, + recordEmojiUse, +} from '../frequent-emojis'; + +describe( 'DEFAULT_FREQUENT_EMOJI_KEYS', () => { + it( 'maps the curated reaction set to normalized hex keys', () => { + // ❤️ 🎉 😄 👀 🚀 with variation selectors stripped. + expect( DEFAULT_FREQUENT_EMOJI_KEYS ).toEqual( [ + '2764', + '1f389', + '1f604', + '1f440', + '1f680', + ] ); + } ); +} ); + +describe( 'recordEmojiUse', () => { + it( 'adds a first use with count 1', () => { + expect( recordEmojiUse( [], '1f44d' ) ).toEqual( [ + { key: '1f44d', count: 1 }, + ] ); + } ); + + it( 'tolerates missing or malformed stored values', () => { + expect( recordEmojiUse( undefined, '1f44d' ) ).toEqual( [ + { key: '1f44d', count: 1 }, + ] ); + expect( recordEmojiUse( 'corrupt', '1f44d' ) ).toEqual( [ + { key: '1f44d', count: 1 }, + ] ); + expect( + recordEmojiUse( + [ null, { key: 42 }, { key: 'ok', count: 'NaN' } ], + '1f44d' + ) + ).toEqual( [ { key: '1f44d', count: 1 } ] ); + } ); + + it( 'returns the sanitized list unchanged when the key is empty', () => { + const entries = [ { key: '2764', count: 3 } ]; + expect( recordEmojiUse( entries, '' ) ).toEqual( entries ); + expect( recordEmojiUse( entries, undefined ) ).toEqual( entries ); + } ); + + it( 'increments an existing entry and reorders by count', () => { + const entries = [ + { key: '2764', count: 2 }, + { key: '1f44d', count: 2 }, + ]; + expect( recordEmojiUse( entries, '1f44d' ) ).toEqual( [ + { key: '1f44d', count: 3 }, + { key: '2764', count: 2 }, + ] ); + } ); + + it( 'ranks the just-used emoji first among equal counts', () => { + const entries = [ + { key: '2764', count: 1 }, + { key: '1f389', count: 1 }, + ]; + expect( recordEmojiUse( entries, '1f680' ) ).toEqual( [ + { key: '1f680', count: 1 }, + { key: '2764', count: 1 }, + { key: '1f389', count: 1 }, + ] ); + } ); + + it( 'keeps more frequent entries ahead of a new pick', () => { + const entries = [ + { key: '2764', count: 5 }, + { key: '1f389', count: 2 }, + ]; + expect( recordEmojiUse( entries, '1f680' ) ).toEqual( [ + { key: '2764', count: 5 }, + { key: '1f389', count: 2 }, + { key: '1f680', count: 1 }, + ] ); + } ); + + it( 'discards the least used entry once the cap is reached', () => { + const entries = Array.from( + { length: MAX_FREQUENT_EMOJIS }, + ( _, i ) => ( { + key: `key-${ i }`, + count: MAX_FREQUENT_EMOJIS - i, + } ) + ); + const next = recordEmojiUse( entries, '1f680' ); + expect( next ).toHaveLength( MAX_FREQUENT_EMOJIS ); + // The lowest-count entry was evicted… + expect( + next.find( ( e ) => e.key === `key-${ MAX_FREQUENT_EMOJIS - 1 }` ) + ).toBeUndefined(); + // …and the new pick survives at the tail, even though it has the + // lowest count. + expect( next[ next.length - 1 ] ).toEqual( { + key: '1f680', + count: 1, + } ); + } ); + + it( 'does not evict anything when bumping an existing entry at the cap', () => { + const entries = Array.from( + { length: MAX_FREQUENT_EMOJIS }, + ( _, i ) => ( { key: `key-${ i }`, count: 2 } ) + ); + const next = recordEmojiUse( entries, 'key-7' ); + expect( next ).toHaveLength( MAX_FREQUENT_EMOJIS ); + expect( next[ 0 ] ).toEqual( { key: 'key-7', count: 3 } ); + } ); +} ); + +describe( 'getFrequentEmojiKeys', () => { + it( 'returns the curated defaults when nothing is stored', () => { + expect( getFrequentEmojiKeys( undefined ) ).toEqual( + DEFAULT_FREQUENT_EMOJI_KEYS + ); + expect( getFrequentEmojiKeys( [] ) ).toEqual( + DEFAULT_FREQUENT_EMOJI_KEYS + ); + } ); + + it( 'lists recorded usage first, then unseen defaults', () => { + const keys = getFrequentEmojiKeys( [ { key: '1f44d', count: 2 } ] ); + expect( keys ).toEqual( [ '1f44d', ...DEFAULT_FREQUENT_EMOJI_KEYS ] ); + } ); + + it( 'does not repeat a default that already has recorded usage', () => { + const keys = getFrequentEmojiKeys( [ { key: '2764', count: 2 } ] ); + expect( keys[ 0 ] ).toBe( '2764' ); + expect( keys.filter( ( key ) => key === '2764' ) ).toHaveLength( 1 ); + } ); + + it( 'caps the list at the maximum', () => { + const entries = Array.from( + { length: MAX_FREQUENT_EMOJIS }, + ( _, i ) => ( { + key: `key-${ i }`, + count: 1, + } ) + ); + const keys = getFrequentEmojiKeys( entries ); + expect( keys ).toHaveLength( MAX_FREQUENT_EMOJIS ); + // The defaults no longer fit once real usage fills the list. + expect( keys ).not.toContain( '2764' ); + } ); +} ); From e9849059351908c5c8fcee644bef4dc80ed32956 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 9 Jul 2026 21:39:19 -0700 Subject: [PATCH 220/362] Add e2e coverage for the Frequently used emoji section Verify the section renders seeded with the curated defaults, hides during search, and picks up newly used emoji on reopen. Also point the grid-width test at a full 8-emoji row, since the first row is now the Frequently used section, which can legitimately be a partial row. --- .../specs/editor/various/block-notes.spec.js | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 2fc5d011cca951..5bf25ea8937052 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1252,6 +1252,82 @@ test.describe( 'Block Notes', () => { ).toHaveCount( 0 ); } ); + test( 'full picker shows a Frequently used section that learns from picks', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Frequently used emoji' }, + comment: 'Learn frequent picks', + } ); + + // Clear any usage persisted by earlier tests or runs so the + // seeded state is deterministic. + await page.evaluate( () => + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core', 'emojiPickerFrequentEmojis', [] ) + ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFullPicker(); + + // The section leads the grid, seeded with the curated + // reaction set (the red heart carries the "Heart" label + // override), so it has content before any recorded picks. + const frequentSection = page + .locator( '.editor-collab-sidebar-panel__picker-list > div' ) + .filter( { hasText: 'Frequently used' } ) + .first(); + await expect( + page + .locator( '.editor-collab-sidebar-panel__picker-category' ) + .first() + ).toHaveText( 'Frequently used' ); + await expect( + frequentSection.getByRole( 'gridcell', { + name: 'Heart', + exact: true, + } ) + ).toBeVisible(); + // An emoji no other test picks is not in the section yet. + await expect( + frequentSection.getByRole( 'gridcell', { + name: 'avocado', + exact: true, + } ) + ).toBeHidden(); + + // While searching, the section is hidden so it doesn't + // duplicate hits from the category results. + await page.getByPlaceholder( 'Search emoji' ).fill( 'avocado' ); + await expect( page.getByText( 'Frequently used' ) ).toBeHidden(); + + // Pick it… + await page + .getByRole( 'gridcell', { name: 'avocado', exact: true } ) + .click(); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'Reaction added.' } ) + ).toBeVisible(); + + // …and on reopening the picker, the recorded pick has joined + // the Frequently used section. + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page.getByRole( 'button', { name: 'More emojis' } ).click(); + await blockNoteUtils.waitForFullPicker(); + await expect( + frequentSection.getByRole( 'gridcell', { + name: 'avocado', + exact: true, + } ) + ).toBeVisible(); + } ); + test( 'can set a default skin tone that applies to picked emoji', async ( { page, blockNoteUtils, @@ -1424,14 +1500,22 @@ test.describe( 'Block Notes', () => { const picker = page.locator( '.editor-collab-sidebar-panel__picker' ); - const lastEmojiInFirstRow = page + // Measure a full 8-emoji row: the first row on screen is the + // "Frequently used" section, which may hold fewer emoji and + // legitimately end short of the right edge. + const lastEmojiInFullRow = page .locator( '.editor-collab-sidebar-panel__picker-row' ) + .filter( { + has: page.locator( + '.editor-collab-sidebar-panel__picker-emoji:nth-child(8)' + ), + } ) .first() .locator( '.editor-collab-sidebar-panel__picker-emoji' ) .last(); const pickerBox = await picker.boundingBox(); - const emojiBox = await lastEmojiInFirstRow.boundingBox(); + const emojiBox = await lastEmojiInFullRow.boundingBox(); const horizontalSlack = pickerBox.x + pickerBox.width - ( emojiBox.x + emojiBox.width ); From 496d549c82480d3c1a80e040ebc7dd5bab4aa237 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 10 Jul 2026 10:58:42 -0700 Subject: [PATCH 221/362] Notes: Author the emoji picker modules as TypeScript Convert the full emoji picker and reaction modules from JavaScript to TypeScript. Per review feedback, new code in the editor package should be authored as TypeScript so it is type-checked (the package sets `checkJs: false`, so only `.ts`/`.tsx` files are checked). - emoji-picker, skin-tone-picker, reaction-display, reaction-emoji-picker become `.tsx` (they render JSX). - emojibase-data, frequent-emojis become `.ts` (no JSX). Add explicit prop and data types (EmojibaseEntry/EmojibaseSkin, the Emojibase messages shape, the reaction summary shape) and a `Window` augmentation for the `gutenberg*` emoji globals. No behavior change; the existing unit and e2e coverage is unaffected. --- .../{emoji-picker.js => emoji-picker.tsx} | 81 +++++---- .../{emojibase-data.js => emojibase-data.ts} | 169 +++++++++++++----- ...{frequent-emojis.js => frequent-emojis.ts} | 48 +++-- ...action-display.js => reaction-display.tsx} | 151 +++++++++++----- ...ji-picker.js => reaction-emoji-picker.tsx} | 68 ++++--- ...in-tone-picker.js => skin-tone-picker.tsx} | 43 ++++- 6 files changed, 389 insertions(+), 171 deletions(-) rename packages/editor/src/components/collab-sidebar/{emoji-picker.js => emoji-picker.tsx} (83%) rename packages/editor/src/components/collab-sidebar/{emojibase-data.js => emojibase-data.ts} (65%) rename packages/editor/src/components/collab-sidebar/{frequent-emojis.js => frequent-emojis.ts} (81%) rename packages/editor/src/components/collab-sidebar/{reaction-display.js => reaction-display.tsx} (74%) rename packages/editor/src/components/collab-sidebar/{reaction-emoji-picker.js => reaction-emoji-picker.tsx} (69%) rename packages/editor/src/components/collab-sidebar/{skin-tone-picker.js => skin-tone-picker.tsx} (80%) diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.js b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx similarity index 83% rename from packages/editor/src/components/collab-sidebar/emoji-picker.js rename to packages/editor/src/components/collab-sidebar/emoji-picker.tsx index d6442ea90defad..a08d586d426b09 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -16,9 +16,22 @@ import { normalizeHexcode, useEmojibaseData, } from './emojibase-data'; +import type { EmojibaseEntry } from './emojibase-data'; import { useFrequentEmojis } from './frequent-emojis'; import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; +/** + * A category bucket of emoji records keyed by its Emojibase `group`. + */ +interface EmojiGroup { + key: number; + emojis: EmojibaseEntry[]; +} + +interface EmojiPickerProps { + onSelect: ( emoji: string ) => void; +} + /** * Preference key (in the `core` scope) storing the user's default emoji * skin tone: 0 (default yellow) through 5 (dark), matching Emojibase @@ -33,11 +46,11 @@ const COLUMNS = 8; * Emojibase's natural ordering (which follows Unicode CLDR). Entries * with no `group` (e.g. component code points) are skipped. * - * @param {Array} data Emoji records from `data.json`. - * @return {Array<{ key: number, emojis: Array }>} Ordered category buckets. + * @param data Emoji records from `data.json`. + * @return Ordered category buckets. */ -export function groupEmojis( data ) { - const buckets = new Map(); +export function groupEmojis( data: EmojibaseEntry[] ): EmojiGroup[] { + const buckets = new Map< number, EmojibaseEntry[] >(); for ( const entry of data ) { if ( typeof entry.group !== 'number' ) { continue; @@ -45,7 +58,7 @@ export function groupEmojis( data ) { if ( ! buckets.has( entry.group ) ) { buckets.set( entry.group, [] ); } - buckets.get( entry.group ).push( entry ); + buckets.get( entry.group )!.push( entry ); } return Array.from( buckets.entries() ) .sort( ( a, b ) => a[ 0 ] - b[ 0 ] ) @@ -57,11 +70,11 @@ export function groupEmojis( data ) { * keeps a stable column count even as the visible list shrinks during * search. * - * @param {Array} emojis Emoji records. - * @return {Array} Rows of up to `COLUMNS` emoji each. + * @param emojis Emoji records. + * @return Rows of up to `COLUMNS` emoji each. */ -export function chunkRows( emojis ) { - const rows = []; +export function chunkRows( emojis: EmojibaseEntry[] ): EmojibaseEntry[][] { + const rows: EmojibaseEntry[][] = []; for ( let i = 0; i < emojis.length; i += COLUMNS ) { rows.push( emojis.slice( i, i + COLUMNS ) ); } @@ -74,12 +87,16 @@ export function chunkRows( emojis ) { * searching for an overridden label still get the expected hit. * Returns the unfiltered list when the query is empty. * - * @param {Array} emojis Emoji records. - * @param {string} query Search query. - * @param {Object|null} overrides Map of `hexcode => translated label`. - * @return {Array} Matching emoji records. + * @param emojis Emoji records. + * @param query Search query. + * @param overrides Map of `hexcode => translated label`. + * @return Matching emoji records. */ -export function searchEmojis( emojis, query, overrides ) { +export function searchEmojis( + emojis: EmojibaseEntry[], + query: string, + overrides: Record< string, string > | null +): EmojibaseEntry[] { const trimmed = query.trim().toLowerCase(); if ( ! trimmed ) { return emojis; @@ -115,12 +132,14 @@ export function searchEmojis( emojis, query, overrides ) { * same-origin from `window.gutenbergEmojibaseUrl`; UI chrome strings * go through `@wordpress/i18n` so GlotPress can translate them. * - * @param {Object} props Component props. - * @param {Function} props.onSelect Called with the selected emoji character. + * @param props Component props. + * @param props.onSelect Called with the selected emoji character. */ -export default function EmojiPicker( { onSelect } ) { +export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { const baseUrl = - typeof window !== 'undefined' ? window.gutenbergEmojibaseUrl : null; + typeof window !== 'undefined' + ? window.gutenbergEmojibaseUrl ?? null + : null; const labelOverrides = typeof window !== 'undefined' && window.gutenbergEmojiLabelOverrides ? window.gutenbergEmojiLabelOverrides @@ -131,8 +150,8 @@ export default function EmojiPicker( { onSelect } ) { locale ); const [ query, setQuery ] = useState( '' ); - const viewportRef = useRef( null ); - const searchRef = useRef( null ); + const viewportRef = useRef< HTMLDivElement >( null ); + const searchRef = useRef< HTMLInputElement >( null ); const { frequentKeys, recordUse } = useFrequentEmojis(); const skinTone = useSelect( @@ -158,10 +177,10 @@ export default function EmojiPicker( { onSelect } ) { * server-supplied override (typically a `__()`-translated string for * locales Emojibase doesn't cover) over the Emojibase data label. * - * @param {Object} entry Emojibase emoji record. - * @return {string} The label to render and use as the accessible name. + * @param entry Emojibase emoji record. + * @return The label to render and use as the accessible name. */ - const labelFor = ( entry ) => + const labelFor = ( entry: EmojibaseEntry ): string => labelOverrides?.[ entry.hexcode ] || entry.label || ''; const groups = useMemo( @@ -170,7 +189,7 @@ export default function EmojiPicker( { onSelect } ) { ); const groupLabelByKey = useMemo( () => { - const map = new Map(); + const map = new Map< number, string >(); if ( messages?.groups ) { for ( const g of messages.groups ) { map.set( g.order, g.message ); @@ -213,7 +232,7 @@ export default function EmojiPicker( { onSelect } ) { // Index base records by their normalized hex key so the stored // frequently-used keys can be resolved back to full emoji records. const recordByHexKey = useMemo( () => { - const map = new Map(); + const map = new Map< string, EmojibaseEntry >(); for ( const entry of data || [] ) { if ( typeof entry.group === 'number' ) { map.set( normalizeHexcode( entry.hexcode ), entry ); @@ -231,7 +250,9 @@ export default function EmojiPicker( { onSelect } ) { return chunkRows( frequentKeys .map( ( key ) => recordByHexKey.get( key ) ) - .filter( Boolean ) + .filter( ( entry ): entry is EmojibaseEntry => + Boolean( entry ) + ) ); }, [ frequentKeys, recordByHexKey, query ] ); @@ -263,11 +284,11 @@ export default function EmojiPicker( { onSelect } ) { * preference and recording usage on selection. Shared between the * "Frequently used" section and the category sections. * - * @param {Array} row Emoji records for the row. - * @param {string} rowKey React key for the row. - * @return {JSX.Element} The rendered row. + * @param row Emoji records for the row. + * @param rowKey React key for the row. + * @return The rendered row. */ - const renderRow = ( row, rowKey ) => ( + const renderRow = ( row: EmojibaseEntry[], rowKey: string ) => ( ; + } +} + +/** + * A single skin-tone variant of an Emojibase emoji record. + */ +export interface EmojibaseSkin { + hexcode: string; + emoji: string; + label?: string; + // A single Fitzpatrick tone, or an array for mixed-tone combinations. + tone?: number | number[]; +} + +/** + * A subset of the Emojibase emoji record shape, covering the fields the + * picker and reaction pills read. + */ +export interface EmojibaseEntry { + hexcode: string; + emoji: string; + label?: string; + tags?: string[]; + group?: number; + order?: number; + skins?: EmojibaseSkin[]; +} + +/** + * A single group heading from the Emojibase `messages.json` dataset. + */ +export interface EmojibaseGroupMessage { + order: number; + message: string; +} + +/** + * The subset of the Emojibase `messages.json` dataset the picker reads. + */ +export interface EmojibaseMessages { + groups?: EmojibaseGroupMessage[]; +} + +/** + * A loaded Emojibase dataset for a single locale. + */ +export interface EmojibaseDataset { + data: EmojibaseEntry[]; + messages: EmojibaseMessages; +} + +/** + * Loading state for a requested Emojibase dataset. + */ +export interface EmojibaseDataState { + data: EmojibaseEntry[] | null; + messages: EmojibaseMessages | null; + isLoading: boolean; + error: Error | null; +} + /** * Locales for which Emojibase ships translated labels and tags. Built * from the directory listing of the `emojibase-data` npm package, kept @@ -53,10 +118,10 @@ export const EMOJIBASE_LOCALES = new Set( [ * falling back to English. Tries the full tag (e.g. `pt-br`) first, then * the language portion (`pt`), then `en`. * - * @param {string} raw The user-facing locale, e.g. `pt-BR` or `fr_FR`. - * @return {string} An Emojibase locale key. + * @param raw The user-facing locale, e.g. `pt-BR` or `fr_FR`. + * @return An Emojibase locale key. */ -export function resolveEmojibaseLocale( raw ) { +export function resolveEmojibaseLocale( raw: string ): string { if ( typeof raw !== 'string' || ! raw ) { return 'en'; } @@ -81,9 +146,9 @@ export function resolveEmojibaseLocale( raw ) { * Detect the active locale for the picker. Prefers `` (set by * WordPress to the site language) and falls back to `navigator.language`. * - * @return {string} A locale key Emojibase ships data for. + * @return A locale key Emojibase ships data for. */ -export function detectLocale() { +export function detectLocale(): string { if ( typeof document !== 'undefined' && document.documentElement?.lang ) { return resolveEmojibaseLocale( document.documentElement.lang ); } @@ -94,44 +159,46 @@ export function detectLocale() { } // Module-level cache so opening the picker twice doesn't refetch. -const dataCache = new Map(); -const inflight = new Map(); +const dataCache = new Map< string, EmojibaseDataset >(); +const inflight = new Map< string, Promise< EmojibaseDataset > >(); /** * Fetch and cache Emojibase `data.json` + `messages.json` for a given * locale. Resolves with `{ data, messages }` or rejects on a network * error so callers can render an error/empty state. * - * @param {string} baseUrl Same-origin base URL for the emojibase-data - * directory (e.g. plugin's - * `build/emojibase-data`). - * @param {string} locale Emojibase locale key. - * @return {Promise<{data: Array, messages: Object}>} Loaded dataset. + * @param baseUrl Same-origin base URL for the emojibase-data + * directory (e.g. plugin's `build/emojibase-data`). + * @param locale Emojibase locale key. + * @return Loaded dataset. */ -export function loadEmojibaseData( baseUrl, locale ) { +export function loadEmojibaseData( + baseUrl: string, + locale: string +): Promise< EmojibaseDataset > { const cacheKey = `${ baseUrl }|${ locale }`; if ( dataCache.has( cacheKey ) ) { - return Promise.resolve( dataCache.get( cacheKey ) ); + return Promise.resolve( dataCache.get( cacheKey ) as EmojibaseDataset ); } if ( inflight.has( cacheKey ) ) { - return inflight.get( cacheKey ); + return inflight.get( cacheKey ) as Promise< EmojibaseDataset >; } const promise = Promise.all( [ fetch( `${ baseUrl }/${ locale }/data.json` ).then( ( r ) => { if ( ! r.ok ) { throw new Error( `Failed to load ${ locale }/data.json` ); } - return r.json(); + return r.json() as Promise< EmojibaseEntry[] >; } ), fetch( `${ baseUrl }/${ locale }/messages.json` ).then( ( r ) => { if ( ! r.ok ) { throw new Error( `Failed to load ${ locale }/messages.json` ); } - return r.json(); + return r.json() as Promise< EmojibaseMessages >; } ), ] ) .then( ( [ data, messages ] ) => { - const value = { data, messages }; + const value: EmojibaseDataset = { data, messages }; dataCache.set( cacheKey, value ); inflight.delete( cacheKey ); return value; @@ -147,13 +214,15 @@ export function loadEmojibaseData( baseUrl, locale ) { /** * React hook that fetches Emojibase data for a given base URL and locale. * - * @param {string} baseUrl Same-origin URL pointing at the emojibase data dir. - * @param {string} locale Emojibase locale key. - * @return {{ data: Array|null, messages: Object|null, isLoading: boolean, error: Error|null }} - * Loading state for the dataset. + * @param baseUrl Same-origin URL pointing at the emojibase data dir. + * @param locale Emojibase locale key. + * @return Loading state for the dataset. */ -export function useEmojibaseData( baseUrl, locale ) { - const [ state, setState ] = useState( () => { +export function useEmojibaseData( + baseUrl: string | null, + locale: string +): EmojibaseDataState { + const [ state, setState ] = useState< EmojibaseDataState >( () => { const cached = dataCache.get( `${ baseUrl }|${ locale }` ); return { data: cached?.data || null, @@ -164,6 +233,9 @@ export function useEmojibaseData( baseUrl, locale ) { } ); useEffect( () => { + if ( ! baseUrl ) { + return; + } let cancelled = false; const cached = dataCache.get( `${ baseUrl }|${ locale }` ); if ( cached ) { @@ -210,12 +282,12 @@ export function useEmojibaseData( baseUrl, locale ) { /** * Normalize an Emojibase hexcode (`2764-FE0F`) to the reaction storage * key form: lowercase with variation selector U+FE0F segments removed - * (`2764`), matching `emojiToHexKey()` in `reaction-emoji-picker.js`. + * (`2764`), matching `emojiToHexKey()` in `reaction-emoji-picker.tsx`. * - * @param {string} hexcode Emojibase hexcode. - * @return {string} Normalized hex key. + * @param hexcode Emojibase hexcode. + * @return Normalized hex key. */ -export function normalizeHexcode( hexcode ) { +export function normalizeHexcode( hexcode: string ): string { return hexcode .split( '-' ) .filter( ( part ) => part.toLowerCase() !== 'fe0f' ) @@ -224,26 +296,30 @@ export function normalizeHexcode( hexcode ) { } // One label map per `baseUrl|locale`, built lazily from the dataset. -const labelMapCache = new Map(); +const labelMapCache = new Map< string, Map< string, string > >(); /** * Build (and cache) a Map from normalized hex key to user-facing emoji * label for a loaded dataset, applying any per-site label overrides * from `window.gutenbergEmojiLabelOverrides`. * - * @param {string} cacheKey `baseUrl|locale` cache key. - * @param {Array} data Emojibase emoji records. - * @return {Map} Map from hex key to label. + * @param cacheKey `baseUrl|locale` cache key. + * @param data Emojibase emoji records. + * @return Map from hex key to label. */ -function buildLabelMap( cacheKey, data ) { - if ( labelMapCache.has( cacheKey ) ) { - return labelMapCache.get( cacheKey ); +function buildLabelMap( + cacheKey: string, + data: EmojibaseEntry[] +): Map< string, string > { + const existing = labelMapCache.get( cacheKey ); + if ( existing ) { + return existing; } const overrides = typeof window !== 'undefined' && window.gutenbergEmojiLabelOverrides ? window.gutenbergEmojiLabelOverrides : null; - const map = new Map(); + const map = new Map< string, string >(); for ( const entry of data ) { if ( ! entry.hexcode || ! entry.label ) { continue; @@ -274,10 +350,10 @@ function buildLabelMap( cacheKey, data ) { * Look up the label for a stored hex-key reaction from the already * loaded dataset, without triggering a fetch. * - * @param {string} hexKey Normalized reaction hex key, e.g. `1f44d`. - * @return {string|null} The label, or null when unknown/not loaded. + * @param hexKey Normalized reaction hex key, e.g. `1f44d`. + * @return The label, or null when unknown/not loaded. */ -export function getCachedEmojiLabel( hexKey ) { +export function getCachedEmojiLabel( hexKey: string ): string | null { if ( typeof window === 'undefined' || ! window.gutenbergEmojibaseUrl ) { return null; } @@ -296,13 +372,16 @@ export function getCachedEmojiLabel( hexKey ) { * cache — so reaction pill tooltips can show the emoji name just like * curated reactions do. * - * @param {string} hexKey Normalized reaction hex key, e.g. `1f44d`. - * @param {boolean} enabled Whether resolution should run (false for - * curated slugs, which have their own labels). - * @return {string|null} The resolved label, or null while unresolved. + * @param hexKey Normalized reaction hex key, e.g. `1f44d`. + * @param enabled Whether resolution should run (false for curated + * slugs, which have their own labels). + * @return The resolved label, or null while unresolved. */ -export function useEmojiLabel( hexKey, enabled ) { - const [ label, setLabel ] = useState( () => +export function useEmojiLabel( + hexKey: string, + enabled: boolean +): string | null { + const [ label, setLabel ] = useState< string | null >( () => enabled ? getCachedEmojiLabel( hexKey ) : null ); diff --git a/packages/editor/src/components/collab-sidebar/frequent-emojis.js b/packages/editor/src/components/collab-sidebar/frequent-emojis.ts similarity index 81% rename from packages/editor/src/components/collab-sidebar/frequent-emojis.js rename to packages/editor/src/components/collab-sidebar/frequent-emojis.ts index 5c3a31d6996430..613cd05355c6e2 100644 --- a/packages/editor/src/components/collab-sidebar/frequent-emojis.js +++ b/packages/editor/src/components/collab-sidebar/frequent-emojis.ts @@ -10,6 +10,15 @@ import { store as preferencesStore } from '@wordpress/preferences'; */ import { REACTION_EMOJIS, emojiToHexKey } from './reaction-emoji-picker'; +/** + * A recorded frequently-used emoji: its normalized hex key and how many + * times the user has picked it. + */ +export interface FrequentEmojiEntry { + key: string; + count: number; +} + /** * Preference key (in the `core` scope) storing the user's frequently * used emoji as an ordered array of `{ key, count }` entries, where @@ -41,18 +50,18 @@ export const DEFAULT_FREQUENT_EMOJI_KEYS = REACTION_EMOJIS.map( ( entry ) => * Drop malformed entries from a stored preference value so a corrupt * or legacy value can never break the picker. * - * @param {*} entries The raw preference value. - * @return {Array<{ key: string, count: number }>} Sanitized entries. + * @param entries The raw preference value. + * @return Sanitized entries. */ -function sanitizeEntries( entries ) { +function sanitizeEntries( entries: unknown ): FrequentEmojiEntry[] { if ( ! Array.isArray( entries ) ) { return []; } return entries.filter( - ( entry ) => - entry && + ( entry ): entry is FrequentEmojiEntry => + !! entry && typeof entry.key === 'string' && - entry.key && + !! entry.key && typeof entry.count === 'number' && Number.isFinite( entry.count ) && entry.count > 0 @@ -67,11 +76,14 @@ function sanitizeEntries( entries ) { * one just recorded — is discarded, so a newly picked emoji always * makes it into the list. * - * @param {Array} entries Current `{ key, count }` entries. - * @param {string} key Normalized hex key of the picked emoji. - * @return {Array} The updated entries. + * @param entries Current `{ key, count }` entries. + * @param key Normalized hex key of the picked emoji. + * @return The updated entries. */ -export function recordEmojiUse( entries, key ) { +export function recordEmojiUse( + entries: unknown, + key: string +): FrequentEmojiEntry[] { const list = sanitizeEntries( entries ); if ( ! key || typeof key !== 'string' ) { return list; @@ -98,10 +110,10 @@ export function recordEmojiUse( entries, key ) { * padded with the curated default reactions the user hasn't recorded * yet, capped at `MAX_FREQUENT_EMOJIS`. * - * @param {Array} entries Stored `{ key, count }` entries. - * @return {string[]} Ordered hex keys. + * @param entries Stored `{ key, count }` entries. + * @return Ordered hex keys. */ -export function getFrequentEmojiKeys( entries ) { +export function getFrequentEmojiKeys( entries: unknown ): string[] { const keys = sanitizeEntries( entries ).map( ( entry ) => entry.key ); const seen = new Set( keys ); for ( const key of DEFAULT_FREQUENT_EMOJI_KEYS ) { @@ -119,10 +131,12 @@ export function getFrequentEmojiKeys( entries ) { * preferences store (`core` scope), the same channel as the skin tone * preference, so it follows the user across sessions and browsers. * - * @return {{ frequentKeys: string[], recordUse: Function }} The - * display-ready hex keys and the usage recorder. + * @return The display-ready hex keys and the usage recorder. */ -export function useFrequentEmojis() { +export function useFrequentEmojis(): { + frequentKeys: string[]; + recordUse: ( key: string ) => void; +} { const registry = useRegistry(); const frequentKeys = useSelect( ( select ) => @@ -135,7 +149,7 @@ export function useFrequentEmojis() { [] ); const recordUse = useCallback( - ( key ) => { + ( key: string ) => { // Read the latest stored value at call time so back-to-back // picks never clobber each other through a stale closure. const entries = registry diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.tsx similarity index 74% rename from packages/editor/src/components/collab-sidebar/reaction-display.js rename to packages/editor/src/components/collab-sidebar/reaction-display.tsx index ca06dde05d0cc6..f9eb4ac03bf40a 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import type { MouseEvent } from 'react'; + /** * WordPress dependencies */ @@ -42,6 +47,28 @@ import { } from './emojibase-data'; import { useFrequentEmojis } from './frequent-emojis'; +/** + * A single slug's entry in the reaction summary: how many reactions it + * has and whether the current user is among them. + */ +interface ReactionSummaryEntry { + count: number; + reacted?: boolean; +} + +/** + * The reaction summary keyed by storage slug (curated slug or hex key). + */ +type ReactionSummary = Record< string, ReactionSummaryEntry >; + +/** + * A comment record as returned by the reactions REST query. + */ +interface ReactionComment { + author_name: string; + content: string | { raw?: string; rendered?: string }; +} + /** * Lazy-load the full emoji picker. Its bundle is only fetched when a * user opens the "More emojis" popover for the first time in a session; @@ -55,7 +82,7 @@ const FullEmojiPicker = lazy( () => import( './emoji-picker' ) ); * cache, so calling this repeatedly (every hover) is free after the * first invocation. */ -function prefetchFullPicker() { +function prefetchFullPicker(): void { import( './emoji-picker' ).catch( () => {} ); if ( typeof window !== 'undefined' && window.gutenbergEmojibaseUrl ) { loadEmojibaseData( window.gutenbergEmojibaseUrl, detectLocale() ).catch( @@ -71,37 +98,45 @@ const emojiBySlug = buildEmojiBySlugMap(); // so it escapes the `overflow: hidden` chain on the collab sidebar // (`.interface-interface-skeleton__sidebar`, // `.editor-collab-sidebar`, `.editor-collab-sidebar-panel`). -const POPOVER_PROPS = { placement: 'bottom-end' }; +const POPOVER_PROPS = { placement: 'bottom-end' } as const; /** * Get the count of reactions for a specific slug. * - * @param {Object} reactions The reactions summary (keyed by slug). - * @param {string} slug The reaction slug to count. - * @return {number} The count of reactions. + * @param reactions The reactions summary (keyed by slug). + * @param slug The reaction slug to count. + * @return The count of reactions. */ -function getReactionCount( reactions, slug ) { +function getReactionCount( + reactions: ReactionSummary | null | undefined, + slug: string +): number { return reactions?.[ slug ]?.count || 0; } /** * Check if the current user has reacted with a specific slug. * - * @param {Object} reactions The reactions summary (keyed by slug). - * @param {string} slug The reaction slug to check. - * @return {boolean} Whether the user has reacted. + * @param reactions The reactions summary (keyed by slug). + * @param slug The reaction slug to check. + * @return Whether the user has reacted. */ -function hasUserReacted( reactions, slug ) { +function hasUserReacted( + reactions: ReactionSummary | null | undefined, + slug: string +): boolean { return reactions?.[ slug ]?.reacted || false; } /** * Get all reaction slugs that have reactions. * - * @param {Object} reactions The reactions summary (keyed by slug). - * @return {string[]} Array of slugs with reactions. + * @param reactions The reactions summary (keyed by slug). + * @return Array of slugs with reactions. */ -function getReactedSlugs( reactions ) { +function getReactedSlugs( + reactions: ReactionSummary | null | undefined +): string[] { if ( ! reactions ) { return []; } @@ -113,11 +148,11 @@ function getReactedSlugs( reactions ) { /** * Generate GitHub-style tooltip text from user names. * - * @param {string[]} names Array of user display names. - * @param {string} emojiLabel The emoji label. - * @return {string} The tooltip text. + * @param names Array of user display names. + * @param emojiLabel The emoji label. + * @return The tooltip text. */ -function formatReactionTooltip( names, emojiLabel ) { +function formatReactionTooltip( names: string[], emojiLabel: string ): string { if ( names.length === 1 ) { return sprintf( /* translators: 1: user name, 2: emoji label. */ @@ -153,20 +188,30 @@ function formatReactionTooltip( names, emojiLabel ) { } // Module-level cache for reaction details: { "noteId:slug": string[] } -const reactionNamesCache = {}; +const reactionNamesCache: Record< string, string[] > = {}; + +interface ReactionButtonProps { + noteId: number; + slug: string; + count: number; + isActive: boolean; + emoji: string; + emojiLabel?: string; + onToggleReaction: ( slug: string ) => void; +} /** * A single reaction pill button that lazy-loads user names on hover. * - * @param {Object} props Component props. - * @param {number} props.noteId The parent note comment ID. - * @param {string} props.slug The emoji slug. - * @param {number} props.count The reaction count. - * @param {boolean} props.isActive Whether the current user reacted. - * @param {string} props.emoji The emoji character. - * @param {string} props.emojiLabel The emoji label, if known - * (curated reactions only). - * @param {Function} props.onToggleReaction Callback to toggle a reaction. + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.slug The emoji slug. + * @param props.count The reaction count. + * @param props.isActive Whether the current user reacted. + * @param props.emoji The emoji character. + * @param props.emojiLabel The emoji label, if known (curated reactions + * only). + * @param props.onToggleReaction Callback to toggle a reaction. */ function ReactionButton( { noteId, @@ -176,7 +221,7 @@ function ReactionButton( { emoji, emojiLabel, onToggleReaction, -} ) { +}: ReactionButtonProps ) { const [ tooltipText, setTooltipText ] = useState( '' ); const [ isFetching, setIsFetching ] = useState( false ); // Reactions picked from the full picker are stored as hex keys and @@ -200,7 +245,7 @@ function ReactionButton( { } setIsFetching( true ); - apiFetch( { + apiFetch< ReactionComment[] >( { path: addQueryArgs( '/wp/v2/comments', { parent: noteId, type: 'reaction', @@ -248,14 +293,16 @@ function ReactionButton( { variant="secondary" size="small" className="editor-collab-sidebar-panel__reaction-button" - onClick={ ( event ) => { + onClick={ ( event: MouseEvent< HTMLElement > ) => { event.stopPropagation(); // When removing the last reaction for this emoji, // the button will disappear. Move focus to the // parent note to prevent focus loss. if ( isActive && count === 1 ) { - event.target - .closest( '.editor-collab-sidebar-panel__thread' ) + ( event.target as HTMLElement ) + .closest< HTMLElement >( + '.editor-collab-sidebar-panel__thread' + ) ?.focus(); } // Invalidate cached names since the reaction set is changing. @@ -276,19 +323,25 @@ function ReactionButton( { ); } +interface ReactionDisplayProps { + noteId: number; + reactions: ReactionSummary | null | undefined; + onToggleReaction: ( slug: string ) => void; +} + /** * Display current reactions with counts as pill-shaped buttons. * - * @param {Object} props Component props. - * @param {number} props.noteId The parent note comment ID. - * @param {Object} props.reactions The reaction summary (keyed by slug). - * @param {Function} props.onToggleReaction Callback to toggle a reaction. + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.reactions The reaction summary (keyed by slug). + * @param props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { noteId, reactions, onToggleReaction, -} ) { +}: ReactionDisplayProps ) { const reactedSlugs = getReactedSlugs( reactions ); if ( reactedSlugs.length === 0 ) { @@ -319,6 +372,12 @@ export default function ReactionDisplay( { ); } +interface AddReactionButtonProps { + noteId: number; + disabled?: boolean; + onToggleReaction: ( slug: string ) => void; +} + /** * Standalone add-reaction button. Opens the curated emoji quick row * (5 emoji); its trailing `+` option swaps the popover content to the @@ -329,17 +388,17 @@ export default function ReactionDisplay( { * editor package must opt in by providing a URL pointing at a * self-hosted emojibase dataset. * - * @param {Object} props Component props. - * @param {number} props.noteId The parent note comment ID. - * @param {boolean} props.disabled Whether the button is disabled - * (e.g. on a resolved note thread). - * @param {Function} props.onToggleReaction Callback to toggle a reaction. + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.disabled Whether the button is disabled (e.g. on a + * resolved note thread). + * @param props.onToggleReaction Callback to toggle a reaction. */ export function AddReactionButton( { noteId, disabled = false, onToggleReaction, -} ) { +}: AddReactionButtonProps ) { const [ isFullPicker, setIsFullPicker ] = useState( false ); const { recordUse } = useFrequentEmojis(); const hasFullPicker = @@ -370,7 +429,7 @@ export function AddReactionButton( { /> ) } renderContent={ ( { onClose } ) => { - const pickReaction = ( slug ) => { + const pickReaction = ( slug: string ) => { onClose(); // Invalidate cached tooltip names since adding this // reaction changes the set of users for the slug. @@ -408,7 +467,9 @@ export function AddReactionButton( { // "Frequently used" section. (The full picker // records its own picks.) recordUse( - emojiToHexKey( emojiBySlug.get( slug )?.emoji ) + emojiToHexKey( + emojiBySlug.get( slug )?.emoji ?? '' + ) ); pickReaction( slug ); } } diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx similarity index 69% rename from packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js rename to packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index e568c3fdcdecb9..04102c2edb4abb 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -5,12 +5,27 @@ import { __, _x } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; import { plus as plusIcon } from '@wordpress/icons'; +/** + * A single curated reaction emoji. + */ +export interface CuratedEmoji { + emoji: string; + label: string; + value: string; +} + +interface ReactionEmojiPickerProps { + onSelect: ( slug: string ) => void; + onMore?: () => void; + onMoreHover?: () => void; +} + /** * Curated emoji set for reactions. * The `value` slug is used as the storage key in the database to avoid * potential encoding issues with emoji characters. */ -export const REACTION_EMOJIS = [ +export const REACTION_EMOJIS: CuratedEmoji[] = [ { emoji: '❤️', label: _x( 'Heart', 'emoji reaction' ), value: 'heart' }, { emoji: '🎉', @@ -43,25 +58,25 @@ const HEX_KEY_RE = /^[0-9a-f]{2,6}(-[0-9a-f]{2,6})*$/; * stripping variation selector U+FE0F so visually equivalent * presentations collapse to the same key. * - * @param {string} emoji The emoji character. - * @return {string} Lowercase hex codepoints joined by `-`. + * @param emoji The emoji character. + * @return Lowercase hex codepoints joined by `-`. */ -export function emojiToHexKey( emoji ) { +export function emojiToHexKey( emoji: string ): string { if ( typeof emoji !== 'string' || ! emoji ) { return ''; } return Array.from( emoji.replace( /️/g, '' ) ) - .map( ( c ) => c.codePointAt( 0 ).toString( 16 ) ) + .map( ( c ) => ( c.codePointAt( 0 ) as number ).toString( 16 ) ) .join( '-' ); } /** * Convert a hex-codepoint sequence back to its emoji character. * - * @param {string} hexKey Lowercase hex codepoints joined by `-`. - * @return {string} The emoji character, or the input on parse failure. + * @param hexKey Lowercase hex codepoints joined by `-`. + * @return The emoji character, or the input on parse failure. */ -export function hexKeyToEmoji( hexKey ) { +export function hexKeyToEmoji( hexKey: string ): string { if ( typeof hexKey !== 'string' || ! HEX_KEY_RE.test( hexKey ) ) { return hexKey; } @@ -79,11 +94,14 @@ export function hexKeyToEmoji( hexKey ) { * a curated reaction (after stripping VS-16) returns its slug, otherwise * returns the hex-codepoint key. * - * @param {string} emoji The emoji character. - * @param {Array} emojis Curated emoji list to match against. - * @return {string} The storage key (slug or hex codepoints). + * @param emoji The emoji character. + * @param emojis Curated emoji list to match against. + * @return The storage key (slug or hex codepoints). */ -export function emojiToStorageKey( emoji, emojis = REACTION_EMOJIS ) { +export function emojiToStorageKey( + emoji: string, + emojis: CuratedEmoji[] = REACTION_EMOJIS +): string { const hex = emojiToHexKey( emoji ); const curated = emojis.find( ( r ) => emojiToHexKey( r.emoji ) === hex ); return curated ? curated.value : hex; @@ -92,10 +110,12 @@ export function emojiToStorageKey( emoji, emojis = REACTION_EMOJIS ) { /** * Build a Map keyed by slug for O(1) emoji and label lookups. * - * @param {Array} emojis The emoji list to index. - * @return {Map} Map from slug to `{ emoji, label, value }` entry. + * @param emojis The emoji list to index. + * @return Map from slug to `{ emoji, label, value }` entry. */ -export function buildEmojiBySlugMap( emojis = REACTION_EMOJIS ) { +export function buildEmojiBySlugMap( + emojis: CuratedEmoji[] = REACTION_EMOJIS +): Map< string, CuratedEmoji > { return new Map( emojis.map( ( entry ) => [ entry.value, entry ] ) ); } @@ -105,21 +125,19 @@ export function buildEmojiBySlugMap( emojis = REACTION_EMOJIS ) { * is a plain button rendered after the listbox (not inside it) so the * listbox only contains selectable options. * - * @param {Object} props Component props. - * @param {Function} props.onSelect Called with the chosen slug when the - * user picks a curated emoji. - * @param {Function} props.onMore When provided, renders the trailing - * `+` button and is called when it is - * clicked. - * @param {Function} props.onMoreHover Called when the `+` button is - * hovered or focused (used to prefetch - * the full picker). + * @param props Component props. + * @param props.onSelect Called with the chosen slug when the user picks a + * curated emoji. + * @param props.onMore When provided, renders the trailing `+` button and + * is called when it is clicked. + * @param props.onMoreHover Called when the `+` button is hovered or focused + * (used to prefetch the full picker). */ export default function ReactionEmojiPicker( { onSelect, onMore, onMoreHover, -} ) { +}: ReactionEmojiPickerProps ) { return (
void; +} + /** * The six selectable skin tones, in display order. Tone `0` is the * default (yellow) presentation — the emoji's base form — and sits @@ -15,7 +34,7 @@ import { useInstanceId } from '@wordpress/compose'; * Every swatch renders the same exemplar emoji (the raised hand) so * the only difference between options is the tone itself. */ -export const SKIN_TONES = [ +export const SKIN_TONES: SkinToneOption[] = [ { tone: 0, emoji: '✋', label: __( 'Default skin tone' ) }, { tone: 1, emoji: '✋🏻', label: __( 'Light skin tone' ) }, { tone: 2, emoji: '✋🏼', label: __( 'Medium-light skin tone' ) }, @@ -32,11 +51,14 @@ export const SKIN_TONES = [ * (whose `tone` is an array and can't be produced by a single-tone * preference). * - * @param {Object} entry Emojibase emoji record. - * @param {number} tone Selected tone, 0 (default) through 5. - * @return {Object} The record to render: a skin variant or the base entry. + * @param entry Emojibase emoji record. + * @param tone Selected tone, 0 (default) through 5. + * @return The record to render: a skin variant or the base entry. */ -export function applySkinTone( entry, tone ) { +export function applySkinTone( + entry: EmojibaseEntry, + tone: number +): EmojibaseEntry | EmojibaseSkin { if ( ! tone || ! Array.isArray( entry.skins ) ) { return entry; } @@ -49,11 +71,14 @@ export function applySkinTone( entry, tone ) { * flyout of six exemplar swatches under an explicit heading. Selecting * a swatch calls `onChange` with the tone number and closes the flyout. * - * @param {Object} props Component props. - * @param {number} props.value The selected tone, 0–5. - * @param {Function} props.onChange Called with the newly selected tone. + * @param props Component props. + * @param props.value The selected tone, 0–5. + * @param props.onChange Called with the newly selected tone. */ -export default function SkinTonePicker( { value, onChange } ) { +export default function SkinTonePicker( { + value, + onChange, +}: SkinTonePickerProps ) { const headingId = useInstanceId( SkinTonePicker, 'editor-collab-sidebar-panel__skin-tone-heading' From 6179be8d837d03f108c9abc311bcff70dc10b8a4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 10 Jul 2026 11:03:27 -0700 Subject: [PATCH 222/362] Remove reaction added/removed snackbars Adding or removing a reaction is already visible in the reaction pill itself, so the snackbars were redundant noise (per review feedback from jasmussen). Re-point the e2e sync gates from the snackbar notices to the reaction pill appearing/disappearing. --- .../src/components/collab-sidebar/hooks.js | 11 --- .../specs/editor/various/block-notes.spec.js | 82 +++---------------- 2 files changed, 12 insertions(+), 81 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 394997d65d050b..c1765c3807919e 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -533,11 +533,6 @@ export function useNoteActions( reactionsMap = {} ) { { force: true }, { throwOnError: true } ); - - createNotice( 'snackbar', __( 'Reaction removed.' ), { - type: 'snackbar', - isDismissible: true, - } ); } else { // Add a new reaction as a comment record. await saveEntityRecord( @@ -552,11 +547,6 @@ export function useNoteActions( reactionsMap = {} ) { }, { throwOnError: true } ); - - createNotice( 'snackbar', __( 'Reaction added.' ), { - type: 'snackbar', - isDismissible: true, - } ); } // `reaction_summary` is computed server-side and @@ -585,7 +575,6 @@ export function useNoteActions( reactionsMap = {} ) { deleteEntityRecord, saveEntityRecord, getCurrentPostId, - createNotice, receiveEntityRecords, ] ); diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 6487bc58a9a68b..7d013955384d49 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -980,12 +980,6 @@ test.describe( 'Block Notes', () => { await blockNoteUtils.addReactionToComment( 'Heart' ); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) - ).toBeVisible(); - // Verify the reaction button appears with count. const reactionButton = page.getByRole( 'button', { name: /Heart/, @@ -1011,37 +1005,9 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toBeVisible(); await expect( reactionButton ).toContainText( '1' ); - // The initial add must surface a success snackbar. Dismiss it so - // the re-add assertion below checks the re-add's own snackbar, - // rather than relying on both being present at once (snackbars - // auto-dismiss, which makes a cumulative count flaky). - const addedNotice = page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ); - await expect( addedNotice ).toBeVisible(); - await addedNotice.click(); - await expect( addedNotice ).toBeHidden(); - - /* - * Clicking the snackbar moves focus (and selection) away from - * the note thread, which collapses it and unmounts the - * reaction pill. Re-select the thread so the pill renders - * again before interacting with it. - */ - await page - .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'treeitem', { name: 'Note: Re-add reaction' } ) - .click(); - await expect( reactionButton ).toBeVisible(); - // Remove the reaction. await reactionButton.click(); await expect( reactionButton ).toBeHidden(); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction removed.' } ) - ).toBeVisible(); // Add the same reaction again. This used to fail two ways: // 1) the parent note's cached `reaction_summary` still @@ -1057,11 +1023,9 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toContainText( '❤' ); await expect( reactionButton ).toContainText( '1' ); - // The re-add must surface its own success snackbar, and the - // duplicate-reaction error must never appear — pins both fixes - // (client refetch + server status='approve' query) against - // regression. - await expect( addedNotice ).toBeVisible(); + // The duplicate-reaction error must never appear — pins both + // fixes (client refetch + server status='approve' query) + // against regression. await expect( page.locator( '.components-snackbar__content', { hasText: /already reacted/i, @@ -1081,23 +1045,13 @@ test.describe( 'Block Notes', () => { // Add a reaction. await blockNoteUtils.addReactionToComment( 'Heart' ); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) - ).toBeVisible(); - - // Click the reaction to remove it. const reactionButton = page.getByRole( 'button', { name: /Heart/, } ); - await reactionButton.click(); + await expect( reactionButton ).toBeVisible(); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction removed.' } ) - ).toBeVisible(); + // Click the reaction to remove it. + await reactionButton.click(); // Verify the reaction button is no longer visible. await expect( reactionButton ).toBeHidden(); @@ -1115,16 +1069,12 @@ test.describe( 'Block Notes', () => { // Add a reaction. await blockNoteUtils.addReactionToComment( 'Celebration' ); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) - ).toBeVisible(); // Hover over the reaction button to trigger tooltip. const reactionButton = page.getByRole( 'button', { name: /Celebration/, } ); + await expect( reactionButton ).toBeVisible(); await reactionButton.hover(); // Verify the tooltip is visible and contains expected text. @@ -1164,10 +1114,9 @@ test.describe( 'Block Notes', () => { await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Enter' ); + // The selected emoji renders as a reaction pill on the note. await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) + page.locator( '.editor-collab-sidebar-panel__reaction-button' ) ).toBeVisible(); } ); @@ -1184,26 +1133,19 @@ test.describe( 'Block Notes', () => { // Add first reaction. await blockNoteUtils.addReactionToComment( 'Smile' ); await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) + page.getByRole( 'button', { name: /Smile/ } ) ).toBeVisible(); // Add second reaction. await blockNoteUtils.addReactionToComment( 'Rocket' ); await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) + page.getByRole( 'button', { name: /Rocket/ } ) ).toBeVisible(); - // Verify both reactions are visible. + // Both reactions remain visible together. await expect( page.getByRole( 'button', { name: /Smile/ } ) ).toBeVisible(); - await expect( - page.getByRole( 'button', { name: /Rocket/ } ) - ).toBeVisible(); } ); test( 'reaction picker portals outside the collab sidebar', async ( { From 3af9768f3267df9fae366cd84ed3b0c3315ebc93 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 10 Jul 2026 11:08:58 -0700 Subject: [PATCH 223/362] Convert new reaction modules to TypeScript New code should be authored as TypeScript. Rename the reaction source and test modules to .tsx and add types; the editor package sets checkJs:false, so the rename opts these files into the base config's strict checking. Interface names match the stacked full picker branch to ease merging up. --- ...action-display.js => reaction-display.tsx} | 137 ++++++++++++------ ...ji-picker.js => reaction-emoji-picker.tsx} | 33 ++++- ...action-display.js => reaction-display.tsx} | 0 ...ji-picker.js => reaction-emoji-picker.tsx} | 0 4 files changed, 121 insertions(+), 49 deletions(-) rename packages/editor/src/components/collab-sidebar/{reaction-display.js => reaction-display.tsx} (69%) rename packages/editor/src/components/collab-sidebar/{reaction-emoji-picker.js => reaction-emoji-picker.tsx} (70%) rename packages/editor/src/components/collab-sidebar/test/{reaction-display.js => reaction-display.tsx} (100%) rename packages/editor/src/components/collab-sidebar/test/{reaction-emoji-picker.js => reaction-emoji-picker.tsx} (100%) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.tsx similarity index 69% rename from packages/editor/src/components/collab-sidebar/reaction-display.js rename to packages/editor/src/components/collab-sidebar/reaction-display.tsx index 047d17023e6b48..f3f0ca07ef607d 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import type { MouseEvent } from 'react'; + /** * WordPress dependencies */ @@ -33,6 +38,24 @@ import ReactionEmojiPicker, { buildEmojiBySlugMap, } from './reaction-emoji-picker'; +interface ReactionSummaryEntry { + count: number; + reacted?: boolean; +} + +/** + * The reaction summary keyed by storage slug. + */ +type ReactionSummary = Record< string, ReactionSummaryEntry >; + +/** + * A comment record as returned by the reactions REST query. + */ +interface ReactionComment { + author_name: string; + content: string | { raw?: string; rendered?: string }; +} + // The curated emoji set is static, so index it once at module load. const emojiBySlug = buildEmojiBySlugMap(); @@ -40,37 +63,45 @@ const emojiBySlug = buildEmojiBySlugMap(); // so it escapes the `overflow: hidden` chain on the collab sidebar // (`.interface-interface-skeleton__sidebar`, // `.editor-collab-sidebar`, `.editor-collab-sidebar-panel`). -const POPOVER_PROPS = { placement: 'bottom-end' }; +const POPOVER_PROPS = { placement: 'bottom-end' } as const; /** * Get the count of reactions for a specific slug. * - * @param {Object} reactions The reactions summary (keyed by slug). - * @param {string} slug The reaction slug to count. - * @return {number} The count of reactions. + * @param reactions The reactions summary (keyed by slug). + * @param slug The reaction slug to count. + * @return The count of reactions. */ -function getReactionCount( reactions, slug ) { +function getReactionCount( + reactions: ReactionSummary | null | undefined, + slug: string +): number { return reactions?.[ slug ]?.count || 0; } /** * Check if the current user has reacted with a specific slug. * - * @param {Object} reactions The reactions summary (keyed by slug). - * @param {string} slug The reaction slug to check. - * @return {boolean} Whether the user has reacted. + * @param reactions The reactions summary (keyed by slug). + * @param slug The reaction slug to check. + * @return Whether the user has reacted. */ -function hasUserReacted( reactions, slug ) { +function hasUserReacted( + reactions: ReactionSummary | null | undefined, + slug: string +): boolean { return reactions?.[ slug ]?.reacted || false; } /** * Get all reaction slugs that have reactions. * - * @param {Object} reactions The reactions summary (keyed by slug). - * @return {string[]} Array of slugs with reactions. + * @param reactions The reactions summary (keyed by slug). + * @return Array of slugs with reactions. */ -function getReactedSlugs( reactions ) { +function getReactedSlugs( + reactions: ReactionSummary | null | undefined +): string[] { if ( ! reactions ) { return []; } @@ -82,11 +113,11 @@ function getReactedSlugs( reactions ) { /** * Generate GitHub-style tooltip text from user names. * - * @param {string[]} names Array of user display names. - * @param {string} emojiLabel The emoji label. - * @return {string} The tooltip text. + * @param names Array of user display names. + * @param emojiLabel The emoji label. + * @return The tooltip text. */ -function formatReactionTooltip( names, emojiLabel ) { +function formatReactionTooltip( names: string[], emojiLabel: string ): string { if ( names.length === 1 ) { return sprintf( /* translators: 1: user name, 2: emoji label. */ @@ -122,19 +153,29 @@ function formatReactionTooltip( names, emojiLabel ) { } // Module-level cache for reaction details: { "noteId:slug": string[] } -const reactionNamesCache = {}; +const reactionNamesCache: Record< string, string[] > = {}; + +interface ReactionButtonProps { + noteId: number; + slug: string; + count: number; + isActive: boolean; + emoji: string; + emojiLabel: string; + onToggleReaction: ( slug: string ) => void; +} /** * A single reaction pill button that lazy-loads user names on hover. * - * @param {Object} props Component props. - * @param {number} props.noteId The parent note comment ID. - * @param {string} props.slug The emoji slug. - * @param {number} props.count The reaction count. - * @param {boolean} props.isActive Whether the current user reacted. - * @param {string} props.emoji The emoji character. - * @param {string} props.emojiLabel The emoji label. - * @param {Function} props.onToggleReaction Callback to toggle a reaction. + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.slug The emoji slug. + * @param props.count The reaction count. + * @param props.isActive Whether the current user reacted. + * @param props.emoji The emoji character. + * @param props.emojiLabel The emoji label. + * @param props.onToggleReaction Callback to toggle a reaction. */ function ReactionButton( { noteId, @@ -144,7 +185,7 @@ function ReactionButton( { emoji, emojiLabel, onToggleReaction, -} ) { +}: ReactionButtonProps ) { const [ tooltipText, setTooltipText ] = useState( '' ); const [ isFetching, setIsFetching ] = useState( false ); @@ -165,7 +206,7 @@ function ReactionButton( { } setIsFetching( true ); - apiFetch( { + apiFetch< ReactionComment[] >( { path: addQueryArgs( '/wp/v2/comments', { parent: noteId, type: 'reaction', @@ -215,14 +256,16 @@ function ReactionButton( { variant="secondary" size="small" className="editor-collab-sidebar-panel__reaction-button" - onClick={ ( event ) => { + onClick={ ( event: MouseEvent< HTMLElement > ) => { event.stopPropagation(); // When removing the last reaction for this emoji, // the button will disappear. Move focus to the // parent note to prevent focus loss. if ( isActive && count === 1 ) { - event.target - .closest( '.editor-collab-sidebar-panel__thread' ) + ( event.target as HTMLElement ) + .closest< HTMLElement >( + '.editor-collab-sidebar-panel__thread' + ) ?.focus(); } // Invalidate cached names since the reaction set is changing. @@ -243,19 +286,25 @@ function ReactionButton( { ); } +interface ReactionDisplayProps { + noteId: number; + reactions: ReactionSummary | null | undefined; + onToggleReaction: ( slug: string ) => void; +} + /** * Display current reactions with counts as pill-shaped buttons. * - * @param {Object} props Component props. - * @param {number} props.noteId The parent note comment ID. - * @param {Object} props.reactions The reaction summary (keyed by slug). - * @param {Function} props.onToggleReaction Callback to toggle a reaction. + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.reactions The reaction summary (keyed by slug). + * @param props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { noteId, reactions, onToggleReaction, -} ) { +}: ReactionDisplayProps ) { const reactedSlugs = getReactedSlugs( reactions ); if ( reactedSlugs.length === 0 ) { @@ -286,21 +335,27 @@ export default function ReactionDisplay( { ); } +interface AddReactionButtonProps { + noteId: number; + disabled?: boolean; + onToggleReaction: ( slug: string ) => void; +} + /** * Standalone add-reaction button with the curated emoji picker * dropdown (the 5-emoji quick row). * - * @param {Object} props Component props. - * @param {number} props.noteId The parent note comment ID. - * @param {boolean} props.disabled Whether the button is disabled - * (e.g. on a resolved note thread). - * @param {Function} props.onToggleReaction Callback to toggle a reaction. + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.disabled Whether the button is disabled (e.g. on a + * resolved note thread). + * @param props.onToggleReaction Callback to toggle a reaction. */ export function AddReactionButton( { noteId, disabled = false, onToggleReaction, -} ) { +}: AddReactionButtonProps ) { return ( void; +} + /** * Curated emoji set for reactions. * The `value` slug is used as the storage key in the database to avoid * potential encoding issues with emoji characters. */ -export const REACTION_EMOJIS = [ +export const REACTION_EMOJIS: CuratedEmoji[] = [ { emoji: '❤️', label: _x( 'Heart', 'emoji reaction' ), value: 'heart' }, { emoji: '🎉', @@ -32,21 +45,25 @@ export const REACTION_EMOJIS = [ /** * Build a Map keyed by slug for O(1) emoji and label lookups. * - * @param {Array} emojis The emoji list to index. - * @return {Map} Map from slug to `{ emoji, label, value }` entry. + * @param emojis The emoji list to index. + * @return Map from slug to `{ emoji, label, value }` entry. */ -export function buildEmojiBySlugMap( emojis = REACTION_EMOJIS ) { +export function buildEmojiBySlugMap( + emojis: CuratedEmoji[] = REACTION_EMOJIS +): Map< string, CuratedEmoji > { return new Map( emojis.map( ( entry ) => [ entry.value, entry ] ) ); } /** * A row of curated emoji buttons. * - * @param {Object} props Component props. - * @param {Function} props.onSelect Called with the chosen slug when the - * user picks a curated emoji. + * @param props Component props. + * @param props.onSelect Called with the chosen slug when the user picks a + * curated emoji. */ -export default function ReactionEmojiPicker( { onSelect } ) { +export default function ReactionEmojiPicker( { + onSelect, +}: ReactionEmojiPickerProps ) { return ( Date: Fri, 10 Jul 2026 11:24:47 -0700 Subject: [PATCH 224/362] Wire the reaction emoji filter to the picker and cover it with e2e The gutenberg_note_reaction_emojis filter only drove REST validation; the picker hardcoded the 5 curated emoji, so filter additions were accepted by the API but never offered in the UI. Inject the filtered list into block editor settings (preloaded, no extra request - the earlier OPTIONS fetch was rejected in review) and read it client-side with a hardcoded fallback and malformed-entry hardening. Wrap the listbox into rows and cap its height so large sets stay usable. Add a test plugin extending the list by 20 emoji plus e2e coverage: the picker offers the extended set, a filter-added slug round-trips through the REST API to a rendered pill, and the picker stays within the viewport with the last entry still selectable. --- lib/compat/wordpress-7.1/block-comments.php | 15 +++ .../plugins/note-reaction-emojis.php | 125 ++++++++++++++++++ .../collab-sidebar/reaction-display.tsx | 13 +- .../collab-sidebar/reaction-emoji-picker.tsx | 34 ++++- .../src/components/collab-sidebar/style.scss | 8 ++ .../test/reaction-emoji-picker.tsx | 52 +++++++- .../provider/use-block-editor-settings.js | 1 + .../specs/editor/various/block-notes.spec.js | 110 +++++++++++++++ 8 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 packages/e2e-tests/plugins/note-reaction-emojis.php diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index 37041400f71a32..d7b896d3218172 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -297,3 +297,18 @@ function gutenberg_get_note_reaction_emojis() { */ return apply_filters( 'gutenberg_note_reaction_emojis', $default_emojis ); } + +/** + * Injects the note reaction emoji list into block editor settings so the + * reaction picker offers the same (filterable) set the REST API accepts. + * + * @since 7.1.0 + * + * @param array $settings Existing block editor settings. + * @return array Updated block editor settings. + */ +function gutenberg_add_note_reaction_emojis_setting( $settings ) { + $settings['noteReactionEmojis'] = gutenberg_get_note_reaction_emojis(); + return $settings; +} +add_filter( 'block_editor_settings_all', 'gutenberg_add_note_reaction_emojis_setting' ); diff --git a/packages/e2e-tests/plugins/note-reaction-emojis.php b/packages/e2e-tests/plugins/note-reaction-emojis.php new file mode 100644 index 00000000000000..af2ad714cc3093 --- /dev/null +++ b/packages/e2e-tests/plugins/note-reaction-emojis.php @@ -0,0 +1,125 @@ + '👍', + 'label' => 'Thumbs up', + 'value' => 'thumbs-up', + ), + array( + 'emoji' => '👎', + 'label' => 'Thumbs down', + 'value' => 'thumbs-down', + ), + array( + 'emoji' => '🔥', + 'label' => 'Fire', + 'value' => 'fire', + ), + array( + 'emoji' => '⭐', + 'label' => 'Star', + 'value' => 'star', + ), + array( + 'emoji' => '👏', + 'label' => 'Clap', + 'value' => 'clap', + ), + array( + 'emoji' => '😂', + 'label' => 'Joy', + 'value' => 'joy', + ), + array( + 'emoji' => '😢', + 'label' => 'Cry', + 'value' => 'cry', + ), + array( + 'emoji' => '😮', + 'label' => 'Wow', + 'value' => 'wow', + ), + array( + 'emoji' => '🤔', + 'label' => 'Thinking', + 'value' => 'thinking', + ), + array( + 'emoji' => '🙏', + 'label' => 'Pray', + 'value' => 'pray', + ), + array( + 'emoji' => '💯', + 'label' => 'Hundred', + 'value' => 'hundred', + ), + array( + 'emoji' => '🎈', + 'label' => 'Balloon', + 'value' => 'balloon', + ), + array( + 'emoji' => '🍕', + 'label' => 'Pizza', + 'value' => 'pizza', + ), + array( + 'emoji' => '🌈', + 'label' => 'Rainbow', + 'value' => 'rainbow', + ), + array( + 'emoji' => '⚡', + 'label' => 'Lightning', + 'value' => 'lightning', + ), + array( + 'emoji' => '🐛', + 'label' => 'Bug', + 'value' => 'bug', + ), + array( + 'emoji' => '🚢', + 'label' => 'Ship', + 'value' => 'ship', + ), + array( + 'emoji' => '🧠', + 'label' => 'Brain', + 'value' => 'brain', + ), + array( + 'emoji' => '🦄', + 'label' => 'Unicorn', + 'value' => 'unicorn', + ), + array( + 'emoji' => '🏆', + 'label' => 'Trophy', + 'value' => 'trophy', + ), + ); + + return array_merge( $emojis, $extra ); +} +add_filter( 'gutenberg_note_reaction_emojis', 'gutenberg_test_note_reaction_emojis' ); diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index f3f0ca07ef607d..76f03aef84d1c3 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -10,7 +10,7 @@ import { __, sprintf, _n } from '@wordpress/i18n'; import { Button, Dropdown } from '@wordpress/components'; import { Stack } from '@wordpress/ui'; import { SVG, Path } from '@wordpress/primitives'; -import { useState, useCallback } from '@wordpress/element'; +import { useState, useCallback, useMemo } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; @@ -36,6 +36,7 @@ const smileyIcon = ( */ import ReactionEmojiPicker, { buildEmojiBySlugMap, + useReactionEmojis, } from './reaction-emoji-picker'; interface ReactionSummaryEntry { @@ -56,9 +57,6 @@ interface ReactionComment { content: string | { raw?: string; rendered?: string }; } -// The curated emoji set is static, so index it once at module load. -const emojiBySlug = buildEmojiBySlugMap(); - // `Dropdown`'s popover is rendered in a portal anchored to , // so it escapes the `overflow: hidden` chain on the collab sidebar // (`.interface-interface-skeleton__sidebar`, @@ -305,6 +303,13 @@ export default function ReactionDisplay( { reactions, onToggleReaction, }: ReactionDisplayProps ) { + // The list is filterable server-side (and static per page load), + // so index it once per list identity. + const emojis = useReactionEmojis(); + const emojiBySlug = useMemo( + () => buildEmojiBySlugMap( emojis ), + [ emojis ] + ); const reactedSlugs = getReactedSlugs( reactions ); if ( reactedSlugs.length === 0 ) { diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 93b5d8d27c3ba1..858181015d8dab 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -3,6 +3,9 @@ */ import { __, _x } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +// @ts-expect-error - No type declarations available for @wordpress/block-editor. +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * A single curated reaction emoji. @@ -42,6 +45,33 @@ export const REACTION_EMOJIS: CuratedEmoji[] = [ * and give stable grouping in the `reaction_summary` aggregation. */ +/** + * Returns the reaction emoji list from block editor settings, falling + * back to the curated defaults. The server injects the list via the + * `gutenberg_note_reaction_emojis` PHP filter, so the picker offers the + * same set the REST API accepts. Malformed entries are dropped. + * + * @return The emoji list to offer in the picker. + */ +export function useReactionEmojis(): CuratedEmoji[] { + return useSelect( ( select ) => { + const settings: Record< string, unknown > = + select( blockEditorStore ).getSettings(); + const emojis = settings.noteReactionEmojis; + if ( ! Array.isArray( emojis ) ) { + return REACTION_EMOJIS; + } + const valid = emojis.filter( + ( entry ): entry is CuratedEmoji => + !! entry && + typeof entry.emoji === 'string' && + typeof entry.label === 'string' && + typeof entry.value === 'string' + ); + return valid.length ? valid : REACTION_EMOJIS; + }, [] ); +} + /** * Build a Map keyed by slug for O(1) emoji and label lookups. * @@ -64,6 +94,8 @@ export function buildEmojiBySlugMap( export default function ReactionEmojiPicker( { onSelect, }: ReactionEmojiPickerProps ) { + const emojis = useReactionEmojis(); + return ( - { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( + { emojis.map( ( { emoji, label, value } ) => ( { expect( onSelect ).toHaveBeenCalledTimes( 1 ); expect( onSelect ).toHaveBeenCalledWith( 'smile' ); } ); + + describe( 'settings-provided emoji list', () => { + afterEach( () => { + // The picker may still be mounted when the settings reset + // lands, so the resulting re-render must be act()-wrapped. + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: undefined, + } ); + } ); + } ); + + it( 'renders the list from editor settings when present', () => { + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: [ + ...REACTION_EMOJIS, + { emoji: '👍', label: 'Thumbs up', value: 'thumbs-up' }, + ], + } ); + render( {} } /> ); + + expect( screen.getAllByRole( 'option' ) ).toHaveLength( + REACTION_EMOJIS.length + 1 + ); + expect( + screen.getByRole( 'option', { name: 'Thumbs up' } ) + ).toBeVisible(); + } ); + + it( 'drops malformed entries and falls back to defaults when none survive', () => { + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: [ + null, + { emoji: '👍' }, + { label: 'No emoji', value: 'no-emoji' }, + ], + } ); + render( {} } /> ); + + expect( screen.getAllByRole( 'option' ) ).toHaveLength( + REACTION_EMOJIS.length + ); + } ); + } ); } ); diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index db85f17fa07d8c..503c1dfa4b642d 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -92,6 +92,7 @@ const BLOCK_EDITOR_SETTINGS = [ 'isRTL', 'locale', 'maxWidth', + 'noteReactionEmojis', 'postContentAttributes', 'postsPerPage', 'readOnly', diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 7d013955384d49..e815677ba54ddc 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1202,6 +1202,116 @@ test.describe( 'Block Notes', () => { // stays selected and the trigger stays mounted. await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); } ); + + test.describe( 'Filtered emoji list', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( + 'gutenberg-test-note-reaction-emojis' + ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-note-reaction-emojis' + ); + } ); + + test( 'picker offers emojis added via the gutenberg_note_reaction_emojis filter', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing filtered emojis' }, + comment: 'Filtered emojis', + } ); + + await page + .getByRole( 'button', { name: 'Add reaction' } ) + .click(); + const emojiPicker = page.locator( + '.editor-collab-sidebar-panel__emoji-picker' + ); + await expect( emojiPicker ).toBeVisible(); + + // The 5 defaults plus the 20 filter-added entries. + await expect( emojiPicker.getByRole( 'option' ) ).toHaveCount( + 25 + ); + await expect( + emojiPicker.getByRole( 'option', { name: 'Heart' } ) + ).toBeVisible(); + await expect( + emojiPicker.getByRole( 'option', { name: 'Thumbs up' } ) + ).toBeVisible(); + } ); + + test( 'can react with a filter-added emoji', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing filtered reaction' }, + comment: 'Filtered reaction', + } ); + + // Exercises the whole path: the picker offers the custom + // entry, the REST API accepts its slug, and the pill + // resolves the slug back to the filtered emoji and label. + await blockNoteUtils.addReactionToComment( 'Unicorn' ); + + const reactionButton = page.getByRole( 'button', { + name: /Unicorn/, + } ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '🦄' ); + await expect( reactionButton ).toContainText( '1' ); + } ); + + test( 'picker stays usable with many emojis', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing large emoji set' }, + comment: 'Large emoji set', + } ); + + await page + .getByRole( 'button', { name: 'Add reaction' } ) + .click(); + const emojiPicker = page.locator( + '.editor-collab-sidebar-panel__emoji-picker' + ); + await expect( emojiPicker ).toBeVisible(); + + // The listbox wraps instead of growing unbounded, so the + // popover stays within the viewport. + const viewport = page.viewportSize(); + const box = await emojiPicker.boundingBox(); + expect( box.width ).toBeLessThan( viewport.width / 2 ); + expect( box.x ).toBeGreaterThanOrEqual( 0 ); + expect( box.x + box.width ).toBeLessThanOrEqual( + viewport.width + ); + + // The last filter-added entry is reachable (scrolls into + // view if needed) and selectable. + const lastOption = emojiPicker.getByRole( 'option', { + name: 'Trophy', + } ); + await lastOption.scrollIntoViewIfNeeded(); + await lastOption.click(); + + const reactionButton = page.getByRole( 'button', { + name: /Trophy/, + } ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '🏆' ); + } ); + } ); } ); test.describe( 'Multiple notes per block', () => { From ff10c2f21906a6da428afc3929ca2ba7c5537cea Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 10 Jul 2026 22:32:53 -0700 Subject: [PATCH 225/362] Notes: Fix build failures from trunk merge Restore the @ts-expect-error on the @wordpress/block-editor import in reaction-emoji-picker.tsx. The directive is required by the project- reference type build (build:all), matching sibling editor .tsx files (collaborators-overlay, sync-connection-error-modal); it only appeared unused against a locally-cached isolated tsconfig. Replace the removed --wpds-elevation-md design token in the picker popover with a component-local elevation token, mirroring the migration in #80099 that removed the shared elevation tokens. --- .../collab-sidebar/reaction-emoji-picker.tsx | 1 + .../editor/src/components/collab-sidebar/style.scss | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 224cc9bd57f3b0..19422bbc769dc1 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -5,6 +5,7 @@ import { __, _x } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; import { plus as plusIcon } from '@wordpress/icons'; import { useSelect } from '@wordpress/data'; +// @ts-expect-error - No type declarations available for @wordpress/block-editor import { store as blockEditorStore } from '@wordpress/block-editor'; /** diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 49c02cfd9cb4fb..f5722d4ca924ca 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -303,9 +303,17 @@ mark.wp-note { // surface around it. .editor-collab-sidebar-panel__picker-popover { .components-popover__content { + // Local elevation token; the shared `--wpds-elevation-md` was removed + // in #80099, which migrated call sites to component-local tokens. + --_wp-editor-picker-elevation-md: + 0 2px 3px rgba(0, 0, 0, 0.05), + 0 4px 5px rgba(0, 0, 0, 0.04), + 0 12px 12px rgba(0, 0, 0, 0.03), + 0 16px 16px rgba(0, 0, 0, 0.02); + padding: 0; border-radius: var(--wpds-border-radius-md); - box-shadow: var(--wpds-elevation-md); + box-shadow: var(--_wp-editor-picker-elevation-md); background-color: var(--wpds-color-background-surface-neutral-weak); } } From 0d9df50114e826eab2dd874f5f532d3075932090 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 10 Jul 2026 23:11:45 -0700 Subject: [PATCH 226/362] Notes: Drop stale 'Reaction added.' snackbar assertions from e2e Base #76767 removed the add/remove reaction snackbars, but the full- picker e2e tests still asserted the 'Reaction added.' notice. The tests already verify the meaningful outcome (the reaction button renders with the correct emoji and count), so drop the four obsolete notice assertions to match the current no-snackbar behavior. --- .../specs/editor/various/block-notes.spec.js | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 554f6ef3a3a2af..2c5c3c7edb9609 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1192,12 +1192,6 @@ test.describe( 'Block Notes', () => { .getByRole( 'gridcell', { name: 'Heart', exact: true } ) .click(); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) - ).toBeVisible(); - // The same reaction button shape that the curated row produces // — contains the heart emoji and a count of 1. If storage // normalization were broken we'd get a stray hex-key button. @@ -1221,12 +1215,6 @@ test.describe( 'Block Notes', () => { await blockNoteUtils.pickFullPickerEmojiBySearch( 'thumbs up' ); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) - ).toBeVisible(); - const reactionButton = page.locator( '.editor-collab-sidebar-panel__reaction-button' ); @@ -1341,11 +1329,6 @@ test.describe( 'Block Notes', () => { await page .getByRole( 'gridcell', { name: 'avocado', exact: true } ) .click(); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) - ).toBeVisible(); // …and on reopening the picker, the recorded pick has joined // the Frequently used section. @@ -1414,12 +1397,6 @@ test.describe( 'Block Notes', () => { } ) .click(); - await expect( - page - .getByRole( 'button', { name: 'Dismiss this notice' } ) - .filter( { hasText: 'Reaction added.' } ) - ).toBeVisible(); - // The stored reaction renders the toned emoji. const reactionButton = page.locator( '.editor-collab-sidebar-panel__reaction-button' From 76ccc269223447b36ebbb8a0b570fa96f6ed8448 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 08:52:23 -0700 Subject: [PATCH 227/362] Notes: Decode emoji code points without mbstring mb_ord() is not polyfilled by the WordPress compatibility layer on all supported versions, and the mbstring extension is recommended rather than required, so calling it unconditionally on init could fatal on WordPress 6.9 installs without the extension. Decode UTF-8 byte-by-byte instead and reject malformed sequences. --- lib/compat/wordpress-7.1/emojibase-data.php | 34 ++++++++++++++++++--- phpunit/emoji-picker-data-test.php | 34 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/lib/compat/wordpress-7.1/emojibase-data.php b/lib/compat/wordpress-7.1/emojibase-data.php index 2dbee3d48502d5..c1351c791d6d0e 100644 --- a/lib/compat/wordpress-7.1/emojibase-data.php +++ b/lib/compat/wordpress-7.1/emojibase-data.php @@ -33,6 +33,11 @@ function gutenberg_emojibase_data_register_inline_script() { * (U+FE0F) so qualified and unqualified presentations collapse to the * same key, matching Emojibase's own normalization. * + * Decodes UTF-8 byte-by-byte rather than via `mb_ord()`: the `mbstring` + * extension is recommended but not required by WordPress, and the + * WordPress compatibility layer does not polyfill `mb_ord()` on all + * supported versions. Returns an empty string for invalid UTF-8. + * * @since 7.1.0 * * @param string $emoji Emoji character. @@ -42,11 +47,32 @@ function gutenberg_emoji_to_hexcode( $emoji ) { if ( ! is_string( $emoji ) || '' === $emoji ) { return ''; } - $length = mb_strlen( $emoji, 'UTF-8' ); + $length = strlen( $emoji ); $parts = array(); - for ( $i = 0; $i < $length; $i++ ) { - $char = mb_substr( $emoji, $i, 1, 'UTF-8' ); - $codepoint = mb_ord( $char, 'UTF-8' ); + for ( $i = 0; $i < $length; ) { + $byte = ord( $emoji[ $i ] ); + if ( $byte < 0x80 ) { + $codepoint = $byte; + $size = 1; + } elseif ( 0xC0 === ( $byte & 0xE0 ) ) { + $codepoint = $byte & 0x1F; + $size = 2; + } elseif ( 0xE0 === ( $byte & 0xF0 ) ) { + $codepoint = $byte & 0x0F; + $size = 3; + } elseif ( 0xF0 === ( $byte & 0xF8 ) ) { + $codepoint = $byte & 0x07; + $size = 4; + } else { + return ''; + } + for ( $j = 1; $j < $size; $j++ ) { + if ( $i + $j >= $length || 0x80 !== ( ord( $emoji[ $i + $j ] ) & 0xC0 ) ) { + return ''; + } + $codepoint = ( $codepoint << 6 ) | ( ord( $emoji[ $i + $j ] ) & 0x3F ); + } + $i += $size; // Skip Variation Selector-16 so qualified `❤️` (2764 FE0F) // matches Emojibase's unqualified `2764` entry. if ( 0xFE0F === $codepoint ) { diff --git a/phpunit/emoji-picker-data-test.php b/phpunit/emoji-picker-data-test.php index 566a11f1393e66..80d449a0313a34 100644 --- a/phpunit/emoji-picker-data-test.php +++ b/phpunit/emoji-picker-data-test.php @@ -53,6 +53,40 @@ public function test_gutenberg_emoji_to_hexcode_handles_invalid_input() { $this->assertSame( '', gutenberg_emoji_to_hexcode( 42 ) ); } + /** + * Malformed UTF-8 must return an empty string rather than emitting a + * garbage hex key. The decoder is byte-level and does not depend on + * the `mbstring` extension. + * + * @covers ::gutenberg_emoji_to_hexcode + */ + public function test_gutenberg_emoji_to_hexcode_rejects_invalid_utf8() { + // Lone continuation byte. + $this->assertSame( '', gutenberg_emoji_to_hexcode( "\x80" ) ); + // Lead byte with missing continuation bytes. + $this->assertSame( '', gutenberg_emoji_to_hexcode( "\xF0\x9F" ) ); + // Truncated 4-byte sequence followed by ASCII. + $this->assertSame( '', gutenberg_emoji_to_hexcode( "\xF0\x9F\x98a" ) ); + // Invalid lead byte (0xF8 starts a 5-byte form, never valid UTF-8). + $this->assertSame( '', gutenberg_emoji_to_hexcode( "\xF8\x88\x80\x80\x80" ) ); + } + + /** + * Code points across the 1-4 byte UTF-8 ranges decode correctly. + * + * @covers ::gutenberg_emoji_to_hexcode + */ + public function test_gutenberg_emoji_to_hexcode_covers_all_byte_lengths() { + // 1 byte: U+0023 (#), part of keycap sequences. + $this->assertSame( '23', gutenberg_emoji_to_hexcode( '#' ) ); + // 2 bytes: U+00A9 (©). + $this->assertSame( 'A9', gutenberg_emoji_to_hexcode( "\u{00A9}" ) ); + // 3 bytes: U+2764 (❤). + $this->assertSame( '2764', gutenberg_emoji_to_hexcode( "\u{2764}" ) ); + // 4 bytes: U+1F600 (😀) — asserted in the basic test as well. + $this->assertSame( '1F600', gutenberg_emoji_to_hexcode( "\u{1F600}" ) ); + } + /** * The default override map seeds the curated reaction emojis so the * full picker shows the same translated label as the curated row. From a06fd3219782ce68ddd3ce2011a68cad2030d6ee Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 08:55:04 -0700 Subject: [PATCH 228/362] Notes: Normalize full-picker picks against the filtered emoji list emojiToStorageKey() fell back to the hard-coded curated defaults, so an emoji added via the gutenberg_note_reaction_emojis filter stored under its slug from the quick row but under its hex key from the full picker, splitting one visual reaction into two reaction_summary buckets. --- .../collab-sidebar/reaction-display.tsx | 9 +- .../collab-sidebar/test/reaction-display.tsx | 84 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index e9ddfd4d6add97..aa492d41cff51c 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -469,7 +469,14 @@ export function AddReactionButton( { > - pickReaction( emojiToStorageKey( emoji ) ) + // Match against the filtered curated + // list, not just the defaults, so a + // filter-provided emoji picked here + // stores under the same slug as the + // quick row. + pickReaction( + emojiToStorageKey( emoji, emojis ) + ) } /> diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index eb3f6c20543d4f..aa1b3abf6bca5b 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -1,13 +1,15 @@ /** * External dependencies */ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; /** * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { dispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies @@ -312,6 +314,86 @@ describe( 'AddReactionButton', () => { expect( onToggleReaction ).toHaveBeenCalledWith( 'rocket' ); } ); + it( 'stores a filter-provided emoji under its slug from both picker paths', async () => { + // A site filter adds 👍 with the slug `thumbs-up`. Picking it from + // the curated quick row and from the full searchable picker must + // both store `thumbs-up`, not the raw hex key `1f44d`, so the two + // paths aggregate into a single reaction_summary bucket. + window.gutenbergEmojibaseUrl = 'https://example.test/emojibase'; + const originalFetch = global.fetch; + global.fetch = jest.fn( ( url ) => + Promise.resolve( { + ok: true, + json: () => + Promise.resolve( + String( url ).includes( 'data.json' ) + ? [ + { + hexcode: '1F44D', + emoji: '👍', + label: 'thumbs up', + group: 0, + }, + ] + : { groups: [ { order: 0, message: 'Smileys' } ] } + ), + } ) + ); + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: [ + { emoji: '👍', label: 'Thumbs up', value: 'thumbs-up' }, + ], + } ); + + try { + const user = userEvent.setup(); + const onToggleReaction = jest.fn(); + render( + + ); + + // Path 1: the curated quick row. + await user.click( + screen.getByRole( 'button', { name: 'Add reaction' } ) + ); + await user.click( + await screen.findByRole( 'option', { name: 'Thumbs up' } ) + ); + expect( onToggleReaction ).toHaveBeenLastCalledWith( 'thumbs-up' ); + + // Path 2: the full searchable picker. + await user.click( + screen.getByRole( 'button', { name: 'Add reaction' } ) + ); + await user.click( + await screen.findByRole( 'button', { name: 'More emojis' } ) + ); + // The quick-row pick above also recorded 👍 as frequently + // used, so the grid shows it twice (Frequently used + its + // category); either cell exercises the same selection path. + await user.click( + ( + await screen.findAllByRole( 'gridcell', { + name: 'thumbs up', + } ) + )[ 0 ] + ); + expect( onToggleReaction ).toHaveBeenLastCalledWith( 'thumbs-up' ); + expect( onToggleReaction ).toHaveBeenCalledTimes( 2 ); + } finally { + global.fetch = originalFetch; + delete window.gutenbergEmojibaseUrl; + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: undefined, + } ); + } ); + } + } ); + it( 'stays focusable but inert when disabled', async () => { const user = userEvent.setup(); render( From ccfff03cb43f3a05818596a69b8ba9456df32d8e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 08:56:48 -0700 Subject: [PATCH 229/362] Notes: Announce emoji search result counts The search effect only spoke the zero-results case, so screen-reader users got no feedback when a query matched. Mirror the block-inserter search pattern: debounce speak() and announce the localized singular/plural count for every settled query. --- .../collab-sidebar/emoji-picker.tsx | 21 +++-- .../collab-sidebar/test/emoji-picker.js | 84 ++++++++++++++++++- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx index a08d586d426b09..1bda82343d13e0 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -1,12 +1,13 @@ /** * WordPress dependencies */ -import { __, _x } from '@wordpress/i18n'; +import { __, _n, _x, sprintf } from '@wordpress/i18n'; import { Composite, SearchControl } from '@wordpress/components'; import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as preferencesStore } from '@wordpress/preferences'; import { speak } from '@wordpress/a11y'; +import { useDebounce } from '@wordpress/compose'; /** * Internal dependencies @@ -257,15 +258,23 @@ export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { }, [ frequentKeys, recordByHexKey, query ] ); // Announce result counts during search so screen readers stay in sync - // with the visible grid as the user types. + // with the visible grid as the user types. Debounced (matching the + // block-inserter search pattern) so fast typing announces only the + // settled result, not every intermediate count. + const debouncedSpeak = useDebounce( speak, 500 ); useEffect( () => { if ( ! query.trim() || isLoading ) { return; } - if ( matchCount === 0 ) { - speak( __( 'No emoji found.' ) ); - } - }, [ query, matchCount, isLoading ] ); + const message = matchCount + ? sprintf( + /* translators: %d: number of emojis matching the search. */ + _n( '%d emoji found.', '%d emojis found.', matchCount ), + matchCount + ) + : __( 'No emoji found.' ); + debouncedSpeak( message ); + }, [ query, matchCount, isLoading, debouncedSpeak ] ); // Reset scroll position when the search query changes so the user // always sees the top match instead of a stale scroll offset. diff --git a/packages/editor/src/components/collab-sidebar/test/emoji-picker.js b/packages/editor/src/components/collab-sidebar/test/emoji-picker.js index 61644766d34706..ff20abe7d3953e 100644 --- a/packages/editor/src/components/collab-sidebar/test/emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/test/emoji-picker.js @@ -3,13 +3,26 @@ */ import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import { speak } from '@wordpress/a11y'; /** * Internal dependencies */ -import { chunkRows, groupEmojis, searchEmojis } from '../emoji-picker'; +import EmojiPicker, { + chunkRows, + groupEmojis, + searchEmojis, +} from '../emoji-picker'; import { EMOJIBASE_LOCALES, resolveEmojibaseLocale } from '../emojibase-data'; +jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) ); + describe( 'resolveEmojibaseLocale', () => { it( 'falls back to English for empty/invalid input', () => { expect( resolveEmojibaseLocale( '' ) ).toBe( 'en' ); @@ -169,6 +182,75 @@ describe( 'searchEmojis', () => { } ); } ); +describe( 'EmojiPicker search announcements', () => { + const originalFetch = global.fetch; + + beforeEach( () => { + speak.mockClear(); + window.gutenbergEmojibaseUrl = 'https://example.test/emojibase'; + global.fetch = jest.fn( ( url ) => + Promise.resolve( { + ok: true, + json: () => + Promise.resolve( + String( url ).includes( 'data.json' ) + ? [ + { + hexcode: '1F600', + emoji: '😀', + label: 'grinning face', + group: 0, + }, + { + hexcode: '1F601', + emoji: '😁', + label: 'beaming face', + group: 0, + }, + ] + : { groups: [ { order: 0, message: 'Smileys' } ] } + ), + } ) + ); + } ); + + afterEach( () => { + global.fetch = originalFetch; + delete window.gutenbergEmojibaseUrl; + } ); + + it( 'announces result counts and the empty state as the query settles', async () => { + const user = userEvent.setup(); + render( {} } /> ); + + // Wait for the dataset to load before searching. + await screen.findAllByRole( 'gridcell' ); + + const searchbox = screen.getByRole( 'searchbox', { + name: 'Search emoji', + } ); + + await user.type( searchbox, 'face' ); + // The announcement is debounced, so it fires once the typing + // settles rather than per keystroke. + await waitFor( () => + expect( speak ).toHaveBeenCalledWith( '2 emojis found.' ) + ); + + await user.clear( searchbox ); + await user.type( searchbox, 'grinning' ); + await waitFor( () => + expect( speak ).toHaveBeenCalledWith( '1 emoji found.' ) + ); + + await user.clear( searchbox ); + await user.type( searchbox, 'zzz' ); + await waitFor( () => + expect( speak ).toHaveBeenCalledWith( 'No emoji found.' ) + ); + } ); +} ); + describe( 'EMOJIBASE_LOCALES drift detection', () => { // `tools/build-scripts/copy-emojibase-data.mjs` hardcodes a parallel `LOCALES` // array — when the build runs it copies exactly those locale From f92a981d2e78283358cb32c907669c505e7e9fbc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 09:04:38 -0700 Subject: [PATCH 230/362] Notes: Focus the selected skin tone when the flyout opens Per the APG listbox pattern, focus goes to the selected option when a listbox with a selection receives focus. The popover's first-element focus and the composite's registration timing raced to put focus on the first swatch instead, so the menu now moves focus to the selected swatch itself on mount and restores focus to the toggle on close. --- .../collab-sidebar/skin-tone-picker.tsx | 137 +++++++++++++----- .../collab-sidebar/test/skin-tone-picker.js | 28 +++- 2 files changed, 126 insertions(+), 39 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx index 9cbeaad481bb50..0995b79fe61197 100644 --- a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx @@ -3,7 +3,8 @@ */ import { __, sprintf } from '@wordpress/i18n'; import { Button, Composite, Dropdown } from '@wordpress/components'; -import { useInstanceId } from '@wordpress/compose'; +import { useFocusReturn, useInstanceId } from '@wordpress/compose'; +import { useEffect } from '@wordpress/element'; /** * Internal dependencies @@ -24,6 +25,11 @@ interface SkinTonePickerProps { onChange: ( tone: number ) => void; } +interface SkinToneMenuProps extends SkinTonePickerProps { + baseId: string; + onClose: () => void; +} + /** * The six selectable skin tones, in display order. Tone `0` is the * default (yellow) presentation — the emoji's base form — and sits @@ -65,6 +71,86 @@ export function applySkinTone( return entry.skins.find( ( skin ) => skin.tone === tone ) || entry; } +/** + * The flyout body: a heading and the six-swatch listbox. + * + * @param props Component props. + * @param props.value The selected tone, 0–5. + * @param props.onChange Called with the newly selected tone. + * @param props.baseId Unique ID prefix for the heading and options. + * @param props.onClose Closes the flyout. + */ +function SkinToneMenu( { + value, + onChange, + baseId, + onClose, +}: SkinToneMenuProps ) { + const headingId = `${ baseId }-heading`; + const optionId = ( tone: number ) => `${ baseId }-option-${ tone }`; + const current = + SKIN_TONES.find( ( option ) => option.tone === value ) || + SKIN_TONES[ 0 ]; + const selectedOptionId = optionId( current.tone ); + + // Per the APG listbox pattern, focus lands on the selected option + // (not the first) when the listbox receives focus. The popover's own + // focus-on-mount and the composite's `defaultActiveId` race against + // item registration, so move focus explicitly once on mount; + // focusing the option also makes it the composite's active item. + useEffect( () => { + document.getElementById( selectedOptionId )?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + // With the popover's focus-on-mount disabled, its automatic focus + // return is disabled too — restore focus to the toggle on unmount. + const focusReturnRef = useFocusReturn(); + + return ( +
+
+ { __( 'Choose your default skin tone' ) } +
+ + { SKIN_TONES.map( ( { tone, emoji, label } ) => ( + { + onChange( tone ); + onClose(); + } } + /> + } + > + { emoji } + + ) ) } + +
+ ); +} + /** * Skin tone selector for the emoji picker: a persistent toggle showing * the currently selected tone (a raised hand in that tone), opening a @@ -79,9 +165,9 @@ export default function SkinTonePicker( { value, onChange, }: SkinTonePickerProps ) { - const headingId = useInstanceId( + const baseId = useInstanceId( SkinTonePicker, - 'editor-collab-sidebar-panel__skin-tone-heading' + 'editor-collab-sidebar-panel__skin-tone' ); const current = SKIN_TONES.find( ( option ) => option.tone === value ) || @@ -90,6 +176,10 @@ export default function SkinTonePicker( { return ( (
diff --git a/packages/editor/src/components/collab-sidebar/test/emoji-picker.js b/packages/editor/src/components/collab-sidebar/test/emoji-picker.js index ff20abe7d3953e..0a82b659eebdc0 100644 --- a/packages/editor/src/components/collab-sidebar/test/emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/test/emoji-picker.js @@ -219,6 +219,30 @@ describe( 'EmojiPicker search announcements', () => { delete window.gutenbergEmojibaseUrl; } ); + it( 'exposes categories as labelled rowgroups and flattens search results', async () => { + const user = userEvent.setup(); + render( {} } /> ); + + await screen.findAllByRole( 'gridcell' ); + + // While browsing, each category is a rowgroup labelled by its + // visible heading, so cell-by-cell navigation has group context. + expect( + screen.getByRole( 'rowgroup', { name: 'Smileys' } ) + ).toBeVisible(); + + // While searching, results collapse into one flat grid with no + // category sections. + await user.type( + screen.getByRole( 'searchbox', { name: 'Search emoji' } ), + 'face' + ); + await waitFor( () => + expect( screen.queryByRole( 'rowgroup' ) ).not.toBeInTheDocument() + ); + expect( screen.getAllByRole( 'gridcell' ) ).toHaveLength( 2 ); + } ); + it( 'announces result counts and the empty state as the query settles', async () => { const user = userEvent.setup(); render( {} } /> ); From e883e285d68e6342796a83b51423135e0fa6dabd Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 09:08:25 -0700 Subject: [PATCH 232/362] Notes: Match the emoji picker popover to standard popover chrome Drop the custom gray surface, elevation, and radius so the picker inherits the same surface as every other editor popover; keep only the zeroed padding. Align the emoji cell focus ring with the standard outset component ring, add scroll margins so keyboard focus is not hidden under the sticky category header, fix the skin-tone toggle to a 32px square, and document the 32px target-size trade-off. --- .../src/components/collab-sidebar/style.scss | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index f5722d4ca924ca..62536bddf74ab6 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -283,6 +283,12 @@ mark.wp-note { overflow-y: auto; } +// Emoji targets (quick row, skin-tone swatches, full-picker grid cells) +// are 32px squares: an intentional compact density matching peer emoji +// pickers (GitHub, Slack) and the editor's compact button size. This +// clears the WCAG 2.2 AA 24px minimum (2.5.8) but not the AAA 44px +// target of 2.5.5 (Enhanced) — at 44px an 8-column grid would exceed +// the sidebar-anchored popover's practical width. .editor-collab-sidebar-panel__emoji-option { width: 32px; font-size: 18px; @@ -296,25 +302,14 @@ mark.wp-note { // // `contentClassName` from `Dropdown` lands on the outer // `.components-popover` wrapper. The inner `.components-popover__content` -// is where the visible surface and Dropdown's default 8px padding live — -// override that here with a higher-specificity selector so the picker -// can manage its own internal padding (search margin, viewport -// padding-inline, list padding-bottom) without an extra ring of empty -// surface around it. +// is where Dropdown's default 8px padding lives — zero it out here so +// the picker can manage its own internal padding (search margin, +// viewport padding-inline, list padding-bottom) without an extra ring +// of empty surface around it. Surface, radius, and elevation are the +// popover defaults so the picker matches every other editor popover. .editor-collab-sidebar-panel__picker-popover { .components-popover__content { - // Local elevation token; the shared `--wpds-elevation-md` was removed - // in #80099, which migrated call sites to component-local tokens. - --_wp-editor-picker-elevation-md: - 0 2px 3px rgba(0, 0, 0, 0.05), - 0 4px 5px rgba(0, 0, 0, 0.04), - 0 12px 12px rgba(0, 0, 0, 0.03), - 0 16px 16px rgba(0, 0, 0, 0.02); - padding: 0; - border-radius: var(--wpds-border-radius-md); - box-shadow: var(--_wp-editor-picker-elevation-md); - background-color: var(--wpds-color-background-surface-neutral-weak); } } @@ -352,6 +347,11 @@ mark.wp-note { .editor-collab-sidebar-panel__skin-tone-toggle { flex-shrink: 0; + // A fixed 32px square so the toggle matches the compact icon-button + // footprint (and the 32px emoji grid cells) instead of growing with + // its emoji text content. + width: $grid-unit-40; + padding: 0; font-size: 18px; justify-content: center; } @@ -408,7 +408,9 @@ mark.wp-note { position: sticky; top: 0; padding: var(--wpds-dimension-padding-sm); - background-color: var(--wpds-color-background-surface-neutral-weak); + // Match the popover's default surface so rows scrolling underneath + // stay hidden. + background-color: $white; color: var(--wpds-color-foreground-content-neutral-weak); font-size: var(--wpds-typography-font-size-xs); font-weight: var(--wpds-typography-font-weight-medium); @@ -438,15 +440,17 @@ mark.wp-note { cursor: var(--wpds-cursor-control); font-size: 18px; line-height: 1; + // Keep the focused cell clear of the sticky category header (and + // give the focus ring breathing room at the bottom edge) when + // keyboard navigation scrolls it into view. + scroll-margin-block: $grid-unit-40 $grid-unit-05; &[data-active-item] { background-color: var(--wpds-color-background-interactive-neutral-weak-active); } &:focus-visible { - outline: - var(--wpds-border-width-focus) solid - var(--wpds-color-stroke-focus); - outline-offset: -1px; + // Standard component focus ring. + @include outset-ring__focus(); } } From d9a475b475f1e80d975f912e6b67b69516c66c57 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Jul 2026 09:08:52 -0700 Subject: [PATCH 233/362] Notes: Add editor CHANGELOG entry for the full emoji picker --- packages/editor/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 9002e759e68f71..84c76054da370e 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -5,6 +5,7 @@ ### New Features - Add an "Attachments" source to the block inserter's Media tab, listing images attached to the current post with the ability to attach and detach them ([#79336](https://github.com/WordPress/gutenberg/pull/79336)). +- Notes: the add-reaction popover now offers a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via `window.gutenbergEmojibaseUrl` ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). ### Enhancements From 6ede3231b92dbc81ae18294bf3cf6942025f9ea9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 14 Jul 2026 19:35:19 -0700 Subject: [PATCH 234/362] Notes: Open the full emoji picker directly from the add-reaction button The intermediate quick-reaction flyout added friction without adding capability: its curated row is now seeded into the full picker's "Frequently used" section, so the same one-click picks remain available while search and the complete emoji set are immediately reachable. The curated quick row is kept only as a fallback for npm consumers that don't configure an Emojibase URL. Seed the frequently-used defaults from the filtered curated list so sites using the gutenberg_note_reaction_emojis filter see their own reaction set before usage fills the list. --- .../collab-sidebar/emoji-picker.tsx | 8 +- .../collab-sidebar/frequent-emojis.ts | 25 +++- .../collab-sidebar/reaction-display.tsx | 60 +++++----- .../collab-sidebar/reaction-emoji-picker.tsx | 81 +++++-------- .../src/components/collab-sidebar/style.scss | 11 +- .../collab-sidebar/test/reaction-display.tsx | 40 +++---- .../test/reaction-emoji-picker.tsx | 28 ----- .../specs/editor/various/block-notes.spec.js | 108 ++++++++---------- 8 files changed, 148 insertions(+), 213 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx index 91239059d0f34d..ca39f61d06936a 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -165,10 +165,10 @@ export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { ); const { set: setPreference } = useDispatch( preferencesStore ); - // Focus the search field on mount. The picker is swapped into the - // add-reaction popover when the quick row's `+` option is clicked, - // and that option unmounts with the quick row — without this the - // focus would fall back to the document body. + // Focus the search field on mount. The picker usually renders after + // the popover has already run its own focus-on-mount pass (the lazy + // chunk and Emojibase data resolve later), so without this focus + // would stay on the interim loading state's container. useEffect( () => { searchRef.current?.focus(); }, [] ); diff --git a/packages/editor/src/components/collab-sidebar/frequent-emojis.ts b/packages/editor/src/components/collab-sidebar/frequent-emojis.ts index 613cd05355c6e2..97675c0cd983b2 100644 --- a/packages/editor/src/components/collab-sidebar/frequent-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/frequent-emojis.ts @@ -8,7 +8,11 @@ import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ -import { REACTION_EMOJIS, emojiToHexKey } from './reaction-emoji-picker'; +import { + REACTION_EMOJIS, + emojiToHexKey, + useReactionEmojis, +} from './reaction-emoji-picker'; /** * A recorded frequently-used emoji: its normalized hex key and how many @@ -110,13 +114,17 @@ export function recordEmojiUse( * padded with the curated default reactions the user hasn't recorded * yet, capped at `MAX_FREQUENT_EMOJIS`. * - * @param entries Stored `{ key, count }` entries. + * @param entries Stored `{ key, count }` entries. + * @param defaultKeys Hex keys used to seed the list before usage fills it. * @return Ordered hex keys. */ -export function getFrequentEmojiKeys( entries: unknown ): string[] { +export function getFrequentEmojiKeys( + entries: unknown, + defaultKeys: string[] = DEFAULT_FREQUENT_EMOJI_KEYS +): string[] { const keys = sanitizeEntries( entries ).map( ( entry ) => entry.key ); const seen = new Set( keys ); - for ( const key of DEFAULT_FREQUENT_EMOJI_KEYS ) { + for ( const key of defaultKeys ) { if ( ! seen.has( key ) ) { keys.push( key ); seen.add( key ); @@ -138,15 +146,20 @@ export function useFrequentEmojis(): { recordUse: ( key: string ) => void; } { const registry = useRegistry(); + // Seed from the filtered curated list (not just the shipped + // defaults) so a site using the `gutenberg_note_reaction_emojis` + // filter sees its own reaction set before usage fills the list. + const emojis = useReactionEmojis(); const frequentKeys = useSelect( ( select ) => getFrequentEmojiKeys( select( preferencesStore ).get( 'core', FREQUENT_EMOJIS_PREFERENCE_KEY - ) + ), + emojis.map( ( entry ) => emojiToHexKey( entry.emoji ) ) ), - [] + [ emojis ] ); const recordUse = useCallback( ( key: string ) => { diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index aa492d41cff51c..0040bd50e13bde 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -77,8 +77,8 @@ interface ReactionComment { } /** - * Lazy-load the full emoji picker. Its bundle is only fetched when a - * user opens the "More emojis" popover for the first time in a session; + * Lazy-load the full emoji picker. Its bundle is only fetched the first + * time a user opens (or hovers) the add-reaction trigger in a session; * the Emojibase JSON dataset is fetched separately on first open. */ const FullEmojiPicker = lazy( () => import( './emoji-picker' ) ); @@ -390,14 +390,16 @@ interface AddReactionButtonProps { } /** - * Standalone add-reaction button. Opens the curated emoji quick row - * (5 emoji); its trailing `+` option swaps the popover content to the - * full searchable picker, so a single trigger covers both. + * Standalone add-reaction button. Opens the full searchable emoji + * picker directly; its "Frequently used" section is seeded with the + * curated reaction set, so the previous quick-row picks stay one click + * away. * - * The `+` option only renders when `window.gutenbergEmojibaseUrl` is + * The full picker only renders when `window.gutenbergEmojibaseUrl` is * set — the Gutenberg plugin sets it via PHP, but npm consumers of the * editor package must opt in by providing a URL pointing at a - * self-hosted emojibase dataset. + * self-hosted emojibase dataset. Without it, the curated quick row is + * offered instead. * * @param props Component props. * @param props.noteId The parent note comment ID. @@ -410,7 +412,6 @@ export function AddReactionButton( { disabled = false, onToggleReaction, }: AddReactionButtonProps ) { - const [ isFullPicker, setIsFullPicker ] = useState( false ); const { recordUse } = useFrequentEmojis(); const emojis = useReactionEmojis(); const emojiBySlug = useMemo( @@ -424,7 +425,7 @@ export function AddReactionButton( { { - // Always reopen on the curated quick row. - setIsFullPicker( false ); - onToggle(); - } } + onClick={ onToggle } + // Warm up the picker while the user is still + // deciding: hovering or focusing the trigger starts + // loading the lazy picker module and the Emojibase + // dataset, so the popover usually opens fully + // populated. + onMouseEnter={ + hasFullPicker ? prefetchFullPicker : undefined + } + onFocus={ hasFullPicker ? prefetchFullPicker : undefined } /> ) } renderContent={ ( { onClose } ) => { @@ -453,7 +459,7 @@ export function AddReactionButton( { onToggleReaction( slug ); }; - if ( isFullPicker ) { + if ( hasFullPicker ) { return ( { - // Count the pick toward the full picker's - // "Frequently used" section. (The full picker - // records its own picks.) + // Keep counting picks toward the full picker's + // "Frequently used" section so the history is + // warm if the site later provides an Emojibase + // URL. recordUse( emojiToHexKey( emojiBySlug.get( slug )?.emoji ?? '' @@ -496,19 +503,6 @@ export function AddReactionButton( { ); pickReaction( slug ); } } - onMore={ - hasFullPicker - ? () => setIsFullPicker( true ) - : undefined - } - // Warm up the picker while the user is still - // deciding: hovering or focusing the `+` starts - // loading the lazy picker module and the Emojibase - // dataset, so the swapped-in view usually renders - // fully populated. - onMoreHover={ - hasFullPicker ? prefetchFullPicker : undefined - } /> ); } } diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 19422bbc769dc1..fa339b09d6b4fa 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -3,7 +3,6 @@ */ import { __, _x } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; -import { plus as plusIcon } from '@wordpress/icons'; import { useSelect } from '@wordpress/data'; // @ts-expect-error - No type declarations available for @wordpress/block-editor import { store as blockEditorStore } from '@wordpress/block-editor'; @@ -19,8 +18,6 @@ export interface CuratedEmoji { interface ReactionEmojiPickerProps { onSelect: ( slug: string ) => void; - onMore?: () => void; - onMoreHover?: () => void; } /** @@ -150,62 +147,42 @@ export function buildEmojiBySlugMap( } /** - * A row of curated emoji buttons, optionally followed by a `+` "More - * emojis" trigger that hands off to the full searchable picker. The `+` - * is a plain button rendered after the listbox (not inside it) so the - * listbox only contains selectable options. + * A row of curated emoji buttons: the fallback picker offered when no + * Emojibase URL is configured (npm consumers of the editor package that + * haven't opted into the full searchable picker). * - * @param props Component props. - * @param props.onSelect Called with the chosen slug when the user picks a - * curated emoji. - * @param props.onMore When provided, renders the trailing `+` button and - * is called when it is clicked. - * @param props.onMoreHover Called when the `+` button is hovered or focused - * (used to prefetch the full picker). + * @param props Component props. + * @param props.onSelect Called with the chosen slug when the user picks a + * curated emoji. */ export default function ReactionEmojiPicker( { onSelect, - onMore, - onMoreHover, }: ReactionEmojiPickerProps ) { const emojis = useReactionEmojis(); return ( -
- - { emojis.map( ( { emoji, label, value } ) => ( - onSelect( value ) } - aria-label={ label } - className="editor-collab-sidebar-panel__emoji-option" - /> - } - > - { emoji } - - ) ) } - - { onMore && ( -
+ + { emojis.map( ( { emoji, label, value } ) => ( + onSelect( value ) } + aria-label={ label } + className="editor-collab-sidebar-panel__emoji-option" + /> + } + > + { emoji } + + ) ) } + ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index a67ae46ad2eb17..2da32090ce2955 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -277,17 +277,10 @@ mark.wp-note { line-height: 1; } -// Curated picker uses Dropdown's default `.components-popover__content` +// Curated fallback picker (shown only when no Emojibase URL is +// configured) uses Dropdown's default `.components-popover__content` // padding (8px) for breathing room around the listbox; no extra rule // needed. - -// Lays out the curated listbox and the trailing `+` "More emojis" -// button (a sibling of the listbox, not one of its options) as a -// single row. -.editor-collab-sidebar-panel__emoji-picker-row { - display: flex; -} - .editor-collab-sidebar-panel__emoji-picker { display: flex; // The emoji list is filterable server-side, so it can grow well diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index aa1b3abf6bca5b..1d34825a6b91c0 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -293,7 +293,7 @@ describe( 'AddReactionButton', () => { apiFetch.mockRejectedValue( new Error( 'not mocked' ) ); } ); - it( 'opens the curated picker and toggles the chosen reaction', async () => { + it( 'falls back to the curated quick row when no Emojibase URL is set', async () => { const user = userEvent.setup(); const onToggleReaction = jest.fn(); render( @@ -314,11 +314,13 @@ describe( 'AddReactionButton', () => { expect( onToggleReaction ).toHaveBeenCalledWith( 'rocket' ); } ); - it( 'stores a filter-provided emoji under its slug from both picker paths', async () => { - // A site filter adds 👍 with the slug `thumbs-up`. Picking it from - // the curated quick row and from the full searchable picker must - // both store `thumbs-up`, not the raw hex key `1f44d`, so the two - // paths aggregate into a single reaction_summary bucket. + it( 'opens the full picker directly and stores a filter-provided emoji under its slug', async () => { + // With an Emojibase URL configured, "Add reaction" opens the full + // searchable picker straight away (no intermediate quick row). + // A site filter adds 👍 with the slug `thumbs-up`; picking it from + // the full picker must store `thumbs-up`, not the raw hex key + // `1f44d`, so it aggregates into the same reaction_summary bucket + // as historical quick-row picks. window.gutenbergEmojibaseUrl = 'https://example.test/emojibase'; const originalFetch = global.fetch; global.fetch = jest.fn( ( url ) => @@ -355,25 +357,17 @@ describe( 'AddReactionButton', () => { /> ); - // Path 1: the curated quick row. await user.click( screen.getByRole( 'button', { name: 'Add reaction' } ) ); - await user.click( - await screen.findByRole( 'option', { name: 'Thumbs up' } ) - ); - expect( onToggleReaction ).toHaveBeenLastCalledWith( 'thumbs-up' ); - - // Path 2: the full searchable picker. - await user.click( - screen.getByRole( 'button', { name: 'Add reaction' } ) - ); - await user.click( - await screen.findByRole( 'button', { name: 'More emojis' } ) - ); - // The quick-row pick above also recorded 👍 as frequently - // used, so the grid shows it twice (Frequently used + its - // category); either cell exercises the same selection path. + // The full picker opens directly — no quick row, no + // intermediate "More emojis" step. + expect( + screen.queryByRole( 'option', { name: 'Thumbs up' } ) + ).not.toBeInTheDocument(); + // The filter-provided emoji is seeded into "Frequently used" + // and also appears in its Emojibase category; either cell + // exercises the same selection path. await user.click( ( await screen.findAllByRole( 'gridcell', { @@ -382,7 +376,7 @@ describe( 'AddReactionButton', () => { )[ 0 ] ); expect( onToggleReaction ).toHaveBeenLastCalledWith( 'thumbs-up' ); - expect( onToggleReaction ).toHaveBeenCalledTimes( 2 ); + expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); } finally { global.fetch = originalFetch; delete window.gutenbergEmojibaseUrl; diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx index 24a640f084af3b..303af52ef77730 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx @@ -68,34 +68,6 @@ describe( 'ReactionEmojiPicker', () => { expect( onSelect ).toHaveBeenCalledWith( 'smile' ); } ); - it( 'omits the "More emojis" trigger unless onMore is provided', () => { - render( {} } /> ); - - expect( - screen.queryByRole( 'button', { name: 'More emojis' } ) - ).not.toBeInTheDocument(); - } ); - - it( 'renders the "More emojis" trigger outside the listbox and calls onMore', async () => { - const user = userEvent.setup(); - const onMore = jest.fn(); - render( - {} } onMore={ onMore } /> - ); - - // The trigger is an action, not a selectable value, so it must not - // be one of the listbox options. - expect( screen.getAllByRole( 'option' ) ).toHaveLength( - REACTION_EMOJIS.length - ); - - await user.click( - screen.getByRole( 'button', { name: 'More emojis' } ) - ); - - expect( onMore ).toHaveBeenCalledTimes( 1 ); - } ); - describe( 'settings-provided emoji list', () => { afterEach( () => { // The picker may still be mounted when the settings reset diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 4f64ed4439cfb8..dbc7c6ff90b5e9 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1337,15 +1337,11 @@ test.describe( 'Block Notes', () => { await addReactionButton.focus(); await page.keyboard.press( 'Enter' ); - // Verify the picker is visible. - const emojiPicker = page.locator( - '.editor-collab-sidebar-panel__emoji-picker' - ); - await expect( emojiPicker ).toBeVisible(); + await blockNoteUtils.waitForFullPicker(); - // Navigate with arrow keys and select. The picker is a horizontal - // listbox, so ArrowRight moves to the next option. - const firstEmoji = emojiPicker.getByRole( 'option' ).first(); + // Navigate with arrow keys and select. The picker grid moves + // the roving tabindex with ArrowRight. + const firstEmoji = page.getByRole( 'gridcell' ).first(); await firstEmoji.focus(); await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Enter' ); @@ -1384,7 +1380,7 @@ test.describe( 'Block Notes', () => { ).toBeVisible(); } ); - test( 'can open the full emoji picker via the + button', async ( { + test( 'opens the full emoji picker directly from the add-reaction button', async ( { page, blockNoteUtils, } ) => { @@ -1395,7 +1391,6 @@ test.describe( 'Block Notes', () => { } ); await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); @@ -1421,7 +1416,6 @@ test.describe( 'Block Notes', () => { // first "heart"-containing label (e.g. "smiling face with // hearts") instead of the curated red heart. await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); await page.getByPlaceholder( 'Search emoji' ).fill( 'Heart' ); await page @@ -1469,7 +1463,6 @@ test.describe( 'Block Notes', () => { } ); await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); await page.keyboard.press( 'Escape' ); @@ -1490,7 +1483,6 @@ test.describe( 'Block Notes', () => { } ); await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); // A query no Emojibase label/tag matches. @@ -1527,7 +1519,6 @@ test.describe( 'Block Notes', () => { ); await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); // The section leads the grid, seeded with the curated @@ -1569,7 +1560,6 @@ test.describe( 'Block Notes', () => { // …and on reopening the picker, the recorded pick has joined // the Frequently used section. await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); await expect( frequentSection.getByRole( 'gridcell', { @@ -1590,7 +1580,6 @@ test.describe( 'Block Notes', () => { } ); await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); // The persistent toggle next to the search field shows the @@ -1654,7 +1643,7 @@ test.describe( 'Block Notes', () => { await page.getByRole( 'button', { name: 'Add reaction' } ).click(); const popover = page.locator( - '.editor-collab-sidebar-panel__add-reaction-popover' + '.editor-collab-sidebar-panel__picker-popover' ); await expect( popover ).toBeVisible(); @@ -1686,9 +1675,7 @@ test.describe( 'Block Notes', () => { await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await expect( - page.locator( '.editor-collab-sidebar-panel__emoji-picker' ) - ).toBeVisible(); + await blockNoteUtils.waitForFullPicker(); // Focus has moved into the portaled popover, but the note's // onBlur handler exempts `.components-popover` so the thread @@ -1707,7 +1694,6 @@ test.describe( 'Block Notes', () => { } ); await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); const popover = page.locator( @@ -1739,7 +1725,6 @@ test.describe( 'Block Notes', () => { } ); await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await page.getByRole( 'button', { name: 'More emojis' } ).click(); await blockNoteUtils.waitForFullPicker(); const picker = page.locator( @@ -1787,7 +1772,7 @@ test.describe( 'Block Notes', () => { ); } ); - test( 'picker offers emojis added via the gutenberg_note_reaction_emojis filter', async ( { + test( 'seeds Frequently used with emojis added via the gutenberg_note_reaction_emojis filter', async ( { page, blockNoteUtils, } ) => { @@ -1800,20 +1785,28 @@ test.describe( 'Block Notes', () => { await page .getByRole( 'button', { name: 'Add reaction' } ) .click(); - const emojiPicker = page.locator( - '.editor-collab-sidebar-panel__emoji-picker' - ); - await expect( emojiPicker ).toBeVisible(); - - // The 5 defaults plus the 20 filter-added entries. - await expect( emojiPicker.getByRole( 'option' ) ).toHaveCount( - 25 - ); + await blockNoteUtils.waitForFullPicker(); + + // The "Frequently used" section is seeded from the + // filtered curated list: the 5 defaults plus the 20 + // filter-added entries. + const frequentGroup = page + .getByRole( 'rowgroup' ) + .filter( { hasText: 'Frequently used' } ); await expect( - emojiPicker.getByRole( 'option', { name: 'Heart' } ) + frequentGroup.getByRole( 'gridcell' ) + ).toHaveCount( 25 ); + await expect( + frequentGroup.getByRole( 'gridcell', { + name: 'Heart', + exact: true, + } ) ).toBeVisible(); await expect( - emojiPicker.getByRole( 'option', { name: 'Thumbs up' } ) + frequentGroup.getByRole( 'gridcell', { + name: 'Thumbs up', + exact: true, + } ) ).toBeVisible(); } ); @@ -1840,7 +1833,7 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toContainText( '1' ); } ); - test( 'picker stays usable with many emojis', async ( { + test( 'picker stays usable with many seeded emojis', async ( { page, blockNoteUtils, } ) => { @@ -1853,15 +1846,16 @@ test.describe( 'Block Notes', () => { await page .getByRole( 'button', { name: 'Add reaction' } ) .click(); - const emojiPicker = page.locator( - '.editor-collab-sidebar-panel__emoji-picker' - ); - await expect( emojiPicker ).toBeVisible(); + await blockNoteUtils.waitForFullPicker(); - // The listbox wraps instead of growing unbounded, so the - // popover stays within the viewport. + // The picker keeps its fixed 8-column footprint no matter + // how many entries the filter seeds, so the popover stays + // within the viewport. + const picker = page.locator( + '.editor-collab-sidebar-panel__picker' + ); const viewport = page.viewportSize(); - const box = await emojiPicker.boundingBox(); + const box = await picker.boundingBox(); expect( box.width ).toBeLessThan( viewport.width / 2 ); expect( box.x ).toBeGreaterThanOrEqual( 0 ); expect( box.x + box.width ).toBeLessThanOrEqual( @@ -1870,9 +1864,9 @@ test.describe( 'Block Notes', () => { // The last filter-added entry is reachable (scrolls into // view if needed) and selectable. - const lastOption = emojiPicker.getByRole( 'option', { - name: 'Trophy', - } ); + const lastOption = page + .getByRole( 'gridcell', { name: 'Trophy', exact: true } ) + .first(); await lastOption.scrollIntoViewIfNeeded(); await lastOption.click(); @@ -2551,16 +2545,16 @@ class BlockNoteUtils { await this.#page .getByRole( 'button', { name: 'Add reaction' } ) .click(); + await this.waitForFullPicker(); - // Wait for the emoji picker popover to appear. - const emojiPicker = this.#page.locator( - '.editor-collab-sidebar-panel__emoji-picker' - ); - await expect( emojiPicker ).toBeVisible(); - - // Click the specific emoji within the picker. - await emojiPicker - .getByRole( 'option', { name: new RegExp( emoji, 'i' ) } ) + // Curated and filter-provided reactions carry exact label + // overrides (e.g. "Heart") and are seeded into the "Frequently + // used" section, so an exact-name gridcell lookup finds them + // without matching Emojibase labels that merely contain the name + // (e.g. "smiling face with hearts"). + await this.#page + .getByRole( 'gridcell', { name: emoji, exact: true } ) + .first() .click(); } @@ -2579,9 +2573,8 @@ class BlockNoteUtils { } /** - * Open the add-reaction popover, click its trailing + option to - * swap in the full emoji picker, search by name, - * and click the first matching emoji. + * Open the emoji picker, search by name, and click the first + * matching emoji. * * @param {string} search Search term (matched against Emojibase * labels, e.g. "red heart" or "thumbs up"). @@ -2590,7 +2583,6 @@ class BlockNoteUtils { await this.#page .getByRole( 'button', { name: 'Add reaction' } ) .click(); - await this.#page.getByRole( 'button', { name: 'More emojis' } ).click(); await this.waitForFullPicker(); await this.#page.getByPlaceholder( 'Search emoji' ).fill( search ); From 5643a47474a9c4749fa4fa5dcd7eae2d19cedcfc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 14 Jul 2026 19:35:45 -0700 Subject: [PATCH 235/362] Notes: Fix emoji grid focus ring clipping and phantom active highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid rows' content-visibility paint containment clips ink overflow at the row box, so the standard outset focus ring lost its top and bottom edges (and its left edge on first-column cells) — most visibly in Firefox. Use an inset ring on grid cells instead. Also scope the grey active-item background to cells that actually hold focus: the composite keeps data-active-item on the last active cell even while focus sits in the search field, which read as a phantom selection on open. Add a matching hover highlight for pointer users. --- .../src/components/collab-sidebar/style.scss | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 2da32090ce2955..f55ee556bc118f 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -455,12 +455,22 @@ mark.wp-note { // keyboard navigation scrolls it into view. scroll-margin-block: $grid-unit-40 $grid-unit-05; - &[data-active-item] { + // Highlight the cell under the pointer and the cell the roving + // tabindex is on — but only while it actually holds focus. The + // composite keeps `data-active-item` on the last active cell even + // when focus is elsewhere (e.g. the search field on open), and a + // highlight without focus reads as a phantom selection. + &:hover, + &[data-active-item]:focus { background-color: var(--wpds-color-background-interactive-neutral-weak-active); } &:focus-visible { - // Standard component focus ring. - @include outset-ring__focus(); + // An inset ring instead of the standard outset ring: grid rows + // use `content-visibility: auto`, whose paint containment clips + // ink overflow at the row box — an outset ring loses its top and + // bottom edges (and its left edge on first-column cells). + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus); + outline-offset: calc(-1 * var(--wpds-border-width-focus)); } } From d776cffdc7dfe7d13e804a9d0d7d475973812af0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 14 Jul 2026 19:36:06 -0700 Subject: [PATCH 236/362] Notes: Drop the redundant 'emoji' suffix from reaction tooltips "Alice reacted with Heart emoji" restates what the pill already shows; "Alice reacted with Heart" matches the phrasing of peer products' reaction tooltips. --- .../src/components/collab-sidebar/reaction-display.tsx | 8 ++++---- .../components/collab-sidebar/test/reaction-display.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index 0040bd50e13bde..709fce4e617bd2 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -160,7 +160,7 @@ function formatReactionTooltip( names: string[], emojiLabel: string ): string { if ( names.length === 1 ) { return sprintf( /* translators: 1: user name, 2: emoji label. */ - __( '%1$s reacted with %2$s emoji' ), + __( '%1$s reacted with %2$s' ), names[ 0 ], emojiLabel ); @@ -169,7 +169,7 @@ function formatReactionTooltip( names: string[], emojiLabel: string ): string { if ( names.length === 2 ) { return sprintf( /* translators: 1: first user name, 2: second user name, 3: emoji label. */ - __( '%1$s and %2$s reacted with %3$s emoji' ), + __( '%1$s and %2$s reacted with %3$s' ), names[ 0 ], names[ 1 ], emojiLabel @@ -180,8 +180,8 @@ function formatReactionTooltip( names: string[], emojiLabel: string ): string { return sprintf( /* translators: 1: first user name, 2: second user name, 3: number of other users, 4: emoji label. */ _n( - '%1$s, %2$s, and %3$d other reacted with %4$s emoji', - '%1$s, %2$s, and %3$d others reacted with %4$s emoji', + '%1$s, %2$s, and %3$d other reacted with %4$s', + '%1$s, %2$s, and %3$d others reacted with %4$s', othersCount ), names[ 0 ], diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index 1d34825a6b91c0..7161c56a84ecae 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -225,7 +225,7 @@ describe( 'ReactionDisplay', () => { await waitFor( () => expect( screen.getByRole( 'button', { - name: 'Alice reacted with Heart emoji', + name: 'Alice reacted with Heart', } ) ).toBeVisible() ); @@ -256,7 +256,7 @@ describe( 'ReactionDisplay', () => { await waitFor( () => expect( screen.getByRole( 'button', { - name: 'Alice, Bob, and 1 other reacted with Heart emoji', + name: 'Alice, Bob, and 1 other reacted with Heart', } ) ).toBeVisible() ); From 1d04ff3240cab9fd27f26d98b8c5cac07286f40e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 14 Jul 2026 19:45:15 -0700 Subject: [PATCH 237/362] Notes: Give reaction pills more breathing room Pressed pills draw a full accent border, and at the xs (4px) gap adjacent borders read as touching. Use the sm (8px) gap between the add-reaction trigger and the pills, and between pills. --- packages/editor/src/components/collab-sidebar/note.js | 2 +- .../editor/src/components/collab-sidebar/reaction-display.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/note.js b/packages/editor/src/components/collab-sidebar/note.js index d1926483b3f460..82e15bbe72989b 100644 --- a/packages/editor/src/components/collab-sidebar/note.js +++ b/packages/editor/src/components/collab-sidebar/note.js @@ -220,7 +220,7 @@ export function Note( { > { body } { isSelected && ( - + + // `sm` gap: the pressed pills draw a full accent border, and at + // the `xs` gap adjacent borders read as touching. + { reactedSlugs.map( ( slug ) => { const count = getReactionCount( reactions, slug ); const isActive = hasUserReacted( reactions, slug ); From f38bc9499d63d0f1f9d039edce1b6cbb2e8f89ad Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 17:48:20 -0700 Subject: [PATCH 238/362] Notes: Expose the skin-tone listbox's horizontal orientation The Composite orientation prop only configures arrow-key handling in the composite store; it never reaches the DOM, so assistive technology fell back to the listbox default of vertical. Render aria-orientation explicitly per the APG listbox pattern. --- .../src/components/collab-sidebar/skin-tone-picker.tsx | 5 +++++ .../src/components/collab-sidebar/test/skin-tone-picker.js | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx index 0995b79fe61197..a7181095938fc8 100644 --- a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx @@ -120,7 +120,12 @@ function SkinToneMenu( {
{ expect( screen.getByText( 'Choose your default skin tone' ) ).toBeVisible(); + // The options are laid out horizontally, so the listbox must + // report its orientation (the ARIA default is vertical). + expect( screen.getByRole( 'listbox' ) ).toHaveAttribute( + 'aria-orientation', + 'horizontal' + ); const options = screen.getAllByRole( 'option' ); expect( options ).toHaveLength( 6 ); expect( options[ 0 ] ).toHaveAccessibleName( 'Default skin tone' ); From 755bc447573daa16c84f59178fd4784bcc2002b2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 17:48:24 -0700 Subject: [PATCH 239/362] Notes: Derive the reactor tooltip from names plus the current label Storing a formatted tooltip string froze whichever emoji label was resolved when the names request settled; for hex-key reactions the Emojibase label can arrive later, leaving the tooltip on the emoji character fallback. Store the fetched names instead and derive the tooltip so it re-renders when the label resolves. Also correct the popover comment: the popover renders through the active Popover.Slot when one exists, not always a body-level portal. --- .../collab-sidebar/reaction-display.tsx | 28 ++++--- .../collab-sidebar/test/reaction-display.tsx | 83 +++++++++++++++++++ 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index 4c14d5797930da..c8d07d239bb05b 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -98,8 +98,9 @@ function prefetchFullPicker(): void { } } -// `Dropdown`'s popover is rendered in a portal anchored to , -// so it escapes the `overflow: hidden` chain on the collab sidebar +// `Dropdown`'s popover renders through the active `Popover.Slot` when +// one exists (falling back to a ``-level container), so either +// way it escapes the `overflow: hidden` chain on the collab sidebar // (`.interface-interface-skeleton__sidebar`, // `.editor-collab-sidebar`, `.editor-collab-sidebar-panel`). const POPOVER_PROPS = { placement: 'bottom-end' } as const; @@ -226,7 +227,7 @@ function ReactionButton( { emojiLabel, onToggleReaction, }: ReactionButtonProps ) { - const [ tooltipText, setTooltipText ] = useState( '' ); + const [ names, setNames ] = useState< string[] | null >( null ); const [ isFetching, setIsFetching ] = useState( false ); // Reactions picked from the full picker are stored as hex keys and // carry no curated label; resolve their name from the Emojibase @@ -234,13 +235,17 @@ function ReactionButton( { // character. Falls back to the emoji character until resolved. const resolvedLabel = useEmojiLabel( slug, ! emojiLabel ); const label = emojiLabel || resolvedLabel || emoji; + // Derive the tooltip from the fetched names and the *current* label + // rather than storing a formatted string: the names request and the + // Emojibase label request race, and a stored string would freeze + // whichever label happened to be resolved first. + const tooltipText = + names && names.length > 0 ? formatReactionTooltip( names, label ) : ''; const fetchReactionNames = useCallback( () => { const cacheKey = `${ noteId }:${ slug }`; if ( reactionNamesCache[ cacheKey ] ) { - setTooltipText( - formatReactionTooltip( reactionNamesCache[ cacheKey ], label ) - ); + setNames( reactionNamesCache[ cacheKey ] ); return; } @@ -259,7 +264,7 @@ function ReactionButton( { } ), } ) .then( ( reactions ) => { - const names = reactions + const fetchedNames = reactions .filter( ( r ) => { const content = typeof r.content === 'object' @@ -272,10 +277,8 @@ function ReactionButton( { } ) .map( ( r ) => r.author_name ); - reactionNamesCache[ cacheKey ] = names; - if ( names.length > 0 ) { - setTooltipText( formatReactionTooltip( names, label ) ); - } + reactionNamesCache[ cacheKey ] = fetchedNames; + setNames( fetchedNames ); } ) .catch( () => { // Silently fall back to count-based label. @@ -283,7 +286,7 @@ function ReactionButton( { .finally( () => { setIsFetching( false ); } ); - }, [ noteId, slug, label, isFetching ] ); + }, [ noteId, slug, isFetching ] ); const defaultLabel = sprintf( /* translators: 1: emoji label, 2: count of reactions */ @@ -311,6 +314,7 @@ function ReactionButton( { } // Invalidate cached names since the reaction set is changing. delete reactionNamesCache[ `${ noteId }:${ slug }` ]; + setNames( null ); onToggleReaction( slug ); } } onMouseEnter={ fetchReactionNames } diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index 7161c56a84ecae..808600cdb4d4cc 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -262,6 +262,89 @@ describe( 'ReactionDisplay', () => { ); } ); + it( 'updates the reactor tooltip when the emoji label resolves after the names', async () => { + // Pin the race between the two requests behind a hex-key pill's + // tooltip: when the reactor names resolve first, the tooltip is + // formatted with the emoji-character fallback label, and it must + // re-derive once the Emojibase dataset supplies the real label. + // A distinct base URL keeps the module-level dataset cache from + // earlier tests out of the way. + window.gutenbergEmojibaseUrl = 'https://example.test/emojibase-race'; + const originalFetch = global.fetch; + let resolveDataset; + const datasetGate = new Promise( ( resolve ) => { + resolveDataset = resolve; + } ); + global.fetch = jest.fn( ( url ) => + datasetGate.then( () => ( { + ok: true, + json: () => + Promise.resolve( + String( url ).includes( 'data.json' ) + ? [ + { + hexcode: '1F44D', + emoji: '👍', + label: 'thumbs up', + group: 0, + }, + ] + : { groups: [] } + ), + } ) ) + ); + apiFetch.mockResolvedValue( [ + { author_name: 'Alice', content: { raw: '1f44d' } }, + ] ); + + try { + const user = userEvent.setup(); + render( + {} } + /> + ); + + await user.hover( + screen.getByRole( 'button', { name: '👍, 1 reaction' } ) + ); + // Names resolved first: the tooltip uses the emoji-character + // fallback label while the dataset request is still pending. + await waitFor( () => + expect( + screen.getByRole( 'button', { + name: 'Alice reacted with 👍', + } ) + ).toBeVisible() + ); + + await act( async () => { + resolveDataset(); + } ); + // The dataset label arrived after the names: the already + // visible tooltip must pick it up, not stay frozen on the + // fallback. + await waitFor( () => + expect( + screen.getByRole( 'button', { + name: 'Alice reacted with thumbs up', + } ) + ).toBeVisible() + ); + } finally { + global.fetch = originalFetch; + delete window.gutenbergEmojibaseUrl; + } + } ); + it( 'keeps the count-based label when the names fetch fails', async () => { const user = userEvent.setup(); apiFetch.mockRejectedValue( new Error( 'network down' ) ); From d6b6d5570d4431cf0ba76f220e961801ee54aafd Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 17:51:02 -0700 Subject: [PATCH 240/362] Notes: Name the reaction picker popups and advertise them on triggers The popover wrappers were unnamed generic containers, so screen readers were never told focus entered a distinct popup or what it was for. Expose both the add-reaction popup and the skin-tone flyout as named non-modal dialogs via popoverProps, and set aria-haspopup on their triggers. The skin-tone flyout uses dialog (not listbox) semantics because it wraps a visible heading alongside the listbox, and a heading can't be a listbox child. Also render aria-orientation on the curated quick-row listbox, which had the same store-only orientation as the skin-tone listbox. --- .../collab-sidebar/reaction-display.tsx | 12 +++++++++- .../collab-sidebar/reaction-emoji-picker.tsx | 4 ++++ .../collab-sidebar/skin-tone-picker.tsx | 14 ++++++++++- .../collab-sidebar/test/reaction-display.tsx | 24 ++++++++++++++++--- .../collab-sidebar/test/skin-tone-picker.js | 19 +++++++++++---- 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index c8d07d239bb05b..96006265d4cdb8 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -429,7 +429,16 @@ export function AddReactionButton( { return ( diff --git a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx index a7181095938fc8..8360a1bba940e8 100644 --- a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx @@ -174,13 +174,24 @@ export default function SkinTonePicker( { SkinTonePicker, 'editor-collab-sidebar-panel__skin-tone' ); + // Mirrors the id `SkinToneMenu` assigns to its heading, so the popup + // container can take its accessible name from the visible heading. + const headingId = `${ baseId }-heading`; const current = SKIN_TONES.find( ( option ) => option.tone === value ) || SKIN_TONES[ 0 ]; return ( { /> ); - await user.click( - screen.getByRole( 'button', { name: 'Add reaction' } ) - ); + const trigger = screen.getByRole( 'button', { name: 'Add reaction' } ); + // The trigger advertises the popup type it opens, and the popup + // itself is a named non-modal dialog so screen readers announce + // where focus landed. + expect( trigger ).toHaveAttribute( 'aria-haspopup', 'dialog' ); + await user.click( trigger ); + expect( + screen.getByRole( 'dialog', { name: 'Add reaction' } ) + ).toBeVisible(); + // The curated options are laid out horizontally, so the listbox + // must report its orientation (the ARIA default is vertical). + expect( + screen.getByRole( 'listbox', { + name: 'Select an emoji reaction', + } ) + ).toHaveAttribute( 'aria-orientation', 'horizontal' ); await user.click( await screen.findByRole( 'option', { name: 'Rocket' } ) ); @@ -443,6 +456,11 @@ describe( 'AddReactionButton', () => { await user.click( screen.getByRole( 'button', { name: 'Add reaction' } ) ); + // The popup is exposed as a named non-modal dialog in the + // full-picker branch too. + expect( + screen.getByRole( 'dialog', { name: 'Add reaction' } ) + ).toBeVisible(); // The full picker opens directly — no quick row, no // intermediate "More emojis" step. expect( diff --git a/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js b/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js index caac5868d42a12..dc982d38e4c333 100644 --- a/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js +++ b/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js @@ -83,12 +83,21 @@ describe( 'SkinTonePicker', () => { const user = userEvent.setup(); render( {} } /> ); - await user.click( - screen.getByRole( 'button', { - name: 'Skin tone: Default skin tone', - } ) - ); + const toggle = screen.getByRole( 'button', { + name: 'Skin tone: Default skin tone', + } ); + // The trigger advertises the popup type it opens. + expect( toggle ).toHaveAttribute( 'aria-haspopup', 'dialog' ); + await user.click( toggle ); + // The popup is a non-modal dialog named by its visible heading, so + // screen readers announce entering it rather than a bare group of + // options appearing. + expect( + screen.getByRole( 'dialog', { + name: 'Choose your default skin tone', + } ) + ).toBeVisible(); expect( screen.getByText( 'Choose your default skin tone' ) ).toBeVisible(); From 777f0b8a4dcfe4f43b07769456972c15cffbef26 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 17:58:04 -0700 Subject: [PATCH 241/362] Notes: Extract the add-reaction picker and survive full-picker failures Move AddReactionButton into its own add-reaction-picker module owning the trigger, popup semantics, lazy loading, and pick normalization, leaving reaction-display with the pills and reactor-name behavior. The full picker previously had no failure recovery: a rejected lazy import escaped Suspense to whatever error boundary existed outside the feature, and a failed dataset request left a dead-end error message with no retry and no way to react at all. A local error boundary and an onError callback from the picker now swap in the curated quick row with a retry affordance, so the core action keeps working through transient network, cache, or deployment failures. Transient loading/failure announcements route through the wordpress/a11y announcer instead of conditionally-mounted live regions: a live region inserted together with its message is not reliably announced, while the announcer's regions exist ahead of time. --- .../collab-sidebar/add-reaction-picker.tsx | 305 ++++++++++++++++++ .../collab-sidebar/emoji-picker.tsx | 39 ++- .../src/components/collab-sidebar/note.js | 3 +- .../collab-sidebar/reaction-display.tsx | 219 +------------ .../src/components/collab-sidebar/style.scss | 11 + .../test/add-reaction-picker.tsx | 258 +++++++++++++++ .../collab-sidebar/test/reaction-display.tsx | 144 +-------- 7 files changed, 620 insertions(+), 359 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx create mode 100644 packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx diff --git a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx new file mode 100644 index 00000000000000..9f2dd75b4871d7 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx @@ -0,0 +1,305 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, Dropdown } from '@wordpress/components'; +import { SVG, Path } from '@wordpress/primitives'; +import { + Component, + Suspense, + lazy, + useEffect, + useMemo, + useState, +} from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; + +/** + * Internal dependencies + */ +import ReactionEmojiPicker, { + emojiToHexKey, + emojiToStorageKey, + buildEmojiBySlugMap, + useReactionEmojis, +} from './reaction-emoji-picker'; +import { detectLocale, loadEmojibaseData } from './emojibase-data'; +import { useFrequentEmojis } from './frequent-emojis'; +import { invalidateReactionNames } from './reaction-display'; + +// Inlined while reactions remain experimental in scope. If/when this +// icon is needed elsewhere it can be promoted to `@wordpress/icons`. +const smileyIcon = ( + + + + +); + +/** + * Load the full emoji picker module. Shared between the lazy component + * and the hover/focus prefetch so both resolve through the same module + * request. + */ +function loadEmojiPicker() { + return import( './emoji-picker' ); +} + +/** + * Lazy-load the full emoji picker. Its bundle is only fetched the first + * time a user opens (or hovers) the add-reaction trigger in a session; + * the Emojibase JSON dataset is fetched separately on first open. + */ +const FullEmojiPicker = lazy( loadEmojiPicker ); + +/** + * Warm the full picker before it opens: start fetching the lazy picker + * chunk and the Emojibase dataset for the active locale. Both loaders + * cache, so calling this repeatedly (every hover) is free after the + * first invocation. + */ +function prefetchFullPicker(): void { + loadEmojiPicker().catch( () => {} ); + if ( typeof window !== 'undefined' && window.gutenbergEmojibaseUrl ) { + loadEmojibaseData( window.gutenbergEmojibaseUrl, detectLocale() ).catch( + () => {} + ); + } +} + +// `Dropdown`'s popover renders through the active `Popover.Slot` when +// one exists (falling back to a ``-level container), so either +// way it escapes the `overflow: hidden` chain on the collab sidebar +// (`.interface-interface-skeleton__sidebar`, +// `.editor-collab-sidebar`, `.editor-collab-sidebar-panel`). +const POPOVER_PROPS = { placement: 'bottom-end' } as const; + +/** + * Visible loading state shown while the lazy picker chunk resolves. + * The announcement goes through the `@wordpress/a11y` announcer — its + * live regions exist before this message — because a live region + * mounted together with its content is not reliably announced. + */ +function PickerLoading() { + useEffect( () => { + speak( __( 'Loading…' ) ); + }, [] ); + return ( +
+
+ { __( 'Loading…' ) } +
+
+ ); +} + +interface PickerErrorBoundaryProps { + onError: () => void; + children: ReactNode; +} + +/** + * Catches render-time failures from the lazy picker — most notably a + * rejected chunk import, which `Suspense` does not handle — and defers + * to the parent, which swaps in the curated fallback picker. + */ +class PickerErrorBoundary extends Component< PickerErrorBoundaryProps > { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch() { + this.props.onError(); + } + + render() { + return this.state.hasError ? null : this.props.children; + } +} + +interface AddReactionButtonProps { + noteId: number; + disabled?: boolean; + onToggleReaction: ( slug: string ) => void; +} + +/** + * Standalone add-reaction button. Opens the full searchable emoji + * picker directly; its "Frequently used" section is seeded with the + * curated reaction set, so the previous quick-row picks stay one click + * away. + * + * The full picker only renders when `window.gutenbergEmojibaseUrl` is + * set — the Gutenberg plugin sets it via PHP, but npm consumers of the + * editor package must opt in by providing a URL pointing at a + * self-hosted emojibase dataset. Without it — or when the picker + * module or its dataset fails to load — the curated quick row is + * offered instead, so adding a reaction keeps working. + * + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.disabled Whether the button is disabled (e.g. on a + * resolved note thread). + * @param props.onToggleReaction Callback to toggle a reaction. + */ +export function AddReactionButton( { + noteId, + disabled = false, + onToggleReaction, +}: AddReactionButtonProps ) { + const { recordUse } = useFrequentEmojis(); + const emojis = useReactionEmojis(); + const emojiBySlug = useMemo( + () => buildEmojiBySlugMap( emojis ), + [ emojis ] + ); + const hasFullPicker = + typeof window !== 'undefined' && !! window.gutenbergEmojibaseUrl; + const [ pickerFailed, setPickerFailed ] = useState( false ); + // A rejected `lazy()` memoizes its failure, so retrying needs a + // fresh lazy component. The module request itself is cached by the + // bundler once it succeeds, so replacements resolve instantly. + const [ LazyPicker, setLazyPicker ] = useState( () => FullEmojiPicker ); + const [ retryKey, setRetryKey ] = useState( 0 ); + + useEffect( () => { + if ( pickerFailed ) { + speak( + __( + 'The full emoji picker couldn’t be loaded. Basic reactions are available.' + ), + 'assertive' + ); + } + }, [ pickerFailed ] ); + + const retryFullPicker = () => { + setLazyPicker( () => lazy( loadEmojiPicker ) ); + // Remount the error boundary so a caught chunk failure is + // cleared along with the failed state. + setRetryKey( ( key ) => key + 1 ); + setPickerFailed( false ); + }; + + const showFullPicker = hasFullPicker && ! pickerFailed; + + return ( + ( + +
+ + ); + } + + return ( + setPickerFailed( true ) } + > + }> + + // Match against the filtered curated + // list, not just the defaults, so a + // filter-provided emoji picked here + // stores under the same slug as a + // quick-row pick. + pickReaction( + emojiToStorageKey( emoji, emojis ) + ) + } + onError={ () => setPickerFailed( true ) } + /> + + + ); + } } + /> + ); +} diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx index ca39f61d06936a..791f0b288f8497 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -31,6 +31,7 @@ interface EmojiGroup { interface EmojiPickerProps { onSelect: ( emoji: string ) => void; + onError?: () => void; } /** @@ -135,8 +136,10 @@ export function searchEmojis( * * @param props Component props. * @param props.onSelect Called with the selected emoji character. + * @param props.onError Called when the Emojibase dataset fails to load, + * so the parent can swap in a fallback picker. */ -export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { +export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { const baseUrl = typeof window !== 'undefined' ? window.gutenbergEmojibaseUrl ?? null @@ -270,6 +273,25 @@ export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { ); }, [ frequentKeys, recordByHexKey, query ] ); + // Transient states are announced through the `@wordpress/a11y` + // announcer — its live regions exist before these messages — because + // a live region mounted together with its content is not reliably + // announced. The visible status nodes below intentionally carry no + // live-region roles for the same reason. + useEffect( () => { + if ( isLoading ) { + speak( __( 'Loading…' ) ); + } + }, [ isLoading ] ); + + // Dataset failures bubble to the parent, which swaps in the curated + // fallback picker so adding a reaction keeps working. + useEffect( () => { + if ( error ) { + onError?.(); + } + }, [ error, onError ] ); + // Announce result counts during search so screen readers stay in sync // with the visible grid as the user types. Debounced (matching the // block-inserter search pattern) so fast typing announces only the @@ -361,26 +383,17 @@ export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { className="editor-collab-sidebar-panel__picker-viewport" > { isLoading && ( -
+
{ __( 'Loading…' ) }
) } { error && ! isLoading && ( -
+
{ __( 'Couldn’t load emojis.' ) }
) } { ! isLoading && ! error && matchCount === 0 && ( -
+
{ __( 'No emoji found.' ) }
) } diff --git a/packages/editor/src/components/collab-sidebar/note.js b/packages/editor/src/components/collab-sidebar/note.js index 82e15bbe72989b..7b46bf85413134 100644 --- a/packages/editor/src/components/collab-sidebar/note.js +++ b/packages/editor/src/components/collab-sidebar/note.js @@ -22,7 +22,8 @@ import { moreVertical, published } from '@wordpress/icons'; */ import { NoteCard } from './note-card'; import { NoteForm } from './note-form'; -import ReactionDisplay, { AddReactionButton } from './reaction-display'; +import ReactionDisplay from './reaction-display'; +import { AddReactionButton } from './add-reaction-picker'; import { unlock } from '../../lock-unlock'; const { Menu } = unlock( componentsPrivateApis ); diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index 96006265d4cdb8..c42469558360f9 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -7,52 +7,21 @@ import type { MouseEvent } from 'react'; * WordPress dependencies */ import { __, sprintf, _n } from '@wordpress/i18n'; -import { Button, Dropdown } from '@wordpress/components'; +import { Button } from '@wordpress/components'; import { Stack } from '@wordpress/ui'; -import { SVG, Path } from '@wordpress/primitives'; -import { - useState, - useCallback, - useMemo, - lazy, - Suspense, -} from '@wordpress/element'; +import { useState, useCallback, useMemo } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; -// Inlined while reactions remain experimental in scope. If/when this -// icon is needed elsewhere it can be promoted to `@wordpress/icons`. -const smileyIcon = ( - - - - -); - /** * Internal dependencies */ -import ReactionEmojiPicker, { - emojiToHexKey, - emojiToStorageKey, +import { hexKeyToEmoji, buildEmojiBySlugMap, useReactionEmojis, } from './reaction-emoji-picker'; -import { - detectLocale, - loadEmojibaseData, - useEmojiLabel, -} from './emojibase-data'; -import { useFrequentEmojis } from './frequent-emojis'; +import { useEmojiLabel } from './emojibase-data'; /** * A single slug's entry in the reaction summary: how many reactions it @@ -76,35 +45,6 @@ interface ReactionComment { content: string | { raw?: string; rendered?: string }; } -/** - * Lazy-load the full emoji picker. Its bundle is only fetched the first - * time a user opens (or hovers) the add-reaction trigger in a session; - * the Emojibase JSON dataset is fetched separately on first open. - */ -const FullEmojiPicker = lazy( () => import( './emoji-picker' ) ); - -/** - * Warm the full picker before it opens: start fetching the lazy picker - * chunk and the Emojibase dataset for the active locale. Both loaders - * cache, so calling this repeatedly (every hover) is free after the - * first invocation. - */ -function prefetchFullPicker(): void { - import( './emoji-picker' ).catch( () => {} ); - if ( typeof window !== 'undefined' && window.gutenbergEmojibaseUrl ) { - loadEmojibaseData( window.gutenbergEmojibaseUrl, detectLocale() ).catch( - () => {} - ); - } -} - -// `Dropdown`'s popover renders through the active `Popover.Slot` when -// one exists (falling back to a ``-level container), so either -// way it escapes the `overflow: hidden` chain on the collab sidebar -// (`.interface-interface-skeleton__sidebar`, -// `.editor-collab-sidebar`, `.editor-collab-sidebar-panel`). -const POPOVER_PROPS = { placement: 'bottom-end' } as const; - /** * Get the count of reactions for a specific slug. * @@ -195,6 +135,18 @@ function formatReactionTooltip( names: string[], emojiLabel: string ): string { // Module-level cache for reaction details: { "noteId:slug": string[] } const reactionNamesCache: Record< string, string[] > = {}; +/** + * Drop the cached reactor names for a note/slug pair. Called whenever a + * reaction is added or removed, since that changes the set of users the + * tooltip lists. + * + * @param noteId The parent note comment ID. + * @param slug The reaction slug. + */ +export function invalidateReactionNames( noteId: number, slug: string ): void { + delete reactionNamesCache[ `${ noteId }:${ slug }` ]; +} + interface ReactionButtonProps { noteId: number; slug: string; @@ -313,7 +265,7 @@ function ReactionButton( { ?.focus(); } // Invalidate cached names since the reaction set is changing. - delete reactionNamesCache[ `${ noteId }:${ slug }` ]; + invalidateReactionNames( noteId, slug ); setNames( null ); onToggleReaction( slug ); } } @@ -388,140 +340,3 @@ export default function ReactionDisplay( { ); } - -interface AddReactionButtonProps { - noteId: number; - disabled?: boolean; - onToggleReaction: ( slug: string ) => void; -} - -/** - * Standalone add-reaction button. Opens the full searchable emoji - * picker directly; its "Frequently used" section is seeded with the - * curated reaction set, so the previous quick-row picks stay one click - * away. - * - * The full picker only renders when `window.gutenbergEmojibaseUrl` is - * set — the Gutenberg plugin sets it via PHP, but npm consumers of the - * editor package must opt in by providing a URL pointing at a - * self-hosted emojibase dataset. Without it, the curated quick row is - * offered instead. - * - * @param props Component props. - * @param props.noteId The parent note comment ID. - * @param props.disabled Whether the button is disabled (e.g. on a - * resolved note thread). - * @param props.onToggleReaction Callback to toggle a reaction. - */ -export function AddReactionButton( { - noteId, - disabled = false, - onToggleReaction, -}: AddReactionButtonProps ) { - const { recordUse } = useFrequentEmojis(); - const emojis = useReactionEmojis(); - const emojiBySlug = useMemo( - () => buildEmojiBySlugMap( emojis ), - [ emojis ] - ); - const hasFullPicker = - typeof window !== 'undefined' && !! window.gutenbergEmojibaseUrl; - - return ( - ( -
- } - > - - // Match against the filtered curated - // list, not just the defaults, so a - // filter-provided emoji picked here - // stores under the same slug as a - // quick-row pick. - pickReaction( - emojiToStorageKey( emoji, emojis ) - ) - } - /> - - ); - } - - return ( - { - // Keep counting picks toward the full picker's - // "Frequently used" section so the history is - // warm if the site later provides an Emojibase - // URL. - recordUse( - emojiToHexKey( - emojiBySlug.get( slug )?.emoji ?? '' - ) - ); - pickReaction( slug ); - } } - /> - ); - } } - /> - ); -} diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index f55ee556bc118f..5bb1c019a2ce8d 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -305,6 +305,17 @@ mark.wp-note { justify-content: center; } +// Curated fallback shown when the full picker's module or dataset +// fails to load: the quick row stays usable, with a retry affordance. +.editor-collab-sidebar-panel__picker-fallback-note { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-xs); + padding-top: var(--wpds-dimension-padding-xs); + color: var(--wpds-color-foreground-content-neutral-weak); + font-size: var(--wpds-typography-font-size-xs); +} + // Full picker popover: width is driven by the picker itself (below) so // the popover wrapper fits its content via Popover's default // `width: min-content`. Setting width on the wrapper instead leaves diff --git a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx new file mode 100644 index 00000000000000..f8be6b24c6896f --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx @@ -0,0 +1,258 @@ +/** + * External dependencies + */ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { speak } from '@wordpress/a11y'; +import { dispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { AddReactionButton } from '../add-reaction-picker'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); +jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) ); + +/* + * The tooltip name cache in reaction-display.tsx is module-level and + * keyed by `noteId:slug`, so each test uses a distinct noteId to stay + * isolated. + */ +let uniqueNoteId = 100; + +describe( 'AddReactionButton', () => { + beforeEach( () => { + uniqueNoteId += 1; + apiFetch.mockReset(); + apiFetch.mockRejectedValue( new Error( 'not mocked' ) ); + speak.mockClear(); + } ); + + it( 'falls back to the curated quick row when no Emojibase URL is set', async () => { + const user = userEvent.setup(); + const onToggleReaction = jest.fn(); + render( + + ); + + const trigger = screen.getByRole( 'button', { name: 'Add reaction' } ); + // The trigger advertises the popup type it opens, and the popup + // itself is a named non-modal dialog so screen readers announce + // where focus landed. + expect( trigger ).toHaveAttribute( 'aria-haspopup', 'dialog' ); + await user.click( trigger ); + expect( + screen.getByRole( 'dialog', { name: 'Add reaction' } ) + ).toBeVisible(); + // The curated options are laid out horizontally, so the listbox + // must report its orientation (the ARIA default is vertical). + expect( + screen.getByRole( 'listbox', { + name: 'Select an emoji reaction', + } ) + ).toHaveAttribute( 'aria-orientation', 'horizontal' ); + await user.click( + await screen.findByRole( 'option', { name: 'Rocket' } ) + ); + + expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); + expect( onToggleReaction ).toHaveBeenCalledWith( 'rocket' ); + } ); + + it( 'opens the full picker directly and stores a filter-provided emoji under its slug', async () => { + // With an Emojibase URL configured, "Add reaction" opens the full + // searchable picker straight away (no intermediate quick row). + // A site filter adds 👍 with the slug `thumbs-up`; picking it from + // the full picker must store `thumbs-up`, not the raw hex key + // `1f44d`, so it aggregates into the same reaction_summary bucket + // as historical quick-row picks. + window.gutenbergEmojibaseUrl = 'https://example.test/emojibase'; + const originalFetch = global.fetch; + global.fetch = jest.fn( ( url ) => + Promise.resolve( { + ok: true, + json: () => + Promise.resolve( + String( url ).includes( 'data.json' ) + ? [ + { + hexcode: '1F44D', + emoji: '👍', + label: 'thumbs up', + group: 0, + }, + ] + : { groups: [ { order: 0, message: 'Smileys' } ] } + ), + } ) + ); + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: [ + { emoji: '👍', label: 'Thumbs up', value: 'thumbs-up' }, + ], + } ); + + try { + const user = userEvent.setup(); + const onToggleReaction = jest.fn(); + render( + + ); + + await user.click( + screen.getByRole( 'button', { name: 'Add reaction' } ) + ); + // The popup is exposed as a named non-modal dialog in the + // full-picker branch too. + expect( + screen.getByRole( 'dialog', { name: 'Add reaction' } ) + ).toBeVisible(); + // The full picker opens directly — no quick row, no + // intermediate "More emojis" step. + expect( + screen.queryByRole( 'option', { name: 'Thumbs up' } ) + ).not.toBeInTheDocument(); + // The filter-provided emoji is seeded into "Frequently used" + // and also appears in its Emojibase category; either cell + // exercises the same selection path. + await user.click( + ( + await screen.findAllByRole( 'gridcell', { + name: 'thumbs up', + } ) + )[ 0 ] + ); + expect( onToggleReaction ).toHaveBeenLastCalledWith( 'thumbs-up' ); + expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); + } finally { + global.fetch = originalFetch; + delete window.gutenbergEmojibaseUrl; + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: undefined, + } ); + } ); + } + } ); + + it( 'falls back to the curated picker with a retry path when the dataset fails', async () => { + // A distinct base URL keeps the module-level dataset cache from + // other tests out of the way; rejected loads are never cached, so + // the retry below triggers a fresh fetch. + window.gutenbergEmojibaseUrl = 'https://example.test/emojibase-down'; + const originalFetch = global.fetch; + let failRequests = true; + global.fetch = jest.fn( ( url ) => + failRequests + ? Promise.reject( new Error( 'network down' ) ) + : Promise.resolve( { + ok: true, + json: () => + Promise.resolve( + String( url ).includes( 'data.json' ) + ? [ + { + hexcode: '1F44D', + emoji: '👍', + label: 'thumbs up', + group: 0, + }, + ] + : { + groups: [ + { + order: 0, + message: 'Smileys', + }, + ], + } + ), + } ) + ); + + try { + const user = userEvent.setup(); + const onToggleReaction = jest.fn(); + render( + + ); + + await user.click( + screen.getByRole( 'button', { name: 'Add reaction' } ) + ); + + // The dataset failed: the curated quick row takes over so + // adding a reaction keeps working, with a visible retry + // affordance… + expect( + await screen.findByRole( 'option', { name: 'Rocket' } ) + ).toBeVisible(); + expect( + screen.getByText( 'Couldn’t load the full emoji picker.' ) + ).toBeVisible(); + // …and the failure is announced through the a11y announcer, + // whose live regions exist before the message (a live region + // mounted together with its content is not reliably announced). + expect( speak ).toHaveBeenCalledWith( + 'The full emoji picker couldn’t be loaded. Basic reactions are available.', + 'assertive' + ); + + // Curated picks work in the fallback state. + await user.click( + screen.getByRole( 'option', { name: 'Rocket' } ) + ); + expect( onToggleReaction ).toHaveBeenCalledWith( 'rocket' ); + + // The failure is sticky across popover open/close until + // retried; once the network recovers, Retry restores the + // full picker. + await user.click( + screen.getByRole( 'button', { name: 'Add reaction' } ) + ); + failRequests = false; + await user.click( screen.getByRole( 'button', { name: 'Retry' } ) ); + expect( + await screen.findAllByRole( 'gridcell', { name: 'thumbs up' } ) + ).not.toHaveLength( 0 ); + } finally { + global.fetch = originalFetch; + delete window.gutenbergEmojibaseUrl; + } + } ); + + it( 'stays focusable but inert when disabled', async () => { + const user = userEvent.setup(); + render( + {} } + /> + ); + + const button = screen.getByRole( 'button', { name: 'Add reaction' } ); + expect( button ).toHaveAttribute( 'aria-disabled', 'true' ); + + await user.click( button ); + expect( + screen.queryByRole( 'option', { name: 'Rocket' } ) + ).not.toBeInTheDocument(); + } ); +} ); diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index a66fdb4b322655..de56b8e7068dad 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -8,13 +8,11 @@ import userEvent from '@testing-library/user-event'; * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; -import { dispatch } from '@wordpress/data'; -import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies */ -import ReactionDisplay, { AddReactionButton } from '../reaction-display'; +import ReactionDisplay from '../reaction-display'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); @@ -368,143 +366,3 @@ describe( 'ReactionDisplay', () => { ).toBeVisible(); } ); } ); - -describe( 'AddReactionButton', () => { - beforeEach( () => { - uniqueNoteId += 1; - apiFetch.mockReset(); - apiFetch.mockRejectedValue( new Error( 'not mocked' ) ); - } ); - - it( 'falls back to the curated quick row when no Emojibase URL is set', async () => { - const user = userEvent.setup(); - const onToggleReaction = jest.fn(); - render( - - ); - - const trigger = screen.getByRole( 'button', { name: 'Add reaction' } ); - // The trigger advertises the popup type it opens, and the popup - // itself is a named non-modal dialog so screen readers announce - // where focus landed. - expect( trigger ).toHaveAttribute( 'aria-haspopup', 'dialog' ); - await user.click( trigger ); - expect( - screen.getByRole( 'dialog', { name: 'Add reaction' } ) - ).toBeVisible(); - // The curated options are laid out horizontally, so the listbox - // must report its orientation (the ARIA default is vertical). - expect( - screen.getByRole( 'listbox', { - name: 'Select an emoji reaction', - } ) - ).toHaveAttribute( 'aria-orientation', 'horizontal' ); - await user.click( - await screen.findByRole( 'option', { name: 'Rocket' } ) - ); - - expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); - expect( onToggleReaction ).toHaveBeenCalledWith( 'rocket' ); - } ); - - it( 'opens the full picker directly and stores a filter-provided emoji under its slug', async () => { - // With an Emojibase URL configured, "Add reaction" opens the full - // searchable picker straight away (no intermediate quick row). - // A site filter adds 👍 with the slug `thumbs-up`; picking it from - // the full picker must store `thumbs-up`, not the raw hex key - // `1f44d`, so it aggregates into the same reaction_summary bucket - // as historical quick-row picks. - window.gutenbergEmojibaseUrl = 'https://example.test/emojibase'; - const originalFetch = global.fetch; - global.fetch = jest.fn( ( url ) => - Promise.resolve( { - ok: true, - json: () => - Promise.resolve( - String( url ).includes( 'data.json' ) - ? [ - { - hexcode: '1F44D', - emoji: '👍', - label: 'thumbs up', - group: 0, - }, - ] - : { groups: [ { order: 0, message: 'Smileys' } ] } - ), - } ) - ); - dispatch( blockEditorStore ).updateSettings( { - noteReactionEmojis: [ - { emoji: '👍', label: 'Thumbs up', value: 'thumbs-up' }, - ], - } ); - - try { - const user = userEvent.setup(); - const onToggleReaction = jest.fn(); - render( - - ); - - await user.click( - screen.getByRole( 'button', { name: 'Add reaction' } ) - ); - // The popup is exposed as a named non-modal dialog in the - // full-picker branch too. - expect( - screen.getByRole( 'dialog', { name: 'Add reaction' } ) - ).toBeVisible(); - // The full picker opens directly — no quick row, no - // intermediate "More emojis" step. - expect( - screen.queryByRole( 'option', { name: 'Thumbs up' } ) - ).not.toBeInTheDocument(); - // The filter-provided emoji is seeded into "Frequently used" - // and also appears in its Emojibase category; either cell - // exercises the same selection path. - await user.click( - ( - await screen.findAllByRole( 'gridcell', { - name: 'thumbs up', - } ) - )[ 0 ] - ); - expect( onToggleReaction ).toHaveBeenLastCalledWith( 'thumbs-up' ); - expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); - } finally { - global.fetch = originalFetch; - delete window.gutenbergEmojibaseUrl; - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteReactionEmojis: undefined, - } ); - } ); - } - } ); - - it( 'stays focusable but inert when disabled', async () => { - const user = userEvent.setup(); - render( - {} } - /> - ); - - const button = screen.getByRole( 'button', { name: 'Add reaction' } ); - expect( button ).toHaveAttribute( 'aria-disabled', 'true' ); - - await user.click( button ); - expect( - screen.queryByRole( 'option', { name: 'Rocket' } ) - ).not.toBeInTheDocument(); - } ); -} ); From 25313a8983d9c0bce1822753609e0393026be0e2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 18:11:12 -0700 Subject: [PATCH 242/362] Notes: Configure the emoji picker through editor settings, not globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Emojibase URL and label overrides were read from window.gutenberg* page globals, and the changelog documented the global as the npm-consumer API — a de facto package contract with no scoping and no composition path; stories and tests had to mutate global state. The feature already reads noteReactionEmojis through block_editor_settings_all, so route the URL and overrides through the same boundary as noteEmojibaseUrl and noteEmojiLabelOverrides settings. PHP now populates settings instead of printing inline scripts, and the gutenberg_emoji_picker_label_overrides filter is unchanged. --- lib/compat/wordpress-7.1/emojibase-data.php | 54 +++++----- packages/editor/CHANGELOG.md | 2 +- .../collab-sidebar/add-reaction-picker.tsx | 43 +++++--- .../collab-sidebar/emoji-picker.tsx | 15 +-- .../collab-sidebar/emojibase-data.ts | 96 +++++++++++++----- .../stories/emoji-picker.story.jsx | 20 ++-- .../stories/note-reactions.story.jsx | 9 +- .../test/add-reaction-picker.tsx | 14 ++- .../collab-sidebar/test/emoji-picker.js | 16 ++- .../collab-sidebar/test/reaction-display.tsx | 22 ++++- .../provider/use-block-editor-settings.js | 2 + phpunit/emoji-picker-data-test.php | 99 ++++++++----------- storybook/main.ts | 6 +- 13 files changed, 240 insertions(+), 158 deletions(-) diff --git a/lib/compat/wordpress-7.1/emojibase-data.php b/lib/compat/wordpress-7.1/emojibase-data.php index c1351c791d6d0e..5d1355bae1e035 100644 --- a/lib/compat/wordpress-7.1/emojibase-data.php +++ b/lib/compat/wordpress-7.1/emojibase-data.php @@ -13,19 +13,25 @@ */ /** - * Adds a global JS variable pointing at the bundled Emojibase data - * directory, before any editor script runs. The picker in - * @wordpress/editor consumes this URL. + * Injects the Emojibase dataset URL and per-emoji label overrides into + * the block editor settings, where the Notes emoji picker in + * `@wordpress/editor` reads them (`noteEmojibaseUrl` and + * `noteEmojiLabelOverrides`). Using the settings pipeline (rather than + * a page global) keeps the configuration scoped to the editor and + * gives npm consumers of the editor package the same documented + * boundary: provide the settings, get the full picker. + * + * @since 7.1.0 + * + * @param array $settings Existing block editor settings. + * @return array Updated block editor settings. */ -function gutenberg_emojibase_data_register_inline_script() { - $url = gutenberg_url( 'build/emojibase-data' ); - wp_add_inline_script( - 'wp-editor', - 'window.gutenbergEmojibaseUrl = ' . wp_json_encode( $url ) . ';', - 'before' - ); +function gutenberg_add_emojibase_settings( $settings ) { + $settings['noteEmojibaseUrl'] = gutenberg_url( 'build/emojibase-data' ); + $settings['noteEmojiLabelOverrides'] = gutenberg_get_emoji_picker_label_overrides(); + return $settings; } -add_action( 'init', 'gutenberg_emojibase_data_register_inline_script' ); +add_filter( 'block_editor_settings_all', 'gutenberg_add_emojibase_settings' ); /** * Convert an emoji character to the uppercase hex code-point sequence @@ -84,21 +90,24 @@ function gutenberg_emoji_to_hexcode( $emoji ) { } /** - * Exposes a per-emoji label override map to the editor's emoji picker. + * Builds the per-emoji label override map exposed to the editor's + * emoji picker. * * Emojibase ships translated labels for 28 locales; for the long tail - * of WordPress-supported locales, those labels stay in English. This - * filter lets sites and plugins fill the gap on a per-emoji basis - * (typically for the small set of emojis they care about most). The - * map is keyed by uppercase Emojibase hex codes so it merges cleanly - * over the per-locale `data.json`. + * of WordPress-supported locales, those labels stay in English. The + * filter below lets sites and plugins fill the gap on a per-emoji + * basis (typically for the small set of emojis they care about most). + * The map is keyed by uppercase Emojibase hex codes so it merges + * cleanly over the per-locale `data.json`. * * Seeded with the curated reaction emojis so the full picker shows * the same translated label as the curated quick-row. * * @since 7.1.0 + * + * @return array Map of `hexcode => translated label`. */ -function gutenberg_emoji_picker_label_overrides_register_inline_script() { +function gutenberg_get_emoji_picker_label_overrides() { $defaults = array(); if ( function_exists( 'gutenberg_get_note_reaction_emojis' ) ) { foreach ( gutenberg_get_note_reaction_emojis() as $entry ) { @@ -134,12 +143,5 @@ function gutenberg_emoji_picker_label_overrides_register_inline_script() { // non-string values would otherwise crash the picker's JS-side // label handling (`label.toLowerCase()` in searchEmojis()). $overrides = is_array( $overrides ) ? $overrides : array(); - $overrides = array_filter( $overrides, 'is_string' ); - - wp_add_inline_script( - 'wp-editor', - 'window.gutenbergEmojiLabelOverrides = ' . wp_json_encode( (object) $overrides ) . ';', - 'before' - ); + return array_filter( $overrides, 'is_string' ); } -add_action( 'init', 'gutenberg_emoji_picker_label_overrides_register_inline_script' ); diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 4313d95bf307fa..1b2cef34255c55 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -5,7 +5,7 @@ ### New Features - Add an "Attachments" source to the block inserter's Media tab, listing images attached to the current post with the ability to attach and detach them ([#79336](https://github.com/WordPress/gutenberg/pull/79336)). -- Notes: the add-reaction popover now offers a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via `window.gutenbergEmojibaseUrl` ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). +- Notes: the add-reaction popover now offers a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via the `noteEmojibaseUrl` editor setting (with optional `noteEmojiLabelOverrides`) ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). ### Enhancements diff --git a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx index 9f2dd75b4871d7..94897097a34cbe 100644 --- a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx @@ -28,7 +28,11 @@ import ReactionEmojiPicker, { buildEmojiBySlugMap, useReactionEmojis, } from './reaction-emoji-picker'; -import { detectLocale, loadEmojibaseData } from './emojibase-data'; +import { + detectLocale, + loadEmojibaseData, + useEmojibaseConfig, +} from './emojibase-data'; import { useFrequentEmojis } from './frequent-emojis'; import { invalidateReactionNames } from './reaction-display'; @@ -70,13 +74,13 @@ const FullEmojiPicker = lazy( loadEmojiPicker ); * chunk and the Emojibase dataset for the active locale. Both loaders * cache, so calling this repeatedly (every hover) is free after the * first invocation. + * + * @param baseUrl Same-origin URL of the Emojibase dataset directory. */ -function prefetchFullPicker(): void { +function prefetchFullPicker( baseUrl: string | null ): void { loadEmojiPicker().catch( () => {} ); - if ( typeof window !== 'undefined' && window.gutenbergEmojibaseUrl ) { - loadEmojibaseData( window.gutenbergEmojibaseUrl, detectLocale() ).catch( - () => {} - ); + if ( baseUrl ) { + loadEmojibaseData( baseUrl, detectLocale() ).catch( () => {} ); } } @@ -144,12 +148,13 @@ interface AddReactionButtonProps { * curated reaction set, so the previous quick-row picks stay one click * away. * - * The full picker only renders when `window.gutenbergEmojibaseUrl` is - * set — the Gutenberg plugin sets it via PHP, but npm consumers of the - * editor package must opt in by providing a URL pointing at a - * self-hosted emojibase dataset. Without it — or when the picker - * module or its dataset fails to load — the curated quick row is - * offered instead, so adding a reaction keeps working. + * The full picker only renders when the `noteEmojibaseUrl` block + * editor setting is present — the Gutenberg plugin injects it via the + * `block_editor_settings_all` PHP filter, and npm consumers of the + * editor package opt in by passing the same setting with a URL + * pointing at a self-hosted emojibase dataset. Without it — or when + * the picker module or its dataset fails to load — the curated quick + * row is offered instead, so adding a reaction keeps working. * * @param props Component props. * @param props.noteId The parent note comment ID. @@ -168,8 +173,8 @@ export function AddReactionButton( { () => buildEmojiBySlugMap( emojis ), [ emojis ] ); - const hasFullPicker = - typeof window !== 'undefined' && !! window.gutenbergEmojibaseUrl; + const { baseUrl } = useEmojibaseConfig(); + const hasFullPicker = !! baseUrl; const [ pickerFailed, setPickerFailed ] = useState( false ); // A rejected `lazy()` memoizes its failure, so retrying needs a // fresh lazy component. The module request itself is cached by the @@ -232,9 +237,15 @@ export function AddReactionButton( { // dataset, so the popover usually opens fully // populated. onMouseEnter={ - hasFullPicker ? prefetchFullPicker : undefined + hasFullPicker + ? () => prefetchFullPicker( baseUrl ) + : undefined + } + onFocus={ + hasFullPicker + ? () => prefetchFullPicker( baseUrl ) + : undefined } - onFocus={ hasFullPicker ? prefetchFullPicker : undefined } /> ) } renderContent={ ( { onClose } ) => { diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx index 791f0b288f8497..d63e639e3cf806 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -15,6 +15,7 @@ import { useDebounce } from '@wordpress/compose'; import { detectLocale, normalizeHexcode, + useEmojibaseConfig, useEmojibaseData, } from './emojibase-data'; import type { EmojibaseEntry } from './emojibase-data'; @@ -131,8 +132,9 @@ export function searchEmojis( * Full searchable emoji picker built from WPDS components * (`SearchControl`, `Composite`) and styled with WPDS tokens. Emoji * data and labels come from Emojibase per-locale files served - * same-origin from `window.gutenbergEmojibaseUrl`; UI chrome strings - * go through `@wordpress/i18n` so GlotPress can translate them. + * same-origin from the `noteEmojibaseUrl` block editor setting; UI + * chrome strings go through `@wordpress/i18n` so GlotPress can + * translate them. * * @param props Component props. * @param props.onSelect Called with the selected emoji character. @@ -140,14 +142,7 @@ export function searchEmojis( * so the parent can swap in a fallback picker. */ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { - const baseUrl = - typeof window !== 'undefined' - ? window.gutenbergEmojibaseUrl ?? null - : null; - const labelOverrides = - typeof window !== 'undefined' && window.gutenbergEmojiLabelOverrides - ? window.gutenbergEmojiLabelOverrides - : null; + const { baseUrl, labelOverrides } = useEmojibaseConfig(); const [ locale ] = useState( detectLocale ); const { data, messages, isLoading, error } = useEmojibaseData( baseUrl, diff --git a/packages/editor/src/components/collab-sidebar/emojibase-data.ts b/packages/editor/src/components/collab-sidebar/emojibase-data.ts index 114667c8c60f68..79392e4a8bc86f 100644 --- a/packages/editor/src/components/collab-sidebar/emojibase-data.ts +++ b/packages/editor/src/components/collab-sidebar/emojibase-data.ts @@ -2,6 +2,9 @@ * WordPress dependencies */ import { useEffect, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +// @ts-expect-error - No type declarations available for @wordpress/block-editor +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Emojibase data loading shared between the full emoji picker (lazy @@ -11,11 +14,45 @@ import { useEffect, useState } from '@wordpress/element'; * JSON dataset — is only ever fetched on demand. */ -declare global { - interface Window { - gutenbergEmojibaseUrl?: string; - gutenbergEmojiLabelOverrides?: Record< string, string >; - } +/** + * Emojibase configuration read from the block editor settings. + */ +export interface EmojibaseConfig { + /** + * Same-origin base URL of the Emojibase dataset directory, or null + * when the site doesn't provide one (the full picker is unavailable). + */ + baseUrl: string | null; + /** + * Per-emoji label overrides keyed by Emojibase hexcode, or null. + */ + labelOverrides: Record< string, string > | null; +} + +/** + * Read the Emojibase configuration from the block editor settings. The + * Gutenberg plugin populates the `noteEmojibaseUrl` and + * `noteEmojiLabelOverrides` settings server-side (via the + * `block_editor_settings_all` filter); npm consumers of the editor + * package opt in by providing the same settings, with the URL pointing + * at a self-hosted Emojibase dataset. + * + * @return The Emojibase base URL and label overrides. + */ +export function useEmojibaseConfig(): EmojibaseConfig { + return useSelect( ( select ) => { + const settings: Record< string, unknown > = + select( blockEditorStore ).getSettings(); + const baseUrl = settings.noteEmojibaseUrl; + const labelOverrides = settings.noteEmojiLabelOverrides; + return { + baseUrl: typeof baseUrl === 'string' && baseUrl ? baseUrl : null, + labelOverrides: + labelOverrides && typeof labelOverrides === 'object' + ? ( labelOverrides as Record< string, string > ) + : null, + }; + }, [] ); } /** @@ -300,25 +337,24 @@ const labelMapCache = new Map< string, Map< string, string > >(); /** * Build (and cache) a Map from normalized hex key to user-facing emoji - * label for a loaded dataset, applying any per-site label overrides - * from `window.gutenbergEmojiLabelOverrides`. + * label for a loaded dataset, applying any per-site label overrides. + * The overrides are page-static (they come from the editor settings), + * so the cache is keyed by dataset alone. * - * @param cacheKey `baseUrl|locale` cache key. - * @param data Emojibase emoji records. + * @param cacheKey `baseUrl|locale` cache key. + * @param data Emojibase emoji records. + * @param overrides Map of `hexcode => translated label`, or null. * @return Map from hex key to label. */ function buildLabelMap( cacheKey: string, - data: EmojibaseEntry[] + data: EmojibaseEntry[], + overrides: Record< string, string > | null ): Map< string, string > { const existing = labelMapCache.get( cacheKey ); if ( existing ) { return existing; } - const overrides = - typeof window !== 'undefined' && window.gutenbergEmojiLabelOverrides - ? window.gutenbergEmojiLabelOverrides - : null; const map = new Map< string, string >(); for ( const entry of data ) { if ( ! entry.hexcode || ! entry.label ) { @@ -350,19 +386,27 @@ function buildLabelMap( * Look up the label for a stored hex-key reaction from the already * loaded dataset, without triggering a fetch. * - * @param hexKey Normalized reaction hex key, e.g. `1f44d`. + * @param hexKey Normalized reaction hex key, e.g. `1f44d`. + * @param baseUrl Same-origin URL of the Emojibase dataset directory. + * @param overrides Map of `hexcode => translated label`, or null. * @return The label, or null when unknown/not loaded. */ -export function getCachedEmojiLabel( hexKey: string ): string | null { - if ( typeof window === 'undefined' || ! window.gutenbergEmojibaseUrl ) { +export function getCachedEmojiLabel( + hexKey: string, + baseUrl: string | null, + overrides: Record< string, string > | null +): string | null { + if ( ! baseUrl ) { return null; } - const cacheKey = `${ window.gutenbergEmojibaseUrl }|${ detectLocale() }`; + const cacheKey = `${ baseUrl }|${ detectLocale() }`; const cached = dataCache.get( cacheKey ); if ( ! cached ) { return null; } - return buildLabelMap( cacheKey, cached.data ).get( hexKey ) || null; + return ( + buildLabelMap( cacheKey, cached.data, overrides ).get( hexKey ) || null + ); } /** @@ -381,18 +425,15 @@ export function useEmojiLabel( hexKey: string, enabled: boolean ): string | null { + const { baseUrl, labelOverrides } = useEmojibaseConfig(); const [ label, setLabel ] = useState< string | null >( () => - enabled ? getCachedEmojiLabel( hexKey ) : null + enabled ? getCachedEmojiLabel( hexKey, baseUrl, labelOverrides ) : null ); useEffect( () => { - if ( ! enabled || label ) { - return; - } - if ( typeof window === 'undefined' || ! window.gutenbergEmojibaseUrl ) { + if ( ! enabled || label || ! baseUrl ) { return; } - const baseUrl = window.gutenbergEmojibaseUrl; const locale = detectLocale(); let cancelled = false; loadEmojibaseData( baseUrl, locale ) @@ -402,7 +443,8 @@ export function useEmojiLabel( } const resolved = buildLabelMap( `${ baseUrl }|${ locale }`, - data + data, + labelOverrides ).get( hexKey ); if ( resolved ) { setLabel( resolved ); @@ -414,7 +456,7 @@ export function useEmojiLabel( return () => { cancelled = true; }; - }, [ hexKey, enabled, label ] ); + }, [ hexKey, enabled, label, baseUrl, labelOverrides ] ); return label; } diff --git a/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.jsx b/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.jsx index e57112d91bab3c..17f871f65bae93 100644 --- a/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.jsx +++ b/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.jsx @@ -3,6 +3,12 @@ */ import { fn } from 'storybook/test'; +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + /** * Internal dependencies */ @@ -14,18 +20,20 @@ import EmojiPicker from '../emoji-picker'; * grouped by category, with a search field filtering on labels and * tags. * - * The component reads its dataset from - * `window.gutenbergEmojibaseUrl` (set by the Gutenberg plugin via an - * inline script). In Storybook the English dataset is served from a - * static directory mapped in `storybook/main.ts`, and the document - * language is pinned to `en` so locale detection is deterministic. + * The component reads its dataset from the `noteEmojibaseUrl` block + * editor setting (populated by the Gutenberg plugin server-side). In + * Storybook the English dataset is served from a static directory + * mapped in `storybook/main.ts`, and the document language is pinned + * to `en` so locale detection is deterministic. */ const meta = { title: 'Editor/EmojiPicker', component: EmojiPicker, decorators: [ ( Story ) => { - window.gutenbergEmojibaseUrl = 'emojibase-data'; + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'emojibase-data', + } ); document.documentElement.lang = 'en'; return ; }, diff --git a/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.jsx b/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.jsx index 87ad7e857eabd8..1edc0c0eee608d 100644 --- a/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.jsx +++ b/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.jsx @@ -8,11 +8,14 @@ import { fn } from 'storybook/test'; */ import { useState } from '@wordpress/element'; import { Stack } from '@wordpress/ui'; +import { dispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies */ -import ReactionDisplay, { AddReactionButton } from '../reaction-display'; +import ReactionDisplay from '../reaction-display'; +import { AddReactionButton } from '../add-reaction-picker'; /** * Sample reaction summary, keyed by reaction slug. Curated reactions are @@ -85,7 +88,9 @@ const meta = { component: ReactionDisplay, decorators: [ ( Story ) => { - window.gutenbergEmojibaseUrl = 'emojibase-data'; + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'emojibase-data', + } ); document.documentElement.lang = 'en'; return ; }, diff --git a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx index f8be6b24c6896f..89181f1cb24636 100644 --- a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx @@ -76,7 +76,6 @@ describe( 'AddReactionButton', () => { // the full picker must store `thumbs-up`, not the raw hex key // `1f44d`, so it aggregates into the same reaction_summary bucket // as historical quick-row picks. - window.gutenbergEmojibaseUrl = 'https://example.test/emojibase'; const originalFetch = global.fetch; global.fetch = jest.fn( ( url ) => Promise.resolve( { @@ -97,6 +96,7 @@ describe( 'AddReactionButton', () => { } ) ); dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/emojibase', noteReactionEmojis: [ { emoji: '👍', label: 'Thumbs up', value: 'thumbs-up' }, ], @@ -139,9 +139,9 @@ describe( 'AddReactionButton', () => { expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); } finally { global.fetch = originalFetch; - delete window.gutenbergEmojibaseUrl; act( () => { dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: undefined, noteReactionEmojis: undefined, } ); } ); @@ -152,7 +152,9 @@ describe( 'AddReactionButton', () => { // A distinct base URL keeps the module-level dataset cache from // other tests out of the way; rejected loads are never cached, so // the retry below triggers a fresh fetch. - window.gutenbergEmojibaseUrl = 'https://example.test/emojibase-down'; + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/emojibase-down', + } ); const originalFetch = global.fetch; let failRequests = true; global.fetch = jest.fn( ( url ) => @@ -233,7 +235,11 @@ describe( 'AddReactionButton', () => { ).not.toHaveLength( 0 ); } finally { global.fetch = originalFetch; - delete window.gutenbergEmojibaseUrl; + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: undefined, + } ); + } ); } } ); diff --git a/packages/editor/src/components/collab-sidebar/test/emoji-picker.js b/packages/editor/src/components/collab-sidebar/test/emoji-picker.js index 0a82b659eebdc0..a909de8033eca7 100644 --- a/packages/editor/src/components/collab-sidebar/test/emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/test/emoji-picker.js @@ -3,13 +3,15 @@ */ import { readFileSync } from 'node:fs'; import path from 'node:path'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; /** * WordPress dependencies */ import { speak } from '@wordpress/a11y'; +import { dispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies @@ -187,7 +189,9 @@ describe( 'EmojiPicker search announcements', () => { beforeEach( () => { speak.mockClear(); - window.gutenbergEmojibaseUrl = 'https://example.test/emojibase'; + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/emojibase', + } ); global.fetch = jest.fn( ( url ) => Promise.resolve( { ok: true, @@ -216,7 +220,13 @@ describe( 'EmojiPicker search announcements', () => { afterEach( () => { global.fetch = originalFetch; - delete window.gutenbergEmojibaseUrl; + // The picker may still be mounted here (RTL cleanup runs after + // this hook), so the settings-driven re-render needs act(). + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: undefined, + } ); + } ); } ); it( 'exposes categories as labelled rowgroups and flattens search results', async () => { diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index de56b8e7068dad..cb4d2a6b3b8f33 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -8,6 +8,8 @@ import userEvent from '@testing-library/user-event'; * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { dispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies @@ -103,7 +105,9 @@ describe( 'ReactionDisplay', () => { } ); it( 'resolves the emoji name from the Emojibase dataset for hex-key reactions', async () => { - window.gutenbergEmojibaseUrl = 'https://example.test/emojibase'; + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/emojibase', + } ); const originalFetch = global.fetch; global.fetch = jest.fn( ( url ) => Promise.resolve( { @@ -149,7 +153,11 @@ describe( 'ReactionDisplay', () => { ).toHaveTextContent( '👍' ); } finally { global.fetch = originalFetch; - delete window.gutenbergEmojibaseUrl; + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: undefined, + } ); + } ); } } ); @@ -267,7 +275,9 @@ describe( 'ReactionDisplay', () => { // re-derive once the Emojibase dataset supplies the real label. // A distinct base URL keeps the module-level dataset cache from // earlier tests out of the way. - window.gutenbergEmojibaseUrl = 'https://example.test/emojibase-race'; + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/emojibase-race', + } ); const originalFetch = global.fetch; let resolveDataset; const datasetGate = new Promise( ( resolve ) => { @@ -339,7 +349,11 @@ describe( 'ReactionDisplay', () => { ); } finally { global.fetch = originalFetch; - delete window.gutenbergEmojibaseUrl; + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: undefined, + } ); + } ); } } ); diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 503c1dfa4b642d..2e5703a46ef298 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -92,6 +92,8 @@ const BLOCK_EDITOR_SETTINGS = [ 'isRTL', 'locale', 'maxWidth', + 'noteEmojibaseUrl', + 'noteEmojiLabelOverrides', 'noteReactionEmojis', 'postContentAttributes', 'postsPerPage', diff --git a/phpunit/emoji-picker-data-test.php b/phpunit/emoji-picker-data-test.php index 80d449a0313a34..3123b3a21344a5 100644 --- a/phpunit/emoji-picker-data-test.php +++ b/phpunit/emoji-picker-data-test.php @@ -91,31 +91,22 @@ public function test_gutenberg_emoji_to_hexcode_covers_all_byte_lengths() { * The default override map seeds the curated reaction emojis so the * full picker shows the same translated label as the curated row. * - * @covers ::gutenberg_emoji_picker_label_overrides_register_inline_script + * @covers ::gutenberg_get_emoji_picker_label_overrides */ public function test_default_overrides_seeded_from_curated_reactions() { - gutenberg_emoji_picker_label_overrides_register_inline_script(); - - $inline = wp_scripts()->get_data( 'wp-editor', 'before' ); - $this->assertNotEmpty( $inline, 'Expected inline script to be registered.' ); - - $payload = is_array( $inline ) ? implode( "\n", $inline ) : (string) $inline; - $this->assertStringContainsString( - 'window.gutenbergEmojiLabelOverrides', - $payload - ); + $overrides = gutenberg_get_emoji_picker_label_overrides(); // Heart, Celebration, Smile, Eyes, Rocket — the five curated // reactions defined in gutenberg_get_note_reaction_emojis(). - $this->assertStringContainsString( '"2764":', $payload ); - $this->assertStringContainsString( '"1F389":', $payload ); - $this->assertStringContainsString( '"1F604":', $payload ); - $this->assertStringContainsString( '"1F440":', $payload ); - $this->assertStringContainsString( '"1F680":', $payload ); + $this->assertArrayHasKey( '2764', $overrides ); + $this->assertArrayHasKey( '1F389', $overrides ); + $this->assertArrayHasKey( '1F604', $overrides ); + $this->assertArrayHasKey( '1F440', $overrides ); + $this->assertArrayHasKey( '1F680', $overrides ); } /** - * @covers ::gutenberg_emoji_picker_label_overrides_register_inline_script + * @covers ::gutenberg_get_emoji_picker_label_overrides */ public function test_filter_can_extend_overrides() { $callback = static function ( $overrides ) { @@ -124,18 +115,15 @@ public function test_filter_can_extend_overrides() { }; add_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - gutenberg_emoji_picker_label_overrides_register_inline_script(); + $overrides = gutenberg_get_emoji_picker_label_overrides(); - $inline = wp_scripts()->get_data( 'wp-editor', 'before' ); - $payload = is_array( $inline ) ? implode( "\n", $inline ) : (string) $inline; - - $this->assertStringContainsString( '"1F44D":"Custom thumbs up"', $payload ); + $this->assertSame( 'Custom thumbs up', $overrides['1F44D'] ); remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); } /** - * @covers ::gutenberg_emoji_picker_label_overrides_register_inline_script + * @covers ::gutenberg_get_emoji_picker_label_overrides */ public function test_filter_can_replace_overrides_entirely() { $callback = static function () { @@ -143,22 +131,16 @@ public function test_filter_can_replace_overrides_entirely() { }; add_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - gutenberg_emoji_picker_label_overrides_register_inline_script(); - - $inline = wp_scripts()->get_data( 'wp-editor', 'before' ); - $payload = is_array( $inline ) ? implode( "\n", $inline ) : (string) $inline; - - // The map serializes to {} (a JS object literal), not []. - $this->assertStringContainsString( - 'window.gutenbergEmojiLabelOverrides = {};', - $payload + $this->assertSame( + array(), + gutenberg_get_emoji_picker_label_overrides() ); remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); } /** - * @covers ::gutenberg_emoji_picker_label_overrides_register_inline_script + * @covers ::gutenberg_get_emoji_picker_label_overrides */ public function test_filter_drops_non_string_values() { $callback = static function () { @@ -171,21 +153,19 @@ public function test_filter_drops_non_string_values() { }; add_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - gutenberg_emoji_picker_label_overrides_register_inline_script(); - - $inline = wp_scripts()->get_data( 'wp-editor', 'before' ); - $payload = is_array( $inline ) ? implode( "\n", $inline ) : (string) $inline; - - $this->assertStringContainsString( '"1F600":"Grin"', $payload ); - $this->assertStringContainsString( '"1F680":"Rocket"', $payload ); - $this->assertStringNotContainsString( '"1F44D"', $payload ); - $this->assertStringNotContainsString( '"1F389":42', $payload ); + $this->assertSame( + array( + '1F600' => 'Grin', + '1F680' => 'Rocket', + ), + gutenberg_get_emoji_picker_label_overrides() + ); remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); } /** - * @covers ::gutenberg_emoji_picker_label_overrides_register_inline_script + * @covers ::gutenberg_get_emoji_picker_label_overrides */ public function test_filter_returning_non_array_is_treated_as_empty() { $callback = static function () { @@ -193,23 +173,30 @@ public function test_filter_returning_non_array_is_treated_as_empty() { }; add_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - gutenberg_emoji_picker_label_overrides_register_inline_script(); - - $inline = wp_scripts()->get_data( 'wp-editor', 'before' ); - $payload = is_array( $inline ) ? implode( "\n", $inline ) : (string) $inline; - - $this->assertStringContainsString( - 'window.gutenbergEmojiLabelOverrides = {};', - $payload + $this->assertSame( + array(), + gutenberg_get_emoji_picker_label_overrides() ); remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); } - public function tear_down() { - // Reset inline script state between tests so the order in which - // the suite runs doesn't pollute the assertions. - wp_scripts()->add_data( 'wp-editor', 'before', array() ); - parent::tear_down(); + /** + * The picker configuration rides on the block editor settings — no + * page globals — so the editor package reads it through its normal + * settings boundary. + * + * @covers ::gutenberg_add_emojibase_settings + */ + public function test_emojibase_settings_injected() { + $settings = gutenberg_add_emojibase_settings( array( 'existing' => true ) ); + + $this->assertTrue( $settings['existing'] ); + $this->assertStringEndsWith( + 'build/emojibase-data', + $settings['noteEmojibaseUrl'] + ); + $this->assertIsArray( $settings['noteEmojiLabelOverrides'] ); + $this->assertArrayHasKey( '2764', $settings['noteEmojiLabelOverrides'] ); } } diff --git a/storybook/main.ts b/storybook/main.ts index a382f7a1cadd9a..4cad65b1e9332e 100644 --- a/storybook/main.ts +++ b/storybook/main.ts @@ -47,9 +47,9 @@ const config: StorybookConfig = { './static', // Serve the English Emojibase dataset for the Editor/EmojiPicker // story, mirroring the `build/emojibase-data//` layout the - // Gutenberg plugin exposes via `window.gutenbergEmojibaseUrl`. Only - // `en` is mapped (the story pins the document language) to keep the - // published Storybook artifact small. + // Gutenberg plugin exposes via the `noteEmojibaseUrl` editor + // setting. Only `en` is mapped (the story pins the document + // language) to keep the published Storybook artifact small. { from: '../node_modules/emojibase-data/en', to: '/emojibase-data/en', From a1e7d01792529c4e3f83799e45a1bb2ccc12c74b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 18:11:16 -0700 Subject: [PATCH 243/362] Notes: Derive picker geometry from WPDS tokens The popover width calc repeated the token values as raw 4px/32px, so a token override could make the width disagree with the rendered grid. Derive cell size, gap, and viewport padding once as custom properties on the picker root and share them across the width calc, rows, and cells; the scrollbar estimate is isolated into a named custom property since the reserved gutter width isn't readable from CSS. The sticky category header keeps $white deliberately: it must match the components popover surface, which is not tokenized. --- .../src/components/collab-sidebar/style.scss | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 5bb1c019a2ce8d..d6c5b5aa2ca134 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -300,7 +300,7 @@ mark.wp-note { // target of 2.5.5 (Enhanced) — at 44px an 8-column grid would exceed // the sidebar-anchored popover's practical width. .editor-collab-sidebar-panel__emoji-option { - width: 32px; + width: var(--wpds-dimension-size-md); font-size: 18px; justify-content: center; } @@ -335,16 +335,26 @@ mark.wp-note { } .editor-collab-sidebar-panel__picker { + // Shared grid geometry, derived once from the WPDS tokens so the + // width calculation below can never disagree with the rendered + // cells, gaps, and padding. The scrollbar allowance is a deliberate, + // isolated estimate: `scrollbar-gutter: stable` reserves the real + // scrollbar width at layout time, but that width isn't readable + // from CSS, so the popover budgets ~17px (the common classic + // scrollbar; overlay-scrollbar platforms reserve nothing and simply + // get a slightly roomier viewport). + --emoji-picker-cell-size: var(--wpds-dimension-size-md); + --emoji-picker-gap: var(--wpds-dimension-gap-xs); + --emoji-picker-viewport-padding: var(--wpds-dimension-padding-xs); + --emoji-picker-scrollbar-allowance: 17px; + display: flex; flex-direction: column; // Width sized to the 8-column emoji grid plus the viewport's // reserved scrollbar gutter, so the popover wraps the visible // emoji area without buttons shrinking under flex pressure when // the scrollbar is present. - // (8 buttons × 32px) + (7 gaps × 4px) + (2 viewport padding × 4px) - // + scrollbar gutter (reserved via `scrollbar-gutter: stable` on - // the viewport below; ~17px on most platforms). - width: calc(8 * 32px + 7 * 4px + 2 * 4px + 17px); + width: calc(8 * var(--emoji-picker-cell-size) + 7 * var(--emoji-picker-gap) + 2 * var(--emoji-picker-viewport-padding) + var(--emoji-picker-scrollbar-allowance)); height: 320px; font-family: var(--wpds-typography-font-family-body); font-size: var(--wpds-typography-font-size-sm); @@ -368,10 +378,10 @@ mark.wp-note { .editor-collab-sidebar-panel__skin-tone-toggle { flex-shrink: 0; - // A fixed 32px square so the toggle matches the compact icon-button - // footprint (and the 32px emoji grid cells) instead of growing with - // its emoji text content. - width: $grid-unit-40; + // A fixed square matching the emoji grid cells, so the toggle keeps + // the compact icon-button footprint instead of growing with its + // emoji text content. + width: var(--emoji-picker-cell-size); padding: 0; font-size: 18px; justify-content: center; @@ -395,7 +405,9 @@ mark.wp-note { } .editor-collab-sidebar-panel__skin-tone-option { - width: 32px; + // Rendered in its own flyout popover, outside the picker's scope, + // so this reads the WPDS size token directly. + width: var(--wpds-dimension-size-md); font-size: 18px; justify-content: center; @@ -411,7 +423,7 @@ mark.wp-note { // or shrink when content height crosses the scroll threshold. scrollbar-gutter: stable; position: relative; - padding-inline: var(--wpds-dimension-padding-xs); + padding-inline: var(--emoji-picker-viewport-padding); } .editor-collab-sidebar-panel__picker-status { @@ -429,8 +441,11 @@ mark.wp-note { position: sticky; top: 0; padding: var(--wpds-dimension-padding-sm); - // Match the popover's default surface so rows scrolling underneath - // stay hidden. + // Match the popover's surface: `.components-popover` paints `$white` + // (not a WPDS token), and rows scrolling underneath must disappear + // behind exactly that color — a token here could drift from the + // actual popover background. Swap both together if the popover + // surface ever moves to a token. background-color: $white; color: var(--wpds-color-foreground-content-neutral-weak); font-size: var(--wpds-typography-font-size-xs); @@ -440,20 +455,20 @@ mark.wp-note { .editor-collab-sidebar-panel__picker-row { display: flex; - gap: var(--wpds-dimension-gap-xs); + gap: var(--emoji-picker-gap); // Skip rendering offscreen rows so the full ~250-row list mounts // quickly. The intrinsic-size hint matches the actual button row // height so scroll geometry stays accurate. content-visibility: auto; - contain-intrinsic-size: auto 32px; + contain-intrinsic-size: auto var(--emoji-picker-cell-size); } .editor-collab-sidebar-panel__picker-emoji { display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: var(--emoji-picker-cell-size); + height: var(--emoji-picker-cell-size); padding: 0; border: 0; border-radius: var(--wpds-border-radius-sm); From 8d973ba93260c91b22f4423a7801a9d73508b3cc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 21 Jul 2026 18:14:38 -0700 Subject: [PATCH 244/362] Notes: Assert Escape focus return and nested-overlay dismissal in e2e The Escape test only proved the popup became hidden; it now also asserts focus returns to the add-reaction trigger. A new test covers the nested skin-tone flyout: the first Escape closes only the flyout and returns focus to its toggle with the full picker still open, and the second Escape closes the picker. --- .../specs/editor/various/block-notes.spec.js | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index dbc7c6ff90b5e9..c0a168028ab6c1 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1470,6 +1470,55 @@ test.describe( 'Block Notes', () => { await expect( page.getByPlaceholder( 'Search emoji' ) ).toBeHidden(); + // Focus returns to the trigger rather than dropping to the + // document body. + await expect( + page.getByRole( 'button', { name: 'Add reaction' } ) + ).toBeFocused(); + } ); + + test( 'Escape in the skin-tone flyout closes only that popup', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Nested overlay dismissal' }, + comment: 'Escape unwinds one layer at a time', + } ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await blockNoteUtils.waitForFullPicker(); + + // Open the nested skin-tone flyout; focus moves into its + // listbox (onto the selected swatch). + const skinToneToggle = page.getByRole( 'button', { + name: /^Skin tone:/, + } ); + await skinToneToggle.click(); + const skinToneListbox = page.getByRole( 'listbox', { + name: 'Choose your default skin tone', + } ); + await expect( skinToneListbox ).toBeVisible(); + + // The first Escape closes only the flyout, returns focus to + // its toggle, and leaves the full picker open. + await page.keyboard.press( 'Escape' ); + await expect( skinToneListbox ).toBeHidden(); + await expect( skinToneToggle ).toBeFocused(); + await expect( + page.getByPlaceholder( 'Search emoji' ) + ).toBeVisible(); + + // The second Escape closes the full picker and returns focus + // to the add-reaction trigger. + await page.keyboard.press( 'Escape' ); + await expect( + page.getByPlaceholder( 'Search emoji' ) + ).toBeHidden(); + await expect( + page.getByRole( 'button', { name: 'Add reaction' } ) + ).toBeFocused(); } ); test( 'full picker shows the empty state when search has no matches', async ( { From 8f87eca0171ab879fa99d62471b92808b5ce9d5b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 22 Jul 2026 08:24:31 -0700 Subject: [PATCH 245/362] Notes: Fix reaction picker layout and arrow-key navigation The picker's popover content is `width: min-content`, which squeezed the wrapping emoji row down to a single column, while the Composite declared `orientation="horizontal"` so only Left/Right moved the roving tab index. The scroll container also clipped the buttons' focus ring. Size the listbox to `max-content`, drop the orientation so both axes move focus, and split the popover padding with the scroll container so the focus ring stays inside it. --- .../collab-sidebar/reaction-emoji-picker.tsx | 6 +++- .../src/components/collab-sidebar/style.scss | 28 ++++++++++++---- .../specs/editor/various/block-notes.spec.js | 32 +++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 858181015d8dab..ed55bc973666a0 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -99,7 +99,11 @@ export default function ReactionEmojiPicker( { return ( diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index b40ac2d120e4df..bac0115a50f467 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -278,9 +278,13 @@ mark.wp-note { line-height: 1; } -// Curated picker uses Dropdown's default `.components-popover__content` -// padding (8px) for breathing room around the listbox; no extra rule -// needed. +// Split the Dropdown's default 8px `.components-popover__content` padding +// between the popover and the scrolling listbox below, so the emoji buttons +// keep the same breathing room while their focus ring stays inside the +// scroll container instead of being clipped by it. +.editor-collab-sidebar-panel__add-reaction-popover .components-popover__content { + padding: $grid-unit-05; +} .editor-collab-sidebar-panel__emoji-picker { display: flex; @@ -289,13 +293,25 @@ mark.wp-note { // buttons) and scroll past 6 rows instead of letting the popover // grow unbounded. flex-wrap: wrap; - max-width: 8 * 32px; - max-height: 6 * 32px; + // `.components-popover__content` is `width: min-content`, which would + // otherwise squeeze this wrapping row down to one column per emoji. + width: max-content; + // Scrolling clips on both axes, so leave room for the focus ring of + // the buttons on the edges. The padding is added to the row and column + // caps below, which are border-box measurements. + padding: $grid-unit-05; + max-width: 8 * 32px + 2 * $grid-unit-05; + max-height: 6 * 32px + 2 * $grid-unit-05; overflow-y: auto; } .editor-collab-sidebar-panel__emoji-option { - width: 32px; + // Pin the track to 32px so the `max-width` above lands exactly 8 emoji + // per row; the compact Button's own padding and minimum size would + // otherwise widen the flex item past the 32px width. + flex: 0 0 32px; + min-width: 0; + padding: 0; font-size: 18px; justify-content: center; } diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index b5aedd2fe032e0..69f00a2d45a397 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1214,6 +1214,38 @@ test.describe( 'Block Notes', () => { ).toBeVisible(); } ); + test( 'picker lays out as a row and moves focus on both axes', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing picker layout' }, + comment: 'Test comment for picker layout', + } ); + + await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + const emojiPicker = page.locator( + '.editor-collab-sidebar-panel__emoji-picker' + ); + await expect( emojiPicker ).toBeVisible(); + + // `.components-popover__content` is `width: min-content`, + // which used to squeeze the wrapping listbox into a single + // column one emoji wide. + const box = await emojiPicker.boundingBox(); + expect( box.width ).toBeGreaterThan( box.height ); + + // The roving tab index moves on both axes, so the picker is + // navigable however the emoji set happens to wrap. + const options = emojiPicker.getByRole( 'option' ); + await options.first().focus(); + await page.keyboard.press( 'ArrowDown' ); + await expect( options.nth( 1 ) ).toBeFocused(); + await page.keyboard.press( 'ArrowUp' ); + await expect( options.first() ).toBeFocused(); + } ); + test( 'can add multiple different reactions to same note', async ( { page, blockNoteUtils, From 5209f0dbcfcfe24d04f3f9bafaf7b6c05c924dde Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 10 Aug 2026 18:45:06 -0700 Subject: [PATCH 246/362] Update the picker keynav test and drop stale dependency-group comments The curated picker wraps into rows, so it deliberately reports no aria-orientation and moves the roving tab index on both axes. --- .../collab-sidebar/add-reaction-picker.tsx | 11 ----------- .../collab-sidebar/emoji-picker.tsx | 7 ------- .../collab-sidebar/emojibase-data.ts | 3 --- .../collab-sidebar/frequent-emojis.ts | 7 ------- .../collab-sidebar/reaction-display.tsx | 11 ----------- .../collab-sidebar/reaction-emoji-picker.tsx | 3 --- .../collab-sidebar/skin-tone-picker.tsx | 7 ------- .../stories/emoji-picker.story.jsx | 11 ----------- .../stories/note-reactions.story.jsx | 11 ----------- .../test/add-reaction-picker.tsx | 19 +++++-------------- .../collab-sidebar/test/emoji-picker.js | 11 ----------- .../collab-sidebar/test/frequent-emojis.js | 3 --- .../collab-sidebar/test/reaction-display.tsx | 11 ----------- .../collab-sidebar/test/skin-tone-picker.js | 7 ------- 14 files changed, 5 insertions(+), 117 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx index 94897097a34cbe..e1dd6e79d63ccc 100644 --- a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx @@ -1,11 +1,4 @@ -/** - * External dependencies - */ import type { ReactNode } from 'react'; - -/** - * WordPress dependencies - */ import { __ } from '@wordpress/i18n'; import { Button, Dropdown } from '@wordpress/components'; import { SVG, Path } from '@wordpress/primitives'; @@ -18,10 +11,6 @@ import { useState, } from '@wordpress/element'; import { speak } from '@wordpress/a11y'; - -/** - * Internal dependencies - */ import ReactionEmojiPicker, { emojiToHexKey, emojiToStorageKey, diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx index d63e639e3cf806..2225fc8e2120d6 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { __, _n, _x, sprintf } from '@wordpress/i18n'; import { Composite, SearchControl } from '@wordpress/components'; import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; @@ -8,10 +5,6 @@ import { useSelect, useDispatch } from '@wordpress/data'; import { store as preferencesStore } from '@wordpress/preferences'; import { speak } from '@wordpress/a11y'; import { useDebounce } from '@wordpress/compose'; - -/** - * Internal dependencies - */ import { detectLocale, normalizeHexcode, diff --git a/packages/editor/src/components/collab-sidebar/emojibase-data.ts b/packages/editor/src/components/collab-sidebar/emojibase-data.ts index 79392e4a8bc86f..a4002482653790 100644 --- a/packages/editor/src/components/collab-sidebar/emojibase-data.ts +++ b/packages/editor/src/components/collab-sidebar/emojibase-data.ts @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { useEffect, useState } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; // @ts-expect-error - No type declarations available for @wordpress/block-editor diff --git a/packages/editor/src/components/collab-sidebar/frequent-emojis.ts b/packages/editor/src/components/collab-sidebar/frequent-emojis.ts index 97675c0cd983b2..b881c04468dcc9 100644 --- a/packages/editor/src/components/collab-sidebar/frequent-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/frequent-emojis.ts @@ -1,13 +1,6 @@ -/** - * WordPress dependencies - */ import { useCallback } from '@wordpress/element'; import { useSelect, useRegistry } from '@wordpress/data'; import { store as preferencesStore } from '@wordpress/preferences'; - -/** - * Internal dependencies - */ import { REACTION_EMOJIS, emojiToHexKey, diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index c42469558360f9..e7611e861c73f7 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -1,21 +1,10 @@ -/** - * External dependencies - */ import type { MouseEvent } from 'react'; - -/** - * WordPress dependencies - */ import { __, sprintf, _n } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; import { Stack } from '@wordpress/ui'; import { useState, useCallback, useMemo } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; - -/** - * Internal dependencies - */ import { hexKeyToEmoji, buildEmojiBySlugMap, diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 6bc261ec23afe0..c7b364fc7d5a0c 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { __, _x } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; diff --git a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx index 8360a1bba940e8..03421576bd5111 100644 --- a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx @@ -1,14 +1,7 @@ -/** - * WordPress dependencies - */ import { __, sprintf } from '@wordpress/i18n'; import { Button, Composite, Dropdown } from '@wordpress/components'; import { useFocusReturn, useInstanceId } from '@wordpress/compose'; import { useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ import type { EmojibaseEntry, EmojibaseSkin } from './emojibase-data'; /** diff --git a/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.jsx b/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.jsx index 17f871f65bae93..5a69d3541e58e9 100644 --- a/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.jsx +++ b/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.jsx @@ -1,17 +1,6 @@ -/** - * External dependencies - */ import { fn } from 'storybook/test'; - -/** - * WordPress dependencies - */ import { dispatch } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; - -/** - * Internal dependencies - */ import EmojiPicker from '../emoji-picker'; /** diff --git a/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.jsx b/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.jsx index 1edc0c0eee608d..f3b434d1519825 100644 --- a/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.jsx +++ b/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.jsx @@ -1,19 +1,8 @@ -/** - * External dependencies - */ import { fn } from 'storybook/test'; - -/** - * WordPress dependencies - */ import { useState } from '@wordpress/element'; import { Stack } from '@wordpress/ui'; import { dispatch } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; - -/** - * Internal dependencies - */ import ReactionDisplay from '../reaction-display'; import { AddReactionButton } from '../add-reaction-picker'; diff --git a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx index 89181f1cb24636..23a6492516d8c4 100644 --- a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.tsx @@ -1,20 +1,9 @@ -/** - * External dependencies - */ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - -/** - * WordPress dependencies - */ import apiFetch from '@wordpress/api-fetch'; import { speak } from '@wordpress/a11y'; import { dispatch } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; - -/** - * Internal dependencies - */ import { AddReactionButton } from '../add-reaction-picker'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); @@ -54,13 +43,15 @@ describe( 'AddReactionButton', () => { expect( screen.getByRole( 'dialog', { name: 'Add reaction' } ) ).toBeVisible(); - // The curated options are laid out horizontally, so the listbox - // must report its orientation (the ARIA default is vertical). + // The curated options wrap into rows once the set grows past one + // row, and a narrow popover can stack them into a column, so the + // listbox deliberately reports no orientation and both axes move + // the roving tab index. expect( screen.getByRole( 'listbox', { name: 'Select an emoji reaction', } ) - ).toHaveAttribute( 'aria-orientation', 'horizontal' ); + ).not.toHaveAttribute( 'aria-orientation' ); await user.click( await screen.findByRole( 'option', { name: 'Rocket' } ) ); diff --git a/packages/editor/src/components/collab-sidebar/test/emoji-picker.js b/packages/editor/src/components/collab-sidebar/test/emoji-picker.js index a909de8033eca7..34116c9a37cdab 100644 --- a/packages/editor/src/components/collab-sidebar/test/emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/test/emoji-picker.js @@ -1,21 +1,10 @@ -/** - * External dependencies - */ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - -/** - * WordPress dependencies - */ import { speak } from '@wordpress/a11y'; import { dispatch } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; - -/** - * Internal dependencies - */ import EmojiPicker, { chunkRows, groupEmojis, diff --git a/packages/editor/src/components/collab-sidebar/test/frequent-emojis.js b/packages/editor/src/components/collab-sidebar/test/frequent-emojis.js index bdfa4c5b7de72e..64cafc75af5bbd 100644 --- a/packages/editor/src/components/collab-sidebar/test/frequent-emojis.js +++ b/packages/editor/src/components/collab-sidebar/test/frequent-emojis.js @@ -1,6 +1,3 @@ -/** - * Internal dependencies - */ import { DEFAULT_FREQUENT_EMOJI_KEYS, MAX_FREQUENT_EMOJIS, diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index cb4d2a6b3b8f33..1e691f1e4c4727 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -1,19 +1,8 @@ -/** - * External dependencies - */ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - -/** - * WordPress dependencies - */ import apiFetch from '@wordpress/api-fetch'; import { dispatch } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; - -/** - * Internal dependencies - */ import ReactionDisplay from '../reaction-display'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); diff --git a/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js b/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js index dc982d38e4c333..e89bb8b9a7cc95 100644 --- a/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js +++ b/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.js @@ -1,12 +1,5 @@ -/** - * External dependencies - */ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - -/** - * Internal dependencies - */ import SkinTonePicker, { SKIN_TONES, applySkinTone } from '../skin-tone-picker'; const RAISED_HAND = { From bb8206e239f15b1a676f5c18df73ca6a00ea3b7d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 10 Aug 2026 18:50:59 -0700 Subject: [PATCH 247/362] Drop stale dependency-group comments and hoist a stray import The new lint rules from trunk reject the old dependency-group comment headers and an import placed after a module-level constant. --- .../collab-sidebar/reaction-display.tsx | 19 ++++--------------- .../collab-sidebar/reaction-emoji-picker.tsx | 3 --- .../collab-sidebar/test/reaction-display.tsx | 11 ----------- .../test/reaction-emoji-picker.tsx | 11 ----------- 4 files changed, 4 insertions(+), 40 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index 76f03aef84d1c3..84339073463729 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -1,11 +1,4 @@ -/** - * External dependencies - */ import type { MouseEvent } from 'react'; - -/** - * WordPress dependencies - */ import { __, sprintf, _n } from '@wordpress/i18n'; import { Button, Dropdown } from '@wordpress/components'; import { Stack } from '@wordpress/ui'; @@ -13,6 +6,10 @@ import { SVG, Path } from '@wordpress/primitives'; import { useState, useCallback, useMemo } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; +import ReactionEmojiPicker, { + buildEmojiBySlugMap, + useReactionEmojis, +} from './reaction-emoji-picker'; // Inlined while reactions remain experimental in scope. If/when this // icon is needed elsewhere it can be promoted to `@wordpress/icons`. @@ -31,14 +28,6 @@ const smileyIcon = ( ); -/** - * Internal dependencies - */ -import ReactionEmojiPicker, { - buildEmojiBySlugMap, - useReactionEmojis, -} from './reaction-emoji-picker'; - interface ReactionSummaryEntry { count: number; reacted?: boolean; diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index ed55bc973666a0..6144e031117818 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { __, _x } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index 4e82feb9977f0a..fbec0b252d2aa0 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -1,17 +1,6 @@ -/** - * External dependencies - */ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - -/** - * WordPress dependencies - */ import apiFetch from '@wordpress/api-fetch'; - -/** - * Internal dependencies - */ import ReactionDisplay, { AddReactionButton } from '../reaction-display'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx index 303af52ef77730..d66ee07f0f083f 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx @@ -1,18 +1,7 @@ -/** - * External dependencies - */ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; - -/** - * WordPress dependencies - */ import { dispatch } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; - -/** - * Internal dependencies - */ import ReactionEmojiPicker, { REACTION_EMOJIS, buildEmojiBySlugMap, From 011b99bc68bdd415fbf0b85932e3a74a79beb80a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 23 Aug 2026 10:22:16 -0700 Subject: [PATCH 248/362] Adapt reaction tests to trunk's typechecked dev projects Trunk now type checks package dev projects (tests and stories) and ships real types for `@wordpress/api-fetch`, so the reaction tests need the mock typed through `jest.mocked`, a `@ts-expect-error` on the untyped `@wordpress/block-editor` import, and `my_reaction_id` declared on the reaction summary entry it already carries at runtime. --- .../collab-sidebar/reaction-display.tsx | 2 ++ .../collab-sidebar/test/reaction-display.tsx | 16 +++++++++------- .../test/reaction-emoji-picker.tsx | 1 + 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reaction-display.tsx index 84339073463729..ef303f7b1d4cca 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-display.tsx @@ -31,6 +31,8 @@ const smileyIcon = ( interface ReactionSummaryEntry { count: number; reacted?: boolean; + // The current user's reaction comment ID, used to delete it again. + my_reaction_id?: number; } /** diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx index fbec0b252d2aa0..cec49c8b4dbcfa 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-display.tsx @@ -5,6 +5,8 @@ import ReactionDisplay, { AddReactionButton } from '../reaction-display'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); +const mockApiFetch = jest.mocked( apiFetch ); + /* * The tooltip name cache in reaction-display.js is module-level and keyed * by `noteId:slug`, so each test uses a distinct noteId to stay isolated. @@ -14,10 +16,10 @@ let uniqueNoteId = 1; describe( 'ReactionDisplay', () => { beforeEach( () => { uniqueNoteId += 1; - apiFetch.mockReset(); + mockApiFetch.mockReset(); // Reject by default so tests that never await a tooltip fetch // fall back to the count-based label instead of hanging. - apiFetch.mockRejectedValue( new Error( 'not mocked' ) ); + mockApiFetch.mockRejectedValue( new Error( 'not mocked' ) ); } ); it( 'renders nothing when there are no reactions', () => { @@ -139,7 +141,7 @@ describe( 'ReactionDisplay', () => { it( 'lazy-loads reacting user names into the label on hover', async () => { const user = userEvent.setup(); - apiFetch.mockResolvedValue( [ + mockApiFetch.mockResolvedValue( [ { author_name: 'Alice', content: { raw: 'heart' } }, // A different emoji on the same note must be filtered out. { author_name: 'Mallory', content: { raw: 'rocket' } }, @@ -170,7 +172,7 @@ describe( 'ReactionDisplay', () => { it( 'formats two and three-plus reactor names GitHub-style', async () => { const user = userEvent.setup(); - apiFetch.mockResolvedValue( [ + mockApiFetch.mockResolvedValue( [ { author_name: 'Alice', content: { raw: 'heart' } }, { author_name: 'Bob', content: { raw: 'heart' } }, { author_name: 'Carol', content: { raw: 'heart' } }, @@ -200,7 +202,7 @@ describe( 'ReactionDisplay', () => { it( 'keeps the count-based label when the names fetch fails', async () => { const user = userEvent.setup(); - apiFetch.mockRejectedValue( new Error( 'network down' ) ); + mockApiFetch.mockRejectedValue( new Error( 'network down' ) ); render( { describe( 'AddReactionButton', () => { beforeEach( () => { uniqueNoteId += 1; - apiFetch.mockReset(); - apiFetch.mockRejectedValue( new Error( 'not mocked' ) ); + mockApiFetch.mockReset(); + mockApiFetch.mockRejectedValue( new Error( 'not mocked' ) ); } ); it( 'opens the curated picker and toggles the chosen reaction', async () => { diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx index d66ee07f0f083f..6323c67d96dbe3 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.tsx @@ -1,6 +1,7 @@ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { dispatch } from '@wordpress/data'; +// @ts-expect-error - No type declarations available for @wordpress/block-editor. import { store as blockEditorStore } from '@wordpress/block-editor'; import ReactionEmojiPicker, { REACTION_EMOJIS, From f1f9816d45cf0555b9149c46ecdffeb8dfe27dc9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 23 Aug 2026 12:15:09 -0700 Subject: [PATCH 249/362] Editor: reference primitives from the build tsconfig project reaction-display.tsx imports @wordpress/primitives, but the reference only landed in the dev tsconfig, so lint:tsconfig failed on the build project. --- packages/editor/tsconfig.build.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor/tsconfig.build.json b/packages/editor/tsconfig.build.json index 24f4fd966459fb..cfccaef3819363 100644 --- a/packages/editor/tsconfig.build.json +++ b/packages/editor/tsconfig.build.json @@ -38,6 +38,7 @@ { "path": "../notices/tsconfig.build.json" }, { "path": "../plugins" }, { "path": "../preferences/tsconfig.build.json" }, + { "path": "../primitives" }, { "path": "../private-apis" }, { "path": "../rich-text" }, { "path": "../server-side-render/tsconfig.build.json" }, From 9e3aff6bd22cfca51c0b303e50b74f1090ac18d8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 10:20:23 -0700 Subject: [PATCH 250/362] Restrict reaction writes to create and delete Reactions are validated as a set on create - author, parent note, target post and canonical 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; toggling a reaction off is a delete. Accept only slugs from the allowed emoji list. Hex codepoint keys were accepted but nothing here can render them back, so they surfaced as the raw storage key in place of the emoji and its label. The full picker brings its own decoder. 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. --- ...-gutenberg-rest-comment-controller-7-1.php | 72 ++++---- ...est-comments-controller-gutenberg-test.php | 158 ++++++++++++++++++ 2 files changed, 202 insertions(+), 28 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index baa4f4a89b6347..4ff398396be593 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -242,6 +242,38 @@ protected function check_read_permission( $comment, $request ) { return parent::check_read_permission( $comment, $request ); } + /** + * Checks if a given request has access to update a comment. + * + * Reactions are immutable: they carry an author, a parent note and a + * canonical emoji slug that `create_item()` validates as a set, and the + * generic update route re-validates none of it. Allowing updates would + * let anyone who can edit the note's post reattribute a reaction to + * another user, move it to a note on a post they cannot edit, or store a + * duplicate or invalid slug. Toggling a reaction off is a delete. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to update the item, error object otherwise. + */ + public function update_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + if ( 'reaction' === $comment->comment_type ) { + return new WP_Error( + 'rest_comment_update_not_allowed', + __( 'Reactions cannot be edited. Remove the reaction and add a new one instead.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + return parent::update_item_permissions_check( $request ); + } + public function create_item_permissions_check( $request ) { $is_note = ! empty( $request['type'] ) && $this->is_note_or_reaction( $request['type'] ); @@ -438,37 +470,17 @@ public function create_item( $request ) { ); } - // Validate the reaction content. We accept either: - // - a curated slug (e.g. "heart"), or - // - a lowercase hex-codepoint sequence joined by `-` (e.g. - // "1f44d" for 👍 or "1f468-200d-1f4bb" for 👨‍💻). - // Raw emoji bytes are rejected because the comments table is - // not guaranteed to be utf8mb4 across all WordPress installs; - // clients normalize before submitting. + // Validate the reaction content against the allowed emoji list + // (filterable via `gutenberg_note_reaction_emojis`). Only a slug + // from that list is accepted: it is the one value the picker can + // render back as an emoji and label. Raw emoji bytes are rejected + // because the comments table is not guaranteed to be utf8mb4 + // across all WordPress installs; clients submit the slug instead. $emojis = gutenberg_get_note_reaction_emojis(); $valid_slugs = wp_list_pluck( $emojis, 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; - $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); - $is_hex_key = (bool) preg_match( - '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', - $emoji_slug - ); - - // A hex-shaped slug must still be made of assignable Unicode code - // points: reject anything above U+10FFFF or in the UTF-16 surrogate - // range (U+D800–U+DFFF). - if ( $is_hex_key ) { - foreach ( explode( '-', $emoji_slug ) as $codepoint ) { - $value = hexdec( $codepoint ); - if ( $value > 0x10FFFF || ( $value >= 0xD800 && $value <= 0xDFFF ) ) { - $is_hex_key = false; - break; - } - } - } - - if ( ! $is_curated_slug && ! $is_hex_key ) { + if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.', 'gutenberg' ), @@ -656,7 +668,11 @@ public function create_item( $request ) { } } - if ( count( $duplicates ) > 1 ) { + // Repoint whenever any matching row survives, not only when this + // request still sees its own duplicate: a competing request may + // already have deleted this request's row, leaving a single + // survivor that is not `$comment_id`. + if ( ! empty( $duplicates ) ) { $survivor_id = array_shift( $duplicates ); foreach ( $duplicates as $duplicate_id ) { wp_delete_comment( $duplicate_id, true ); diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index d50344ad121383..e3eac1083c9ccc 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -665,6 +665,10 @@ public function data_invalid_reaction_inputs() { 'no parent' => array( 'none', 'heart', true, 'rest_comment_invalid_parent', 400 ), 'parent is a regular comment' => array( 'comment', 'heart', true, 'rest_comment_invalid_parent', 400 ), 'content is not in emoji list' => array( 'note', 'invalid_emoji', true, 'rest_comment_invalid_reaction', 400 ), + // Hex storage keys are the full picker's format; this limited set + // cannot render them back, so the API must not accept them yet. + 'hex codepoint key' => array( 'note', '1f44d', true, 'rest_comment_invalid_reaction', 400 ), + 'hex ZWJ sequence key' => array( 'note', '1f468-200d-1f4bb', true, 'rest_comment_invalid_reaction', 400 ), 'anonymous user' => array( 'none', 'heart', false, 'rest_comment_login_required', 401 ), ); } @@ -1002,4 +1006,158 @@ public function test_notes_and_reactions_excluded_from_comment_count() { 'Only the regular comment should be counted; notes and reactions must be excluded.' ); } + + /** + * Creates an approved reaction row directly, bypassing REST validation. + * + * @param int $post_id Post the parent note belongs to. + * @param int $note_id Parent note comment ID. + * @param int $user_id Reacting user ID. + * @param string $slug Reaction storage slug. + * @return int Reaction comment ID. + */ + protected function create_reaction( $post_id, $note_id, $user_id, $slug = 'heart' ) { + return wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => $slug, + 'comment_approved' => 1, + 'user_id' => $user_id, + ) + ); + } + + /** + * Reactions are validated as a set on create and the generic update route + * re-validates none of it, so updating one is not allowed at all. + */ + public function test_cannot_update_reaction() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => 'rocket' ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + $this->assertSame( 'heart', get_comment( $reaction_id )->comment_content ); + } + + /** + * The reactor's identity must not be reassignable through the update route. + */ + public function test_cannot_update_reaction_author() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$author_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'author' => self::$editor_id ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + $this->assertSame( (string) self::$author_id, get_comment( $reaction_id )->user_id ); + } + + /** + * A reaction must not be movable onto a note on a post the user cannot edit. + */ + public function test_cannot_move_reaction_to_note_on_other_post() { + $editable_post = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $other_post = self::factory()->post->create( array( 'post_author' => self::$admin_id ) ); + $note_id = $this->create_note( $editable_post, self::$editor_id ); + $other_note_id = $this->create_note( $other_post, self::$admin_id ); + $reaction_id = $this->create_reaction( $editable_post, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'parent' => $other_note_id, + 'post' => $other_post, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + $reaction = get_comment( $reaction_id ); + $this->assertSame( (string) $note_id, $reaction->comment_parent ); + $this->assertSame( (string) $editable_post, $reaction->comment_post_ID ); + } + + /** + * Notes remain editable - only reactions are locked down. + */ + public function test_can_still_update_note() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => 'Edited note' ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + + + + /** + * The cleanup in create_item() must repoint to the surviving row even when + * a competing request has already deleted this request's own row - the + * losing side of the same race the sibling test covers from the winner. + */ + public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + // The earliest row: the one every request converges on. + $survivor_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + // Delete this request's freshly inserted row right after it lands, as + // a competing request running the same cleanup would. + $deleted = false; + $race = function ( $comment_id, $comment ) use ( $survivor_id, &$deleted ) { + if ( ! $deleted && 'reaction' === $comment->comment_type && (int) $comment_id !== $survivor_id ) { + $deleted = true; + wp_delete_comment( $comment_id, true ); + } + }; + add_action( 'wp_insert_comment', $race, 10, 2 ); + + try { + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => 'heart', + 'author' => self::$editor_id, + ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + $response = rest_get_server()->dispatch( $request ); + } finally { + remove_action( 'wp_insert_comment', $race, 10 ); + } + + $this->assertTrue( $deleted, 'The race injection did not run.' ); + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $survivor_id, $response->get_data()['id'], 'Response did not repoint to the surviving row.' ); + } } From fae6d4535f3d6e69895e14e11e12ce89510ee1cc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 10:20:46 -0700 Subject: [PATCH 251/362] Remove a note's reactions when the note goes Core cascades a trashed note only to its `note` children, and `wp_delete_comment()` reparents children one level up instead of deleting them. Reactions therefore outlived the note they belonged to: approved rows, still carrying the reactor's identity and date, after the editor reported the note as deleted. Cascade trash, untrash and permanent deletion to a note's reactions. Replies are covered too, since core trashes and deletes each one in turn. --- lib/compat/wordpress-7.1/block-comments.php | 93 +++++++++++++++++++ ...est-comments-controller-gutenberg-test.php | 71 ++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index 55b31929abcb85..103f00869d8557 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -313,6 +313,99 @@ function gutenberg_add_note_reaction_emojis_setting( $settings ) { } add_filter( 'block_editor_settings_all', 'gutenberg_add_note_reaction_emojis_setting' ); +/** + * Returns the reaction children of a note. + * + * @since 7.1.0 + * + * @param WP_Comment $note The note whose reactions to fetch. + * @param string $status Comment status to match. Default 'all'. + * @return int[] Reaction comment IDs. + */ +function gutenberg_get_note_reaction_ids( $note, $status = 'all' ) { + return get_comments( + array( + 'parent' => $note->comment_ID, + 'type' => 'reaction', + 'status' => $status, + 'fields' => 'ids', + 'orderby' => 'comment_ID', + ) + ); +} + +/** + * Permanently deletes a note's reactions along with the note. + * + * `wp_delete_comment()` reparents a deleted comment's children one level up + * rather than deleting them, so reactions would otherwise survive their note + * as approved, orphaned rows still carrying the reactor's identity. Core + * cascades only `note` children (see `wp_trash_comment()`), so reactions need + * their own cascade. + * + * Runs on `delete_comment`, which fires before the reparenting query. + * + * @since 7.1.0 + * + * @param string $comment_id The comment ID as a numeric string. + * @param WP_Comment $comment The comment being deleted. + */ +function gutenberg_delete_note_reactions( $comment_id, $comment ) { + if ( ! $comment instanceof WP_Comment || 'note' !== $comment->comment_type ) { + return; + } + + foreach ( gutenberg_get_note_reaction_ids( $comment ) as $reaction_id ) { + wp_delete_comment( $reaction_id, true ); + } +} +add_action( 'delete_comment', 'gutenberg_delete_note_reactions', 10, 2 ); + +/** + * Trashes a note's reactions along with the note. + * + * Core cascades a trashed note to its `note` children only, so reactions + * would otherwise stay approved under a trashed note. Replies are covered + * because core trashes each one, which fires this action again. + * + * @since 7.1.0 + * + * @param string $comment_id The comment ID as a numeric string. + * @param WP_Comment $comment The trashed comment. + */ +function gutenberg_trash_note_reactions( $comment_id, $comment ) { + if ( ! $comment instanceof WP_Comment || 'note' !== $comment->comment_type ) { + return; + } + + foreach ( gutenberg_get_note_reaction_ids( $comment, 'approve' ) as $reaction_id ) { + wp_trash_comment( $reaction_id ); + } +} +add_action( 'trashed_comment', 'gutenberg_trash_note_reactions', 10, 2 ); + +/** + * Restores a note's reactions along with the note. + * + * The counterpart to gutenberg_trash_note_reactions(), so reopening a note + * from the trash brings its reactions back with it. + * + * @since 7.1.0 + * + * @param string $comment_id The comment ID as a numeric string. + * @param WP_Comment $comment The untrashed comment. + */ +function gutenberg_untrash_note_reactions( $comment_id, $comment ) { + if ( ! $comment instanceof WP_Comment || 'note' !== $comment->comment_type ) { + return; + } + + foreach ( gutenberg_get_note_reaction_ids( $comment, 'trash' ) as $reaction_id ) { + wp_untrash_comment( $reaction_id ); + } +} +add_action( 'untrashed_comment', 'gutenberg_untrash_note_reactions', 10, 2 ); + /** * Allows the note mention chip markup through comment kses. * diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index e3eac1083c9ccc..14932e1737b7e0 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -1113,8 +1113,79 @@ public function test_can_still_update_note() { $this->assertSame( 200, $response->get_status() ); } + /** + * `wp_delete_comment()` reparents children rather than deleting them, so + * without a cascade the reactions would outlive their note as orphan rows. + */ + public function test_permanently_deleting_note_deletes_its_reactions() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + $other_id = $this->create_reaction( $post_id, $note_id, self::$author_id, 'rocket' ); + + wp_delete_comment( $note_id, true ); + $this->assertNull( get_comment( $reaction_id ), 'Reaction outlived its note.' ); + $this->assertNull( get_comment( $other_id ), 'Reaction outlived its note.' ); + $this->assertEmpty( + get_comments( + array( + 'post_id' => $post_id, + 'type' => 'reaction', + 'status' => 'all', + ) + ), + 'Reaction rows remained after permanent cleanup.' + ); + } + /** + * Deleting a reply must take that reply's own reactions with it. + */ + public function test_permanently_deleting_note_reply_deletes_its_reactions() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reply_id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'note', + 'comment_content' => 'A reply', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + ) + ); + + $note_reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + $reply_reaction_id = $this->create_reaction( $post_id, $reply_id, self::$editor_id ); + + wp_delete_comment( $reply_id, true ); + + $this->assertNull( get_comment( $reply_reaction_id ), 'Reply reaction outlived its reply.' ); + $this->assertNotNull( get_comment( $note_reaction_id ), 'Root note reaction should be untouched.' ); + } + + /** + * Core cascades a trashed note to its `note` children only. + */ + public function test_trashing_note_trashes_and_restores_its_reactions() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled; trashing force-deletes.' ); + } + + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + wp_trash_comment( $note_id ); + $this->assertSame( 'trash', wp_get_comment_status( $reaction_id ), 'Reaction stayed approved under a trashed note.' ); + + wp_untrash_comment( $note_id ); + $this->assertSame( 'approved', wp_get_comment_status( $reaction_id ), 'Reaction was not restored with its note.' ); + } /** * The cleanup in create_item() must repoint to the surviving row even when From 9ef52cfd52038f9f2772d63a38aa2256323fcd57 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 10:21:19 -0700 Subject: [PATCH 252/362] Present the reaction picker as a group of buttons The picker declared a listbox but no option carried `aria-selected`, and the component never learned which reactions the user had already made - so it exposed a selection model it could not back. Picking an emoji also performs the action and closes the popover, which is not what selecting an option means. A labelled group of buttons describes what is actually there. The roving tab index stays. --- .../collab-sidebar/reaction-emoji-picker.tsx | 10 ++++-- .../src/components/collab-sidebar/style.scss | 2 +- .../collab-sidebar/test/reaction-display.tsx | 4 +-- .../test/reaction-emoji-picker.tsx | 20 ++++++------ .../specs/editor/various/block-notes.spec.js | 32 +++++++++---------- 5 files changed, 36 insertions(+), 32 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 6144e031117818..6fca9d42b55807 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -95,13 +95,18 @@ export default function ReactionEmojiPicker( { return ( { emojis.map( ( { emoji, label, value } ) => ( @@ -109,7 +114,6 @@ export default function ReactionEmojiPicker( { key={ value } render={ + > + + { emoji } + + { count } + + { accessibleLabel } + ); } @@ -409,14 +431,15 @@ export function AddReactionButton( { popoverProps={ POPOVER_PROPS } contentClassName="editor-collab-sidebar-panel__add-reaction-popover" renderToggle={ ( { isOpen, onToggle } ) => ( -
option.tone === value ) || @@ -178,16 +167,14 @@ export default function SkinTonePicker( { ( +
+ + ); +} + interface AddReactionButtonProps { noteId: number; disabled?: boolean; @@ -241,18 +284,10 @@ export function AddReactionButton( { if ( pickerFailed ) { return ( -
- -
- { __( 'Couldn’t load the full emoji picker.' ) } - -
-
+ ); } diff --git a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx index 0579b4d2e5252c..bf6a05af06ea9e 100644 --- a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx @@ -250,6 +250,58 @@ describe( 'AddReactionButton', () => { } } ); + it( 'keeps focus in the popover when the dataset fails after the search is focused', async () => { + // Its own base URL, so the dataset cache from other tests is not hit. + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/emojibase-late-failure', + } ); + const originalFetch = global.fetch; + let rejectDataset: ( error: Error ) => void = () => {}; + global.fetch = vi.fn( + () => + new Promise< Response >( ( _resolve, reject ) => { + rejectDataset = reject; + } ) + ); + + try { + const user = userEvent.setup(); + render( + {} } + /> + ); + + await user.click( + screen.getByRole( 'button', { name: 'Add reaction' } ) + ); + const search = await screen.findByRole( 'combobox' ); + expect( search ).toHaveFocus(); + + // Replacing the focused search with the fallback must not drop + // focus to the body, or Escape stops closing the popover. + await act( async () => { + rejectDataset( new Error( 'network down' ) ); + } ); + expect( + await screen.findByRole( 'button', { name: 'Heart' } ) + ).toHaveFocus(); + + await user.keyboard( '{Escape}' ); + expect( + screen.queryByRole( 'dialog', { name: 'Add reaction' } ) + ).not.toBeInTheDocument(); + } finally { + global.fetch = originalFetch; + await act( async () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: undefined, + } ); + } ); + } + } ); + it( 'stays focusable but inert when disabled', async () => { const user = userEvent.setup(); render( From 1714c046f39bd3816eb04afcbb103b197cb3640a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 08:34:55 -0700 Subject: [PATCH 317/362] Notes: Pad the end of the emoji grid to match its sides Only search results had padding below the last row, so the browsing grid ended flush against the bottom of the scroller. Give the list the same bottom padding as its sides in both modes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017UUSKYuqUkNzRPHMbKEBiw --- .../editor/src/components/collab-sidebar/style.scss | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index ba6ca08e5c5857..d8b06dc5c84c84 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -539,12 +539,16 @@ mark.wp-note { min-inline-size: var(--emoji-picker-grid-width); } +// The last row gets the same padding as the sides. +.editor-collab-sidebar-panel__picker-list { + padding-block-end: var(--emoji-picker-padding); +} + // While browsing, the category heading spaces the grid from the input. // Search results have no heading, so pad them instead: the full picker -// padding below the input (with the search row's bottom padding) and -// the same below the last row. +// padding below the input, with the search row's bottom padding. .editor-collab-sidebar-panel__picker-list.is-searching { - padding-block: calc(var(--emoji-picker-padding) - var(--wpds-dimension-padding-sm)) var(--emoji-picker-padding); + padding-block-start: calc(var(--emoji-picker-padding) - var(--wpds-dimension-padding-sm)); } .editor-collab-sidebar-panel__picker-group + .editor-collab-sidebar-panel__picker-group { From 5068548a6bf70c90d340c36664a70ee04658404c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 09:35:34 -0700 Subject: [PATCH 318/362] Notes: Keep an emptied reaction emoji list empty The REST API validates slugs against the filtered gutenberg_note_reaction_emojis list, so restoring the shipped defaults on the client offered picks the server rejects, and made the full picker store default emoji under rejected slugs instead of hex keys. Only fall back when the setting is absent, and hide the add-reaction button when there is neither a list nor Emojibase data to pick from. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM9GGbuAmeVJv6g4rUGiep --- .../collab-sidebar/add-reaction-picker.tsx | 5 +++ .../collab-sidebar/reaction-emoji-picker.tsx | 8 ++-- .../test/add-reaction-picker.jsdom.test.tsx | 26 ++++++++++++ .../test/reaction-emoji-picker.jsdom.test.tsx | 41 +++++++++++++++++-- 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx index 87b04f08389bfc..f96220f08d895e 100644 --- a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx @@ -216,6 +216,11 @@ export function AddReactionButton( { const showFullPicker = hasFullPicker && ! pickerFailed; + // With no Emojibase data and an emptied list there is nothing to pick. + if ( ! hasFullPicker && ! emojis.length ) { + return null; + } + return ( { expect( onToggleReaction ).toHaveBeenCalledWith( 'rocket' ); } ); + it( 'renders nothing when the filtered list is empty and no Emojibase URL is set', () => { + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: [], + } ); + } ); + try { + render( + + ); + + expect( + screen.queryByRole( 'button', { name: 'Add reaction' } ) + ).not.toBeInTheDocument(); + } finally { + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: undefined, + } ); + } ); + } + } ); + it( 'opens the full picker directly and stores a filter-provided emoji under its slug', async () => { // With an Emojibase URL configured, "Add reaction" opens the full // searchable picker straight away (no intermediate quick row). diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx index 7ecaa5fcc6f661..5be3278eb90778 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx @@ -92,7 +92,7 @@ describe( 'ReactionEmojiPicker', () => { ).toBeVisible(); } ); - it( 'drops malformed entries and falls back to defaults when none survive', async () => { + it( 'drops malformed entries without restoring the defaults', async () => { dispatch( blockEditorStore ).updateSettings( { noteReactionEmojis: [ null, @@ -100,11 +100,40 @@ describe( 'ReactionEmojiPicker', () => { { label: 'No emoji', value: 'no-emoji' }, ], } ); - render( {} } /> ); + const { unmount } = render( + {} } /> + ); - expect( await screen.findAllByRole( 'button' ) ).toHaveLength( - REACTION_EMOJIS.length + expect( + screen.getByRole( 'group', { + name: 'Add an emoji reaction', + } ) + ).toBeInTheDocument(); + expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 ); + // Unmount before `afterEach` restores the defaults, which would + // otherwise mount items whose Composite updates escape act(). + unmount(); + } ); + + it( 'keeps an explicitly empty filtered list empty', async () => { + // The server validates against the filtered list, so offering + // the defaults here would produce slugs it rejects. + dispatch( blockEditorStore ).updateSettings( { + noteReactionEmojis: [], + } ); + const { unmount } = render( + {} } /> ); + + expect( + screen.getByRole( 'group', { + name: 'Add an emoji reaction', + } ) + ).toBeInTheDocument(); + expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 ); + // Unmount before `afterEach` restores the defaults, which would + // otherwise mount items whose Composite updates escape act(). + unmount(); } ); } ); } ); @@ -177,6 +206,10 @@ describe( 'emojiToStorageKey', () => { expect( emojiToStorageKey( '❤' ) ).toBe( 'heart' ); } ); + it( 'uses the hex key for a default emoji missing from the list', () => { + expect( emojiToStorageKey( '🎉', [] ) ).toBe( '1f389' ); + } ); + it( 'falls back to the padded hex key for other emoji', () => { expect( emojiToStorageKey( '👍' ) ).toBe( '1f44d' ); expect( emojiToStorageKey( '©️' ) ).toBe( '00a9' ); From 1966b99b007388571f0cbdfae9e42bdd08bf76f4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 09:35:41 -0700 Subject: [PATCH 319/362] Notes: Count a deduplicated reaction add once Concurrent adds for the same emoji converge server-side on one surviving row and all return its ID, but the local delta counted each response, so a failed refresh left an inflated count and a phantom count after removal. Skip the increment when the returned ID is already recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM9GGbuAmeVJv6g4rUGiep --- .../src/components/collab-sidebar/hooks.js | 5 +++ .../use-note-actions-reactions.jsdom.test.tsx | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index eea0f01145f0d7..c3529a046996f5 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -211,6 +211,11 @@ function applyReactionDelta( note, emoji, addedReactionId ) { const entry = summary[ emoji ]; if ( addedReactionId ) { + // Concurrent adds converge server-side on one surviving row, so a + // repeated ID is already counted. + if ( entry?.my_reaction_id === addedReactionId ) { + return note; + } summary[ emoji ] = { count: ( entry?.count || 0 ) + 1, reacted: true, diff --git a/packages/editor/src/components/collab-sidebar/test/use-note-actions-reactions.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/use-note-actions-reactions.jsdom.test.tsx index 6d0531550a0710..2adc8d76ed7bdc 100644 --- a/packages/editor/src/components/collab-sidebar/test/use-note-actions-reactions.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/test/use-note-actions-reactions.jsdom.test.tsx @@ -69,6 +69,49 @@ describe( 'useNoteActions onToggleReaction', () => { mockApiFetch.mockReset(); } ); + it( 'counts concurrent adds that converge on one reaction once', async () => { + // The server dedupes concurrent inserts and returns the surviving + // row's ID to every request; the refreshes that would correct the + // count all fail. + actions.saveEntityRecord.mockResolvedValue( { id: 100 } ); + mockApiFetch.mockRejectedValue( new Error( 'offline' ) ); + + const { result } = renderHook( () => useNoteActions( {} ) ); + + await act( async () => { + await Promise.all( [ + result.current.onToggleReaction( { + commentId: 7, + emoji: 'heart', + } ), + result.current.onToggleReaction( { + commentId: 7, + emoji: 'heart', + } ), + ] ); + } ); + + expect( records.get( 7 )?.reaction_summary ).toEqual( { + heart: { count: 1, reacted: true, my_reaction_id: 100 }, + } ); + + // Removing the one surviving reaction leaves no phantom count. + const { result: after } = renderHook( () => + useNoteActions( { 7: records.get( 7 )!.reaction_summary } ) + ); + await act( async () => { + await after.current.onToggleReaction( { + commentId: 7, + emoji: 'heart', + } ); + } ); + + expect( records.get( 7 )?.reaction_summary ).toEqual( {} ); + actions.saveEntityRecord.mockImplementation( async () => ( { + id: nextReactionId++, + } ) ); + } ); + it( 'ignores a refresh that a newer reaction has overtaken', async () => { const heartRefresh = deferred< unknown >(); const rocketRefresh = deferred< unknown >(); From 6dce7bc157ea978792d061c80d4c6067ef644995 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 09:57:04 -0700 Subject: [PATCH 320/362] Notes: Reject reactions on trashed or spam notes A reaction created under a hidden note is approved immediately but never reached by the trash/restore cascade, leaving a live row under a hidden note that still counts in reaction summaries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JdkbayU2THTgtekRr1M8kK --- ...-gutenberg-rest-comment-controller-7-2.php | 9 ++++ ...est-comments-controller-gutenberg-test.php | 45 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index 91fac78ecdac3a..1a4f3458508100 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -475,6 +475,15 @@ public function create_item( $request ) { ); } + // A reaction under a hidden note would escape the trash/restore cascade. + if ( in_array( $parent_comment->comment_approved, array( 'trash', 'spam' ), true ) ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction cannot be added to a trashed or spam note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + // The parent note must belong to the post the reaction targets. if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { return new WP_Error( diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 7217f2bd10ec5f..20843923368b87 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -627,6 +627,51 @@ public function test_cannot_create_reaction_on_note_from_different_post() { $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } + /** + * A reaction created under a hidden note would never be reached by the + * trash/restore cascade, so the note must be live. + * + * @dataProvider data_hidden_note_statuses + * + * @param string $status Status to move the parent note to. + */ + public function test_cannot_create_reaction_on_hidden_note( $status ) { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + wp_set_comment_status( $note_id, $status ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => 'heart', + 'author' => self::$editor_id, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + /** + * Data provider for test_cannot_create_reaction_on_hidden_note(). + * + * @return array[] + */ + public function data_hidden_note_statuses() { + return array( + 'trash' => array( 'trash' ), + 'spam' => array( 'spam' ), + ); + } + /** * Creating a note requires edit access to the target post. */ From 0c9911560f06b64b9617d632824796ecb7a30662 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 09:57:18 -0700 Subject: [PATCH 321/362] Notes: Only clear the reaction trash flag on reactions The untrashed_comment callback ran a meta delete for every comment restored anywhere on the site. The flag is only ever set on reactions, so bail early like the sibling cascade callbacks do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JdkbayU2THTgtekRr1M8kK --- lib/compat/wordpress-7.2/note-reactions.php | 11 ++++++++--- ...est-comments-controller-gutenberg-test.php | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index d53ec71954216c..60d2950ef5e429 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -365,9 +365,14 @@ function gutenberg_untrash_note_reactions( $comment_id, $comment ) { * * @since 7.2.0 * - * @param string $comment_id The comment ID as a numeric string. + * @param string $comment_id The comment ID as a numeric string. + * @param WP_Comment $comment The untrashed comment. */ -function gutenberg_clear_note_reaction_trash_flag( $comment_id ) { +function gutenberg_clear_note_reaction_trash_flag( $comment_id, $comment ) { + if ( ! $comment instanceof WP_Comment || 'reaction' !== $comment->comment_type ) { + return; + } + delete_comment_meta( $comment_id, '_wp_trash_meta_with_note' ); } -add_action( 'untrashed_comment', 'gutenberg_clear_note_reaction_trash_flag', 5 ); +add_action( 'untrashed_comment', 'gutenberg_clear_note_reaction_trash_flag', 5, 2 ); diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 20843923368b87..777722facce30e 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -1391,6 +1391,25 @@ public function test_untrashing_note_does_not_restore_removed_reactions() { $this->assertSame( '', get_comment_meta( $live_id, '_wp_trash_meta_with_note', true ), 'The restored reaction kept its cascade flag.' ); } + /** + * The cascade flag only ever sits on reactions, so restoring any other + * comment must leave its meta alone. + */ + public function test_untrashing_regular_comment_keeps_its_meta() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled; trashing force-deletes.' ); + } + + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $post_id ) ); + add_comment_meta( $comment_id, '_wp_trash_meta_with_note', '1', true ); + + wp_trash_comment( $comment_id ); + wp_untrash_comment( $comment_id ); + + $this->assertSame( '1', get_comment_meta( $comment_id, '_wp_trash_meta_with_note', true ) ); + } + /** * Trashing a note trashes its reactions, so permanently deleting it * afterwards must still find and delete them. From d8f77d6a289c84f2ad6bc52f3ce4a865fc9c7590 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 09:57:19 -0700 Subject: [PATCH 322/362] Notes: Fall back to default internal comment types when filtered empty Callers build a NOT IN ( ... ) clause from this list, so a filter returning an empty array produced invalid SQL in the admin comment, feed and comment count queries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JdkbayU2THTgtekRr1M8kK --- lib/compat/wordpress-7.2/note-reactions.php | 9 ++++++++- phpunit/note-reactions-feed-test.php | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index 60d2950ef5e429..e4a962357842f7 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -48,7 +48,14 @@ function gutenberg_get_internal_comment_types() { * * @param string[] $types List of internal comment type slugs. */ - return apply_filters( 'gutenberg_internal_comment_types', array( 'note', 'reaction' ) ); + $types = apply_filters( 'gutenberg_internal_comment_types', array( 'note', 'reaction' ) ); + + // Callers build `NOT IN ( ... )` from this list, which is invalid SQL when empty. + if ( ! is_array( $types ) || empty( $types ) ) { + return array( 'note', 'reaction' ); + } + + return array_values( $types ); } /** diff --git a/phpunit/note-reactions-feed-test.php b/phpunit/note-reactions-feed-test.php index ba5de53fd80bbd..1ecca9b05adfd1 100644 --- a/phpunit/note-reactions-feed-test.php +++ b/phpunit/note-reactions-feed-test.php @@ -72,4 +72,13 @@ public function test_post_comment_feed_excludes_reactions() { $this->assertContains( self::$comment_id, $ids ); $this->assertNotContains( self::$reaction_id, $ids ); } + + public function test_feed_query_survives_empty_internal_types_filter() { + add_filter( 'gutenberg_internal_comment_types', '__return_empty_array' ); + $this->go_to( '/?feed=comments-rss2' ); + + $ids = $this->get_feed_comment_ids(); + $this->assertContains( self::$comment_id, $ids ); + $this->assertNotContains( self::$reaction_id, $ids ); + } } From 9fe562829d35c0a4a903b1c69cc80721e8a47e2f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 10:23:31 -0700 Subject: [PATCH 323/362] Notes: Let sites limit which emoji reactions accept Any valid hex key passed validation, so the reaction emoji list could not act as an allowlist and there was no way to remove individual emoji from the full picker. Add a gutenberg_note_reaction_emoji_rules filter with allow_unlisted and exclude, enforce it in the REST controller, and expose it to the editor as noteReactionEmojiRules. Named emoji and their skin-tone variants stay accepted; exclusions cover skin-tone variants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM9GGbuAmeVJv6g4rUGiep --- ...-gutenberg-rest-comment-controller-7-2.php | 6 + lib/compat/wordpress-7.2/note-reactions.php | 115 +++++++++++++++++- ...est-comments-controller-gutenberg-test.php | 87 +++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index 1a4f3458508100..d1f510447c8a02 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -532,6 +532,12 @@ public function create_item( $request ) { } } + // Sites can exclude emoji or limit reactions to the named list + // via `gutenberg_note_reaction_emoji_rules`. + if ( $is_hex_key && ! $is_curated_slug ) { + $is_hex_key = gutenberg_is_note_reaction_hex_key_allowed( $emoji_slug ); + } + if ( ! $is_curated_slug && ! $is_hex_key ) { return new WP_Error( 'rest_comment_invalid_reaction', diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index e4a962357842f7..a22504d1f95d84 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -258,11 +258,124 @@ function gutenberg_get_note_reaction_emojis() { * @return array Updated block editor settings. */ function gutenberg_add_note_reaction_emojis_setting( $settings ) { - $settings['noteReactionEmojis'] = gutenberg_get_note_reaction_emojis(); + $rules = gutenberg_get_note_reaction_emoji_rules(); + + $settings['noteReactionEmojis'] = gutenberg_get_note_reaction_emojis(); + $settings['noteReactionEmojiRules'] = array( + 'allowUnlisted' => $rules['allow_unlisted'], + 'exclude' => $rules['exclude'], + ); return $settings; } add_filter( 'block_editor_settings_all', 'gutenberg_add_note_reaction_emojis_setting' ); +/** + * Normalizes an emoji hex key to the reaction storage form: lowercase, + * each code point padded to four digits, U+FE0F and skin-tone modifiers + * stripped. Accepts Emojibase hexcodes (`1F44D-1F3FB`) and stored keys. + * + * @since 7.2.0 + * + * @param string $hex_key Hex code points joined by `-`. + * @return string The base emoji's hex key, or empty string when invalid. + */ +function gutenberg_normalize_note_reaction_hex_key( $hex_key ) { + if ( ! is_string( $hex_key ) || ! preg_match( '/^[0-9a-f]{1,6}(-[0-9a-f]{1,6})*$/i', $hex_key ) ) { + return ''; + } + $parts = array(); + foreach ( explode( '-', strtolower( $hex_key ) ) as $part ) { + $value = hexdec( $part ); + // Variation Selector-16 and the five Fitzpatrick skin-tone modifiers. + if ( 0xFE0F === $value || ( $value >= 0x1F3FB && $value <= 0x1F3FF ) ) { + continue; + } + $parts[] = str_pad( $part, 4, '0', STR_PAD_LEFT ); + } + return implode( '-', $parts ); +} + +/** + * Returns the rules for reactions picked outside the named emoji list, + * which the full picker stores as hex keys. + * + * @since 7.2.0 + * + * @return array { + * @type bool $allow_unlisted Whether emoji outside the named list are accepted. + * @type string[] $exclude Normalized hex keys that are never accepted. + * } + */ +function gutenberg_get_note_reaction_emoji_rules() { + /** + * Filters which emoji note reactions accept beyond the named list from + * `gutenberg_note_reaction_emojis`. Named emoji are always accepted. + * + * @since 7.2.0 + * + * @param array $rules { + * @type bool $allow_unlisted Whether any emoji from the full picker is + * accepted. False limits reactions to the + * named list. Default true. + * @type string[] $exclude Emojibase hexcodes to reject, such as + * `1F595`. Excluding an emoji also excludes + * its skin-tone variants. Default empty. + * } + */ + $rules = apply_filters( + 'gutenberg_note_reaction_emoji_rules', + array( + 'allow_unlisted' => true, + 'exclude' => array(), + ) + ); + $rules = is_array( $rules ) ? $rules : array(); + + $exclude = array(); + if ( isset( $rules['exclude'] ) && is_array( $rules['exclude'] ) ) { + foreach ( $rules['exclude'] as $hexcode ) { + $hex_key = gutenberg_normalize_note_reaction_hex_key( $hexcode ); + if ( '' !== $hex_key ) { + $exclude[] = $hex_key; + } + } + } + + return array( + 'allow_unlisted' => ! isset( $rules['allow_unlisted'] ) || (bool) $rules['allow_unlisted'], + 'exclude' => array_values( array_unique( $exclude ) ), + ); +} + +/** + * Whether a hex-key reaction is accepted under the emoji rules. An emoji + * from the named list is always accepted, including its skin-tone variants. + * + * @since 7.2.0 + * + * @param string $hex_key The submitted hex key. + * @return bool Whether the reaction is allowed. + */ +function gutenberg_is_note_reaction_hex_key_allowed( $hex_key ) { + $base = gutenberg_normalize_note_reaction_hex_key( $hex_key ); + if ( '' === $base ) { + return false; + } + + foreach ( gutenberg_get_note_reaction_emojis() as $entry ) { + if ( + is_array( $entry ) && + ! empty( $entry['emoji'] ) && + gutenberg_normalize_note_reaction_hex_key( gutenberg_emoji_to_hexcode( $entry['emoji'] ) ) === $base + ) { + return true; + } + } + + $rules = gutenberg_get_note_reaction_emoji_rules(); + return $rules['allow_unlisted'] && ! in_array( $base, $rules['exclude'], true ); +} + /** * Returns the reaction children of a note. * diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 777722facce30e..678bdb4d06ba72 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -1010,6 +1010,93 @@ public function test_reaction_emojis_filter_affects_validation() { } } + /** + * `gutenberg_note_reaction_emoji_rules` controls which hex-key reactions + * (full picker picks outside the named list) the controller accepts. + * + * @dataProvider data_reaction_emoji_rules + * + * @param mixed $rules The filtered rules. + * @param string $content The reaction storage key to submit. + * @param int $status Expected response status. + */ + public function test_reaction_emoji_rules_affect_validation( $rules, $content, $status ) { + $filter = function () use ( $rules ) { + return $rules; + }; + add_filter( 'gutenberg_note_reaction_emoji_rules', $filter ); + + try { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => $content, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + if ( 201 === $status ) { + $this->assertSame( 201, $response->get_status() ); + } else { + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, $status ); + } + } finally { + remove_filter( 'gutenberg_note_reaction_emoji_rules', $filter ); + } + } + + public function data_reaction_emoji_rules() { + $exclude_thumbs = array( 'exclude' => array( '1F44D' ) ); + $named_only = array( 'allow_unlisted' => false ); + + return array( + 'excluded emoji' => array( $exclude_thumbs, '1f44d', 400 ), + 'skin-tone variant of excluded' => array( $exclude_thumbs, '1f44d-1f3fd', 400 ), + 'emoji that is not excluded' => array( $exclude_thumbs, '1f389', 201 ), + 'exclusion written with VS-16' => array( array( 'exclude' => array( '2763-FE0F' ) ), '2763', 400 ), + 'legacy unpadded key' => array( array( 'exclude' => array( '00A9' ) ), 'a9', 400 ), + 'named-only rejects unlisted' => array( $named_only, '1f44d', 400 ), + 'named-only accepts named slug' => array( $named_only, 'heart', 201 ), + 'named-only accepts named as hex' => array( $named_only, '2764', 201 ), + 'named emoji wins over an exclusion' => array( array( 'exclude' => array( '2764' ) ), '2764', 201 ), + 'malformed rules fall back' => array( 'not-an-array', '1f389', 201 ), + ); + } + + public function test_reaction_emoji_rules_reach_editor_settings() { + $filter = function () { + return array( + 'allow_unlisted' => false, + 'exclude' => array( '1F595', 'not hex', 42 ), + ); + }; + add_filter( 'gutenberg_note_reaction_emoji_rules', $filter ); + + try { + $settings = gutenberg_add_note_reaction_emojis_setting( array() ); + } finally { + remove_filter( 'gutenberg_note_reaction_emoji_rules', $filter ); + } + + $this->assertSame( + array( + 'allowUnlisted' => false, + 'exclude' => array( '1f595' ), + ), + $settings['noteReactionEmojiRules'] + ); + } + public function test_schema_includes_reaction_summary() { $controller = new Gutenberg_REST_Comment_Controller_7_2(); $schema = $controller->get_item_schema(); From 828f3a294012700ccedd2532bf69657fedf08afc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 10:25:18 -0700 Subject: [PATCH 324/362] Notes: Apply the emoji rules in the reaction picker The full picker offered every Emojibase emoji regardless of the site's rules, so an excluded or unlisted pick would fail on save. Filter the dataset through the same allow_unlisted and exclude rules the REST API applies, which also keeps them out of search and Frequently used, and hide the add-reaction button when the rules leave nothing to pick. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XM9GGbuAmeVJv6g4rUGiep --- .../collab-sidebar/add-reaction-picker.tsx | 7 +- .../collab-sidebar/emoji-picker.tsx | 28 ++++- .../collab-sidebar/reaction-emoji-picker.tsx | 106 ++++++++++++++++++ .../test/add-reaction-picker.jsdom.test.tsx | 30 +++++ .../test/emoji-picker.jsdom.test.tsx | 92 +++++++++++++++ .../test/reaction-emoji-picker.jsdom.test.tsx | 44 ++++++++ 6 files changed, 304 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx index f96220f08d895e..44b0801aa674a5 100644 --- a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx @@ -22,6 +22,7 @@ import ReactionEmojiPicker, { emojiToHexKey, emojiToStorageKey, buildEmojiBySlugMap, + useReactionEmojiRules, useReactionEmojis, } from './reaction-emoji-picker'; import { @@ -182,6 +183,7 @@ export function AddReactionButton( { }: AddReactionButtonProps ) { const { recordUse } = useFrequentEmojis(); const emojis = useReactionEmojis(); + const rules = useReactionEmojiRules(); const emojiBySlug = useMemo( () => buildEmojiBySlugMap( emojis ), [ emojis ] @@ -216,8 +218,9 @@ export function AddReactionButton( { const showFullPicker = hasFullPicker && ! pickerFailed; - // With no Emojibase data and an emptied list there is nothing to pick. - if ( ! hasFullPicker && ! emojis.length ) { + // With an emptied list and no full picker, or a full picker limited to + // that list, there is nothing to pick. + if ( ! emojis.length && ( ! hasFullPicker || ! rules.allowUnlisted ) ) { return null; } diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx index 477983c4ce7e26..d7b01243d283a4 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -21,6 +21,12 @@ import { } from './emojibase-data'; import type { EmojibaseEntry } from './emojibase-data'; import { useFrequentEmojis } from './frequent-emojis'; +import { + getNamedHexKeys, + isReactionEmojiAllowed, + useReactionEmojiRules, + useReactionEmojis, +} from './reaction-emoji-picker'; import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; /** @@ -211,7 +217,27 @@ export function searchEmojis( export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { const { baseUrl, labelOverrides } = useEmojibaseConfig(); const [ locale ] = useState( detectLocale ); - const { data, isLoading, error } = useEmojibaseData( baseUrl, locale ); + const { + data: dataset, + isLoading, + error, + } = useEmojibaseData( baseUrl, locale ); + const namedEmojis = useReactionEmojis(); + const rules = useReactionEmojiRules(); + // Offer only what the REST API accepts under the site's emoji rules. + const data = useMemo( () => { + if ( ! dataset ) { + return dataset; + } + const namedKeys = getNamedHexKeys( namedEmojis ); + return dataset.filter( ( entry ) => + isReactionEmojiAllowed( + normalizeHexcode( entry.hexcode ), + rules, + namedKeys + ) + ); + }, [ dataset, namedEmojis, rules ] ); const [ query, setQuery ] = useState( '' ); const searchRef = useRef< HTMLInputElement >( null ); diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 59c2f5e29aab0a..8833545bb2ce3a 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -1,6 +1,7 @@ import { __, _x } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; // @ts-expect-error - No type declarations available for @wordpress/block-editor import { store as blockEditorStore } from '@wordpress/block-editor'; @@ -174,6 +175,111 @@ export function useReactionEmojis(): CuratedEmoji[] { }, [] ); } +/** + * Which emoji the full picker offers beyond the named list. Mirrors the + * server's `gutenberg_note_reaction_emoji_rules`. + */ +export interface ReactionEmojiRules { + allowUnlisted: boolean; + // Normalized hex keys that are never accepted. + exclude: string[]; +} + +const DEFAULT_REACTION_EMOJI_RULES: ReactionEmojiRules = { + allowUnlisted: true, + exclude: [], +}; + +/** + * Drop skin-tone modifiers so a variant resolves to its base emoji. + * + * @param hexKey Normalized hex key. + * @return The base emoji's hex key. + */ +function stripSkinTones( hexKey: string ): string { + return hexKey + .split( '-' ) + .filter( ( part ) => ! /^1f3f[b-f]$/.test( part ) ) + .join( '-' ); +} + +/** + * Read the emoji rules from the raw editor setting, defaulting to any emoji + * when it is absent or malformed. + * + * @param raw The `noteReactionEmojiRules` setting. + * @return The parsed rules. + */ +export function parseReactionEmojiRules( raw: unknown ): ReactionEmojiRules { + if ( ! raw || typeof raw !== 'object' ) { + return DEFAULT_REACTION_EMOJI_RULES; + } + const { allowUnlisted, exclude } = raw as Record< string, unknown >; + return { + allowUnlisted: allowUnlisted !== false, + exclude: Array.isArray( exclude ) + ? exclude + .filter( ( key ): key is string => typeof key === 'string' ) + .map( ( key ) => key.toLowerCase() ) + : [], + }; +} + +/** + * The emoji rules from editor settings. + * + * @return The rules the REST API applies to hex-key reactions. + */ +export function useReactionEmojiRules(): ReactionEmojiRules { + const raw = useSelect( + ( select ) => + ( + select( blockEditorStore ).getSettings() as Record< + string, + unknown + > + ).noteReactionEmojiRules, + [] + ); + return useMemo( () => parseReactionEmojiRules( raw ), [ raw ] ); +} + +/** + * Hex keys of the named emoji, which the rules always accept. + * + * @param emojis The named emoji list. + * @return Set of base hex keys. + */ +export function getNamedHexKeys( emojis: CuratedEmoji[] ): Set< string > { + return new Set( + emojis.map( ( entry ) => + stripSkinTones( emojiToHexKey( entry.emoji ) ) + ) + ); +} + +/** + * Whether the rules accept an emoji. Named emoji are always accepted, and + * skin-tone variants follow their base emoji. Must agree with + * `gutenberg_is_note_reaction_hex_key_allowed()`. + * + * @param hexKey Normalized hex key of the emoji. + * @param rules The emoji rules. + * @param namedKeys Hex keys from `getNamedHexKeys()`. + * @return Whether the emoji can be used as a reaction. + */ +export function isReactionEmojiAllowed( + hexKey: string, + rules: ReactionEmojiRules, + namedKeys: Set< string > +): boolean { + const base = stripSkinTones( hexKey ); + if ( namedKeys.has( base ) ) { + return true; + } + return rules.allowUnlisted && ! rules.exclude.includes( base ); +} + /** * Build a Map keyed by slug for O(1) emoji and label lookups. * diff --git a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx index f05f0c9047d55f..5df12e6f0e5450 100644 --- a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx @@ -107,6 +107,36 @@ describe( 'AddReactionButton', () => { } } ); + it( 'renders nothing when the full picker is limited to an empty list', () => { + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/empty-named-only', + noteReactionEmojis: [], + noteReactionEmojiRules: { allowUnlisted: false, exclude: [] }, + } ); + } ); + try { + render( + + ); + + expect( + screen.queryByRole( 'button', { name: 'Add reaction' } ) + ).not.toBeInTheDocument(); + } finally { + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: undefined, + noteReactionEmojis: undefined, + noteReactionEmojiRules: undefined, + } ); + } ); + } + } ); + it( 'opens the full picker directly and stores a filter-provided emoji under its slug', async () => { // With an Emojibase URL configured, "Add reaction" opens the full // searchable picker straight away (no intermediate quick row). diff --git a/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx index f8a94751a0871f..1af7962855c17e 100644 --- a/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx @@ -424,6 +424,98 @@ describe( 'EmojiPicker search announcements', () => { } ); } ); +describe( 'EmojiPicker emoji rules', () => { + const originalFetch = global.fetch; + + beforeEach( () => { + dispatch( preferencesStore ).set( + 'core', + FREQUENT_EMOJIS_PREFERENCE_KEY, + [] + ); + global.fetch = vi.fn( ( url: RequestInfo | URL ) => + Promise.resolve( { + ok: true, + json: () => + Promise.resolve( + String( url ).includes( 'data.json' ) + ? [ + { + hexcode: '1F600', + emoji: '😀', + label: 'grinning face', + group: 0, + }, + { + hexcode: '1F389', + emoji: '🎉', + label: 'party popper', + group: 6, + }, + { + hexcode: '1F595', + emoji: '🖕', + label: 'middle finger', + group: 1, + }, + ] + : {} + ), + } as unknown as Response ) + ); + } ); + + afterEach( () => { + global.fetch = originalFetch; + act( () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: undefined, + noteReactionEmojiRules: undefined, + } ); + } ); + } ); + + it( 'drops excluded emoji from the grid', async () => { + dispatch( blockEditorStore ).updateSettings( { + // A distinct URL keeps the module-level dataset cache apart. + noteEmojibaseUrl: 'https://example.test/rules-exclude', + noteReactionEmojiRules: { + allowUnlisted: true, + exclude: [ '1f595' ], + }, + } ); + render( {} } /> ); + + expect( + await screen.findByRole( 'gridcell', { name: 'grinning face' } ) + ).toBeVisible(); + expect( + screen.queryByRole( 'gridcell', { name: 'middle finger' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'offers only the named list when unlisted emoji are not allowed', async () => { + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/rules-named-only', + noteReactionEmojiRules: { allowUnlisted: false, exclude: [] }, + } ); + render( {} } /> ); + + // 🎉 is in the default named list; it also seeds "Frequently used". + expect( + ( await screen.findAllByRole( 'gridcell' ) ).map( ( cell ) => + cell.getAttribute( 'aria-label' ) + ) + ).not.toContain( 'grinning face' ); + expect( + screen.getAllByRole( 'gridcell', { name: 'party popper' } ).length + ).toBeGreaterThan( 0 ); + expect( + screen.queryByRole( 'gridcell', { name: 'middle finger' } ) + ).not.toBeInTheDocument(); + } ); +} ); + describe( 'EMOJIBASE_LOCALES drift detection', () => { // `tools/build-scripts/copy-emojibase-data.mjs` hardcodes a parallel `LOCALES` // array — when the build runs it copies exactly those locale diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx index 5be3278eb90778..54d905ab38db89 100644 --- a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx @@ -10,6 +10,9 @@ import ReactionEmojiPicker, { hexKeyToEmoji, REACTION_EMOJIS, buildEmojiBySlugMap, + getNamedHexKeys, + isReactionEmojiAllowed, + parseReactionEmojiRules, } from '../reaction-emoji-picker'; describe( 'buildEmojiBySlugMap', () => { @@ -215,3 +218,44 @@ describe( 'emojiToStorageKey', () => { expect( emojiToStorageKey( '©️' ) ).toBe( '00a9' ); } ); } ); + +describe( 'parseReactionEmojiRules', () => { + it( 'allows any emoji when the setting is absent or malformed', () => { + const any = { allowUnlisted: true, exclude: [] }; + expect( parseReactionEmojiRules( undefined ) ).toEqual( any ); + expect( parseReactionEmojiRules( 'strict' ) ).toEqual( any ); + } ); + + it( 'keeps string exclusions, lowercased', () => { + expect( + parseReactionEmojiRules( { + allowUnlisted: false, + exclude: [ '1F595', 42, null ], + } ) + ).toEqual( { allowUnlisted: false, exclude: [ '1f595' ] } ); + } ); +} ); + +describe( 'isReactionEmojiAllowed', () => { + const named = getNamedHexKeys( REACTION_EMOJIS ); + + it( 'rejects an excluded emoji and its skin-tone variants', () => { + const rules = { allowUnlisted: true, exclude: [ '1f44d' ] }; + expect( isReactionEmojiAllowed( '1f44d', rules, named ) ).toBe( false ); + expect( isReactionEmojiAllowed( '1f44d-1f3fd', rules, named ) ).toBe( + false + ); + expect( isReactionEmojiAllowed( '1f600', rules, named ) ).toBe( true ); + } ); + + it( 'accepts only named emoji when unlisted emoji are not allowed', () => { + const rules = { allowUnlisted: false, exclude: [] }; + expect( isReactionEmojiAllowed( '2764', rules, named ) ).toBe( true ); + expect( isReactionEmojiAllowed( '1f600', rules, named ) ).toBe( false ); + } ); + + it( 'accepts a named emoji even when it is excluded', () => { + const rules = { allowUnlisted: true, exclude: [ '2764' ] }; + expect( isReactionEmojiAllowed( '2764', rules, named ) ).toBe( true ); + } ); +} ); From 602ad3b7cd8d06d2b127094319d1d03520da1d5c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 15:45:48 -0700 Subject: [PATCH 325/362] Notes: Merge the reaction emoji filters into one Two filters for one decision made it easy to miss how they interact, since a named emoji is accepted even when the rules filter excludes it. A single gutenberg_note_reaction_emoji_settings filter carries the named list, allow_unlisted and exclude together, and keys a callback leaves out keep their defaults. Neither old filter has shipped, so nothing breaks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QuxqhqLQ9MZ6yiUMLqCG3t --- ...-gutenberg-rest-comment-controller-7-2.php | 4 +- lib/compat/wordpress-7.2/note-reactions.php | 138 +++++++++--------- .../plugins/note-reaction-emojis.php | 13 +- .../collab-sidebar/frequent-emojis.ts | 2 +- .../collab-sidebar/reaction-emoji-picker.tsx | 9 +- ...est-comments-controller-gutenberg-test.php | 63 +++++--- 6 files changed, 128 insertions(+), 101 deletions(-) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index d1f510447c8a02..c5c34ce30f9956 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -495,7 +495,7 @@ public function create_item( $request ) { // Validate the reaction content. We accept either: // - a curated slug (e.g. "heart") from the allowed emoji list - // (filterable via `gutenberg_note_reaction_emojis`), or + // (filterable via `gutenberg_note_reaction_emoji_settings`), or // - a lowercase hex-codepoint sequence joined by `-` (e.g. // "1f44d" for 👍 or "1f468-200d-1f4bb" for 👨‍💻), which is how // the full emoji picker stores a pick outside that list. @@ -533,7 +533,7 @@ public function create_item( $request ) { } // Sites can exclude emoji or limit reactions to the named list - // via `gutenberg_note_reaction_emoji_rules`. + // via `gutenberg_note_reaction_emoji_settings`. if ( $is_hex_key && ! $is_curated_slug ) { $is_hex_key = gutenberg_is_note_reaction_hex_key_allowed( $emoji_slug ); } diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index a22504d1f95d84..ca103c43c3b127 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -197,18 +197,19 @@ function gutenberg_exclude_notes_from_comment_count_7_2( $new_count, $old_count, add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_2', 10, 3 ); /** - * Returns the allowed emojis for note reactions. - * - * Each emoji is an associative array with: - * - `emoji` (string) The emoji character. - * - `label` (string) A translated human-readable label. - * - `value` (string) A slug used as the storage key. + * Returns the note reaction emoji settings: the named emoji list plus the + * rules for emoji picked from the full picker, which are stored as hex keys. * * @since 7.2.0 * - * @return array[] List of emoji definitions. + * @return array { + * @type array[] $emojis Named emoji definitions, each with `emoji`, + * `label`, and `value` keys. + * @type bool $allow_unlisted Whether emoji outside the named list are accepted. + * @type string[] $exclude Normalized hex keys that are never accepted. + * } */ -function gutenberg_get_note_reaction_emojis() { +function gutenberg_get_note_reaction_emoji_settings() { $default_emojis = array( array( 'emoji' => '❤️', @@ -238,14 +239,61 @@ function gutenberg_get_note_reaction_emojis() { ); /** - * Filters the list of allowed emojis for note reactions. + * Filters which emoji note reactions accept. + * + * Named emoji are always accepted. `allow_unlisted` and `exclude` only + * apply to emoji picked from the full picker. * * @since 7.2.0 * - * @param array[] $emojis List of emoji definitions. Each item has - * `emoji`, `label`, and `value` keys. + * @param array $settings { + * @type array[] $emojis Named emoji definitions. Each item has + * `emoji`, `label`, and `value` keys. + * @type bool $allow_unlisted Whether any emoji from the full picker is + * accepted. False limits reactions to the + * named list. Default true. + * @type string[] $exclude Emojibase hexcodes to reject, such as + * `1F595`. Excluding an emoji also excludes + * its skin-tone variants. Default empty. + * } */ - return apply_filters( 'gutenberg_note_reaction_emojis', $default_emojis ); + $defaults = array( + 'emojis' => $default_emojis, + 'allow_unlisted' => true, + 'exclude' => array(), + ); + $settings = apply_filters( 'gutenberg_note_reaction_emoji_settings', $defaults ); + // Keys a callback leaves out keep their defaults. + $settings = is_array( $settings ) ? wp_parse_args( $settings, $defaults ) : $defaults; + + $exclude = array(); + if ( is_array( $settings['exclude'] ) ) { + foreach ( $settings['exclude'] as $hexcode ) { + $hex_key = gutenberg_normalize_note_reaction_hex_key( $hexcode ); + if ( '' !== $hex_key ) { + $exclude[] = $hex_key; + } + } + } + + return array( + 'emojis' => is_array( $settings['emojis'] ) ? array_values( $settings['emojis'] ) : array(), + 'allow_unlisted' => (bool) $settings['allow_unlisted'], + 'exclude' => array_values( array_unique( $exclude ) ), + ); +} + +/** + * Returns the named emojis for note reactions. + * + * @since 7.2.0 + * + * @return array[] List of emoji definitions, each with `emoji`, `label`, + * and `value` keys. + */ +function gutenberg_get_note_reaction_emojis() { + $settings = gutenberg_get_note_reaction_emoji_settings(); + return $settings['emojis']; } /** @@ -258,12 +306,12 @@ function gutenberg_get_note_reaction_emojis() { * @return array Updated block editor settings. */ function gutenberg_add_note_reaction_emojis_setting( $settings ) { - $rules = gutenberg_get_note_reaction_emoji_rules(); + $emoji_settings = gutenberg_get_note_reaction_emoji_settings(); - $settings['noteReactionEmojis'] = gutenberg_get_note_reaction_emojis(); + $settings['noteReactionEmojis'] = $emoji_settings['emojis']; $settings['noteReactionEmojiRules'] = array( - 'allowUnlisted' => $rules['allow_unlisted'], - 'exclude' => $rules['exclude'], + 'allowUnlisted' => $emoji_settings['allow_unlisted'], + 'exclude' => $emoji_settings['exclude'], ); return $settings; } @@ -295,58 +343,6 @@ function gutenberg_normalize_note_reaction_hex_key( $hex_key ) { return implode( '-', $parts ); } -/** - * Returns the rules for reactions picked outside the named emoji list, - * which the full picker stores as hex keys. - * - * @since 7.2.0 - * - * @return array { - * @type bool $allow_unlisted Whether emoji outside the named list are accepted. - * @type string[] $exclude Normalized hex keys that are never accepted. - * } - */ -function gutenberg_get_note_reaction_emoji_rules() { - /** - * Filters which emoji note reactions accept beyond the named list from - * `gutenberg_note_reaction_emojis`. Named emoji are always accepted. - * - * @since 7.2.0 - * - * @param array $rules { - * @type bool $allow_unlisted Whether any emoji from the full picker is - * accepted. False limits reactions to the - * named list. Default true. - * @type string[] $exclude Emojibase hexcodes to reject, such as - * `1F595`. Excluding an emoji also excludes - * its skin-tone variants. Default empty. - * } - */ - $rules = apply_filters( - 'gutenberg_note_reaction_emoji_rules', - array( - 'allow_unlisted' => true, - 'exclude' => array(), - ) - ); - $rules = is_array( $rules ) ? $rules : array(); - - $exclude = array(); - if ( isset( $rules['exclude'] ) && is_array( $rules['exclude'] ) ) { - foreach ( $rules['exclude'] as $hexcode ) { - $hex_key = gutenberg_normalize_note_reaction_hex_key( $hexcode ); - if ( '' !== $hex_key ) { - $exclude[] = $hex_key; - } - } - } - - return array( - 'allow_unlisted' => ! isset( $rules['allow_unlisted'] ) || (bool) $rules['allow_unlisted'], - 'exclude' => array_values( array_unique( $exclude ) ), - ); -} - /** * Whether a hex-key reaction is accepted under the emoji rules. An emoji * from the named list is always accepted, including its skin-tone variants. @@ -362,7 +358,8 @@ function gutenberg_is_note_reaction_hex_key_allowed( $hex_key ) { return false; } - foreach ( gutenberg_get_note_reaction_emojis() as $entry ) { + $settings = gutenberg_get_note_reaction_emoji_settings(); + foreach ( $settings['emojis'] as $entry ) { if ( is_array( $entry ) && ! empty( $entry['emoji'] ) && @@ -372,8 +369,7 @@ function gutenberg_is_note_reaction_hex_key_allowed( $hex_key ) { } } - $rules = gutenberg_get_note_reaction_emoji_rules(); - return $rules['allow_unlisted'] && ! in_array( $base, $rules['exclude'], true ); + return $settings['allow_unlisted'] && ! in_array( $base, $settings['exclude'], true ); } /** diff --git a/packages/e2e-tests/plugins/note-reaction-emojis.php b/packages/e2e-tests/plugins/note-reaction-emojis.php index af2ad714cc3093..db424cf420af03 100644 --- a/packages/e2e-tests/plugins/note-reaction-emojis.php +++ b/packages/e2e-tests/plugins/note-reaction-emojis.php @@ -9,14 +9,14 @@ /** * Extends the note reaction emoji list with many extra entries to - * exercise the `gutenberg_note_reaction_emojis` filter end-to-end: + * exercise the `gutenberg_note_reaction_emoji_settings` filter end-to-end: * the picker must offer the extended set, the REST API must accept * the custom slugs, and the picker UI must stay usable at this size. * - * @param array[] $emojis Default emoji definitions. - * @return array[] Extended emoji definitions. + * @param array $settings Default emoji settings. + * @return array Settings with an extended named emoji list. */ -function gutenberg_test_note_reaction_emojis( $emojis ) { +function gutenberg_test_note_reaction_emojis( $settings ) { $extra = array( array( 'emoji' => '👍', @@ -120,6 +120,7 @@ function gutenberg_test_note_reaction_emojis( $emojis ) { ), ); - return array_merge( $emojis, $extra ); + $settings['emojis'] = array_merge( $settings['emojis'], $extra ); + return $settings; } -add_filter( 'gutenberg_note_reaction_emojis', 'gutenberg_test_note_reaction_emojis' ); +add_filter( 'gutenberg_note_reaction_emoji_settings', 'gutenberg_test_note_reaction_emojis' ); diff --git a/packages/editor/src/components/collab-sidebar/frequent-emojis.ts b/packages/editor/src/components/collab-sidebar/frequent-emojis.ts index 638f00bf1018ee..cf97a6df11165b 100644 --- a/packages/editor/src/components/collab-sidebar/frequent-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/frequent-emojis.ts @@ -125,7 +125,7 @@ export function useFrequentEmojis(): { const registry = useRegistry(); /* * Seed from the filtered list, not the shipped defaults, so a site - * using `gutenberg_note_reaction_emojis` sees its own set. + * using `gutenberg_note_reaction_emoji_settings` sees its own set. */ const emojis = useReactionEmojis(); const frequentKeys = useSelect( diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 8833545bb2ce3a..5b6ee92b11cc3c 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -150,9 +150,9 @@ export function emojiToStorageKey( /** * The reaction emoji list from editor settings, falling back to the curated * defaults only when the setting is absent. The server injects it via - * `gutenberg_note_reaction_emojis`, so the picker offers the set the REST API - * accepts. Malformed entries drop, and an empty result stays empty: the - * server rejects the defaults' slugs once a filter removes them. + * `gutenberg_note_reaction_emoji_settings`, so the picker offers the set + * the REST API accepts. Malformed entries drop, and an empty result stays + * empty: the server rejects the defaults' slugs once a filter removes them. * * @return The emoji list to offer in the picker. */ @@ -177,7 +177,8 @@ export function useReactionEmojis(): CuratedEmoji[] { /** * Which emoji the full picker offers beyond the named list. Mirrors the - * server's `gutenberg_note_reaction_emoji_rules`. + * `allow_unlisted` and `exclude` from the server's + * `gutenberg_note_reaction_emoji_settings`. */ export interface ReactionEmojiRules { allowUnlisted: boolean; diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 678bdb4d06ba72..678a90c0a30192 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -972,10 +972,11 @@ public function test_reaction_emojis_filter_affects_validation() { ), ); - $filter = function () use ( $custom_emoji ) { - return $custom_emoji; + $filter = function ( $settings ) use ( $custom_emoji ) { + $settings['emojis'] = $custom_emoji; + return $settings; }; - add_filter( 'gutenberg_note_reaction_emojis', $filter ); + add_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); try { wp_set_current_user( self::$editor_id ); @@ -1006,25 +1007,25 @@ public function test_reaction_emojis_filter_affects_validation() { } finally { // Always remove the filter so a failed assertion above does not // leak it into the rest of the suite. - remove_filter( 'gutenberg_note_reaction_emojis', $filter ); + remove_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); } } /** - * `gutenberg_note_reaction_emoji_rules` controls which hex-key reactions + * `gutenberg_note_reaction_emoji_settings` controls which hex-key reactions * (full picker picks outside the named list) the controller accepts. * * @dataProvider data_reaction_emoji_rules * - * @param mixed $rules The filtered rules. + * @param mixed $rules The filtered settings. * @param string $content The reaction storage key to submit. * @param int $status Expected response status. */ public function test_reaction_emoji_rules_affect_validation( $rules, $content, $status ) { - $filter = function () use ( $rules ) { - return $rules; + $filter = function ( $settings ) use ( $rules ) { + return is_array( $rules ) ? array_merge( $settings, $rules ) : $rules; }; - add_filter( 'gutenberg_note_reaction_emoji_rules', $filter ); + add_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); try { wp_set_current_user( self::$editor_id ); @@ -1051,7 +1052,7 @@ public function test_reaction_emoji_rules_affect_validation( $rules, $content, $ $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, $status ); } } finally { - remove_filter( 'gutenberg_note_reaction_emoji_rules', $filter ); + remove_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); } } @@ -1073,19 +1074,47 @@ public function data_reaction_emoji_rules() { ); } + /** + * A callback that returns only some keys keeps the defaults for the rest, + * and a non-array return falls back to the defaults entirely. + */ + public function test_reaction_emoji_settings_fill_missing_keys_from_defaults() { + $defaults = gutenberg_get_note_reaction_emoji_settings(); + + $partial = function () { + return array( 'allow_unlisted' => false ); + }; + add_filter( 'gutenberg_note_reaction_emoji_settings', $partial ); + try { + $settings = gutenberg_get_note_reaction_emoji_settings(); + } finally { + remove_filter( 'gutenberg_note_reaction_emoji_settings', $partial ); + } + $this->assertFalse( $settings['allow_unlisted'] ); + $this->assertSame( $defaults['emojis'], $settings['emojis'] ); + $this->assertSame( array(), $settings['exclude'] ); + + add_filter( 'gutenberg_note_reaction_emoji_settings', '__return_false' ); + try { + $settings = gutenberg_get_note_reaction_emoji_settings(); + } finally { + remove_filter( 'gutenberg_note_reaction_emoji_settings', '__return_false' ); + } + $this->assertSame( $defaults, $settings ); + } + public function test_reaction_emoji_rules_reach_editor_settings() { - $filter = function () { - return array( - 'allow_unlisted' => false, - 'exclude' => array( '1F595', 'not hex', 42 ), - ); + $filter = function ( $settings ) { + $settings['allow_unlisted'] = false; + $settings['exclude'] = array( '1F595', 'not hex', 42 ); + return $settings; }; - add_filter( 'gutenberg_note_reaction_emoji_rules', $filter ); + add_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); try { $settings = gutenberg_add_note_reaction_emojis_setting( array() ); } finally { - remove_filter( 'gutenberg_note_reaction_emoji_rules', $filter ); + remove_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); } $this->assertSame( From 557d1e9e81e1f658e22c053f2121a0bc1818d4a3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 1 Oct 2026 15:50:04 -0700 Subject: [PATCH 326/362] Notes: Announce the emoji grid once after a pending load Searching filters the grid synchronously, so announcing a live result count on every keystroke only adds screen reader noise. Drop the count and leave the no-results message to Autocomplete.Empty. The initial dataset does load asynchronously, so announce once when a pending load fills the grid. Cached openings and searches stay quiet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- .../collab-sidebar/emoji-picker.tsx | 39 +++++++++++++--- .../test/emoji-picker.jsdom.test.tsx | 44 +++++++++++++------ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx index d7b01243d283a4..0b29e85f8645d7 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -241,6 +241,20 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { const [ query, setQuery ] = useState( '' ); const searchRef = useRef< HTMLInputElement >( null ); + /* + * Announce once when a pending load fills the grid. A cached dataset + * renders straight away, and searching filters synchronously, so + * neither needs an announcement. + */ + const [ hasJustLoaded, setHasJustLoaded ] = useState( false ); + const wasLoadingRef = useRef( isLoading ); + useEffect( () => { + if ( wasLoadingRef.current && ! isLoading && ! error ) { + setHasJustLoaded( true ); + } + wasLoadingRef.current = isLoading; + }, [ isLoading, error ] ); + const { frequentKeys, recordUse } = useFrequentEmojis(); const skinTone = useSelect( ( select ) => @@ -349,8 +363,6 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { skinTone, ] ); - const matchCount = isSearching ? items.length : 0; - /** * Render grid rows of emoji cells, recording usage on selection. * @@ -399,13 +411,22 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { status = __( 'Loading…' ); } else if ( error ) { status = __( 'Couldn’t load emojis.' ); - } else if ( isSearching && matchCount > 0 ) { + } else if ( hasJustLoaded && ! isSearching && groups.length > 0 ) { + // An empty dataset is left to `Autocomplete.Empty`. + const emojiCount = groups.reduce( + ( total, group ) => total + group.emojis.length, + 0 + ); status = ( { sprintf( - /* translators: %d: number of emojis matching the search. */ - _n( '%d emoji found.', '%d emojis found.', matchCount ), - matchCount + /* translators: %d: number of emojis available to pick. */ + _n( + '%d emoji available.', + '%d emojis available.', + emojiCount + ), + emojiCount ) } ); @@ -428,7 +449,11 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { // Enter picks the top hit once the user has typed. autoHighlight value={ query } - onValueChange={ setQuery } + onValueChange={ ( value: string ) => { + setQuery( value ); + // Searches stay quiet, so drop the load announcement. + setHasJustLoaded( false ); + } } >
diff --git a/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx index 1af7962855c17e..94e521fdfb4895 100644 --- a/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx @@ -376,7 +376,7 @@ describe( 'EmojiPicker search announcements', () => { expect( onSelect ).toHaveBeenCalledWith( '😁' ); } ); - it( 'reports result counts and the empty state through the Autocomplete status', async () => { + it( 'leaves synchronous search results unannounced and reports the empty state', async () => { const user = userEvent.setup(); render( {} } /> ); @@ -388,15 +388,12 @@ describe( 'EmojiPicker search announcements', () => { } ); await user.type( searchbox, 'face' ); - expect( await screen.findByText( '2 emojis found.' ) ).toHaveAttribute( - 'data-visually-hidden' + await waitFor( () => + expect( screen.getAllByRole( 'gridcell' ) ).toHaveLength( 2 ) ); - - await user.clear( searchbox ); - await user.type( searchbox, 'grinning' ); expect( - await screen.findByText( '1 emoji found.' ) - ).toBeInTheDocument(); + screen.queryByText( /found|available/ ) + ).not.toBeInTheDocument(); await user.clear( searchbox ); await user.type( searchbox, 'zzz' ); @@ -406,21 +403,42 @@ describe( 'EmojiPicker search announcements', () => { expect( screen.getByRole( 'grid', { name: 'Emoji' } ) ).toBeVisible(); } ); - it( 'clears the count when the query is cleared', async () => { - const user = userEvent.setup(); + it( 'announces once when a pending load fills the grid, but not for a cached opening', async () => { + // The dataset cache is module-wide, so use a URL no other test loads. + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: 'https://example.test/emojibase-pending-load', + } ); + const { unmount } = render( {} } /> ); + + expect( + await screen.findByText( '2 emojis available.' ) + ).toHaveAttribute( 'data-visually-hidden' ); + + unmount(); render( {} } /> ); await screen.findAllByRole( 'gridcell' ); + expect( screen.queryByText( /available/ ) ).not.toBeInTheDocument(); + } ); + + it( 'drops the load announcement once the user searches', async () => { + const user = userEvent.setup(); + dispatch( blockEditorStore ).updateSettings( { + noteEmojibaseUrl: + 'https://example.test/emojibase-search-after-load', + } ); + render( {} } /> ); + + await screen.findByText( '2 emojis available.' ); const searchbox = screen.getByRole( 'combobox', { name: 'Search emoji', } ); - await user.type( searchbox, 'grinning' ); await user.clear( searchbox ); - // Clearing restores the full grid, so no count describes it. - expect( screen.queryByText( /found/ ) ).not.toBeInTheDocument(); + // Restoring the full grid is synchronous, so nothing is announced. + expect( screen.queryByText( /available/ ) ).not.toBeInTheDocument(); } ); } ); From d602a9a133c6db835935957051d0ca4b2ff4775f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 1 Oct 2026 15:50:11 -0700 Subject: [PATCH 327/362] Notes: Drop the inline padding on emoji category headings Aligns each heading with the grid's first column. The Autocomplete group label spacing will be revisited in the component itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- packages/editor/src/components/collab-sidebar/style.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index d8b06dc5c84c84..5ab645bc0ea371 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -564,6 +564,8 @@ mark.wp-note { // scroll behind exactly that color. Move both together if it changes. background-color: $white; z-index: 1; + // Align the heading with the grid's first column. + padding-inline: 0; } .editor-collab-sidebar-panel__picker-row { From 769b97219caf92ecb571d106d2c4988ec3caa0a6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 10:33:11 -0700 Subject: [PATCH 328/362] Notes: Remove emoji picker stories Features rarely ship stories, and the picker is easier to exercise in the editor itself. Drops the Storybook static dir that served the Emojibase data to them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- .../stories/emoji-picker.story.tsx | 48 -------- .../stories/note-reactions.story.tsx | 105 ------------------ storybook/main.ts | 34 +----- 3 files changed, 1 insertion(+), 186 deletions(-) delete mode 100644 packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.tsx delete mode 100644 packages/editor/src/components/collab-sidebar/stories/note-reactions.story.tsx diff --git a/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.tsx b/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.tsx deleted file mode 100644 index a2c92a845772ed..00000000000000 --- a/packages/editor/src/components/collab-sidebar/stories/emoji-picker.story.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; -import { dispatch } from '@wordpress/data'; -// @ts-expect-error - No type declarations available for @wordpress/block-editor. -import { store as blockEditorStore } from '@wordpress/block-editor'; -import EmojiPicker from '../emoji-picker'; - -/** - * The full searchable emoji picker used by Notes reactions: a 6-column - * grid grouped by category, filtered by label and tag search. - * - * The dataset comes from the `noteEmojibaseUrl` editor setting. Storybook - * serves the English one from a static directory mapped in - * `storybook/main.ts`, with the document language pinned to `en`. - */ -const meta: Meta< typeof EmojiPicker > = { - title: 'Editor/EmojiPicker', - component: EmojiPicker, - decorators: [ - ( Story ) => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'emojibase-data', - } ); - document.documentElement.lang = 'en'; - return ; - }, - ], - args: { - onSelect: fn(), - }, - argTypes: { - onSelect: { control: false }, - }, - parameters: { - // The search field keeps focus and arrow keys move through the grid, - // so the scrollable viewport is reachable by keyboard. - a11y: { - config: { - rules: [ - { id: 'scrollable-region-focusable', enabled: false }, - ], - }, - }, - }, -}; -export default meta; - -export const Default: StoryObj< typeof EmojiPicker > = {}; diff --git a/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.tsx b/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.tsx deleted file mode 100644 index 82fd0d91ace91e..00000000000000 --- a/packages/editor/src/components/collab-sidebar/stories/note-reactions.story.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; -import { useState } from '@wordpress/element'; -import { Stack } from '@wordpress/ui'; -import { dispatch } from '@wordpress/data'; -// @ts-expect-error - No type declarations available for @wordpress/block-editor. -import { store as blockEditorStore } from '@wordpress/block-editor'; -import ReactionDisplay from '../reaction-display'; -import { AddReactionButton } from '../add-reaction-picker'; - -/** - * A reaction summary as the REST API returns it, keyed by reaction slug. - */ -type ReactionSummary = Record< string, { count: number; reacted: boolean } >; - -/** - * Sample summary covering both storage forms: a curated slug ("heart") and - * a full-picker hex key ("1f338" is 🌸). - */ -const INITIAL_REACTIONS: ReactionSummary = { - heart: { count: 2, reacted: true }, - '1f338': { count: 1, reacted: false }, -}; - -/** - * The Notes reaction row as rendered in the collab sidebar: an add button - * opening the picker, plus toggleable count pills. - * - * State is simulated locally. Tooltips fall back to plain counts, since - * Storybook has no REST API. - * - * @param props Component props. - * @param props.onToggleReaction Called with the slug of the toggled reaction. - */ -function NoteReactions( { - onToggleReaction, -}: { - onToggleReaction?: ( slug: string ) => void; -} ) { - const [ reactions, setReactions ] = - useState< ReactionSummary >( INITIAL_REACTIONS ); - - const toggleReaction = ( slug: string ) => { - onToggleReaction?.( slug ); - setReactions( ( previous ) => { - const entry = previous[ slug ]; - if ( entry?.reacted ) { - const next = { ...previous }; - if ( entry.count <= 1 ) { - delete next[ slug ]; - } else { - next[ slug ] = { - count: entry.count - 1, - reacted: false, - }; - } - return next; - } - return { - ...previous, - [ slug ]: { - count: ( entry?.count || 0 ) + 1, - reacted: true, - }, - }; - } ); - }; - - return ( - - - - - ); -} - -const meta: Meta< typeof NoteReactions > = { - title: 'Editor/Notes Reactions', - component: NoteReactions, - decorators: [ - ( Story ) => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'emojibase-data', - } ); - document.documentElement.lang = 'en'; - return ; - }, - ], - args: { - onToggleReaction: fn(), - }, - argTypes: { - onToggleReaction: { control: false }, - }, -}; -export default meta; - -export const Default: StoryObj< typeof NoteReactions > = {}; diff --git a/storybook/main.ts b/storybook/main.ts index 5c43c24e7ba4c0..f7dc6882b12ed3 100644 --- a/storybook/main.ts +++ b/storybook/main.ts @@ -6,7 +6,6 @@ import emotion from '@rolldown/plugin-emotion'; import type { StorybookConfig } from '@storybook/react-vite'; import dsTokenFallbacks from '@wordpress/theme/postcss-plugins/postcss-ds-token-fallbacks'; import dsTokenFallbacksJs from '@wordpress/theme/vite-plugins/vite-ds-token-fallbacks'; -import { createPostcssBrowserPlugin } from './postcss-browser-plugin.mjs'; import { statusIndexer } from './status-indexer.js'; /** @@ -18,28 +17,6 @@ function getAbsolutePath( packageName: string ) { ); } -/** - * Serve the English Emojibase dataset for the Editor/EmojiPicker story, - * mirroring the `build/emojibase-data//` layout. Only `en` is - * mapped, to keep the published Storybook artifact small. - * - * Resolved rather than hardcoded to `node_modules/`, since the package is - * declared by the `tools/build-scripts` workspace and a non-hoisted - * install would otherwise point at a missing directory. - */ -function emojibaseStaticDirs() { - try { - return [ - { - from: path.join( getAbsolutePath( 'emojibase-data' ), 'en' ), - to: '/emojibase-data/en', - }, - ]; - } catch { - return []; - } -} - const { NODE_ENV = 'development' } = process.env; const stories = [ @@ -77,7 +54,7 @@ const config: StorybookConfig = { stories, // Tags stories with their `componentStatus` so the sidebar can show it. experimental_indexers: ( existing = [] ) => [ statusIndexer, ...existing ], - staticDirs: [ './static', ...emojibaseStaticDirs() ], + staticDirs: [ './static' ], addons: [ { name: getAbsolutePath( '@storybook/addon-docs' ), @@ -178,15 +155,6 @@ const config: StorybookConfig = { }, ], }, - optimizeDeps: { - rolldownOptions: { - plugins: [ - createPostcssBrowserPlugin( - path.resolve( import.meta.dirname, '..' ) - ), - ], - }, - }, plugins: [ dsTokenFallbacksJs(), react() as PluginOption, From 0b055e4efc611d872e0b0accb546584267aeb2a9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 10:34:03 -0700 Subject: [PATCH 329/362] Notes: Remove emoji reaction unit tests The picker is built on Autocomplete, which is already tested in @wordpress/ui and Base UI, and the integration is covered end to end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- .../test/add-reaction-picker.jsdom.test.tsx | 379 ----------- .../test/emoji-picker.jsdom.test.tsx | 562 ----------------- .../test/frequent-emojis.jsdom.test.ts | 156 ----- .../test/reaction-display.jsdom.test.tsx | 593 ------------------ .../test/reaction-emoji-picker.jsdom.test.tsx | 261 -------- .../test/skin-tone-picker.jsdom.test.tsx | 191 ------ .../use-note-actions-reactions.jsdom.test.tsx | 169 ----- 7 files changed, 2311 deletions(-) delete mode 100644 packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx delete mode 100644 packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx delete mode 100644 packages/editor/src/components/collab-sidebar/test/frequent-emojis.jsdom.test.ts delete mode 100644 packages/editor/src/components/collab-sidebar/test/reaction-display.jsdom.test.tsx delete mode 100644 packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx delete mode 100644 packages/editor/src/components/collab-sidebar/test/skin-tone-picker.jsdom.test.tsx delete mode 100644 packages/editor/src/components/collab-sidebar/test/use-note-actions-reactions.jsdom.test.tsx diff --git a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx deleted file mode 100644 index 5df12e6f0e5450..00000000000000 --- a/packages/editor/src/components/collab-sidebar/test/add-reaction-picker.jsdom.test.tsx +++ /dev/null @@ -1,379 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import apiFetch from '@wordpress/api-fetch'; -import { speak } from '@wordpress/a11y'; -import { dispatch } from '@wordpress/data'; -// @ts-expect-error - No type declarations available for @wordpress/block-editor. -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { AddReactionButton } from '../add-reaction-picker'; - -globalThis.wpVitest.mockMatchMedia(); - -vi.mock( import( '@wordpress/api-fetch' ), async ( importOriginal ) => { - const original = await importOriginal(); - - return { - ...original, - default: vi.fn(), - } as unknown as typeof original; -} ); -vi.mock( import( '@wordpress/a11y' ), async ( importOriginal ) => { - const original = await importOriginal(); - - return { - ...original, - speak: vi.fn(), - } as unknown as typeof original; -} ); - -const mockApiFetch = vi.mocked( apiFetch ); -const mockSpeak = vi.mocked( speak ); - -/* - * The tooltip name cache in reaction-display.tsx is module-level and - * keyed by `noteId:slug`, so each test uses a distinct noteId to stay - * isolated. - */ -let uniqueNoteId = 100; - -describe( 'AddReactionButton', () => { - beforeEach( () => { - uniqueNoteId += 1; - mockApiFetch.mockReset(); - mockApiFetch.mockRejectedValue( new Error( 'not mocked' ) ); - mockSpeak.mockClear(); - } ); - - it( 'falls back to the curated quick row when no Emojibase URL is set', async () => { - const user = userEvent.setup(); - const onToggleReaction = vi.fn(); - render( - - ); - - const trigger = screen.getByRole( 'button', { name: 'Add reaction' } ); - // The trigger advertises the popup type it opens, and the popup - // itself is a named non-modal dialog so screen readers announce - // where focus landed. - expect( trigger ).toHaveAttribute( 'aria-haspopup', 'dialog' ); - await user.click( trigger ); - expect( - screen.getByRole( 'dialog', { name: 'Add reaction' } ) - ).toBeVisible(); - // The curated options wrap into rows once the set grows past one - // row, and a narrow popover can stack them into a column, so the - // group deliberately reports no orientation and both axes move - // the roving tab index. - expect( - screen.getByRole( 'group', { - name: 'Add an emoji reaction', - } ) - ).not.toHaveAttribute( 'aria-orientation' ); - await user.click( - await screen.findByRole( 'button', { name: 'Rocket' } ) - ); - - expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); - expect( onToggleReaction ).toHaveBeenCalledWith( 'rocket' ); - } ); - - it( 'renders nothing when the filtered list is empty and no Emojibase URL is set', () => { - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteReactionEmojis: [], - } ); - } ); - try { - render( - - ); - - expect( - screen.queryByRole( 'button', { name: 'Add reaction' } ) - ).not.toBeInTheDocument(); - } finally { - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteReactionEmojis: undefined, - } ); - } ); - } - } ); - - it( 'renders nothing when the full picker is limited to an empty list', () => { - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/empty-named-only', - noteReactionEmojis: [], - noteReactionEmojiRules: { allowUnlisted: false, exclude: [] }, - } ); - } ); - try { - render( - - ); - - expect( - screen.queryByRole( 'button', { name: 'Add reaction' } ) - ).not.toBeInTheDocument(); - } finally { - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: undefined, - noteReactionEmojis: undefined, - noteReactionEmojiRules: undefined, - } ); - } ); - } - } ); - - it( 'opens the full picker directly and stores a filter-provided emoji under its slug', async () => { - // With an Emojibase URL configured, "Add reaction" opens the full - // searchable picker straight away (no intermediate quick row). - // A site filter adds 👍 with the slug `thumbs-up`; picking it from - // the full picker must store `thumbs-up`, not the raw hex key - // `1f44d`, so it aggregates into the same reaction_summary bucket - // as historical quick-row picks. - const originalFetch = global.fetch; - global.fetch = vi.fn( ( url: RequestInfo | URL ) => - Promise.resolve( { - ok: true, - json: () => - Promise.resolve( - String( url ).includes( 'data.json' ) - ? [ - { - hexcode: '1F44D', - emoji: '👍', - label: 'thumbs up', - group: 0, - }, - ] - : {} - ), - } as unknown as Response ) - ); - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/emojibase', - noteReactionEmojis: [ - { emoji: '👍', label: 'Thumbs up', value: 'thumbs-up' }, - ], - } ); - - try { - const user = userEvent.setup(); - const onToggleReaction = vi.fn(); - render( - - ); - - await user.click( - screen.getByRole( 'button', { name: 'Add reaction' } ) - ); - // The popup is exposed as a named non-modal dialog in the - // full-picker branch too. - expect( - screen.getByRole( 'dialog', { name: 'Add reaction' } ) - ).toBeVisible(); - // The full picker opens directly — no quick row, no - // intermediate "More emojis" step. - expect( - screen.queryByRole( 'button', { name: 'Thumbs up' } ) - ).not.toBeInTheDocument(); - // The filter-provided emoji is seeded into "Frequently used" - // and also appears in its Emojibase category; either cell - // exercises the same selection path. - await user.click( - ( - await screen.findAllByRole( 'gridcell', { - name: 'thumbs up', - } ) - )[ 0 ] - ); - expect( onToggleReaction ).toHaveBeenLastCalledWith( 'thumbs-up' ); - expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); - } finally { - global.fetch = originalFetch; - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: undefined, - noteReactionEmojis: undefined, - } ); - } ); - } - } ); - - it( 'falls back to the curated picker with a retry path when the dataset fails', async () => { - // A distinct base URL keeps the module-level dataset cache from - // other tests out of the way; rejected loads are never cached, so - // the retry below triggers a fresh fetch. - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/emojibase-down', - } ); - const originalFetch = global.fetch; - let failRequests = true; - global.fetch = vi.fn( ( url: RequestInfo | URL ) => - failRequests - ? Promise.reject( new Error( 'network down' ) ) - : Promise.resolve( { - ok: true, - json: () => - Promise.resolve( - String( url ).includes( 'data.json' ) - ? [ - { - hexcode: '1F44D', - emoji: '👍', - label: 'thumbs up', - group: 0, - }, - ] - : {} - ), - } as unknown as Response ) - ); - - try { - const user = userEvent.setup(); - const onToggleReaction = vi.fn(); - render( - - ); - - await user.click( - screen.getByRole( 'button', { name: 'Add reaction' } ) - ); - - // The dataset failed: the curated quick row takes over so - // adding a reaction keeps working, with a visible retry - // affordance… - expect( - await screen.findByRole( 'button', { name: 'Rocket' } ) - ).toBeVisible(); - expect( - screen.getByText( 'Couldn’t load the full emoji picker.' ) - ).toBeVisible(); - // …and the failure is announced through the a11y announcer, - // whose live regions exist before the message (a live region - // mounted together with its content is not reliably announced). - expect( speak ).toHaveBeenCalledWith( - 'The full emoji picker couldn’t be loaded. Basic reactions are available.', - 'assertive' - ); - - // Curated picks work in the fallback state. - await user.click( - screen.getByRole( 'button', { name: 'Rocket' } ) - ); - expect( onToggleReaction ).toHaveBeenCalledWith( 'rocket' ); - - // The failure is sticky across popover open/close until - // retried; once the network recovers, Retry restores the - // full picker. - await user.click( - screen.getByRole( 'button', { name: 'Add reaction' } ) - ); - failRequests = false; - await user.click( screen.getByRole( 'button', { name: 'Retry' } ) ); - expect( - await screen.findAllByRole( 'gridcell', { name: 'thumbs up' } ) - ).not.toHaveLength( 0 ); - } finally { - global.fetch = originalFetch; - // The settings reset re-renders the mounted picker, and its - // Composite settles a tick later, so the wrapper has to await. - await act( async () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: undefined, - } ); - } ); - } - } ); - - it( 'keeps focus in the popover when the dataset fails after the search is focused', async () => { - // Its own base URL, so the dataset cache from other tests is not hit. - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/emojibase-late-failure', - } ); - const originalFetch = global.fetch; - let rejectDataset: ( error: Error ) => void = () => {}; - global.fetch = vi.fn( - () => - new Promise< Response >( ( _resolve, reject ) => { - rejectDataset = reject; - } ) - ); - - try { - const user = userEvent.setup(); - render( - {} } - /> - ); - - await user.click( - screen.getByRole( 'button', { name: 'Add reaction' } ) - ); - const search = await screen.findByRole( 'combobox' ); - expect( search ).toHaveFocus(); - - // Replacing the focused search with the fallback must not drop - // focus to the body, or Escape stops closing the popover. - await act( async () => { - rejectDataset( new Error( 'network down' ) ); - } ); - expect( - await screen.findByRole( 'button', { name: 'Heart' } ) - ).toHaveFocus(); - - await user.keyboard( '{Escape}' ); - expect( - screen.queryByRole( 'dialog', { name: 'Add reaction' } ) - ).not.toBeInTheDocument(); - } finally { - global.fetch = originalFetch; - await act( async () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: undefined, - } ); - } ); - } - } ); - - it( 'stays focusable but inert when disabled', async () => { - const user = userEvent.setup(); - render( - {} } - /> - ); - - const button = screen.getByRole( 'button', { name: 'Add reaction' } ); - expect( button ).toHaveAttribute( 'aria-disabled', 'true' ); - - await user.click( button ); - expect( - screen.queryByRole( 'button', { name: 'Rocket' } ) - ).not.toBeInTheDocument(); - } ); -} ); diff --git a/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx deleted file mode 100644 index 94e521fdfb4895..00000000000000 --- a/packages/editor/src/components/collab-sidebar/test/emoji-picker.jsdom.test.tsx +++ /dev/null @@ -1,562 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { dispatch } from '@wordpress/data'; -// @ts-expect-error - No type declarations available for @wordpress/block-editor. -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { store as preferencesStore } from '@wordpress/preferences'; -import EmojiPicker, { - chunkRows, - getGroupLabel, - groupEmojis, - searchEmojis, -} from '../emoji-picker'; -import { EMOJIBASE_LOCALES, resolveEmojibaseLocale } from '../emojibase-data'; -import { FREQUENT_EMOJIS_PREFERENCE_KEY } from '../frequent-emojis'; -import type { EmojibaseEntry } from '../emojibase-data'; - -globalThis.wpVitest.mockMatchMedia(); - -describe( 'resolveEmojibaseLocale', () => { - it( 'falls back to English for empty/invalid input', () => { - expect( resolveEmojibaseLocale( '' ) ).toBe( 'en' ); - // Values outside the declared signature: `` and - // `navigator.language` are page data, so the runtime guard has to - // hold even when they are missing or not a string. - const invalid = [ null, undefined, 42 ] as unknown as string[]; - for ( const value of invalid ) { - expect( resolveEmojibaseLocale( value ) ).toBe( 'en' ); - } - } ); - - it( 'returns supported locales unchanged', () => { - expect( resolveEmojibaseLocale( 'fr' ) ).toBe( 'fr' ); - expect( resolveEmojibaseLocale( 'de' ) ).toBe( 'de' ); - expect( resolveEmojibaseLocale( 'ja' ) ).toBe( 'ja' ); - } ); - - it( 'normalizes case and underscore separators', () => { - expect( resolveEmojibaseLocale( 'FR' ) ).toBe( 'fr' ); - expect( resolveEmojibaseLocale( 'fr_FR' ) ).toBe( 'fr' ); - expect( resolveEmojibaseLocale( 'pt_BR' ) ).toBe( 'pt' ); - } ); - - it( 'matches regional variants Emojibase ships', () => { - expect( resolveEmojibaseLocale( 'en-GB' ) ).toBe( 'en-gb' ); - expect( resolveEmojibaseLocale( 'es-MX' ) ).toBe( 'es-mx' ); - } ); - - it( 'falls back to language portion when full tag is unsupported', () => { - expect( resolveEmojibaseLocale( 'fr-CA' ) ).toBe( 'fr' ); - expect( resolveEmojibaseLocale( 'de-AT' ) ).toBe( 'de' ); - expect( resolveEmojibaseLocale( 'pt-PT' ) ).toBe( 'pt' ); - } ); - - it( 'maps Traditional Chinese variants to zh-hant', () => { - expect( resolveEmojibaseLocale( 'zh-TW' ) ).toBe( 'zh-hant' ); - expect( resolveEmojibaseLocale( 'zh-HK' ) ).toBe( 'zh-hant' ); - expect( resolveEmojibaseLocale( 'zh-MO' ) ).toBe( 'zh-hant' ); - expect( resolveEmojibaseLocale( 'zh-Hant' ) ).toBe( 'zh-hant' ); - } ); - - it( 'falls back to English for fully unsupported locales', () => { - expect( resolveEmojibaseLocale( 'xx' ) ).toBe( 'en' ); - expect( resolveEmojibaseLocale( 'klingon' ) ).toBe( 'en' ); - } ); -} ); - -describe( 'getGroupLabel', () => { - /* - * Verbatim from the `# group:` lines of Unicode's `emoji-test.txt`, - * in its order. Component (2) is omitted: `groupEmojis` drops it. - */ - it( "uses Unicode's own category names", () => { - expect( [ 0, 1, 3, 4, 5, 6, 7, 8, 9 ].map( getGroupLabel ) ).toEqual( [ - 'Smileys & Emotion', - 'People & Body', - 'Animals & Nature', - 'Food & Drink', - 'Travel & Places', - 'Activities', - 'Objects', - 'Symbols', - 'Flags', - ] ); - } ); - - it( 'returns an empty heading for an unknown group', () => { - expect( getGroupLabel( 42 ) ).toBe( '' ); - } ); -} ); - -describe( 'groupEmojis', () => { - it( 'returns empty array for empty input', () => { - expect( groupEmojis( [] ) ).toEqual( [] ); - } ); - - it( 'skips entries without a numeric group', () => { - const data: EmojibaseEntry[] = [ - { hexcode: '1F44B', emoji: '👋', group: 1 }, - { hexcode: '1F3FB', emoji: '🏻' }, // Skin-tone component, no group. - ]; - const out = groupEmojis( data ); - expect( out ).toHaveLength( 1 ); - expect( out[ 0 ].emojis ).toHaveLength( 1 ); - expect( out[ 0 ].emojis[ 0 ].hexcode ).toBe( '1F44B' ); - } ); - - it( 'skips the Component group, which is not pickable on its own', () => { - const data: EmojibaseEntry[] = [ - { hexcode: '1F44B', emoji: '👋', group: 1 }, - // Skin-tone swatches and hair modifiers only ever combine. - { hexcode: '1F3FB', emoji: '🏻', group: 2 }, - { hexcode: '1F9B0', emoji: '🦰', group: 2 }, - ]; - const out = groupEmojis( data ); - expect( out.map( ( g ) => g.key ) ).toEqual( [ 1 ] ); - } ); - - it( 'buckets emojis by group and sorts groups numerically', () => { - const data: EmojibaseEntry[] = [ - { hexcode: 'B', emoji: '🅱', group: 8 }, - { hexcode: 'A', emoji: '😀', group: 0 }, - { hexcode: 'C', emoji: '😺', group: 0 }, - { hexcode: 'D', emoji: '🌍', group: 3 }, - ]; - const out = groupEmojis( data ); - expect( out.map( ( g ) => g.key ) ).toEqual( [ 0, 3, 8 ] ); - expect( out[ 0 ].emojis.map( ( e ) => e.hexcode ) ).toEqual( [ - 'A', - 'C', - ] ); - } ); -} ); - -/** - * Build a run of placeholder emoji records; only the count matters to - * the row chunker. - * - * @param length How many records to build. - * @return Emoji records. - */ -function makeEntries( length: number ): EmojibaseEntry[] { - return Array.from( { length }, ( _, i ) => ( { - hexcode: `${ i }`, - emoji: '😀', - } ) ); -} - -describe( 'chunkRows', () => { - it( 'returns empty array for empty input', () => { - expect( chunkRows( [] ) ).toEqual( [] ); - } ); - - it( 'splits into rows of 6 with a final partial row', () => { - const input = makeEntries( 10 ); - const rows = chunkRows( input ); - expect( rows ).toHaveLength( 2 ); - expect( rows[ 0 ] ).toHaveLength( 6 ); - expect( rows[ 1 ] ).toHaveLength( 4 ); - } ); - - it( 'uses one row when input fits in a single row', () => { - expect( chunkRows( makeEntries( 5 ) ) ).toHaveLength( 1 ); - } ); -} ); - -describe( 'searchEmojis', () => { - const sample: EmojibaseEntry[] = [ - { - hexcode: '1F600', - emoji: '😀', - label: 'grinning face', - tags: [ 'cheerful', 'happy', 'smile' ], - }, - { - hexcode: '2764', - emoji: '❤️', - label: 'red heart', - tags: [ 'love' ], - }, - { - hexcode: '1F389', - emoji: '🎉', - label: 'party popper', - tags: [ 'celebration', 'birthday' ], - }, - ]; - - it( 'returns the unfiltered list when query is empty/whitespace', () => { - expect( searchEmojis( sample, '', null ) ).toBe( sample ); - expect( searchEmojis( sample, ' ', null ) ).toBe( sample ); - } ); - - it( 'matches against the emoji label case-insensitively', () => { - const results = searchEmojis( sample, 'GRIN', null ); - expect( results.map( ( e ) => e.hexcode ) ).toEqual( [ '1F600' ] ); - } ); - - it( 'matches against the tags array', () => { - const results = searchEmojis( sample, 'birthday', null ); - expect( results.map( ( e ) => e.hexcode ) ).toEqual( [ '1F389' ] ); - } ); - - it( 'matches the override label when one is provided', () => { - const overrides = { 2764: 'Heart' }; - const results = searchEmojis( sample, 'heart', overrides ); - // Both the override label "Heart" and Emojibase "red heart" - // match the query — the same emoji, one match. - expect( results.map( ( e ) => e.hexcode ) ).toEqual( [ '2764' ] ); - } ); - - it( 'still matches the original Emojibase label when an override is set', () => { - const overrides = { 2764: 'Heart' }; - // "red heart" matches via the original Emojibase label even - // though the override label has replaced the visible name. - const results = searchEmojis( sample, 'red', overrides ); - expect( results.map( ( e ) => e.hexcode ) ).toEqual( [ '2764' ] ); - } ); - - it( 'matches an override keyed the way the server writes it', () => { - // `gutenberg_emoji_picker_label_overrides` strips U+FE0F and pads - // to four digits, so the key never equals the raw Emojibase - // hexcode for the ~quarter of entries that keep the selector. - const zwj: EmojibaseEntry[] = [ - { - hexcode: '2764-FE0F-200D-1F525', - emoji: '❤️‍🔥', - label: 'heart on fire', - }, - ]; - const results = searchEmojis( zwj, 'flamme', { - '2764-200D-1F525': 'Flammendes Herz', - } ); - expect( results.map( ( e ) => e.hexcode ) ).toEqual( [ - '2764-FE0F-200D-1F525', - ] ); - } ); - - it( 'returns an empty array when nothing matches', () => { - expect( searchEmojis( sample, 'submarine', null ) ).toEqual( [] ); - } ); - - it( 'tolerates emoji entries missing labels or tags', () => { - const odd: EmojibaseEntry[] = [ { hexcode: 'X', emoji: '?' } ]; - expect( () => searchEmojis( odd, 'foo', null ) ).not.toThrow(); - expect( searchEmojis( odd, 'foo', null ) ).toEqual( [] ); - } ); -} ); - -describe( 'EmojiPicker search announcements', () => { - const originalFetch = global.fetch; - - beforeEach( () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/emojibase', - } ); - // Picks persist in the shared preferences store, and a leftover - // "Frequently used" row shifts every grid position below it. - dispatch( preferencesStore ).set( - 'core', - FREQUENT_EMOJIS_PREFERENCE_KEY, - [] - ); - global.fetch = vi.fn( ( url: RequestInfo | URL ) => - Promise.resolve( { - ok: true, - json: () => - Promise.resolve( - String( url ).includes( 'data.json' ) - ? [ - { - hexcode: '1F600', - emoji: '😀', - label: 'grinning face', - group: 0, - }, - { - hexcode: '1F601', - emoji: '😁', - label: 'beaming face', - group: 0, - }, - ] - : {} - ), - } as unknown as Response ) - ); - } ); - - afterEach( () => { - global.fetch = originalFetch; - // The picker may still be mounted here (RTL cleanup runs after - // this hook), so the settings-driven re-render needs act(). - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: undefined, - } ); - } ); - } ); - - it( 'exposes categories as labelled rowgroups and flattens search results', async () => { - const user = userEvent.setup(); - render( {} } /> ); - - await screen.findAllByRole( 'gridcell' ); - - // While browsing, each category is a rowgroup labelled by its - // visible heading, so cell-by-cell navigation has group context. - // The heading is Unicode's own name for the group. - expect( - screen.getByRole( 'rowgroup', { name: 'Smileys & Emotion' } ) - ).toBeVisible(); - - // While searching, results collapse into one flat grid with no - // category sections. - await user.type( - screen.getByRole( 'combobox', { name: 'Search emoji' } ), - 'face' - ); - await waitFor( () => - expect( screen.queryByRole( 'rowgroup' ) ).not.toBeInTheDocument() - ); - expect( screen.getAllByRole( 'gridcell' ) ).toHaveLength( 2 ); - } ); - - it( 'keeps focus in the search field while the arrow keys move through the grid', async () => { - const user = userEvent.setup(); - const onSelect = vi.fn(); - render( ); - - await screen.findAllByRole( 'gridcell' ); - const searchbox = screen.getByRole( 'combobox', { - name: 'Search emoji', - } ); - await waitFor( () => expect( searchbox ).toHaveFocus() ); - - await user.keyboard( '{ArrowDown}' ); - await waitFor( () => - expect( searchbox ).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole( 'gridcell', { name: 'grinning face' } ).id - ) - ); - - await user.keyboard( '{ArrowRight}' ); - await waitFor( () => - expect( searchbox ).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole( 'gridcell', { name: 'beaming face' } ).id - ) - ); - expect( searchbox ).toHaveFocus(); - - await user.keyboard( '{Enter}' ); - expect( onSelect ).toHaveBeenCalledWith( '😁' ); - // Picking does not copy the emoji into the search field. - expect( searchbox ).toHaveValue( '' ); - } ); - - it( 'picks the top search result with Enter', async () => { - const user = userEvent.setup(); - const onSelect = vi.fn(); - render( ); - - await screen.findAllByRole( 'gridcell' ); - await user.type( - screen.getByRole( 'combobox', { name: 'Search emoji' } ), - 'beaming' - ); - await waitFor( () => - expect( screen.getAllByRole( 'gridcell' ) ).toHaveLength( 1 ) - ); - await user.keyboard( '{Enter}' ); - expect( onSelect ).toHaveBeenCalledWith( '😁' ); - } ); - - it( 'leaves synchronous search results unannounced and reports the empty state', async () => { - const user = userEvent.setup(); - render( {} } /> ); - - // Wait for the dataset to load before searching. - await screen.findAllByRole( 'gridcell' ); - - const searchbox = screen.getByRole( 'combobox', { - name: 'Search emoji', - } ); - - await user.type( searchbox, 'face' ); - await waitFor( () => - expect( screen.getAllByRole( 'gridcell' ) ).toHaveLength( 2 ) - ); - expect( - screen.queryByText( /found|available/ ) - ).not.toBeInTheDocument(); - - await user.clear( searchbox ); - await user.type( searchbox, 'zzz' ); - expect( await screen.findByText( 'No emoji found.' ) ).toBeVisible(); - expect( screen.queryAllByRole( 'gridcell' ) ).toHaveLength( 0 ); - // The grid stays mounted while empty, as the Autocomplete expects. - expect( screen.getByRole( 'grid', { name: 'Emoji' } ) ).toBeVisible(); - } ); - - it( 'announces once when a pending load fills the grid, but not for a cached opening', async () => { - // The dataset cache is module-wide, so use a URL no other test loads. - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/emojibase-pending-load', - } ); - const { unmount } = render( {} } /> ); - - expect( - await screen.findByText( '2 emojis available.' ) - ).toHaveAttribute( 'data-visually-hidden' ); - - unmount(); - render( {} } /> ); - - await screen.findAllByRole( 'gridcell' ); - expect( screen.queryByText( /available/ ) ).not.toBeInTheDocument(); - } ); - - it( 'drops the load announcement once the user searches', async () => { - const user = userEvent.setup(); - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: - 'https://example.test/emojibase-search-after-load', - } ); - render( {} } /> ); - - await screen.findByText( '2 emojis available.' ); - - const searchbox = screen.getByRole( 'combobox', { - name: 'Search emoji', - } ); - await user.type( searchbox, 'grinning' ); - await user.clear( searchbox ); - - // Restoring the full grid is synchronous, so nothing is announced. - expect( screen.queryByText( /available/ ) ).not.toBeInTheDocument(); - } ); -} ); - -describe( 'EmojiPicker emoji rules', () => { - const originalFetch = global.fetch; - - beforeEach( () => { - dispatch( preferencesStore ).set( - 'core', - FREQUENT_EMOJIS_PREFERENCE_KEY, - [] - ); - global.fetch = vi.fn( ( url: RequestInfo | URL ) => - Promise.resolve( { - ok: true, - json: () => - Promise.resolve( - String( url ).includes( 'data.json' ) - ? [ - { - hexcode: '1F600', - emoji: '😀', - label: 'grinning face', - group: 0, - }, - { - hexcode: '1F389', - emoji: '🎉', - label: 'party popper', - group: 6, - }, - { - hexcode: '1F595', - emoji: '🖕', - label: 'middle finger', - group: 1, - }, - ] - : {} - ), - } as unknown as Response ) - ); - } ); - - afterEach( () => { - global.fetch = originalFetch; - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: undefined, - noteReactionEmojiRules: undefined, - } ); - } ); - } ); - - it( 'drops excluded emoji from the grid', async () => { - dispatch( blockEditorStore ).updateSettings( { - // A distinct URL keeps the module-level dataset cache apart. - noteEmojibaseUrl: 'https://example.test/rules-exclude', - noteReactionEmojiRules: { - allowUnlisted: true, - exclude: [ '1f595' ], - }, - } ); - render( {} } /> ); - - expect( - await screen.findByRole( 'gridcell', { name: 'grinning face' } ) - ).toBeVisible(); - expect( - screen.queryByRole( 'gridcell', { name: 'middle finger' } ) - ).not.toBeInTheDocument(); - } ); - - it( 'offers only the named list when unlisted emoji are not allowed', async () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/rules-named-only', - noteReactionEmojiRules: { allowUnlisted: false, exclude: [] }, - } ); - render( {} } /> ); - - // 🎉 is in the default named list; it also seeds "Frequently used". - expect( - ( await screen.findAllByRole( 'gridcell' ) ).map( ( cell ) => - cell.getAttribute( 'aria-label' ) - ) - ).not.toContain( 'grinning face' ); - expect( - screen.getAllByRole( 'gridcell', { name: 'party popper' } ).length - ).toBeGreaterThan( 0 ); - expect( - screen.queryByRole( 'gridcell', { name: 'middle finger' } ) - ).not.toBeInTheDocument(); - } ); -} ); - -describe( 'EMOJIBASE_LOCALES drift detection', () => { - // `tools/build-scripts/copy-emojibase-data.mjs` hardcodes a parallel `LOCALES` - // array — when the build runs it copies exactly those locale - // directories into `build/emojibase-data/`. If the JS set drifts - // from the build script, the picker either fetches a missing locale - // (broken UI) or never uses a locale that was needlessly shipped - // (wasted disk). Pin both here. - it( 'stays in sync with tools/build-scripts/copy-emojibase-data.mjs', () => { - const buildScript = readFileSync( - path.resolve( - __dirname, - '../../../../../../tools/build-scripts/copy-emojibase-data.mjs' - ), - 'utf8' - ); - const localesArray = buildScript.match( - /const LOCALES = \[([\s\S]*?)\];/ - )?.[ 1 ]; - expect( localesArray ).toBeTruthy(); - const buildLocales = new Set( - [ ...localesArray!.matchAll( /'([^']+)'/g ) ].map( ( m ) => m[ 1 ] ) - ); - - expect( buildLocales ).toEqual( EMOJIBASE_LOCALES ); - } ); -} ); diff --git a/packages/editor/src/components/collab-sidebar/test/frequent-emojis.jsdom.test.ts b/packages/editor/src/components/collab-sidebar/test/frequent-emojis.jsdom.test.ts deleted file mode 100644 index 87ac72b62c0cf9..00000000000000 --- a/packages/editor/src/components/collab-sidebar/test/frequent-emojis.jsdom.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - DEFAULT_FREQUENT_EMOJI_KEYS, - MAX_FREQUENT_EMOJIS, - getFrequentEmojiKeys, - recordEmojiUse, -} from '../frequent-emojis'; - -describe( 'DEFAULT_FREQUENT_EMOJI_KEYS', () => { - it( 'maps the curated reaction set to normalized hex keys', () => { - // ❤️ 🎉 😄 👀 🚀 with variation selectors stripped. - expect( DEFAULT_FREQUENT_EMOJI_KEYS ).toEqual( [ - '2764', - '1f389', - '1f604', - '1f440', - '1f680', - ] ); - } ); -} ); - -describe( 'recordEmojiUse', () => { - it( 'adds a first use with count 1', () => { - expect( recordEmojiUse( [], '1f44d' ) ).toEqual( [ - { key: '1f44d', count: 1 }, - ] ); - } ); - - it( 'tolerates missing or malformed stored values', () => { - expect( recordEmojiUse( undefined, '1f44d' ) ).toEqual( [ - { key: '1f44d', count: 1 }, - ] ); - expect( recordEmojiUse( 'corrupt', '1f44d' ) ).toEqual( [ - { key: '1f44d', count: 1 }, - ] ); - expect( - recordEmojiUse( - [ null, { key: 42 }, { key: 'ok', count: 'NaN' } ], - '1f44d' - ) - ).toEqual( [ { key: '1f44d', count: 1 } ] ); - } ); - - it( 'returns the sanitized list unchanged when the key is empty', () => { - const entries = [ { key: '2764', count: 3 } ]; - expect( recordEmojiUse( entries, '' ) ).toEqual( entries ); - // A caller reaching the guard with no key at all — outside the - // declared signature, but the runtime guard covers it. - expect( - recordEmojiUse( entries, undefined as unknown as string ) - ).toEqual( entries ); - } ); - - it( 'increments an existing entry and reorders by count', () => { - const entries = [ - { key: '2764', count: 2 }, - { key: '1f44d', count: 2 }, - ]; - expect( recordEmojiUse( entries, '1f44d' ) ).toEqual( [ - { key: '1f44d', count: 3 }, - { key: '2764', count: 2 }, - ] ); - } ); - - it( 'ranks the just-used emoji first among equal counts', () => { - const entries = [ - { key: '2764', count: 1 }, - { key: '1f389', count: 1 }, - ]; - expect( recordEmojiUse( entries, '1f680' ) ).toEqual( [ - { key: '1f680', count: 1 }, - { key: '2764', count: 1 }, - { key: '1f389', count: 1 }, - ] ); - } ); - - it( 'keeps more frequent entries ahead of a new pick', () => { - const entries = [ - { key: '2764', count: 5 }, - { key: '1f389', count: 2 }, - ]; - expect( recordEmojiUse( entries, '1f680' ) ).toEqual( [ - { key: '2764', count: 5 }, - { key: '1f389', count: 2 }, - { key: '1f680', count: 1 }, - ] ); - } ); - - it( 'discards the least used entry once the cap is reached', () => { - const entries = Array.from( - { length: MAX_FREQUENT_EMOJIS }, - ( _, i ) => ( { - key: `key-${ i }`, - count: MAX_FREQUENT_EMOJIS - i, - } ) - ); - const next = recordEmojiUse( entries, '1f680' ); - expect( next ).toHaveLength( MAX_FREQUENT_EMOJIS ); - // The lowest-count entry was evicted… - expect( - next.find( ( e ) => e.key === `key-${ MAX_FREQUENT_EMOJIS - 1 }` ) - ).toBeUndefined(); - // …and the new pick survives at the tail, even though it has the - // lowest count. - expect( next[ next.length - 1 ] ).toEqual( { - key: '1f680', - count: 1, - } ); - } ); - - it( 'does not evict anything when bumping an existing entry at the cap', () => { - const entries = Array.from( - { length: MAX_FREQUENT_EMOJIS }, - ( _, i ) => ( { key: `key-${ i }`, count: 2 } ) - ); - const next = recordEmojiUse( entries, 'key-7' ); - expect( next ).toHaveLength( MAX_FREQUENT_EMOJIS ); - expect( next[ 0 ] ).toEqual( { key: 'key-7', count: 3 } ); - } ); -} ); - -describe( 'getFrequentEmojiKeys', () => { - it( 'returns the curated defaults when nothing is stored', () => { - expect( getFrequentEmojiKeys( undefined ) ).toEqual( - DEFAULT_FREQUENT_EMOJI_KEYS - ); - expect( getFrequentEmojiKeys( [] ) ).toEqual( - DEFAULT_FREQUENT_EMOJI_KEYS - ); - } ); - - it( 'lists recorded usage first, then unseen defaults', () => { - const keys = getFrequentEmojiKeys( [ { key: '1f44d', count: 2 } ] ); - expect( keys ).toEqual( [ '1f44d', ...DEFAULT_FREQUENT_EMOJI_KEYS ] ); - } ); - - it( 'does not repeat a default that already has recorded usage', () => { - const keys = getFrequentEmojiKeys( [ { key: '2764', count: 2 } ] ); - expect( keys[ 0 ] ).toBe( '2764' ); - expect( keys.filter( ( key ) => key === '2764' ) ).toHaveLength( 1 ); - } ); - - it( 'caps the list at the maximum', () => { - const entries = Array.from( - { length: MAX_FREQUENT_EMOJIS }, - ( _, i ) => ( { - key: `key-${ i }`, - count: 1, - } ) - ); - const keys = getFrequentEmojiKeys( entries ); - expect( keys ).toHaveLength( MAX_FREQUENT_EMOJIS ); - // The defaults no longer fit once real usage fills the list. - expect( keys ).not.toContain( '2764' ); - } ); -} ); diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-display.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-display.jsdom.test.tsx deleted file mode 100644 index 2d22edb9d04b0b..00000000000000 --- a/packages/editor/src/components/collab-sidebar/test/reaction-display.jsdom.test.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import apiFetch from '@wordpress/api-fetch'; -import { dispatch } from '@wordpress/data'; -// @ts-expect-error - No type declarations available for @wordpress/block-editor. -import { store as blockEditorStore } from '@wordpress/block-editor'; -import ReactionDisplay, { invalidateReactionNames } from '../reaction-display'; - -vi.mock( import( '@wordpress/api-fetch' ), async ( importOriginal ) => { - const original = await importOriginal(); - - return { - ...original, - default: vi.fn(), - } as unknown as typeof original; -} ); - -const mockApiFetch = vi.mocked( apiFetch ); - -/* - * The tooltip name cache in reaction-display.js is module-level and keyed - * by `noteId:slug`, so each test uses a distinct noteId to stay isolated. - */ -let uniqueNoteId = 1; - -describe( 'ReactionDisplay', () => { - beforeEach( () => { - uniqueNoteId += 1; - mockApiFetch.mockReset(); - // Reject by default so tests that never await a tooltip fetch - // fall back to the count-based label instead of hanging. - mockApiFetch.mockRejectedValue( new Error( 'not mocked' ) ); - } ); - - it( 'renders nothing when there are no reactions', () => { - const { container } = render( - {} } - /> - ); - - expect( container ).toBeEmptyDOMElement(); - } ); - - it( 'renders nothing when every reaction has a zero count', () => { - const { container } = render( - {} } - /> - ); - - expect( container ).toBeEmptyDOMElement(); - } ); - - it( 'renders a pill per reacted emoji with count and pressed state', () => { - render( - {} } - /> - ); - - const heart = screen.getByRole( 'button', { - name: 'Heart, 2 reactions', - pressed: true, - } ); - expect( heart ).toBeVisible(); - expect( heart ).toHaveTextContent( '❤️' ); - expect( heart ).toHaveTextContent( '2' ); - - const rocket = screen.getByRole( 'button', { - name: 'Rocket, 1 reaction', - pressed: false, - } ); - expect( rocket ).toBeVisible(); - expect( rocket ).toHaveTextContent( '🚀' ); - expect( rocket ).toHaveTextContent( '1' ); - } ); - - it( 'falls back to the raw slug for emojis outside the curated set', () => { - render( - {} } - /> - ); - - expect( - screen.getByRole( 'button', { name: 'custom, 1 reaction' } ) - ).toHaveTextContent( 'custom' ); - } ); - - it( 'resolves the emoji name from the Emojibase dataset once the user reaches the pill', async () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/emojibase', - } ); - const originalFetch = global.fetch; - global.fetch = vi.fn( ( url: RequestInfo | URL ) => - Promise.resolve( { - ok: true, - json: () => - Promise.resolve( - String( url ).includes( 'data.json' ) - ? [ - { - hexcode: '1F44D', - emoji: '👍', - label: 'thumbs up', - group: 0, - }, - ] - : { groups: [] } - ), - } as unknown as Response ) - ); - - try { - render( - {} } - /> - ); - - // The dataset is ~775KB, so it stays unfetched while the pill - // merely renders; the emoji character stands in for the label. - const pill = await screen.findByRole( 'button', { - name: '👍, 2 reactions', - } ); - expect( global.fetch ).not.toHaveBeenCalled(); - - // Reaching the pill is what asks for the tooltip, and the - // accessible name then uses the Emojibase label. - await userEvent.hover( pill ); - expect( - await screen.findByRole( 'button', { - name: 'thumbs up, 2 reactions', - } ) - ).toHaveTextContent( '👍' ); - } finally { - global.fetch = originalFetch; - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: undefined, - } ); - } ); - } - } ); - - it( 'calls onToggleReaction with the slug when a pill is clicked', async () => { - const user = userEvent.setup(); - const onToggleReaction = vi.fn(); - render( - - ); - - await user.click( - screen.getByRole( 'button', { name: 'Heart, 2 reactions' } ) - ); - - expect( onToggleReaction ).toHaveBeenCalledTimes( 1 ); - expect( onToggleReaction ).toHaveBeenCalledWith( 'heart' ); - } ); - - it( 'moves focus to the parent thread when removing the last reaction', async () => { - const user = userEvent.setup(); - render( -
- {} } - /> -
- ); - - await user.click( - screen.getByRole( 'button', { name: 'Heart, 1 reaction' } ) - ); - - expect( screen.getByTestId( 'thread' ) ).toHaveFocus(); - } ); - - it( 'lazy-loads reacting user names into the label on hover', async () => { - const user = userEvent.setup(); - mockApiFetch.mockResolvedValue( [ - { author_name: 'Alice', content: { raw: 'heart' } }, - // A different emoji on the same note must be filtered out. - { author_name: 'Mallory', content: { raw: 'rocket' } }, - ] ); - render( - {} } - /> - ); - - await user.hover( - screen.getByRole( 'button', { name: 'Heart, 1 reaction' } ) - ); - - await waitFor( () => - expect( - screen.getByRole( 'button', { - name: 'Alice reacted with Heart', - } ) - ).toBeVisible() - ); - expect( apiFetch ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'stops showing invalidated reactor names while the refetch runs', async () => { - const user = userEvent.setup(); - const noteId = uniqueNoteId; - mockApiFetch.mockResolvedValue( [ - { author_name: 'Alice', content: { raw: 'heart' } }, - ] ); - const { rerender } = render( - {} } - /> - ); - - const pill = screen.getByRole( 'button', { - name: 'Heart, 1 reaction', - } ); - await user.hover( pill ); - await waitFor( () => - expect( - screen.getByRole( 'button', { - name: 'Alice reacted with Heart', - } ) - ).toBeVisible() - ); - - // Someone adds the same reaction from the full picker, which - // invalidates the cached names but cannot reach this pill's state. - invalidateReactionNames( noteId, 'heart' ); - let resolveRefetch!: ( names: unknown[] ) => void; - mockApiFetch.mockImplementation( - () => - new Promise( ( resolve ) => { - resolveRefetch = resolve; - } ) - ); - rerender( - {} } - /> - ); - - await user.unhover( pill ); - await user.hover( pill ); - - // The refetch is still in flight: the label must not claim Alice - // is the only reactor against a count of two. - await waitFor( () => - expect( - screen.getByRole( 'button', { name: 'Heart, 2 reactions' } ) - ).toBeVisible() - ); - - await act( async () => { - resolveRefetch( [ - { author_name: 'Alice', content: { raw: 'heart' } }, - { author_name: 'Bob', content: { raw: 'heart' } }, - ] ); - } ); - expect( - screen.getByRole( 'button', { - name: 'Alice and Bob reacted with Heart', - } ) - ).toBeVisible(); - } ); - - it( 'formats two and three-plus reactor names GitHub-style', async () => { - const user = userEvent.setup(); - mockApiFetch.mockResolvedValue( [ - { author_name: 'Alice', content: { raw: 'heart' } }, - { author_name: 'Bob', content: { raw: 'heart' } }, - { author_name: 'Carol', content: { raw: 'heart' } }, - ] ); - render( - {} } - /> - ); - - await user.hover( - screen.getByRole( 'button', { name: 'Heart, 3 reactions' } ) - ); - - await waitFor( () => - expect( - screen.getByRole( 'button', { - name: 'Alice, Bob, and 1 other reacted with Heart', - } ) - ).toBeVisible() - ); - } ); - - it( 'updates the reactor tooltip when the emoji label resolves after the names', async () => { - // Pin the race between the two requests behind a hex-key pill's - // tooltip: when the reactor names resolve first, the tooltip is - // formatted with the emoji-character fallback label, and it must - // re-derive once the Emojibase dataset supplies the real label. - // A distinct base URL keeps the module-level dataset cache from - // earlier tests out of the way. - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: 'https://example.test/emojibase-race', - } ); - const originalFetch = global.fetch; - let resolveDataset!: () => void; - const datasetGate = new Promise< void >( ( resolve ) => { - resolveDataset = resolve; - } ); - global.fetch = vi.fn( ( url: RequestInfo | URL ) => - datasetGate.then( - () => - ( { - ok: true, - json: () => - Promise.resolve( - String( url ).includes( 'data.json' ) - ? [ - { - hexcode: '1F44D', - emoji: '👍', - label: 'thumbs up', - group: 0, - }, - ] - : { groups: [] } - ), - } ) as unknown as Response - ) - ); - mockApiFetch.mockResolvedValue( [ - { author_name: 'Alice', content: { raw: '1f44d' } }, - ] ); - - try { - const user = userEvent.setup(); - render( - {} } - /> - ); - - await user.hover( - screen.getByRole( 'button', { name: '👍, 1 reaction' } ) - ); - // Names resolved first: the tooltip uses the emoji-character - // fallback label while the dataset request is still pending. - await waitFor( () => - expect( - screen.getByRole( 'button', { - name: 'Alice reacted with 👍', - } ) - ).toBeVisible() - ); - - await act( async () => { - resolveDataset(); - } ); - // The dataset label arrived after the names: the already - // visible tooltip must pick it up, not stay frozen on the - // fallback. - await waitFor( () => - expect( - screen.getByRole( 'button', { - name: 'Alice reacted with thumbs up', - } ) - ).toBeVisible() - ); - } finally { - global.fetch = originalFetch; - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteEmojibaseUrl: undefined, - } ); - } ); - } - } ); - - it( 'walks every page of reactions before naming reactors', async () => { - const user = userEvent.setup(); - // A full first page forces a second request; the target slug's - // reactor only appears on that second page. - const firstPage = Array.from( { length: 100 }, ( _, i ) => ( { - author_name: `Rocketeer ${ i }`, - content: { raw: 'rocket' }, - } ) ); - mockApiFetch - .mockResolvedValueOnce( firstPage ) - .mockResolvedValueOnce( [ - { author_name: 'Alice', content: { raw: 'heart' } }, - ] ); - render( - {} } - /> - ); - - await user.hover( - screen.getByRole( 'button', { name: 'Heart, 1 reaction' } ) - ); - - await waitFor( () => - expect( - screen.getByRole( 'button', { - name: 'Alice reacted with Heart', - } ) - ).toBeVisible() - ); - expect( apiFetch ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'names reactors when the last page is exactly full', async () => { - const user = userEvent.setup(); - // Exactly 100 reactions: the walk asks for a second page, which the - // endpoint rejects as out of range. - const fullPage = Array.from( { length: 100 }, ( _, i ) => ( { - author_name: i === 0 ? 'Alice' : `Rocketeer ${ i }`, - content: { raw: i === 0 ? 'heart' : 'rocket' }, - } ) ); - mockApiFetch.mockResolvedValueOnce( fullPage ).mockRejectedValueOnce( { - code: 'rest_comment_invalid_page_number', - message: - 'The page number requested is larger than the number of pages available.', - data: { status: 400 }, - } ); - render( - {} } - /> - ); - - await user.hover( - screen.getByRole( 'button', { name: 'Heart, 1 reaction' } ) - ); - - await waitFor( () => - expect( - screen.getByRole( 'button', { - name: 'Alice reacted with Heart', - } ) - ).toBeVisible() - ); - expect( apiFetch ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'keeps the count-based label when the reaction walk is truncated', async () => { - const user = userEvent.setup(); - // Every page comes back full, so the walk hits its page cap without - // ever seeing the end of the list. A partial name list would read as - // complete, so the count-based label has to stand. - mockApiFetch.mockResolvedValue( - Array.from( { length: 100 }, () => ( { - author_name: 'Alice', - content: { raw: 'heart' }, - } ) ) - ); - render( - {} } - /> - ); - - await user.hover( - screen.getByRole( 'button', { name: 'Heart, 1200 reactions' } ) - ); - - await waitFor( () => expect( apiFetch ).toHaveBeenCalledTimes( 10 ) ); - expect( - screen.getByRole( 'button', { name: 'Heart, 1200 reactions' } ) - ).toBeVisible(); - } ); - - it( 'leaves pills focusable but inert on a resolved thread', async () => { - const user = userEvent.setup(); - const onToggleReaction = vi.fn(); - render( - - ); - - const pill = screen.getByRole( 'button', { - name: 'Heart, 1 reaction', - } ); - expect( pill ).toHaveAttribute( 'aria-disabled', 'true' ); - - await user.click( pill ); - expect( onToggleReaction ).not.toHaveBeenCalled(); - } ); - - it( 'keeps the count-based label when the names fetch fails', async () => { - const user = userEvent.setup(); - mockApiFetch.mockRejectedValue( new Error( 'network down' ) ); - render( - {} } - /> - ); - - await user.hover( - screen.getByRole( 'button', { name: 'Heart, 1 reaction' } ) - ); - - await waitFor( () => expect( apiFetch ).toHaveBeenCalledTimes( 1 ) ); - expect( - screen.getByRole( 'button', { name: 'Heart, 1 reaction' } ) - ).toBeVisible(); - } ); -} ); diff --git a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx deleted file mode 100644 index 54d905ab38db89..00000000000000 --- a/packages/editor/src/components/collab-sidebar/test/reaction-emoji-picker.jsdom.test.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { dispatch } from '@wordpress/data'; -// @ts-expect-error - No type declarations available for @wordpress/block-editor. -import { store as blockEditorStore } from '@wordpress/block-editor'; -import ReactionEmojiPicker, { - emojiToHexKey, - emojiToStorageKey, - hexKeyToEmoji, - REACTION_EMOJIS, - buildEmojiBySlugMap, - getNamedHexKeys, - isReactionEmojiAllowed, - parseReactionEmojiRules, -} from '../reaction-emoji-picker'; - -describe( 'buildEmojiBySlugMap', () => { - it( 'indexes every curated emoji by its slug', () => { - const map = buildEmojiBySlugMap(); - - expect( map.size ).toBe( REACTION_EMOJIS.length ); - REACTION_EMOJIS.forEach( ( entry ) => { - expect( map.get( entry.value ) ).toBe( entry ); - } ); - } ); - - it( 'indexes a custom emoji list when provided', () => { - const custom = [ { emoji: '⭐', label: 'Star', value: 'star' } ]; - const map = buildEmojiBySlugMap( custom ); - - expect( map.size ).toBe( 1 ); - expect( map.get( 'star' ) ).toEqual( custom[ 0 ] ); - expect( map.get( 'heart' ) ).toBeUndefined(); - } ); -} ); - -describe( 'ReactionEmojiPicker', () => { - it( 'renders a labelled group with one button per curated emoji', async () => { - render( {} } /> ); - - // Composite settles its active item after mount, so the first query - // awaits that update instead of letting it land outside the test. - expect( - await screen.findByRole( 'group', { - name: 'Add an emoji reaction', - } ) - ).toBeVisible(); - - const buttons = screen.getAllByRole( 'button' ); - expect( buttons ).toHaveLength( REACTION_EMOJIS.length ); - REACTION_EMOJIS.forEach( ( { label } ) => { - expect( - screen.getByRole( 'button', { name: label } ) - ).toBeVisible(); - } ); - } ); - - it( 'calls onSelect with the storage slug, not the emoji character', async () => { - const user = userEvent.setup(); - const onSelect = vi.fn(); - render( ); - - await user.click( screen.getByRole( 'button', { name: 'Smile' } ) ); - - expect( onSelect ).toHaveBeenCalledTimes( 1 ); - expect( onSelect ).toHaveBeenCalledWith( 'smile' ); - } ); - - describe( 'settings-provided emoji list', () => { - afterEach( () => { - // The picker may still be mounted when the settings reset - // lands, so the resulting re-render must be act()-wrapped. - act( () => { - dispatch( blockEditorStore ).updateSettings( { - noteReactionEmojis: undefined, - } ); - } ); - } ); - - it( 'renders the list from editor settings when present', async () => { - dispatch( blockEditorStore ).updateSettings( { - noteReactionEmojis: [ - ...REACTION_EMOJIS, - { emoji: '👍', label: 'Thumbs up', value: 'thumbs-up' }, - ], - } ); - render( {} } /> ); - - expect( await screen.findAllByRole( 'button' ) ).toHaveLength( - REACTION_EMOJIS.length + 1 - ); - expect( - screen.getByRole( 'button', { name: 'Thumbs up' } ) - ).toBeVisible(); - } ); - - it( 'drops malformed entries without restoring the defaults', async () => { - dispatch( blockEditorStore ).updateSettings( { - noteReactionEmojis: [ - null, - { emoji: '👍' }, - { label: 'No emoji', value: 'no-emoji' }, - ], - } ); - const { unmount } = render( - {} } /> - ); - - expect( - screen.getByRole( 'group', { - name: 'Add an emoji reaction', - } ) - ).toBeInTheDocument(); - expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 ); - // Unmount before `afterEach` restores the defaults, which would - // otherwise mount items whose Composite updates escape act(). - unmount(); - } ); - - it( 'keeps an explicitly empty filtered list empty', async () => { - // The server validates against the filtered list, so offering - // the defaults here would produce slugs it rejects. - dispatch( blockEditorStore ).updateSettings( { - noteReactionEmojis: [], - } ); - const { unmount } = render( - {} } /> - ); - - expect( - screen.getByRole( 'group', { - name: 'Add an emoji reaction', - } ) - ).toBeInTheDocument(); - expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 ); - // Unmount before `afterEach` restores the defaults, which would - // otherwise mount items whose Composite updates escape act(). - unmount(); - } ); - } ); -} ); - -describe( 'emojiToHexKey', () => { - it( 'zero-pads code points to Emojibase hexcode width', () => { - expect( emojiToHexKey( '©️' ) ).toBe( '00a9' ); - expect( emojiToHexKey( '®️' ) ).toBe( '00ae' ); - expect( emojiToHexKey( '0️⃣' ) ).toBe( '0030-20e3' ); - } ); - - it( 'strips the variation selector', () => { - expect( emojiToHexKey( '❤️' ) ).toBe( '2764' ); - expect( emojiToHexKey( '❤️‍🔥' ) ).toBe( '2764-200d-1f525' ); - } ); - - it( 'leaves already wide code points unpadded', () => { - expect( emojiToHexKey( '👍' ) ).toBe( '1f44d' ); - expect( emojiToHexKey( '👨‍💻' ) ).toBe( '1f468-200d-1f4bb' ); - } ); - - it( 'returns an empty string for non-emoji input', () => { - expect( emojiToHexKey( '' ) ).toBe( '' ); - expect( emojiToHexKey( undefined as unknown as string ) ).toBe( '' ); - } ); -} ); - -describe( 'hexKeyToEmoji', () => { - it( 're-qualifies text-presentation emoji so they render in colour', () => { - expect( hexKeyToEmoji( '2764' ) ).toBe( '❤️' ); - expect( hexKeyToEmoji( '263a' ) ).toBe( '☺️' ); - expect( hexKeyToEmoji( '00a9' ) ).toBe( '©️' ); - expect( hexKeyToEmoji( '0030-20e3' ) ).toBe( '0️⃣' ); - } ); - - it( 're-qualifies components inside a ZWJ sequence', () => { - expect( hexKeyToEmoji( '2764-200d-1f525' ) ).toBe( '❤️‍🔥' ); - expect( hexKeyToEmoji( '1f9d4-200d-2642' ) ).toBe( '🧔‍♂️' ); - } ); - - it( 'leaves emoji-presentation code points unqualified', () => { - expect( hexKeyToEmoji( '1f44d' ) ).toBe( '👍' ); - expect( hexKeyToEmoji( '1f468-200d-1f4bb' ) ).toBe( '👨‍💻' ); - } ); - - it( 'omits the selector before a skin-tone modifier', () => { - expect( hexKeyToEmoji( '270c-1f3fb' ) ).toBe( '✌🏻' ); - } ); - - it( 'reads legacy unpadded keys', () => { - expect( hexKeyToEmoji( 'a9' ) ).toBe( '©️' ); - } ); - - it( 'returns the input unchanged when it is not a hex key', () => { - expect( hexKeyToEmoji( 'heart' ) ).toBe( 'heart' ); - expect( hexKeyToEmoji( 'ffffff' ) ).toBe( 'ffffff' ); - } ); - - it( 'round-trips every emoji it produces a key for', () => { - const emojis = [ '❤️', '☺️', '©️', '0️⃣', '❤️‍🔥', '🧔‍♂️', '👍', '✌🏻' ]; - emojis.forEach( ( emoji ) => { - expect( hexKeyToEmoji( emojiToHexKey( emoji ) ) ).toBe( emoji ); - } ); - } ); -} ); - -describe( 'emojiToStorageKey', () => { - it( 'collapses a curated emoji to its slug', () => { - expect( emojiToStorageKey( '❤️' ) ).toBe( 'heart' ); - expect( emojiToStorageKey( '❤' ) ).toBe( 'heart' ); - } ); - - it( 'uses the hex key for a default emoji missing from the list', () => { - expect( emojiToStorageKey( '🎉', [] ) ).toBe( '1f389' ); - } ); - - it( 'falls back to the padded hex key for other emoji', () => { - expect( emojiToStorageKey( '👍' ) ).toBe( '1f44d' ); - expect( emojiToStorageKey( '©️' ) ).toBe( '00a9' ); - } ); -} ); - -describe( 'parseReactionEmojiRules', () => { - it( 'allows any emoji when the setting is absent or malformed', () => { - const any = { allowUnlisted: true, exclude: [] }; - expect( parseReactionEmojiRules( undefined ) ).toEqual( any ); - expect( parseReactionEmojiRules( 'strict' ) ).toEqual( any ); - } ); - - it( 'keeps string exclusions, lowercased', () => { - expect( - parseReactionEmojiRules( { - allowUnlisted: false, - exclude: [ '1F595', 42, null ], - } ) - ).toEqual( { allowUnlisted: false, exclude: [ '1f595' ] } ); - } ); -} ); - -describe( 'isReactionEmojiAllowed', () => { - const named = getNamedHexKeys( REACTION_EMOJIS ); - - it( 'rejects an excluded emoji and its skin-tone variants', () => { - const rules = { allowUnlisted: true, exclude: [ '1f44d' ] }; - expect( isReactionEmojiAllowed( '1f44d', rules, named ) ).toBe( false ); - expect( isReactionEmojiAllowed( '1f44d-1f3fd', rules, named ) ).toBe( - false - ); - expect( isReactionEmojiAllowed( '1f600', rules, named ) ).toBe( true ); - } ); - - it( 'accepts only named emoji when unlisted emoji are not allowed', () => { - const rules = { allowUnlisted: false, exclude: [] }; - expect( isReactionEmojiAllowed( '2764', rules, named ) ).toBe( true ); - expect( isReactionEmojiAllowed( '1f600', rules, named ) ).toBe( false ); - } ); - - it( 'accepts a named emoji even when it is excluded', () => { - const rules = { allowUnlisted: true, exclude: [ '2764' ] }; - expect( isReactionEmojiAllowed( '2764', rules, named ) ).toBe( true ); - } ); -} ); diff --git a/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.jsdom.test.tsx deleted file mode 100644 index ec030c01b738c9..00000000000000 --- a/packages/editor/src/components/collab-sidebar/test/skin-tone-picker.jsdom.test.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import SkinTonePicker, { SKIN_TONES, applySkinTone } from '../skin-tone-picker'; -import type { EmojibaseEntry } from '../emojibase-data'; - -// The Dropdown's popover animates from opacity 0; the mock reports -// reduced motion, so the flyout is visible as soon as it opens. -globalThis.wpVitest.mockMatchMedia(); - -const RAISED_HAND: EmojibaseEntry = { - label: 'raised hand', - hexcode: '270B', - emoji: '✋️', - skins: [ - { - label: 'raised hand: light skin tone', - hexcode: '270B-1F3FB', - emoji: '✋🏻', - tone: 1, - }, - { - label: 'raised hand: medium-light skin tone', - hexcode: '270B-1F3FC', - emoji: '✋🏼', - tone: 2, - }, - { - label: 'raised hand: medium skin tone', - hexcode: '270B-1F3FD', - emoji: '✋🏽', - tone: 3, - }, - { - label: 'raised hand: medium-dark skin tone', - hexcode: '270B-1F3FE', - emoji: '✋🏾', - tone: 4, - }, - { - label: 'raised hand: dark skin tone', - hexcode: '270B-1F3FF', - emoji: '✋🏿', - tone: 5, - }, - ], -}; - -describe( 'SKIN_TONES', () => { - it( 'lists the default tone first, then tones 1-5', () => { - expect( SKIN_TONES.map( ( t ) => t.tone ) ).toEqual( [ - 0, 1, 2, 3, 4, 5, - ] ); - } ); - - it( 'uses the raised hand as the exemplar for every swatch', () => { - for ( const { emoji } of SKIN_TONES ) { - expect( emoji.codePointAt( 0 ) ).toBe( 0x270b ); - } - } ); -} ); - -describe( 'applySkinTone', () => { - it( 'returns the base entry for the default tone', () => { - expect( applySkinTone( RAISED_HAND, 0 ) ).toBe( RAISED_HAND ); - } ); - - it( 'returns the matching skin variant for tones 1-5', () => { - expect( applySkinTone( RAISED_HAND, 1 ).emoji ).toBe( '✋🏻' ); - expect( applySkinTone( RAISED_HAND, 3 ).emoji ).toBe( '✋🏽' ); - expect( applySkinTone( RAISED_HAND, 5 ).emoji ).toBe( '✋🏿' ); - } ); - - it( 'returns the base entry for emoji without skin variants', () => { - const heart: EmojibaseEntry = { - label: 'red heart', - hexcode: '2764-FE0F', - emoji: '❤️', - }; - expect( applySkinTone( heart, 3 ) ).toBe( heart ); - } ); - - it( 'ignores mixed-tone combination variants', () => { - const handshake: EmojibaseEntry = { - label: 'handshake', - hexcode: '1F91D', - emoji: '🤝', - skins: [ - { - label: 'handshake: light, dark', - hexcode: '1FAF1-1F3FB-200D-1FAF2-1F3FF', - emoji: '🫱🏻‍🫲🏿', - tone: [ 1, 5 ], - }, - ], - }; - expect( applySkinTone( handshake, 1 ) ).toBe( handshake ); - } ); -} ); - -describe( 'SkinTonePicker', () => { - it( 'shows the current tone on the persistent toggle', () => { - render( {} } /> ); - - const toggle = screen.getByRole( 'button', { - name: 'Skin tone: Medium skin tone', - } ); - expect( toggle ).toHaveTextContent( '✋🏽' ); - } ); - - it( 'opens a flyout with a heading and six selectable swatches', async () => { - const user = userEvent.setup(); - render( {} } /> ); - - const toggle = screen.getByRole( 'button', { - name: 'Skin tone: Default skin tone', - } ); - // The trigger advertises the popup type it opens. - expect( toggle ).toHaveAttribute( 'aria-haspopup', 'dialog' ); - await user.click( toggle ); - - // The popup is a non-modal dialog named by its visible heading, so - // screen readers announce entering it rather than a bare group of - // options appearing. - expect( - screen.getByRole( 'dialog', { - name: 'Choose your default skin tone', - } ) - ).toBeVisible(); - expect( - screen.getByText( 'Choose your default skin tone' ) - ).toBeVisible(); - // The options are laid out horizontally, so the listbox must - // report its orientation (the ARIA default is vertical). - expect( screen.getByRole( 'listbox' ) ).toHaveAttribute( - 'aria-orientation', - 'horizontal' - ); - const options = screen.getAllByRole( 'option' ); - expect( options ).toHaveLength( 6 ); - expect( options[ 0 ] ).toHaveAccessibleName( 'Default skin tone' ); - expect( options[ 0 ] ).toHaveAttribute( 'aria-selected', 'true' ); - expect( options[ 5 ] ).toHaveAttribute( 'aria-selected', 'false' ); - } ); - - it( 'places focus on the selected tone when the listbox receives focus', async () => { - const user = userEvent.setup(); - render( {} } /> ); - - await user.click( - screen.getByRole( 'button', { - name: 'Skin tone: Medium-dark skin tone', - } ) - ); - - // Per the APG listbox pattern, focus goes to the selected option - // (not the first) when the listbox receives focus. - await waitFor( () => - expect( - screen.getByRole( 'option', { name: 'Medium-dark skin tone' } ) - ).toHaveFocus() - ); - } ); - - it( 'reports the chosen tone and closes the flyout', async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( ); - - await user.click( - screen.getByRole( 'button', { - name: 'Skin tone: Default skin tone', - } ) - ); - await user.click( - screen.getByRole( 'option', { name: 'Dark skin tone' } ) - ); - - expect( onChange ).toHaveBeenCalledWith( 5 ); - expect( - screen.queryByText( 'Choose your default skin tone' ) - ).not.toBeInTheDocument(); - // Focus returns to the toggle when the flyout closes, even though - // the flyout manages its own focus on mount. - expect( - screen.getByRole( 'button', { - name: 'Skin tone: Default skin tone', - } ) - ).toHaveFocus(); - } ); -} ); diff --git a/packages/editor/src/components/collab-sidebar/test/use-note-actions-reactions.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/test/use-note-actions-reactions.jsdom.test.tsx deleted file mode 100644 index 2adc8d76ed7bdc..00000000000000 --- a/packages/editor/src/components/collab-sidebar/test/use-note-actions-reactions.jsdom.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, renderHook } from '@testing-library/react'; -import apiFetch from '@wordpress/api-fetch'; -import { useNoteActions } from '../hooks'; - -// The editor store touches matchMedia at import time. -vi.hoisted( () => globalThis.wpVitest.mockMatchMedia() ); - -vi.mock( import( '@wordpress/api-fetch' ), async ( importOriginal ) => { - const original = await importOriginal(); - - return { - ...original, - default: vi.fn(), - } as unknown as typeof original; -} ); - -type Summary = Record< - string, - { count: number; reacted: boolean; my_reaction_id?: number } ->; -type NoteRecord = { id: number; reaction_summary: Summary }; - -/* - * A single fake store behind both `useSelect` and `useDispatch`, so the - * hook's cache reads see its own writes. - */ -const records = new Map< number, NoteRecord >(); -let nextReactionId = 100; -const actions = { - createNotice: vi.fn(), - saveEntityRecord: vi.fn( async () => ( { id: nextReactionId++ } ) ), - deleteEntityRecord: vi.fn( async () => {} ), - receiveEntityRecords: vi.fn( - ( _kind: string, _name: string, items: NoteRecord[] ) => { - items.forEach( ( item ) => records.set( item.id, item ) ); - } - ), - getEntityRecord: ( _kind: string, _name: string, id: number ) => - records.get( id ), - getCurrentPostId: () => 1, - updateBlockAttributes: vi.fn(), -}; - -vi.mock( import( '@wordpress/data' ), async ( importOriginal ) => { - const original = await importOriginal(); - - return { - ...original, - useDispatch: () => actions, - useSelect: () => actions, - } as unknown as typeof original; -} ); - -const mockApiFetch = vi.mocked( apiFetch ); - -function deferred< T >() { - let resolve!: ( value: T ) => void; - const promise = new Promise< T >( ( r ) => { - resolve = r; - } ); - return { promise, resolve }; -} - -describe( 'useNoteActions onToggleReaction', () => { - beforeEach( () => { - records.clear(); - records.set( 7, { id: 7, reaction_summary: {} } ); - mockApiFetch.mockReset(); - } ); - - it( 'counts concurrent adds that converge on one reaction once', async () => { - // The server dedupes concurrent inserts and returns the surviving - // row's ID to every request; the refreshes that would correct the - // count all fail. - actions.saveEntityRecord.mockResolvedValue( { id: 100 } ); - mockApiFetch.mockRejectedValue( new Error( 'offline' ) ); - - const { result } = renderHook( () => useNoteActions( {} ) ); - - await act( async () => { - await Promise.all( [ - result.current.onToggleReaction( { - commentId: 7, - emoji: 'heart', - } ), - result.current.onToggleReaction( { - commentId: 7, - emoji: 'heart', - } ), - ] ); - } ); - - expect( records.get( 7 )?.reaction_summary ).toEqual( { - heart: { count: 1, reacted: true, my_reaction_id: 100 }, - } ); - - // Removing the one surviving reaction leaves no phantom count. - const { result: after } = renderHook( () => - useNoteActions( { 7: records.get( 7 )!.reaction_summary } ) - ); - await act( async () => { - await after.current.onToggleReaction( { - commentId: 7, - emoji: 'heart', - } ); - } ); - - expect( records.get( 7 )?.reaction_summary ).toEqual( {} ); - actions.saveEntityRecord.mockImplementation( async () => ( { - id: nextReactionId++, - } ) ); - } ); - - it( 'ignores a refresh that a newer reaction has overtaken', async () => { - const heartRefresh = deferred< unknown >(); - const rocketRefresh = deferred< unknown >(); - mockApiFetch - .mockReturnValueOnce( heartRefresh.promise ) - .mockReturnValueOnce( rocketRefresh.promise ); - - const { result } = renderHook( () => useNoteActions( {} ) ); - - let heartToggle!: Promise< void >; - let rocketToggle!: Promise< void >; - await act( async () => { - heartToggle = result.current.onToggleReaction( { - commentId: 7, - emoji: 'heart', - } ); - rocketToggle = result.current.onToggleReaction( { - commentId: 7, - emoji: 'rocket', - } ); - // Let both mutations land, so both refreshes are in flight. - await Promise.resolve(); - await Promise.resolve(); - } ); - expect( mockApiFetch ).toHaveBeenCalledTimes( 2 ); - - // The newer refresh sees both reactions and lands first. - await act( async () => { - rocketRefresh.resolve( { - id: 7, - reaction_summary: { - heart: { count: 1, reacted: true, my_reaction_id: 100 }, - rocket: { count: 1, reacted: true, my_reaction_id: 101 }, - }, - } ); - await rocketToggle; - } ); - - // The older snapshot, taken before the rocket landed, arrives last. - await act( async () => { - heartRefresh.resolve( { - id: 7, - reaction_summary: { - heart: { count: 1, reacted: true, my_reaction_id: 100 }, - }, - } ); - await heartToggle; - } ); - - expect( records.get( 7 )?.reaction_summary ).toEqual( { - heart: { count: 1, reacted: true, my_reaction_id: 100 }, - rocket: { count: 1, reacted: true, my_reaction_id: 101 }, - } ); - } ); -} ); From 4b76e27c1db88f250e020f48e2c27b0690a808a7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 10:34:05 -0700 Subject: [PATCH 330/362] Notes: Prune emoji reaction e2e tests Drop tests that measure layout pixels, re-test Autocomplete or Popover behavior, or are subsumed by another test, keeping the flows that exercise the reaction feature itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- .../specs/editor/various/block-notes.spec.js | 530 +----------------- 1 file changed, 1 insertion(+), 529 deletions(-) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 6d8893d369aa0e..85f5fd60559772 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1241,30 +1241,6 @@ test.describe( 'Block Notes', () => { ).toHaveText( 'Original note text' ); } ); - test( 'can remove own emoji reaction by clicking it', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing reaction removal' }, - comment: 'Test comment for removing reactions', - } ); - - // Add a reaction. - await blockNoteUtils.addReactionToComment( 'Heart' ); - const reactionButton = page.getByRole( 'button', { - name: /Heart/, - } ); - await expect( reactionButton ).toBeVisible(); - - // Click the reaction to remove it. - await reactionButton.click(); - - // Verify the reaction button is no longer visible. - await expect( reactionButton ).toBeHidden(); - } ); - test( 'can see reaction tooltip on hover', async ( { page, blockNoteUtils, @@ -1334,73 +1310,7 @@ test.describe( 'Block Notes', () => { ).toBeVisible(); } ); - test( 'arrow keys cross from one emoji category into the next', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing category crossing' }, - comment: 'Category crossing', - } ); - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await blockNoteUtils.waitForFullPicker(); - - const searchField = page.getByRole( 'combobox', { - name: 'Search emoji', - } ); - const firstCategory = page - .getByRole( 'grid' ) - .getByRole( 'rowgroup' ) - .first(); - const firstCategoryRows = await firstCategory - .getByRole( 'row' ) - .count(); - - // One press lands on the first row; one per row after that - // walks out of the first category and into the second. - for ( let i = 0; i <= firstCategoryRows; i++ ) { - await page.keyboard.press( 'ArrowDown' ); - } - const activeId = await searchField.getAttribute( - 'aria-activedescendant' - ); - await expect( - page - .getByRole( 'grid' ) - .getByRole( 'rowgroup' ) - .nth( 1 ) - .locator( `[id="${ activeId }"]` ) - ).toHaveCount( 1 ); - await expect( searchField ).toBeFocused(); - } ); - - test( 'Enter picks the top search result', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing Enter on search' }, - comment: 'Enter on search', - } ); - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await blockNoteUtils.waitForFullPicker(); - - await page.keyboard.type( 'rocket' ); - await expect( - page.getByRole( 'gridcell' ).first() - ).toHaveAttribute( 'data-highlighted' ); - await page.keyboard.press( 'Enter' ); - - await expect( - page.locator( '.editor-collab-sidebar-panel__reaction-button', { - hasText: '🚀', - } ) - ).toBeVisible(); - } ); - - test( 'the add-reaction trigger stays out of the way until hovered', async ( { + test( 'the add-reaction trigger is revealed on hover and focus', async ( { page, blockNoteUtils, } ) => { @@ -1415,40 +1325,6 @@ test.describe( 'Block Notes', () => { ); const note = page.locator( '.editor-collab-sidebar-panel__note' ); - // The trigger floats in the note's top corner rather than - // claiming a row: the note is no taller for carrying it. - const reactionRow = page.locator( - '.editor-collab-sidebar-panel__reactions' - ); - await expect( reactionRow ).toHaveClass( /is-floating/ ); - await expect( - page.locator( '.editor-collab-sidebar-panel__reaction-button' ) - ).toHaveCount( 0 ); - - const noteBody = page.locator( - '.editor-collab-sidebar-panel__note-body' - ); - const noteText = page.locator( - '.editor-collab-sidebar-panel__note-content' - ); - expect( ( await noteBody.boundingBox() ).height ).toBe( - ( await noteText.boundingBox() ).height - ); - - // It lines up under the note's other actions, and the text keeps - // clear of it rather than running underneath. - const triggerBox = await trigger.boundingBox(); - const actionsBox = await page - .getByRole( 'button', { name: 'Actions' } ) - .boundingBox(); - expect( triggerBox.x ).toBe( actionsBox.x ); - const textRight = await noteText.evaluate( ( element ) => { - const range = document.createRange(); - range.selectNodeContents( element ); - return range.getBoundingClientRect().right; - } ); - expect( textRight ).toBeLessThanOrEqual( triggerBox.x ); - // Park the pointer outside the sidebar: adding the note leaves it // over the thread, which would hold the trigger open. await page.mouse.move( 0, 0 ); @@ -1465,71 +1341,6 @@ test.describe( 'Block Notes', () => { await expect( trigger ).toHaveCSS( 'opacity', '1' ); } ); - test( 'only the hovered note in a thread reveals its trigger', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing per-note hover' }, - comment: 'Test comment for per-note hover', - } ); - - const replyForm = page.getByRole( 'textbox', { name: 'Reply to' } ); - await replyForm.click(); - await replyForm.pressSequentially( - 'Test reply for per-note hover' - ); - await page - .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Reply', exact: true } ) - .click(); - - const notes = page.locator( '.editor-collab-sidebar-panel__note' ); - await expect( notes ).toHaveCount( 2 ); - const triggers = page.locator( - '.editor-collab-sidebar-panel__add-reaction' - ); - - // Reacting is per note, so the pointer resting on one note should - // not offer the option on the other. - await notes.first().hover(); - await expect( triggers.first() ).toHaveCSS( 'opacity', '1' ); - await expect( triggers.last() ).toHaveCSS( 'opacity', '0' ); - - await notes.last().hover(); - await expect( triggers.last() ).toHaveCSS( 'opacity', '1' ); - await expect( triggers.first() ).toHaveCSS( 'opacity', '0' ); - } ); - - test( 'the add-reaction trigger stays visible once the note has reactions', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing the trailing trigger' }, - comment: 'Test comment for the trailing trigger', - } ); - - await blockNoteUtils.addReactionToComment( 'Heart' ); - await expect( - page.getByRole( 'button', { name: /Heart/ } ) - ).toBeVisible(); - - // With pills to sit beside, the trigger trails them in the row - // rather than floating, and stays put without a hover. Picking - // the emoji leaves focus on the trigger, which would hold it open, - // so hand focus back to the still-selected thread. - await page - .locator( '.editor-collab-sidebar-panel__thread' ) - .focus(); - await page.mouse.move( 0, 0 ); - await expect( - page.locator( '.editor-collab-sidebar-panel__add-reaction' ) - ).toHaveCSS( 'opacity', '1' ); - } ); - test( 'reactions stay visible once the thread is deselected', async ( { page, editor, @@ -1572,46 +1383,6 @@ test.describe( 'Block Notes', () => { ); } ); - test( 'falls back to the curated quick row, laid out as a row with focus on both axes', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing picker layout' }, - comment: 'Test comment for picker layout', - } ); - - await page - .getByRole( 'button', { name: 'Add reaction' } ) - .click(); - - // Without a dataset URL the full picker cannot load, so - // adding a reaction falls back to the curated quick row. - await expect( - page.getByPlaceholder( 'Search emoji' ) - ).toBeHidden(); - const emojiPicker = page.locator( - '.editor-collab-sidebar-panel__emoji-picker' - ); - await expect( emojiPicker ).toBeVisible(); - - // `.components-popover__content` is `width: min-content`, - // which used to squeeze the wrapping button group into a - // single column one emoji wide. - const box = await emojiPicker.boundingBox(); - expect( box.width ).toBeGreaterThan( box.height ); - - // The roving tab index moves on both axes, so the picker is - // navigable however the emoji set happens to wrap. - const buttons = emojiPicker.getByRole( 'button' ); - await buttons.first().focus(); - await page.keyboard.press( 'ArrowDown' ); - await expect( buttons.nth( 1 ) ).toBeFocused(); - await page.keyboard.press( 'ArrowUp' ); - await expect( buttons.first() ).toBeFocused(); - } ); - test( 'a curated pick from the fallback row still adds a reaction', async ( { page, blockNoteUtils, @@ -1694,55 +1465,6 @@ test.describe( 'Block Notes', () => { await expect( reactionPill ).toBeEnabled(); } ); - test( 'can add multiple different reactions to same note', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing multiple reactions' }, - comment: 'Test comment for multiple reactions', - } ); - - // Add first reaction. - await blockNoteUtils.addReactionToComment( 'Smile' ); - await expect( - page.getByRole( 'button', { name: /Smile/ } ) - ).toBeVisible(); - - // Add second reaction. - await blockNoteUtils.addReactionToComment( 'Rocket' ); - await expect( - page.locator( '.editor-collab-sidebar-panel__reaction-button', { - hasText: '🚀', - } ) - ).toBeVisible(); - - // Both reactions remain visible together. - await expect( - page.getByRole( 'button', { name: /Smile/ } ) - ).toBeVisible(); - } ); - - test( 'opens the full emoji picker directly from the add-reaction button', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing full emoji picker' }, - comment: 'Open the full picker', - } ); - - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - - await blockNoteUtils.waitForFullPicker(); - - await expect( - page.getByPlaceholder( 'Search emoji' ) - ).toBeVisible(); - } ); - test( 'a full-picker pick that matches a curated emoji stores as the curated slug', async ( { page, blockNoteUtils, @@ -1796,31 +1518,6 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toContainText( '👍' ); } ); - test( 'pressing Escape closes the full-picker popover', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Escape closes picker' }, - comment: 'Close picker with Escape', - } ); - - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await blockNoteUtils.waitForFullPicker(); - - await page.keyboard.press( 'Escape' ); - - await expect( - page.getByPlaceholder( 'Search emoji' ) - ).toBeHidden(); - // Focus returns to the trigger rather than dropping to the - // document body. - await expect( - page.getByRole( 'button', { name: 'Add reaction' } ) - ).toBeFocused(); - } ); - test( 'Escape in the skin-tone flyout closes only that popup', async ( { page, blockNoteUtils, @@ -1865,42 +1562,6 @@ test.describe( 'Block Notes', () => { ).toBeFocused(); } ); - test( 'full picker shows the empty state when search has no matches', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Search empty state' }, - comment: 'Empty search state', - } ); - - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await blockNoteUtils.waitForFullPicker(); - - const popover = page.locator( - '.editor-collab-sidebar-panel__picker-popover' - ); - const { width: openWidth } = await popover.boundingBox(); - - // A query no Emojibase label/tag matches. - await page - .getByPlaceholder( 'Search emoji' ) - .fill( 'zzzzzznoresults' ); - - await expect( page.getByText( 'No emoji found.' ) ).toBeVisible(); - await expect( - page.getByRole( 'grid' ).getByRole( 'gridcell' ) - ).toHaveCount( 0 ); - - // The popover keeps its width rather than collapsing around - // the empty grid. - const { width: emptyWidth } = await popover.boundingBox(); - expect( Math.abs( emptyWidth - openWidth ) ).toBeLessThanOrEqual( - 1 - ); - } ); - test( 'full picker shows a Frequently used section that learns from picks', async ( { page, blockNoteUtils, @@ -2027,35 +1688,6 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toContainText( '👍🏿' ); } ); - test( 'reaction picker portals outside the collab sidebar', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing popover portal' }, - comment: 'Popover portal', - } ); - - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - - const popover = page.locator( - '.editor-collab-sidebar-panel__picker-popover' - ); - await expect( popover ).toBeVisible(); - - // The popover must portal out of the sidebar; otherwise the - // `overflow: hidden` chain on `.editor-collab-sidebar-panel` - // (and the framework `.interface-interface-skeleton__sidebar`) - // would clip the picker. Pin the contract by asserting the - // popover has no sidebar-panel ancestor. - await expect( popover ).toHaveCount( 1 ); - const isPortaled = await popover.evaluate( - ( el ) => ! el.closest( '.editor-collab-sidebar-panel' ) - ); - expect( isPortaled ).toBe( true ); - } ); - test( 'note remains selected while reaction picker is open', async ( { page, blockNoteUtils, @@ -2080,84 +1712,6 @@ test.describe( 'Block Notes', () => { await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); } ); - test( 'full-picker popover wraps tightly to the picker width', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing tight popover layout' }, - comment: 'Tight layout', - } ); - - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await blockNoteUtils.waitForFullPicker(); - - const popover = page.locator( - '.editor-collab-sidebar-panel__picker-popover' - ); - const picker = page.locator( - '.editor-collab-sidebar-panel__picker' - ); - - const popoverBox = await popover.boundingBox(); - const pickerBox = await picker.boundingBox(); - - // Popover wrapper must wrap tightly to the picker. If the - // popover is wider, the surface background renders as a - // visible band beside the picker. - expect( - Math.abs( popoverBox.width - pickerBox.width ) - ).toBeLessThanOrEqual( 1 ); - } ); - - test( 'emoji grid fills the picker width', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing grid fills picker' }, - comment: 'Grid fill', - } ); - - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); - await blockNoteUtils.waitForFullPicker(); - - const picker = page.locator( - '.editor-collab-sidebar-panel__picker' - ); - // Measure a full 6-emoji row: the first row on screen is the - // "Frequently used" section, which may hold fewer emoji and - // legitimately end short of the right edge. - const fullRowEmojis = page - .locator( '.editor-collab-sidebar-panel__picker-row' ) - .filter( { - has: page.locator( - '.editor-collab-sidebar-panel__picker-emoji:nth-child(6)' - ), - } ) - .first() - .locator( '.editor-collab-sidebar-panel__picker-emoji' ); - - const pickerBox = await picker.boundingBox(); - const firstBox = await fullRowEmojis.first().boundingBox(); - const lastBox = await fullRowEmojis.last().boundingBox(); - const leftSlack = firstBox.x - pickerBox.x; - const rightSlack = - pickerBox.x + pickerBox.width - ( lastBox.x + lastBox.width ); - - // The grid sits centered: 16px padding on each side, plus an - // equal scrollbar gutter on both edges where scrollbars take - // space. Catches the picker being sized wider than the grid - // (a band of empty space on the right) or the grid - // overflowing under the scrollbar. - expect( Math.abs( leftSlack - rightSlack ) ).toBeLessThanOrEqual( - 1 - ); - expect( rightSlack ).toBeLessThanOrEqual( 16 + 17 ); - } ); - test.describe( 'Filtered emoji list', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.activatePlugin( @@ -2171,44 +1725,6 @@ test.describe( 'Block Notes', () => { ); } ); - test( 'seeds Frequently used with emojis added via the gutenberg_note_reaction_emojis filter', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing filtered emojis' }, - comment: 'Filtered emojis', - } ); - - await page - .getByRole( 'button', { name: 'Add reaction' } ) - .click(); - await blockNoteUtils.waitForFullPicker(); - - // The "Frequently used" section is seeded from the - // filtered curated list: the 5 defaults plus the 20 - // filter-added entries. - const frequentGroup = page - .getByRole( 'rowgroup' ) - .filter( { hasText: 'Frequently used' } ); - await expect( - frequentGroup.getByRole( 'gridcell' ) - ).toHaveCount( 25 ); - await expect( - frequentGroup.getByRole( 'gridcell', { - name: 'Heart', - exact: true, - } ) - ).toBeVisible(); - await expect( - frequentGroup.getByRole( 'gridcell', { - name: 'Thumbs up', - exact: true, - } ) - ).toBeVisible(); - } ); - test( 'can react with a filter-added emoji', async ( { page, blockNoteUtils, @@ -2231,50 +1747,6 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toContainText( '🦄' ); await expect( reactionButton ).toContainText( '1' ); } ); - - test( 'picker stays usable with many seeded emojis', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing large emoji set' }, - comment: 'Large emoji set', - } ); - - await page - .getByRole( 'button', { name: 'Add reaction' } ) - .click(); - await blockNoteUtils.waitForFullPicker(); - - // The picker keeps its fixed 8-column footprint no matter - // how many entries the filter seeds, so the popover stays - // within the viewport. - const picker = page.locator( - '.editor-collab-sidebar-panel__picker' - ); - const viewport = page.viewportSize(); - const box = await picker.boundingBox(); - expect( box.width ).toBeLessThan( viewport.width / 2 ); - expect( box.x ).toBeGreaterThanOrEqual( 0 ); - expect( box.x + box.width ).toBeLessThanOrEqual( - viewport.width - ); - - // The last filter-added entry is reachable (scrolls into - // view if needed) and selectable. - const lastOption = page - .getByRole( 'gridcell', { name: 'Trophy', exact: true } ) - .first(); - await lastOption.scrollIntoViewIfNeeded(); - await lastOption.click(); - - const reactionButton = page.getByRole( 'button', { - name: /Trophy/, - } ); - await expect( reactionButton ).toBeVisible(); - await expect( reactionButton ).toContainText( '🏆' ); - } ); } ); } ); From 80507d4ca1d0373fbb750725c5e968a6fddc4294 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 10:35:00 -0700 Subject: [PATCH 331/362] Notes: Prune emoji reaction PHP tests Drop tests that repeat a code path another test already pins, and fold the hexcode conversion cases into a single data provider. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- phpunit/emoji-picker-data-test.php | 114 ++++-------------- ...est-comments-controller-gutenberg-test.php | 103 ---------------- 2 files changed, 23 insertions(+), 194 deletions(-) diff --git a/phpunit/emoji-picker-data-test.php b/phpunit/emoji-picker-data-test.php index 1df4a387bc706a..46f992f7838c5f 100644 --- a/phpunit/emoji-picker-data-test.php +++ b/phpunit/emoji-picker-data-test.php @@ -11,86 +11,35 @@ class Emoji_Picker_Data_Test extends WP_UnitTestCase { /** - * @covers ::gutenberg_emoji_to_hexcode - */ - public function test_gutenberg_emoji_to_hexcode_basic_codepoint() { - $this->assertSame( '1F600', gutenberg_emoji_to_hexcode( '😀' ) ); - $this->assertSame( '1F389', gutenberg_emoji_to_hexcode( '🎉' ) ); - $this->assertSame( '1F680', gutenberg_emoji_to_hexcode( '🚀' ) ); - } - - /** - * Variation Selector-16 (U+FE0F) is stripped so qualified - * presentations match Emojibase's unqualified `hexcode` keys. + * @dataProvider data_gutenberg_emoji_to_hexcode * * @covers ::gutenberg_emoji_to_hexcode - */ - public function test_gutenberg_emoji_to_hexcode_strips_vs16() { - $this->assertSame( '2764', gutenberg_emoji_to_hexcode( "\u{2764}\u{FE0F}" ) ); - $this->assertSame( '2764', gutenberg_emoji_to_hexcode( '❤️' ) ); - } - - /** - * ZWJ sequences round-trip with a `-`-joined uppercase hex string, - * matching Emojibase's representation for combined emoji. * - * @covers ::gutenberg_emoji_to_hexcode + * @param mixed $input Value to convert. + * @param string $expected Expected Emojibase `hexcode` key. */ - public function test_gutenberg_emoji_to_hexcode_zwj_sequence() { - // 👨‍💻 = U+1F468 + U+200D + U+1F4BB - $this->assertSame( - '1F468-200D-1F4BB', - gutenberg_emoji_to_hexcode( '👨‍💻' ) - ); - // 0️⃣ = U+0030 + U+FE0F + U+20E3, padded to Emojibase's `0030-20E3`. - $this->assertSame( - '0030-20E3', - gutenberg_emoji_to_hexcode( '0️⃣' ) - ); + public function test_gutenberg_emoji_to_hexcode( $input, $expected ) { + $this->assertSame( $expected, gutenberg_emoji_to_hexcode( $input ) ); } - /** - * @covers ::gutenberg_emoji_to_hexcode - */ - public function test_gutenberg_emoji_to_hexcode_handles_invalid_input() { - $this->assertSame( '', gutenberg_emoji_to_hexcode( '' ) ); - $this->assertSame( '', gutenberg_emoji_to_hexcode( null ) ); - $this->assertSame( '', gutenberg_emoji_to_hexcode( 42 ) ); - } - - /** - * Malformed UTF-8 must return an empty string rather than emitting a - * garbage hex key. The decoder is byte-level and does not depend on - * the `mbstring` extension. - * - * @covers ::gutenberg_emoji_to_hexcode - */ - public function test_gutenberg_emoji_to_hexcode_rejects_invalid_utf8() { - // Lone continuation byte. - $this->assertSame( '', gutenberg_emoji_to_hexcode( "\x80" ) ); - // Lead byte with missing continuation bytes. - $this->assertSame( '', gutenberg_emoji_to_hexcode( "\xF0\x9F" ) ); - // Truncated 4-byte sequence followed by ASCII. - $this->assertSame( '', gutenberg_emoji_to_hexcode( "\xF0\x9F\x98a" ) ); - // Invalid lead byte (0xF8 starts a 5-byte form, never valid UTF-8). - $this->assertSame( '', gutenberg_emoji_to_hexcode( "\xF8\x88\x80\x80\x80" ) ); - } - - /** - * Code points across the 1-4 byte UTF-8 ranges decode correctly, each - * zero-padded to Emojibase's four-digit `hexcode` width. - * - * @covers ::gutenberg_emoji_to_hexcode - */ - public function test_gutenberg_emoji_to_hexcode_covers_all_byte_lengths() { - // 1 byte: U+0023 (#), part of keycap sequences. - $this->assertSame( '0023', gutenberg_emoji_to_hexcode( '#' ) ); - // 2 bytes: U+00A9 (©). - $this->assertSame( '00A9', gutenberg_emoji_to_hexcode( "\u{00A9}" ) ); - // 3 bytes: U+2764 (❤). - $this->assertSame( '2764', gutenberg_emoji_to_hexcode( "\u{2764}" ) ); - // 4 bytes: U+1F600 (😀) — asserted in the basic test as well. - $this->assertSame( '1F600', gutenberg_emoji_to_hexcode( "\u{1F600}" ) ); + public function data_gutenberg_emoji_to_hexcode() { + return array( + // One code point per UTF-8 byte length, zero-padded to four digits. + '1 byte' => array( '#', '0023' ), + '2 bytes' => array( "\u{00A9}", '00A9' ), + '3 bytes' => array( "\u{2764}", '2764' ), + '4 bytes' => array( "\u{1F600}", '1F600' ), + // VS16 is stripped to match Emojibase's unqualified keys. + 'VS16 stripped' => array( "\u{2764}\u{FE0F}", '2764' ), + 'ZWJ sequence' => array( "\u{1F468}\u{200D}\u{1F4BB}", '1F468-200D-1F4BB' ), + 'keycap sequence' => array( "\u{0030}\u{FE0F}\u{20E3}", '0030-20E3' ), + 'empty string' => array( '', '' ), + 'non-string' => array( 42, '' ), + // Malformed UTF-8 never yields a garbage key. + 'lone continuation byte' => array( "\x80", '' ), + 'truncated sequence' => array( "\xF0\x9F\x98a", '' ), + 'invalid lead byte' => array( "\xF8\x88\x80\x80\x80", '' ), + ); } /** @@ -128,23 +77,6 @@ public function test_filter_can_extend_overrides() { remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); } - /** - * @covers ::gutenberg_get_emoji_picker_label_overrides - */ - public function test_filter_can_replace_overrides_entirely() { - $callback = static function () { - return array(); - }; - add_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - - $this->assertSame( - array(), - gutenberg_get_emoji_picker_label_overrides() - ); - - remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - } - /** * @covers ::gutenberg_get_emoji_picker_label_overrides */ diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 678a90c0a30192..795f70eccd598c 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -908,28 +908,6 @@ function ( $comment ) { $this->assertSame( (int) $hearts[0]->comment_ID, $response->get_data()['id'] ); } - public function test_can_create_different_reactions_on_same_note() { - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create(); - $note_id = $this->create_note( $post_id, self::$editor_id ); - - $emojis = array( 'heart', 'rocket', 'smile' ); - foreach ( $emojis as $emoji ) { - $params = array( - 'post' => $post_id, - 'type' => 'reaction', - 'parent' => $note_id, - 'content' => $emoji, - 'author' => self::$editor_id, - ); - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 201, $response->get_status() ); - } - } - /** * @dataProvider data_valid_reaction_emojis */ @@ -1126,26 +1104,6 @@ public function test_reaction_emoji_rules_reach_editor_settings() { ); } - public function test_schema_includes_reaction_summary() { - $controller = new Gutenberg_REST_Comment_Controller_7_2(); - $schema = $controller->get_item_schema(); - - $this->assertArrayHasKey( 'reaction_summary', $schema['properties'] ); - - $reaction_summary_schema = $schema['properties']['reaction_summary']; - $this->assertTrue( $reaction_summary_schema['readonly'] ); - $this->assertSame( 'object', $reaction_summary_schema['type'] ); - $this->assertContains( 'view', $reaction_summary_schema['context'] ); - $this->assertContains( 'edit', $reaction_summary_schema['context'] ); - - // Verify additionalProperties structure. - $this->assertArrayHasKey( 'additionalProperties', $reaction_summary_schema ); - $additional = $reaction_summary_schema['additionalProperties']; - $this->assertArrayHasKey( 'count', $additional['properties'] ); - $this->assertArrayHasKey( 'reacted', $additional['properties'] ); - $this->assertArrayHasKey( 'my_reaction_id', $additional['properties'] ); - } - public function test_note_response_includes_reaction_summary() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); @@ -1209,20 +1167,6 @@ public function test_reaction_summary_shows_not_reacted_for_other_user() { $this->assertSame( 0, $data['reaction_summary']['heart']['my_reaction_id'] ); } - public function test_reaction_summary_empty_when_no_reactions() { - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create(); - $note_id = $this->create_note( $post_id, self::$editor_id ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); - $request->set_param( 'context', 'edit' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertArrayHasKey( 'reaction_summary', $data ); - $this->assertEmpty( $data['reaction_summary'] ); - } - /** * The batch prefetch must cover the notes on the requested page, not the * first page, or every note falls back to its own summary queries. @@ -1345,53 +1289,6 @@ public function test_cannot_update_reaction() { $this->assertSame( 'heart', get_comment( $reaction_id )->comment_content ); } - /** - * The reactor's identity must not be reassignable through the update route. - */ - public function test_cannot_update_reaction_author() { - $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); - $note_id = $this->create_note( $post_id, self::$editor_id ); - $reaction_id = $this->create_reaction( $post_id, $note_id, self::$author_id ); - - wp_set_current_user( self::$editor_id ); - $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array( 'author' => self::$editor_id ) ) ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); - $this->assertSame( (string) self::$author_id, get_comment( $reaction_id )->user_id ); - } - - /** - * A reaction must not be movable onto a note on a post the user cannot edit. - */ - public function test_cannot_move_reaction_to_note_on_other_post() { - $editable_post = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); - $other_post = self::factory()->post->create( array( 'post_author' => self::$admin_id ) ); - $note_id = $this->create_note( $editable_post, self::$editor_id ); - $other_note_id = $this->create_note( $other_post, self::$admin_id ); - $reaction_id = $this->create_reaction( $editable_post, $note_id, self::$editor_id ); - - wp_set_current_user( self::$editor_id ); - $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( - wp_json_encode( - array( - 'parent' => $other_note_id, - 'post' => $other_post, - ) - ) - ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); - $reaction = get_comment( $reaction_id ); - $this->assertSame( (string) $note_id, $reaction->comment_parent ); - $this->assertSame( (string) $editable_post, $reaction->comment_post_ID ); - } - /** * Notes remain editable - only reactions are locked down. */ From c60df22726132b94f67f56ac555503fa22a516fe Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 12:22:16 -0700 Subject: [PATCH 332/362] Notes: Drive the emoji picker entirely through Autocomplete Drop the curated `Composite` row that stood in for the picker when the Emojibase URL was missing or the dataset failed to load, along with the lazy chunk, error boundary, and retry path around it. The grid now takes the named reaction set as its dataset in those cases, so one Autocomplete grid handles every state and the search field keeps focus. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014UeFrtihY1D6EHEGdSkNHA --- .../plugins/note-emojibase-unavailable.php | 4 +- .../collab-sidebar/add-reaction-picker.tsx | 267 ++---------------- .../collab-sidebar/emoji-picker.tsx | 72 +++-- .../collab-sidebar/emojibase-data.ts | 12 +- .../collab-sidebar/reaction-emoji-picker.tsx | 53 +--- .../src/components/collab-sidebar/style.scss | 53 ---- .../specs/editor/various/block-notes.spec.js | 28 +- 7 files changed, 99 insertions(+), 390 deletions(-) diff --git a/packages/e2e-tests/plugins/note-emojibase-unavailable.php b/packages/e2e-tests/plugins/note-emojibase-unavailable.php index 787f008e69eb8e..3fe8e7480e0f15 100644 --- a/packages/e2e-tests/plugins/note-emojibase-unavailable.php +++ b/packages/e2e-tests/plugins/note-emojibase-unavailable.php @@ -8,8 +8,8 @@ */ /** - * Drops the Emojibase dataset URL so the editor falls back to the curated - * quick row. Runs late to win over `gutenberg_add_emojibase_settings()`. + * Drops the Emojibase dataset URL so the picker offers only the named + * reaction set. Runs late to win over `gutenberg_add_emojibase_settings()`. * * @param array $settings Existing block editor settings. * @return array Updated block editor settings. diff --git a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx index 44b0801aa674a5..88824e4b0d2816 100644 --- a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx @@ -1,6 +1,5 @@ -import type { ReactNode } from 'react'; import { __ } from '@wordpress/i18n'; -import { Button, Dropdown } from '@wordpress/components'; +import { Dropdown } from '@wordpress/components'; /* * `IconButton` is pending Design System review (WordPress/gutenberg#76135); * used here so the trigger matches the reaction pills beside it. @@ -8,20 +7,9 @@ import { Button, Dropdown } from '@wordpress/components'; // eslint-disable-next-line @wordpress/use-recommended-components import { IconButton } from '@wordpress/ui'; import { reaction as reactionIcon } from '@wordpress/icons'; +import EmojiPicker from './emoji-picker'; import { - Component, - Suspense, - lazy, - useEffect, - useMemo, - useRef, - useState, -} from '@wordpress/element'; -import { speak } from '@wordpress/a11y'; -import ReactionEmojiPicker, { - emojiToHexKey, emojiToStorageKey, - buildEmojiBySlugMap, useReactionEmojiRules, useReactionEmojis, } from './reaction-emoji-picker'; @@ -30,132 +18,14 @@ import { loadEmojibaseData, useEmojibaseConfig, } from './emojibase-data'; -import { useFrequentEmojis } from './frequent-emojis'; import { invalidateReactionNames } from './reaction-display'; -// Shared by the lazy component and the prefetch, so both hit one request. -function loadEmojiPicker() { - return import( './emoji-picker' ); -} - -/** - * Lazy-load the full picker so its module body runs on first hover or open - * rather than on every editor load. - * - * Whether a *download* is also deferred depends on the bundler: npm - * consumers who code-split get a chunk, while the plugin build bundles - * `wpScript` packages as one IIFE that esbuild cannot split. - */ -const FullEmojiPicker = lazy( loadEmojiPicker ); - -/** - * Warm the picker before it opens. Both loaders cache, so repeat calls on - * every hover are free. - * - * @param baseUrl Same-origin URL of the Emojibase dataset directory. - */ -function prefetchFullPicker( baseUrl: string | null ): void { - loadEmojiPicker().catch( () => {} ); - if ( baseUrl ) { - loadEmojibaseData( baseUrl, detectLocale() ).catch( () => {} ); - } -} - /* * `Dropdown`'s popover renders through `Popover.Slot` or a ``-level * container, so either way it escapes the sidebar's `overflow: hidden`. */ const POPOVER_PROPS = { placement: 'bottom-end' } as const; -/** - * Loading state for the lazy picker chunk. Announced via the - * `@wordpress/a11y` announcer, whose live regions already exist: one - * mounted together with its content is not reliably announced. - */ -function PickerLoading() { - useEffect( () => { - speak( __( 'Loading…' ) ); - }, [] ); - return ( -
- { /* The viewport reserves the same scrollbar gutter as the grid. */ } -
-
- { __( 'Loading…' ) } -
-
-
- ); -} - -interface PickerErrorBoundaryProps { - onError: () => void; - children: ReactNode; -} - -/** - * Catches render-time failures from the lazy picker, including a rejected - * chunk import that `Suspense` does not handle, so the parent can swap in - * the curated fallback. - */ -class PickerErrorBoundary extends Component< PickerErrorBoundaryProps > { - state = { hasError: false }; - - static getDerivedStateFromError() { - return { hasError: true }; - } - - componentDidCatch() { - this.props.onError(); - } - - render() { - return this.state.hasError ? null : this.props.children; - } -} - -interface PickerFallbackProps { - onSelect: ( slug: string ) => void; - onRetry: () => void; -} - -/** - * The curated quick row with a retry path, shown when the full picker fails. - * - * @param props Component props. - * @param props.onSelect Called with the chosen curated slug. - * @param props.onRetry Called to retry loading the full picker. - */ -function PickerFallback( { onSelect, onRetry }: PickerFallbackProps ) { - const ref = useRef< HTMLDivElement >( null ); - - /* - * Replacing the focused search field drops focus to the body, where - * Escape no longer closes the popover, so move it to a basic reaction. - */ - useEffect( () => { - const { activeElement, body } = ref.current?.ownerDocument ?? {}; - if ( ! activeElement || activeElement === body ) { - ref.current?.querySelector< HTMLElement >( 'button' )?.focus(); - } - }, [] ); - - return ( -
- -
- { __( 'Couldn’t load the full emoji picker.' ) } - -
-
- ); -} - interface AddReactionButtonProps { noteId: number; disabled?: boolean; @@ -163,12 +33,8 @@ interface AddReactionButtonProps { } /** - * Standalone add-reaction button, opening the full searchable picker with - * its "Frequently used" section seeded from the curated set. - * - * The full picker needs the `noteEmojibaseUrl` editor setting. Without it, - * or when the module or dataset fails to load, the curated quick row is - * offered instead so adding a reaction keeps working. + * Standalone add-reaction button, opening the searchable emoji picker + * with its "Frequently used" section seeded from the named set. * * @param props Component props. * @param props.noteId The parent note comment ID. @@ -181,46 +47,19 @@ export function AddReactionButton( { disabled = false, onToggleReaction, }: AddReactionButtonProps ) { - const { recordUse } = useFrequentEmojis(); const emojis = useReactionEmojis(); const rules = useReactionEmojiRules(); - const emojiBySlug = useMemo( - () => buildEmojiBySlugMap( emojis ), - [ emojis ] - ); const { baseUrl } = useEmojibaseConfig(); - const hasFullPicker = !! baseUrl; - const [ pickerFailed, setPickerFailed ] = useState( false ); - /* - * A rejected `lazy()` memoizes its failure, so a retry needs a fresh - * component; the underlying module request stays cached. - */ - const [ LazyPicker, setLazyPicker ] = useState( () => FullEmojiPicker ); - const [ retryKey, setRetryKey ] = useState( 0 ); - - useEffect( () => { - if ( pickerFailed ) { - speak( - __( - 'The full emoji picker couldn’t be loaded. Basic reactions are available.' - ), - 'assertive' - ); - } - }, [ pickerFailed ] ); - - const retryFullPicker = () => { - setLazyPicker( () => lazy( loadEmojiPicker ) ); - // Remount the error boundary to clear a caught chunk failure. - setRetryKey( ( key ) => key + 1 ); - setPickerFailed( false ); - }; - const showFullPicker = hasFullPicker && ! pickerFailed; + // Warm the dataset before the popover opens; the loader caches, so + // repeat calls on every hover are free. + const prefetchDataset = baseUrl + ? () => loadEmojibaseData( baseUrl, detectLocale() ).catch( () => {} ) + : undefined; - // With an emptied list and no full picker, or a full picker limited to + // With an emptied named list and no dataset, or a dataset limited to // that list, there is nothing to pick. - if ( ! emojis.length && ( ! hasFullPicker || ! rules.allowUnlisted ) ) { + if ( ! emojis.length && ( ! baseUrl || ! rules.allowUnlisted ) ) { return null; } @@ -237,11 +76,7 @@ export function AddReactionButton( { role: 'dialog', 'aria-label': __( 'Add reaction' ), } } - contentClassName={ - showFullPicker - ? 'editor-collab-sidebar-panel__picker-popover' - : 'editor-collab-sidebar-panel__add-reaction-popover' - } + contentClassName="editor-collab-sidebar-panel__picker-popover" renderToggle={ ( { isOpen, onToggle } ) => ( prefetchFullPicker( baseUrl ) - : undefined - } - onFocus={ - hasFullPicker - ? () => prefetchFullPicker( baseUrl ) - : undefined - } + onMouseEnter={ prefetchDataset } + onFocus={ prefetchDataset } + /> + ) } + renderContent={ ( { onClose } ) => ( + { + onClose(); + // Adding a reaction changes the slug's reactor list. + const slug = emojiToStorageKey( emoji, emojis ); + invalidateReactionNames( noteId, slug ); + onToggleReaction( slug ); + } } /> ) } - renderContent={ ( { onClose } ) => { - const pickReaction = ( slug: string ) => { - onClose(); - // Adding a reaction changes the slug's reactor list. - invalidateReactionNames( noteId, slug ); - onToggleReaction( slug ); - }; - const pickCurated = ( slug: string ) => { - /* - * Still count picks, so "Frequently used" is warm if - * the site later serves an Emojibase URL. - */ - recordUse( - emojiToHexKey( emojiBySlug.get( slug )?.emoji ?? '' ) - ); - pickReaction( slug ); - }; - - if ( ! hasFullPicker ) { - return ; - } - - if ( pickerFailed ) { - return ( - - ); - } - - return ( - setPickerFailed( true ) } - > - }> - - /* - * Match the filtered list, not the - * defaults, so a filter-provided - * emoji stores under the same slug. - */ - pickReaction( - emojiToStorageKey( emoji, emojis ) - ) - } - onError={ () => setPickerFailed( true ) } - /> - - - ); - } } /> ); } diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx index 0b29e85f8645d7..3e13e1d940a7bc 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/emoji-picker.tsx @@ -22,11 +22,13 @@ import { import type { EmojibaseEntry } from './emojibase-data'; import { useFrequentEmojis } from './frequent-emojis'; import { + emojiToHexKey, getNamedHexKeys, isReactionEmojiAllowed, useReactionEmojiRules, useReactionEmojis, } from './reaction-emoji-picker'; +import type { CuratedEmoji } from './reaction-emoji-picker'; import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; /** @@ -61,7 +63,6 @@ interface EmojiOptionGroup { interface EmojiPickerProps { onSelect: ( emoji: string ) => void; - onError?: () => void; } /** @@ -205,16 +206,33 @@ export function searchEmojis( } /** - * Full searchable emoji picker. Emoji data and labels come from the - * per-locale Emojibase files at `noteEmojibaseUrl`; UI chrome strings go - * through `@wordpress/i18n`. + * Shape the named reaction list like Emojibase records, so the grid can + * offer it through the same cells and search when no dataset is loaded. + * + * @param emojis The named emoji list. + * @return Equivalent Emojibase-style records. + */ +export function namedEmojisToEntries( + emojis: CuratedEmoji[] +): EmojibaseEntry[] { + return emojis.map( ( { emoji, label } ) => ( { + hexcode: emojiToHexKey( emoji ), + emoji, + label, + } ) ); +} + +/** + * Searchable emoji picker. Emoji data and labels come from the per-locale + * Emojibase files at `noteEmojibaseUrl`; UI chrome strings go through + * `@wordpress/i18n`. Without a dataset (no URL configured, or the fetch + * failed) the grid offers the named reaction set instead, so reacting + * keeps working. * * @param props Component props. * @param props.onSelect Called with the selected emoji character. - * @param props.onError Called when the Emojibase dataset fails to load, - * so the parent can swap in a fallback picker. */ -export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { +export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { const { baseUrl, labelOverrides } = useEmojibaseConfig(); const [ locale ] = useState( detectLocale ); const { @@ -224,10 +242,11 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { } = useEmojibaseData( baseUrl, locale ); const namedEmojis = useReactionEmojis(); const rules = useReactionEmojiRules(); + const hasDataset = !! dataset; // Offer only what the REST API accepts under the site's emoji rules. const data = useMemo( () => { if ( ! dataset ) { - return dataset; + return namedEmojisToEntries( namedEmojis ); } const namedKeys = getNamedHexKeys( namedEmojis ); return dataset.filter( ( entry ) => @@ -275,14 +294,14 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { }, [] ); const groups = useMemo( - () => ( data ? groupEmojis( data ) : [] ), - [ data ] + () => ( hasDataset ? groupEmojis( data ) : [] ), + [ data, hasDataset ] ); // Resolves stored frequently-used hex keys back to full records. const recordByHexKey = useMemo( () => { const map = new Map< string, EmojibaseEntry >(); - for ( const entry of data || [] ) { + for ( const entry of data ) { if ( typeof entry.group === 'number' ) { map.set( normalizeHexcode( entry.hexcode ), entry ); } @@ -291,6 +310,8 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { }, [ data ] ); const isSearching = !! query.trim(); + // The named set is a handful of emoji, so it needs no category headings. + const isFlat = isSearching || ! hasDataset; /* * The items handed to `Autocomplete.Root`: category groups while @@ -319,6 +340,12 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { }; }; + if ( ! hasDataset ) { + return searchEmojis( data, query, labelOverrides ).map( ( entry ) => + toOption( entry, 'named' ) + ); + } + if ( isSearching ) { /* * One flat grid of results, as in the macOS picker: @@ -354,6 +381,8 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { } ) ), ].filter( ( group ) => group.items.length > 0 ); }, [ + data, + hasDataset, groups, isSearching, query, @@ -395,22 +424,13 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) { ) ); - // The parent swaps in the curated picker so reacting keeps working. - useEffect( () => { - if ( error ) { - onError?.(); - } - }, [ error, onError ] ); - - if ( ! baseUrl ) { - return null; - } - let status: ReactNode = null; if ( isLoading ) { status = __( 'Loading…' ); } else if ( error ) { - status = __( 'Couldn’t load emojis.' ); + status = __( + 'Couldn’t load the full emoji picker. Basic reactions are available.' + ); } else if ( hasJustLoaded && ! isSearching && groups.length > 0 ) { // An empty dataset is left to `Autocomplete.Empty`. const emojiCount = groups.reduce( @@ -497,16 +517,16 @@ export default function EmojiPicker( { onSelect, onError }: EmojiPickerProps ) {
{ status } - { isLoading || error ? null : __( 'No emoji found.' ) } + { isLoading ? null : __( 'No emoji found.' ) } - { isSearching + { isFlat ? renderRows( items as EmojiOption[] ) : ( group: EmojiOptionGroup ) => ( | null; } @@ -223,7 +224,8 @@ export function useEmojibaseData( const cached = dataCache.get( `${ baseUrl }|${ locale }` ); return { data: cached?.data || null, - isLoading: ! cached, + // Nothing loads without a URL, so never report it as pending. + isLoading: !! baseUrl && ! cached, error: null, }; } ); diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx index 5b6ee92b11cc3c..e6a29fea6037c3 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx @@ -1,5 +1,4 @@ -import { __, _x } from '@wordpress/i18n'; -import { Button, Composite } from '@wordpress/components'; +import { _x } from '@wordpress/i18n'; import { useSelect } from '@wordpress/data'; import { useMemo } from '@wordpress/element'; // @ts-expect-error - No type declarations available for @wordpress/block-editor @@ -14,10 +13,6 @@ export interface CuratedEmoji { value: string; } -interface ReactionEmojiPickerProps { - onSelect: ( slug: string ) => void; -} - /** * Curated emoji set for reactions. * The `value` slug is used as the storage key in the database to avoid @@ -292,49 +287,3 @@ export function buildEmojiBySlugMap( ): Map< string, CuratedEmoji > { return new Map( emojis.map( ( entry ) => [ entry.value, entry ] ) ); } - -/** - * A row of curated emoji buttons: the fallback picker used when no - * Emojibase URL is configured. - * - * @param props Component props. - * @param props.onSelect Called with the chosen slug when the user picks a - * curated emoji. - */ -export default function ReactionEmojiPicker( { - onSelect, -}: ReactionEmojiPickerProps ) { - const emojis = useReactionEmojis(); - - return ( - - { emojis.map( ( { emoji, label, value } ) => ( - onSelect( value ) } - aria-label={ label } - className="editor-collab-sidebar-panel__emoji-option" - /> - } - > - { emoji } - - ) ) } - - ); -} diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 5ab645bc0ea371..8e228132ea04ae 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -373,59 +373,6 @@ mark.wp-note { line-height: 1; } -// Split the Dropdown's default 8px `.components-popover__content` padding -// between the popover and the scrolling emoji group below, so the emoji buttons -// keep the same breathing room while their focus ring stays inside the -// scroll container instead of being clipped by it. -.editor-collab-sidebar-panel__add-reaction-popover .components-popover__content { - padding: $grid-unit-05; -} - - -.editor-collab-sidebar-panel__emoji-picker { - display: flex; - // The emoji list is filterable server-side, so it can grow well - // beyond the 5 defaults. Wrap into rows of 8 (32px compact - // buttons) and scroll past 6 rows instead of letting the popover - // grow unbounded. - flex-wrap: wrap; - // `.components-popover__content` is `width: min-content`, which would - // otherwise squeeze this wrapping row down to one column per emoji. - width: max-content; - // Scrolling clips on both axes, so leave room for the focus ring of - // the buttons on the edges. The padding is added to the row and column - // caps below, which are border-box measurements. - padding: $grid-unit-05; - max-width: 8 * 32px + 2 * $grid-unit-05; - max-height: 6 * 32px + 2 * $grid-unit-05; - overflow-y: auto; -} - -// 32px emoji targets: compact density matching peer pickers and the -// editor's compact button size. Clears the WCAG 2.2 AA 24px minimum -// (2.5.8) but not the AAA 44px target (2.5.5), at which an 8-column grid -// would outgrow the sidebar-anchored popover. -.editor-collab-sidebar-panel__emoji-option { - // Pin the track to 32px so the `max-width` above lands exactly 8 emoji - // per row; the compact Button's own padding and minimum size would - // otherwise widen the flex item past the 32px width. - flex: 0 0 32px; - min-width: 0; - padding: 0; - font-size: 18px; - justify-content: center; -} - -// Curated fallback shown when the full picker's module or dataset fails. -.editor-collab-sidebar-panel__picker-fallback-note { - display: flex; - align-items: center; - gap: var(--wpds-dimension-gap-xs); - padding-top: var(--wpds-dimension-padding-xs); - color: var(--wpds-color-foreground-content-neutral-weak); - font-size: var(--wpds-typography-font-size-xs); -} - // Width comes from the picker below, so the wrapper fits it via Popover's // default `width: min-content`; sizing the wrapper instead leaves slack. // diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 85f5fd60559772..4ac85e3dda4cae 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1383,26 +1383,34 @@ test.describe( 'Block Notes', () => { ); } ); - test( 'a curated pick from the fallback row still adds a reaction', async ( { + test( 'the picker offers the named set and a pick still adds a reaction', async ( { page, blockNoteUtils, } ) => { await blockNoteUtils.addBlockWithNote( { type: 'core/paragraph', - attributes: { content: 'Fallback pick' }, - comment: 'Test comment for fallback pick', + attributes: { content: 'Named pick' }, + comment: 'Test comment for named pick', } ); await page .getByRole( 'button', { name: 'Add reaction' } ) .click(); - const emojiPicker = page.locator( - '.editor-collab-sidebar-panel__emoji-picker' - ); - await expect( emojiPicker ).toBeVisible(); - await emojiPicker - .getByRole( 'button', { name: /Heart/i } ) - .click(); + await blockNoteUtils.waitForFullPicker(); + + // Only the named set, with no category headings. + const grid = page.getByRole( 'grid' ); + await expect( grid.getByRole( 'gridcell' ) ).toHaveCount( 5 ); + await expect( + page.locator( + '.editor-collab-sidebar-panel__picker-category' + ) + ).toHaveCount( 0 ); + + // Search still filters the named set. + await page.getByPlaceholder( 'Search emoji' ).fill( 'heart' ); + await expect( grid.getByRole( 'gridcell' ) ).toHaveCount( 1 ); + await grid.getByRole( 'gridcell', { name: 'Heart' } ).click(); await expect( page.getByRole( 'button', { name: /Heart/ } ) From f053bf0d35e0f4512fd1ad0d196ac9500f63bb84 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 12:30:57 -0700 Subject: [PATCH 333/362] Notes: Group reactions under a subdirectory with a useReaction hook Move the reaction modules into `collab-sidebar/reactions/` and lift the toggle logic out of `useNoteActions` into `useReaction( note )`, which reads `reaction_summary` from the note record it is given. That removes the `reactionsMap` and `onToggleReaction` props threaded from the sidebar root down to each note, and lets the hook own the reactor-name cache invalidation instead of each trigger repeating it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014UeFrtihY1D6EHEGdSkNHA --- .../src/components/collab-sidebar/README.md | 10 + .../src/components/collab-sidebar/hooks.js | 191 +-------------- .../src/components/collab-sidebar/index.jsx | 9 +- .../components/collab-sidebar/note-thread.jsx | 8 - .../src/components/collab-sidebar/note.jsx | 26 +- .../src/components/collab-sidebar/notes.jsx | 13 +- .../add-reaction-button.tsx} | 11 +- .../{ => reactions}/emoji-picker.tsx | 4 +- .../{ => reactions}/emojibase-data.ts | 0 .../{ => reactions}/frequent-emojis.ts | 2 +- .../{ => reactions}/reaction-display.tsx | 5 +- .../reaction-emojis.ts} | 0 .../{ => reactions}/skin-tone-picker.tsx | 0 .../collab-sidebar/reactions/use-reaction.ts | 222 ++++++++++++++++++ 14 files changed, 252 insertions(+), 249 deletions(-) rename packages/editor/src/components/collab-sidebar/{add-reaction-picker.tsx => reactions/add-reaction-button.tsx} (89%) rename packages/editor/src/components/collab-sidebar/{ => reactions}/emoji-picker.tsx (99%) rename packages/editor/src/components/collab-sidebar/{ => reactions}/emojibase-data.ts (100%) rename packages/editor/src/components/collab-sidebar/{ => reactions}/frequent-emojis.ts (99%) rename packages/editor/src/components/collab-sidebar/{ => reactions}/reaction-display.tsx (98%) rename packages/editor/src/components/collab-sidebar/{reaction-emoji-picker.tsx => reactions/reaction-emojis.ts} (100%) rename packages/editor/src/components/collab-sidebar/{ => reactions}/skin-tone-picker.tsx (100%) create mode 100644 packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts diff --git a/packages/editor/src/components/collab-sidebar/README.md b/packages/editor/src/components/collab-sidebar/README.md index 50795bab669425..d92a8f6c028925 100644 --- a/packages/editor/src/components/collab-sidebar/README.md +++ b/packages/editor/src/components/collab-sidebar/README.md @@ -24,6 +24,16 @@ collab-sidebar/ ├── note-indicator-toolbar.jsx NoteAvatarIndicator - toolbar participants avatars ├── floating-container.jsx FloatingContainer - stack wrapper that applies `top` in floating mode │ +├── reactions/ emoji reactions on a note +│ ├── use-reaction.ts useReaction( note ) - the note's `reaction_summary` + toggleReaction +│ ├── reaction-display.tsx ReactionDisplay - reaction pills with reactor-name tooltips +│ ├── add-reaction-button.tsx AddReactionButton - the picker's popover trigger +│ ├── emoji-picker.tsx EmojiPicker - Autocomplete grid over the Emojibase dataset +│ ├── skin-tone-picker.tsx SkinTonePicker - default skin tone preference flyout +│ ├── emojibase-data.ts Emojibase dataset loading, labels, and settings +│ ├── frequent-emojis.ts useFrequentEmojis - persisted "Frequently used" section +│ └── reaction-emojis.ts named reaction set, storage keys, and the site's emoji rules +│ ├── hooks.js useNoteThreads, useNoteActions, useNoteSelection, useFloatingBoard, useEnableFloatingSidebar ├── utils.js focusNoteThread, getNoteExcerpt, sanitizeNoteContent, calculateNotePositions, getAvatarBorderColor ├── board-store.js createBoardStore - DOM measurement for the floating layout diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index c3529a046996f5..8dc653223f4cd0 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -1,7 +1,6 @@ import { speak } from '@wordpress/a11y'; import { __ } from '@wordpress/i18n'; import { - useCallback, useState, useEffect, useLayoutEffect, @@ -17,8 +16,6 @@ import { privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { store as noticesStore } from '@wordpress/notices'; -import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; import { decodeEntities } from '@wordpress/html-entities'; import { store as interfaceStore } from '@wordpress/interface'; import { store as editorStore } from '../../store'; @@ -57,22 +54,6 @@ export function useNoteThreads( postId ) { { enabled: !! postId && typeof postId === 'number' } ); - // Build reactionsMap from the reaction_summary field on each note. - // Shape: { [noteId]: { [emojiSlug]: { count, reacted, my_reaction_id } } } - const reactionsMap = useMemo( () => { - if ( ! threads || threads.length === 0 ) { - return {}; - } - - const map = {}; - threads.forEach( ( thread ) => { - if ( thread.reaction_summary ) { - map[ thread.id ] = thread.reaction_summary; - } - } ); - return map; - }, [ threads ] ); - const { getBlockAttributes } = useSelect( blockEditorStore ); const { clientIds } = useSelect( ( select ) => { const { getClientIdsWithDescendants } = select( blockEditorStore ); @@ -189,63 +170,12 @@ export function useNoteThreads( postId ) { return { notes, unresolvedNotes, - reactionsMap, }; } -/** - * Folds a completed reaction toggle into a cached note record. - * - * Used to keep `reaction_summary` usable when the refetch that would - * normally replace it fails: without it, the next toggle reads a stale - * `reacted` / `my_reaction_id` pair and takes the wrong branch. - * - * @param {Object} note The cached note record. - * @param {string} emoji The reaction storage slug that changed. - * @param {number} [addedReactionId] The new reaction's comment ID when one was - * added; omitted when one was removed. - * @return {Object} The note with an updated `reaction_summary`. - */ -function applyReactionDelta( note, emoji, addedReactionId ) { - const summary = { ...( note.reaction_summary || {} ) }; - const entry = summary[ emoji ]; - - if ( addedReactionId ) { - // Concurrent adds converge server-side on one surviving row, so a - // repeated ID is already counted. - if ( entry?.my_reaction_id === addedReactionId ) { - return note; - } - summary[ emoji ] = { - count: ( entry?.count || 0 ) + 1, - reacted: true, - my_reaction_id: addedReactionId, - }; - } else if ( entry ) { - const count = entry.count - 1; - if ( count > 0 ) { - summary[ emoji ] = { count, reacted: false }; - } else { - delete summary[ emoji ]; - } - } - - return { ...note, reaction_summary: summary }; -} - -/* - * Per-note count of landed reaction mutations. Each toggle refetches the - * whole `reaction_summary`, so a refresh issued before a later mutation - * landed would overwrite that mutation's result; the counter lets it tell. - * Module-level so every `useNoteActions` instance shares it. - */ -const reactionMutationCounts = new Map(); - -export function useNoteActions( reactionsMap = {} ) { +export function useNoteActions() { const { createNotice } = useDispatch( noticesStore ); - const { saveEntityRecord, deleteEntityRecord, receiveEntityRecords } = - useDispatch( coreStore ); - const { getEntityRecord } = useSelect( coreStore ); + const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); const { getCurrentPostId } = useSelect( editorStore ); const { getBlockAttributes, @@ -479,127 +409,10 @@ export function useNoteActions( reactionsMap = {} ) { } }; - const onToggleReaction = useCallback( - async ( { commentId, emoji } ) => { - // Check if the user already reacted via reaction_summary. - const noteReactions = reactionsMap[ commentId ] || {}; - const emojiData = noteReactions[ emoji ]; - const isRemoving = !! ( - emojiData?.reacted && emojiData?.my_reaction_id - ); - let addedReactionId; - - try { - if ( isRemoving ) { - // Force-delete the reaction comment rather than - // trashing it (the WP REST default). Reactions - // don't have a trash workflow, and a trashed - // reaction would otherwise linger in `wp_comments` - // indefinitely each time the user toggles it off. - await deleteEntityRecord( - 'root', - 'comment', - emojiData.my_reaction_id, - { force: true }, - { throwOnError: true } - ); - } else { - // Add a new reaction as a comment record. - const saved = await saveEntityRecord( - 'root', - 'comment', - { - post: getCurrentPostId(), - type: 'reaction', - parent: commentId, - content: emoji, - status: 'approve', - }, - { throwOnError: true } - ); - addedReactionId = saved?.id; - } - } catch ( error ) { - onError( error ); - return; - } - - // `reaction_summary` is computed server-side and cached on the - // parent note's entity record. Mutating a reaction comment - // doesn't invalidate that field, so a subsequent toggle would - // read stale `reacted` / `my_reaction_id` data and route into - // the wrong branch (deleting an already-removed comment). - // - // The mutation has landed, so fold its known effect into the - // cached record first. That keeps the next toggle correct even - // if the refetch below never succeeds. - const mutationCount = - ( reactionMutationCounts.get( commentId ) ?? 0 ) + 1; - reactionMutationCounts.set( commentId, mutationCount ); - - const cached = getEntityRecord( 'root', 'comment', commentId ); - if ( cached ) { - receiveEntityRecords( 'root', 'comment', [ - applyReactionDelta( - cached, - emoji, - isRemoving ? undefined : addedReactionId - ), - ] ); - } - - // Then refetch the parent note (1 record) for the authoritative - // summary, which also picks up other users' reactions. - // `receiveEntityRecords` with no `query` arg updates the - // per-record cache, which the list selector reads through by ID - // — so the LIST view picks up the fresh `reaction_summary` - // without re-fetching every other note on the post. Only the - // summary is requested and merged, so the view-context response - // can't replace the cached edit-context fields like - // `content.raw`, which seeds the edit form. - try { - const refreshed = await apiFetch( { - path: addQueryArgs( `/wp/v2/comments/${ commentId }`, { - _fields: 'id,reaction_summary', - } ), - } ); - // A newer mutation landed after this snapshot was requested; - // its own refresh will carry the authoritative summary. - if ( - reactionMutationCounts.get( commentId ) !== mutationCount - ) { - return; - } - const latest = getEntityRecord( 'root', 'comment', commentId ); - if ( latest ) { - receiveEntityRecords( 'root', 'comment', [ - { - ...latest, - reaction_summary: refreshed.reaction_summary, - }, - ] ); - } - } catch { - // The toggle itself succeeded and the local delta above - // already keeps this note's reactions consistent, so there - // is nothing to report; the next load reconciles the rest. - } - }, - [ - reactionsMap, - deleteEntityRecord, - saveEntityRecord, - getCurrentPostId, - getEntityRecord, - receiveEntityRecords, - ] - ); - return { onCreate, onEdit, onDelete, - onToggleReaction, }; } diff --git a/packages/editor/src/components/collab-sidebar/index.jsx b/packages/editor/src/components/collab-sidebar/index.jsx index 4aba3426c59da2..009bd0dd23a23a 100644 --- a/packages/editor/src/components/collab-sidebar/index.jsx +++ b/packages/editor/src/components/collab-sidebar/index.jsx @@ -64,7 +64,7 @@ function NotesSidebar( { postId } ) { [] ); - const { notes, unresolvedNotes, reactionsMap } = useNoteThreads( postId ); + const { notes, unresolvedNotes } = useNoteThreads( postId ); // Only enable the floating sidebar for large viewports. const showFloatingSidebar = isLargeViewport; @@ -193,11 +193,7 @@ function NotesSidebar( { postId } ) { icon={ commentIcon } closeLabel={ __( 'Close Notes' ) } > - + ) } { isLargeViewport && ( @@ -213,7 +209,6 @@ function NotesSidebar( { postId } ) { notes={ unresolvedNotes } sidebarRef={ sidebarRef } styles={ { backgroundColor } } - reactionsMap={ reactionsMap } isFloating /> diff --git a/packages/editor/src/components/collab-sidebar/note-thread.jsx b/packages/editor/src/components/collab-sidebar/note-thread.jsx index 37a0ad99deae5d..78f5bf55da8cf1 100644 --- a/packages/editor/src/components/collab-sidebar/note-thread.jsx +++ b/packages/editor/src/components/collab-sidebar/note-thread.jsx @@ -33,8 +33,6 @@ export function NoteThread( { onEditNote, onAddReply, onDeleteNote, - onToggleReaction, - reactionsMap, isSelected, sidebarRef, floating, @@ -245,8 +243,6 @@ export function NoteThread( { onEditNote={ onEditNote } onDeleteNote={ onDeleteNote } onResolve={ handleResolve } - onToggleReaction={ onToggleReaction } - reactions={ reactionsMap?.[ note.id ] } isThreadResolved={ isThreadResolved } /> { isSelected && @@ -258,8 +254,6 @@ export function NoteThread( { isSelected={ isSelected } onEditNote={ onEditNote } onDeleteNote={ onDeleteNote } - onToggleReaction={ onToggleReaction } - reactions={ reactionsMap?.[ reply.id ] } isThreadResolved={ isThreadResolved } /> ) ) } @@ -298,8 +292,6 @@ export function NoteThread( { isSelected={ false } onEditNote={ onEditNote } onDeleteNote={ onDeleteNote } - onToggleReaction={ onToggleReaction } - reactions={ reactionsMap?.[ lastReply.id ] } isThreadResolved={ isThreadResolved } /> ) } diff --git a/packages/editor/src/components/collab-sidebar/note.jsx b/packages/editor/src/components/collab-sidebar/note.jsx index 8b4804dd51c4b6..0d5af646bac19a 100644 --- a/packages/editor/src/components/collab-sidebar/note.jsx +++ b/packages/editor/src/components/collab-sidebar/note.jsx @@ -11,8 +11,9 @@ import { __, _x, sprintf } from '@wordpress/i18n'; import { moreVertical, published } from '@wordpress/icons'; import { NoteCard } from './note-card'; import { NoteForm } from './note-form'; -import ReactionDisplay, { getReactedSlugs } from './reaction-display'; -import { AddReactionButton } from './add-reaction-picker'; +import ReactionDisplay, { getReactedSlugs } from './reactions/reaction-display'; +import { AddReactionButton } from './reactions/add-reaction-button'; +import { useReaction } from './reactions/use-reaction'; function NoteActionsMenu( { items, buttonRef } ) { return ( @@ -53,10 +54,9 @@ export function Note( { onEditNote, onDeleteNote, onResolve, - onToggleReaction, - reactions, isThreadResolved = false, } ) { + const { reactions, toggleReaction } = useReaction( note ); const [ actionState, setActionState ] = useState( null ); const actionButtonRef = useRef( null ); @@ -86,8 +86,7 @@ export function Note( { const hasReactions = getReactedSlugs( reactions ).length > 0; // Not while editing: the trigger floats over the note's corner, which // during an edit is the form's own text field. - const canReact = - isSelected && !! onToggleReaction && actionState !== 'edit'; + const canReact = isSelected && actionState !== 'edit'; const metaStatus = note.meta?._wp_note_status; const isResolutionNote = metaStatus === 'resolved' || metaStatus === 'reopen'; @@ -253,12 +252,7 @@ export function Note( { noteId={ note.id } reactions={ reactions } disabled={ isThreadResolved } - onToggleReaction={ ( emoji ) => - onToggleReaction?.( { - commentId: note.id, - emoji, - } ) - } + onToggleReaction={ toggleReaction } > { /* * Trails the pills, in the same tree position @@ -269,14 +263,8 @@ export function Note( { */ } { canReact && ( - onToggleReaction( { - commentId: note.id, - emoji, - } ) - } + onToggleReaction={ toggleReaction } /> ) } diff --git a/packages/editor/src/components/collab-sidebar/notes.jsx b/packages/editor/src/components/collab-sidebar/notes.jsx index 609315f444cd4c..8ef6aefbb000e7 100644 --- a/packages/editor/src/components/collab-sidebar/notes.jsx +++ b/packages/editor/src/components/collab-sidebar/notes.jsx @@ -15,19 +15,12 @@ import { store as editorStore } from '../../store'; const { useBlockElement } = unlock( blockEditorPrivateApis ); -export function Notes( { - notes, - sidebarRef, - isFloating = false, - styles, - reactionsMap, -} ) { +export function Notes( { notes, sidebarRef, isFloating = false, styles } ) { const { onCreate: onAddReply, onEdit: onEditNote, onDelete, - onToggleReaction, - } = useNoteActions( reactionsMap ); + } = useNoteActions(); const { selectNote } = unlock( useDispatch( editorStore ) ); const { selectBlock, toggleBlockSpotlight } = unlock( useDispatch( blockEditorStore ) @@ -253,8 +246,6 @@ export function Notes( { onAddReply={ onAddReply } onDeleteNote={ handleDelete } onEditNote={ onEditNote } - onToggleReaction={ onToggleReaction } - reactionsMap={ reactionsMap } isSelected={ selectedNote === thread.id } sidebarRef={ sidebarRef } floating={ diff --git a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx similarity index 89% rename from packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx rename to packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx index 88824e4b0d2816..91407b638175f8 100644 --- a/packages/editor/src/components/collab-sidebar/add-reaction-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx @@ -12,13 +12,12 @@ import { emojiToStorageKey, useReactionEmojiRules, useReactionEmojis, -} from './reaction-emoji-picker'; +} from './reaction-emojis'; import { detectLocale, loadEmojibaseData, useEmojibaseConfig, } from './emojibase-data'; -import { invalidateReactionNames } from './reaction-display'; /* * `Dropdown`'s popover renders through `Popover.Slot` or a ``-level @@ -27,7 +26,6 @@ import { invalidateReactionNames } from './reaction-display'; const POPOVER_PROPS = { placement: 'bottom-end' } as const; interface AddReactionButtonProps { - noteId: number; disabled?: boolean; onToggleReaction: ( slug: string ) => void; } @@ -37,13 +35,11 @@ interface AddReactionButtonProps { * with its "Frequently used" section seeded from the named set. * * @param props Component props. - * @param props.noteId The parent note comment ID. * @param props.disabled Whether the button is disabled (e.g. on a * resolved note thread). * @param props.onToggleReaction Callback to toggle a reaction. */ export function AddReactionButton( { - noteId, disabled = false, onToggleReaction, }: AddReactionButtonProps ) { @@ -98,10 +94,7 @@ export function AddReactionButton( { { onClose(); - // Adding a reaction changes the slug's reactor list. - const slug = emojiToStorageKey( emoji, emojis ); - invalidateReactionNames( noteId, slug ); - onToggleReaction( slug ); + onToggleReaction( emojiToStorageKey( emoji, emojis ) ); } } /> ) } diff --git a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx similarity index 99% rename from packages/editor/src/components/collab-sidebar/emoji-picker.tsx rename to packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx index 3e13e1d940a7bc..4d5f377ffa8567 100644 --- a/packages/editor/src/components/collab-sidebar/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -27,8 +27,8 @@ import { isReactionEmojiAllowed, useReactionEmojiRules, useReactionEmojis, -} from './reaction-emoji-picker'; -import type { CuratedEmoji } from './reaction-emoji-picker'; +} from './reaction-emojis'; +import type { CuratedEmoji } from './reaction-emojis'; import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; /** diff --git a/packages/editor/src/components/collab-sidebar/emojibase-data.ts b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts similarity index 100% rename from packages/editor/src/components/collab-sidebar/emojibase-data.ts rename to packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts diff --git a/packages/editor/src/components/collab-sidebar/frequent-emojis.ts b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts similarity index 99% rename from packages/editor/src/components/collab-sidebar/frequent-emojis.ts rename to packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts index cf97a6df11165b..192925d0d93869 100644 --- a/packages/editor/src/components/collab-sidebar/frequent-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts @@ -5,7 +5,7 @@ import { REACTION_EMOJIS, emojiToHexKey, useReactionEmojis, -} from './reaction-emoji-picker'; +} from './reaction-emojis'; /** * A recorded frequently-used emoji: its normalized hex key and how many diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx similarity index 98% rename from packages/editor/src/components/collab-sidebar/reaction-display.tsx rename to packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx index 0c0359e8604ce3..3ce15d5666b6db 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx @@ -13,7 +13,7 @@ import { hexKeyToEmoji, buildEmojiBySlugMap, useReactionEmojis, -} from './reaction-emoji-picker'; +} from './reaction-emojis'; import { useEmojiLabel } from './emojibase-data'; interface ReactionSummaryEntry { @@ -26,7 +26,7 @@ interface ReactionSummaryEntry { /** * The reaction summary keyed by storage slug (curated slug or hex key). */ -type ReactionSummary = Record< string, ReactionSummaryEntry >; +export type ReactionSummary = Record< string, ReactionSummaryEntry >; /** * A comment record as returned by the reactions REST query. @@ -342,7 +342,6 @@ function ReactionButton( { ) ?.focus(); } - invalidateReactionNames( noteId, slug ); setNames( null ); onToggleReaction( slug ); } } diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts similarity index 100% rename from packages/editor/src/components/collab-sidebar/reaction-emoji-picker.tsx rename to packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts diff --git a/packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx similarity index 100% rename from packages/editor/src/components/collab-sidebar/skin-tone-picker.tsx rename to packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx diff --git a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts new file mode 100644 index 00000000000000..c68b595500a4e4 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts @@ -0,0 +1,222 @@ +import { __ } from '@wordpress/i18n'; +import { useCallback } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { decodeEntities } from '@wordpress/html-entities'; +import { store as editorStore } from '../../../store'; +import { invalidateReactionNames } from './reaction-display'; +import type { ReactionSummary } from './reaction-display'; + +/** + * The parts of a note comment record that reactions read. + */ +export interface ReactionTarget { + id: number; + reaction_summary?: ReactionSummary | null; +} + +/** + * Folds a completed reaction toggle into a cached note record. + * + * Used to keep `reaction_summary` usable when the refetch that would + * normally replace it fails: without it, the next toggle reads a stale + * `reacted` / `my_reaction_id` pair and takes the wrong branch. + * + * @param note The cached note record. + * @param slug The reaction storage slug that changed. + * @param addedReactionId The new reaction's comment ID when one was added; + * omitted when one was removed. + * @return The note with an updated `reaction_summary`. + */ +export function applyReactionDelta< T extends ReactionTarget >( + note: T, + slug: string, + addedReactionId?: number +): T { + const summary: ReactionSummary = { ...( note.reaction_summary || {} ) }; + const entry = summary[ slug ]; + + if ( addedReactionId ) { + // Concurrent adds converge server-side on one surviving row, so a + // repeated ID is already counted. + if ( entry?.my_reaction_id === addedReactionId ) { + return note; + } + summary[ slug ] = { + count: ( entry?.count || 0 ) + 1, + reacted: true, + my_reaction_id: addedReactionId, + }; + } else if ( entry ) { + const count = entry.count - 1; + if ( count > 0 ) { + summary[ slug ] = { count, reacted: false }; + } else { + delete summary[ slug ]; + } + } + + return { ...note, reaction_summary: summary }; +} + +/* + * Per-note count of landed reaction mutations. Each toggle refetches the + * whole `reaction_summary`, so a refresh issued before a later mutation + * landed would overwrite that mutation's result; the counter lets it tell. + * Module-level so every `useReaction` instance shares it. + */ +const reactionMutationCounts = new Map< number, number >(); + +/** + * A note's reactions and a callback to toggle one. + * + * `reaction_summary` is computed server-side and cached on the note's + * entity record, so the summary the note carries is the source of truth + * for whether the current user has already reacted with a slug. + * + * @param note The note comment record. + * @return The note's reaction summary and the toggle callback. + */ +export function useReaction( note: ReactionTarget ) { + const { id: noteId, reaction_summary: reactions } = note; + const { createNotice } = useDispatch( noticesStore ); + const { saveEntityRecord, deleteEntityRecord, receiveEntityRecords } = + useDispatch( coreStore ); + const { getEntityRecord } = useSelect( coreStore ); + const { getCurrentPostId } = useSelect( editorStore ); + + const toggleReaction = useCallback( + async ( slug: string ) => { + const entry = reactions?.[ slug ]; + const myReactionId = entry?.reacted + ? entry.my_reaction_id + : undefined; + const isRemoving = !! myReactionId; + let addedReactionId: number | undefined; + + try { + if ( myReactionId ) { + // Force-delete the reaction comment rather than + // trashing it (the WP REST default). Reactions + // don't have a trash workflow, and a trashed + // reaction would otherwise linger in `wp_comments` + // indefinitely each time the user toggles it off. + await deleteEntityRecord( + 'root', + 'comment', + myReactionId, + { force: true }, + { throwOnError: true } + ); + } else { + // Add a new reaction as a comment record. + const saved = await saveEntityRecord( + 'root', + 'comment', + { + post: getCurrentPostId(), + type: 'reaction', + parent: noteId, + content: slug, + status: 'approve', + }, + { throwOnError: true } + ); + addedReactionId = saved?.id; + } + } catch ( error ) { + const { message, code } = ( error ?? {} ) as { + message?: string; + code?: string; + }; + createNotice( + 'error', + message && code !== 'unknown_error' + ? decodeEntities( message ) + : __( 'An error occurred while performing an update.' ), + { type: 'snackbar', isDismissible: true } + ); + return; + } + + // The slug's reactor list changed, so the pill tooltip refetches. + invalidateReactionNames( noteId, slug ); + + // Mutating a reaction comment doesn't invalidate the cached + // `reaction_summary`, so a subsequent toggle would read stale + // `reacted` / `my_reaction_id` data and route into the wrong + // branch (deleting an already-removed comment). + // + // The mutation has landed, so fold its known effect into the + // cached record first. That keeps the next toggle correct even + // if the refetch below never succeeds. + const mutationCount = + ( reactionMutationCounts.get( noteId ) ?? 0 ) + 1; + reactionMutationCounts.set( noteId, mutationCount ); + + const cached = getEntityRecord( 'root', 'comment', noteId ) as + ReactionTarget | undefined; + if ( cached ) { + receiveEntityRecords( 'root', 'comment', [ + applyReactionDelta( + cached, + slug, + isRemoving ? undefined : addedReactionId + ), + ] ); + } + + // Then refetch the parent note (1 record) for the authoritative + // summary, which also picks up other users' reactions. + // `receiveEntityRecords` with no `query` arg updates the + // per-record cache, which the list selector reads through by ID + // - so the LIST view picks up the fresh `reaction_summary` + // without re-fetching every other note on the post. Only the + // summary is requested and merged, so the view-context response + // can't replace the cached edit-context fields like + // `content.raw`, which seeds the edit form. + try { + const refreshed = await apiFetch< { + reaction_summary: ReactionSummary; + } >( { + path: addQueryArgs( `/wp/v2/comments/${ noteId }`, { + _fields: 'id,reaction_summary', + } ), + } ); + // A newer mutation landed after this snapshot was requested; + // its own refresh will carry the authoritative summary. + if ( reactionMutationCounts.get( noteId ) !== mutationCount ) { + return; + } + const latest = getEntityRecord( 'root', 'comment', noteId ); + if ( latest ) { + receiveEntityRecords( 'root', 'comment', [ + { + ...latest, + reaction_summary: refreshed.reaction_summary, + }, + ] ); + } + } catch { + // The toggle itself succeeded and the local delta above + // already keeps this note's reactions consistent, so there + // is nothing to report; the next load reconciles the rest. + } + }, + [ + noteId, + reactions, + createNotice, + deleteEntityRecord, + saveEntityRecord, + getCurrentPostId, + getEntityRecord, + receiveEntityRecords, + ] + ); + + return { reactions, toggleReaction }; +} From beea59426427b30511f906d01874e80684d1a793 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 15:45:03 -0700 Subject: [PATCH 334/362] UI: Add Autocomplete.Trigger Base UI's Autocomplete has a Trigger part for pickers that put the search input inside the popup and open it from a button, as in its grid layout example. Wrap it so editor code can build that pattern without reaching into @base-ui/react directly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8d57dBsBFSa51D1Dk46vU --- packages/ui/CHANGELOG.md | 1 + .../ui/src/form/primitives/autocomplete/index.ts | 1 + .../autocomplete/stories/index.story.tsx | 1 + .../src/form/primitives/autocomplete/trigger.tsx | 15 +++++++++++++++ .../ui/src/form/primitives/autocomplete/types.ts | 4 ++++ 5 files changed, 22 insertions(+) create mode 100644 packages/ui/src/form/primitives/autocomplete/trigger.tsx diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 1119827fb45771..5e8ea3dd2f807e 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -11,6 +11,7 @@ ### New Features +- `Autocomplete`: Add a `Trigger` subcomponent that opens the popup from a button, for pickers that put the input inside the popup ([#83538](https://github.com/WordPress/gutenberg/pull/83538)). - `Autocomplete`, `Combobox`, `Select`, `SearchableSelect`, `SearchableChipSelect`, and the select controls: Add `Separator` subcomponents for dividing popup items and groups ([#83776](https://github.com/WordPress/gutenberg/pull/83776)). - `Progress`: Add a compound progress component with a track, indicator, accessible label, and formatted value. ([#83781](https://github.com/WordPress/gutenberg/pull/83781)) - Add `SearchableSelectControl` component ([#80979](https://github.com/WordPress/gutenberg/pull/80979)). diff --git a/packages/ui/src/form/primitives/autocomplete/index.ts b/packages/ui/src/form/primitives/autocomplete/index.ts index 30cf49e502e066..163d9b7122cd73 100644 --- a/packages/ui/src/form/primitives/autocomplete/index.ts +++ b/packages/ui/src/form/primitives/autocomplete/index.ts @@ -17,5 +17,6 @@ export { Root } from './root'; export { Row } from './row'; export { Separator } from './separator'; export { Status } from './status'; +export { Trigger } from './trigger'; export { useFilteredItems } from './use-filtered-items'; export { Value } from './value'; diff --git a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx index 89628d18ba412c..f177fde384b036 100644 --- a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx @@ -43,6 +43,7 @@ const meta: Meta< typeof Autocomplete.Root > = { 'Autocomplete.Value': Autocomplete.Value, 'Autocomplete.Empty': Autocomplete.Empty, 'Autocomplete.Status': Autocomplete.Status, + 'Autocomplete.Trigger': Autocomplete.Trigger, 'Autocomplete.Clear': Autocomplete.Clear, }, argTypes: { diff --git a/packages/ui/src/form/primitives/autocomplete/trigger.tsx b/packages/ui/src/form/primitives/autocomplete/trigger.tsx new file mode 100644 index 00000000000000..d6dd73c3d3671e --- /dev/null +++ b/packages/ui/src/form/primitives/autocomplete/trigger.tsx @@ -0,0 +1,15 @@ +import { Autocomplete as _Autocomplete } from '@base-ui/react/autocomplete'; +import { forwardRef } from '@wordpress/element'; +import type { AutocompleteTriggerProps } from './types'; + +/** + * A button that opens the popup. Use it when `Autocomplete.Input` lives + * inside `Autocomplete.Popup`, such as a picker opened from a toolbar + * button. The button is unstyled, so pass a `render` element for its look. + */ +export const Trigger = forwardRef< + HTMLButtonElement, + AutocompleteTriggerProps +>( function UnforwardedTrigger( props, ref ) { + return <_Autocomplete.Trigger ref={ ref } { ...props } />; +} ); diff --git a/packages/ui/src/form/primitives/autocomplete/types.ts b/packages/ui/src/form/primitives/autocomplete/types.ts index 15b08b1a6eaebc..bb1d3f86733cf4 100644 --- a/packages/ui/src/form/primitives/autocomplete/types.ts +++ b/packages/ui/src/form/primitives/autocomplete/types.ts @@ -132,4 +132,8 @@ export type AutocompleteStatusProps = ComponentProps< children?: React.ReactNode; }; +export type AutocompleteTriggerProps = ComponentProps< + typeof _Autocomplete.Trigger +>; + export type AutocompleteValueProps = _Autocomplete.Value.Props; From 0b79c1c35505789897862d60be05a973a6e0a4c7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 15:47:52 -0700 Subject: [PATCH 335/362] Notes: Open the emoji picker from Autocomplete.Trigger Build the add-reaction picker the way Base UI's grid example does: the Autocomplete owns its trigger, portal, and popup, with the search field inside the popup. The Dropdown wrapper, its hand-set dialog role, and the manual focus-on-mount pass go away; the popup now names itself, focuses the search field on open, and returns focus to the trigger. Every note renders a trigger, so the Emojibase fetch waits until one is hovered, focused, or opened. With the input inside the popup, Base UI exposes the trigger as a combobox with a dialog popup, so the e2e locators follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8d57dBsBFSa51D1Dk46vU --- .../reactions/add-reaction-button.tsx | 56 ++------ .../collab-sidebar/reactions/emoji-picker.tsx | 125 ++++++++++++------ .../src/components/collab-sidebar/style.scss | 42 ++---- .../specs/editor/various/block-notes-utils.js | 4 +- .../specs/editor/various/block-notes.spec.js | 38 ++++-- 5 files changed, 130 insertions(+), 135 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx index 91407b638175f8..fd8ab721187b1c 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx @@ -1,5 +1,4 @@ import { __ } from '@wordpress/i18n'; -import { Dropdown } from '@wordpress/components'; /* * `IconButton` is pending Design System review (WordPress/gutenberg#76135); * used here so the trigger matches the reaction pills beside it. @@ -13,17 +12,7 @@ import { useReactionEmojiRules, useReactionEmojis, } from './reaction-emojis'; -import { - detectLocale, - loadEmojibaseData, - useEmojibaseConfig, -} from './emojibase-data'; - -/* - * `Dropdown`'s popover renders through `Popover.Slot` or a ``-level - * container, so either way it escapes the sidebar's `overflow: hidden`. - */ -const POPOVER_PROPS = { placement: 'bottom-end' } as const; +import { useEmojibaseConfig } from './emojibase-data'; interface AddReactionButtonProps { disabled?: boolean; @@ -47,12 +36,6 @@ export function AddReactionButton( { const rules = useReactionEmojiRules(); const { baseUrl } = useEmojibaseConfig(); - // Warm the dataset before the popover opens; the loader caches, so - // repeat calls on every hover are free. - const prefetchDataset = baseUrl - ? () => loadEmojibaseData( baseUrl, detectLocale() ).catch( () => {} ) - : undefined; - // With an emptied named list and no dataset, or a dataset limited to // that list, there is nothing to pick. if ( ! emojis.length && ( ! baseUrl || ! rules.allowUnlisted ) ) { @@ -60,20 +43,9 @@ export function AddReactionButton( { } return ( - ( + - ) } - renderContent={ ( { onClose } ) => ( - { - onClose(); - onToggleReaction( emojiToStorageKey( emoji, emojis ) ); - } } /> - ) } + } + disabled={ disabled } + onSelect={ ( emoji ) => + onToggleReaction( emojiToStorageKey( emoji, emojis ) ) + } /> ); } diff --git a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx index 4d5f377ffa8567..e94be9db9f66e6 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import clsx from 'clsx'; import { __, _n, _x, sprintf } from '@wordpress/i18n'; import { @@ -62,6 +62,19 @@ interface EmojiOptionGroup { } interface EmojiPickerProps { + /** + * The button that opens the picker, passed to `Autocomplete.Trigger` + * as its `render` element. + */ + trigger: ReactElement; + /** + * Whether the trigger is disabled. + */ + disabled?: boolean; + /** + * Accessible name for the picker popup. + */ + label: string; onSelect: ( emoji: string ) => void; } @@ -223,23 +236,40 @@ export function namedEmojisToEntries( } /** - * Searchable emoji picker. Emoji data and labels come from the per-locale - * Emojibase files at `noteEmojibaseUrl`; UI chrome strings go through - * `@wordpress/i18n`. Without a dataset (no URL configured, or the fetch - * failed) the grid offers the named reaction set instead, so reacting - * keeps working. + * Searchable emoji picker: a trigger button opening an autocomplete popup + * with the search field on top of the emoji grid. Emoji data and labels + * come from the per-locale Emojibase files at `noteEmojibaseUrl`; UI chrome + * strings go through `@wordpress/i18n`. Without a dataset (no URL + * configured, or the fetch failed) the grid offers the named reaction set + * instead, so reacting keeps working. * * @param props Component props. + * @param props.trigger The button that opens the picker. + * @param props.disabled Whether the trigger is disabled. + * @param props.label Accessible name for the popup. * @param props.onSelect Called with the selected emoji character. */ -export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { +export default function EmojiPicker( { + trigger, + disabled = false, + label, + onSelect, +}: EmojiPickerProps ) { + const [ isOpen, setIsOpen ] = useState( false ); + /* + * Every note renders a trigger, so hold the dataset fetch until the + * user reaches for one: hovering or focusing it warms the data before + * the popup opens. + */ + const [ isWarm, setIsWarm ] = useState( false ); + const warm = () => setIsWarm( true ); const { baseUrl, labelOverrides } = useEmojibaseConfig(); const [ locale ] = useState( detectLocale ); const { data: dataset, isLoading, error, - } = useEmojibaseData( baseUrl, locale ); + } = useEmojibaseData( isWarm ? baseUrl : null, locale ); const namedEmojis = useReactionEmojis(); const rules = useReactionEmojiRules(); const hasDataset = !! dataset; @@ -258,7 +288,6 @@ export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { ); }, [ dataset, namedEmojis, rules ] ); const [ query, setQuery ] = useState( '' ); - const searchRef = useRef< HTMLInputElement >( null ); /* * Announce once when a pending load fills the grid. A cached dataset @@ -285,14 +314,6 @@ export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { ); const { set: setPreference } = useDispatch( preferencesStore ); - /* - * The popover runs its focus-on-mount pass before the lazy chunk and - * dataset resolve, so focus would otherwise stay on the loading state. - */ - useEffect( () => { - searchRef.current?.focus(); - }, [] ); - const groups = useMemo( () => ( hasDataset ? groupEmojis( data ) : [] ), [ data, hasDataset ] @@ -413,6 +434,7 @@ export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { // Enter on the highlighted cell clicks it too. onClick={ () => { recordUse( option.hexKey ); + setIsOpen( false ); onSelect( option.value ); } } > @@ -453,32 +475,53 @@ export default function EmojiPicker( { onSelect }: EmojiPickerProps ) { } return ( -
- { /* - * An always-open inline autocomplete: focus stays in the search - * field while the arrow keys move a highlight through the grid - * (`aria-activedescendant`), and Enter picks the highlighted - * emoji. - */ } - { - setQuery( value ); - // Searches stay quiet, so drop the load announcement. - setHasJustLoaded( false ); - } } + { + setIsOpen( nextOpen ); + if ( nextOpen ) { + warm(); + } + } } + // Start each opening from the full grid. + onOpenChangeComplete={ ( nextOpen: boolean ) => { + if ( ! nextOpen ) { + setQuery( '' ); + } + } } + items={ items } + filter={ null } + // Enter picks the top hit once the user has typed. + autoHighlight + value={ query } + onValueChange={ ( value: string, { reason } ) => { + // Picking a cell would otherwise fill the search field. + if ( reason === 'item-press' ) { + return; + } + setQuery( value ); + // Searches stay quiet, so drop the load announcement. + setHasJustLoaded( false ); + } } + > + + + } >
-
-
+ + ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 8e228132ea04ae..32d9e4bce80184 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -273,14 +273,6 @@ mark.wp-note { // radius, the neutral minimal treatment marks the trigger and reactions // left by others, and the brand outline marks the current user's own. // Only the row layout and the emoji's own metrics live here. -.editor-collab-sidebar-panel__add-reaction { - // `Dropdown` wraps its trigger in an inline-block container. As a - // flex item that becomes a block, leaving the inline-flex trigger on - // a line box whose descender space makes the wrapper taller than the - // 24px button and stretches the pills beside it to match. A flex - // wrapper has no line box, so the whole row stays at button height. - display: flex; -} // The note's text and, below it, the reaction row. Anchors the trigger while // it floats, so it lands on the note itself rather than on whatever else the @@ -321,7 +313,7 @@ mark.wp-note { // with a control it has no way to reveal. @media (hover: hover) { .editor-collab-sidebar-panel__reactions.is-floating - .editor-collab-sidebar-panel__add-reaction { + .editor-collab-sidebar-panel__add-reaction-button { // Fading rather than removing keeps the button in the tab order and // keeps the action row from reflowing as the pointer moves over it. opacity: 0; @@ -336,16 +328,16 @@ mark.wp-note { // reaction belongs to one note, so a thread-wide reveal would offer the // option on every note in it at once. .editor-collab-sidebar-panel__note:hover - .editor-collab-sidebar-panel__add-reaction, + .editor-collab-sidebar-panel__add-reaction-button, // Reveal on the trigger itself rather than on the thread: the thread is // `tabIndex={ 0 }`, so a thread-level `:focus-within` would hold for as // long as the note is selected and the fade would never take effect. .editor-collab-sidebar-panel__reactions - .editor-collab-sidebar-panel__add-reaction:focus-within, - // While the picker is open the pointer is over the popover, not the - // thread, and focus has moved into the popover's own portal. + .editor-collab-sidebar-panel__add-reaction-button:focus, + // While the picker is open the pointer is over the popup, not the + // thread, and focus has moved into the popup's own portal. .editor-collab-sidebar-panel__reactions - .editor-collab-sidebar-panel__add-reaction:has([aria-expanded="true"]) { + .editor-collab-sidebar-panel__add-reaction-button[data-popup-open] { opacity: 1; } } @@ -362,7 +354,7 @@ mark.wp-note { // Opening the picker with a pointer leaves DOM focus on the trigger once // it closes, parking a focus ring on the note at rest. Keep the ring for // keyboard focus and while the picker is open, and drop it otherwise. -.editor-collab-sidebar-panel__add-reaction-button:focus:not(:focus-visible):not([aria-expanded="true"]) { +.editor-collab-sidebar-panel__add-reaction-button:focus:not(:focus-visible):not([data-popup-open]) { outline: none; } @@ -373,19 +365,8 @@ mark.wp-note { line-height: 1; } -// Width comes from the picker below, so the wrapper fits it via Popover's -// default `width: min-content`; sizing the wrapper instead leaves slack. -// -// `contentClassName` lands on the outer `.components-popover`, while -// Dropdown's 8px padding lives on `.components-popover__content`. Zero it -// so the picker manages its own padding. Surface, radius, and elevation -// stay at the popover defaults. -.editor-collab-sidebar-panel__picker-popover { - .components-popover__content { - padding: 0; - } -} - +// The picker is the `Autocomplete.Popup` itself, which supplies the +// surface, radius, and elevation; the rules below own its layout. .editor-collab-sidebar-panel__picker { // Grid geometry from the design: six 40px cells with a 4px gap and // 16px padding on both sides. @@ -507,9 +488,8 @@ mark.wp-note { .editor-collab-sidebar-panel__picker-category { position: sticky; top: 0; - // `.components-popover` paints `$white`, not a token, and rows must - // scroll behind exactly that color. Move both together if it changes. - background-color: $white; + // Rows scroll behind it, so match the `Autocomplete.Popup` surface. + background-color: var(--wpds-color-background-surface-neutral-strong); z-index: 1; // Align the heading with the grid's first column. padding-inline: 0; diff --git a/test/e2e/specs/editor/various/block-notes-utils.js b/test/e2e/specs/editor/various/block-notes-utils.js index 667d55995c75a1..0e1c091845b019 100644 --- a/test/e2e/specs/editor/various/block-notes-utils.js +++ b/test/e2e/specs/editor/various/block-notes-utils.js @@ -152,7 +152,7 @@ class BlockNoteUtils { } async addReactionToComment( emoji ) { await this.#page - .getByRole( 'button', { name: 'Add reaction' } ) + .getByRole( 'combobox', { name: 'Add reaction' } ) .click(); await this.waitForFullPicker(); @@ -190,7 +190,7 @@ class BlockNoteUtils { */ async pickFullPickerEmojiBySearch( search ) { await this.#page - .getByRole( 'button', { name: 'Add reaction' } ) + .getByRole( 'combobox', { name: 'Add reaction' } ) .click(); await this.waitForFullPicker(); diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 4ac85e3dda4cae..6556a828a41f5e 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1280,7 +1280,7 @@ test.describe( 'Block Notes', () => { } ); // Open the emoji picker with keyboard. - const addReactionButton = page.getByRole( 'button', { + const addReactionButton = page.getByRole( 'combobox', { name: 'Add reaction', } ); await addReactionButton.focus(); @@ -1320,9 +1320,9 @@ test.describe( 'Block Notes', () => { comment: 'Test comment for the hover trigger', } ); - const trigger = page.locator( - '.editor-collab-sidebar-panel__add-reaction' - ); + const trigger = page.getByRole( 'combobox', { + name: 'Add reaction', + } ); const note = page.locator( '.editor-collab-sidebar-panel__note' ); // Park the pointer outside the sidebar: adding the note leaves it @@ -1337,7 +1337,7 @@ test.describe( 'Block Notes', () => { // the thread, which stays focused for as long as it is selected. await page.mouse.move( 0, 0 ); await expect( trigger ).toHaveCSS( 'opacity', '0' ); - await page.getByRole( 'button', { name: 'Add reaction' } ).focus(); + await trigger.focus(); await expect( trigger ).toHaveCSS( 'opacity', '1' ); } ); @@ -1365,7 +1365,7 @@ test.describe( 'Block Notes', () => { .getByRole( 'textbox', { name: 'Add title' } ) .focus(); await expect( - page.getByRole( 'button', { name: 'Add reaction' } ) + page.getByRole( 'combobox', { name: 'Add reaction' } ) ).toHaveCount( 0 ); await expect( reactionButton ).toBeVisible(); } ); @@ -1394,7 +1394,7 @@ test.describe( 'Block Notes', () => { } ); await page - .getByRole( 'button', { name: 'Add reaction' } ) + .getByRole( 'combobox', { name: 'Add reaction' } ) .click(); await blockNoteUtils.waitForFullPicker(); @@ -1449,7 +1449,7 @@ test.describe( 'Block Notes', () => { // Resolving posts a "Marked as resolved" reply that carries its // own add trigger, so the root note's is the first of the two. const addReaction = sidebar - .getByRole( 'button', { name: 'Add reaction' } ) + .getByRole( 'combobox', { name: 'Add reaction' } ) .first(); const resolveButton = sidebar.getByRole( 'button', { name: 'Resolve', @@ -1488,7 +1488,9 @@ test.describe( 'Block Notes', () => { * "Heart" is label-overridden, and the helper's regex lookup * would otherwise match "smiling face with hearts" first. */ - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); await blockNoteUtils.waitForFullPicker(); await page.getByPlaceholder( 'Search emoji' ).fill( 'Heart' ); await page @@ -1566,7 +1568,7 @@ test.describe( 'Block Notes', () => { page.getByPlaceholder( 'Search emoji' ) ).toBeHidden(); await expect( - page.getByRole( 'button', { name: 'Add reaction' } ) + page.getByRole( 'combobox', { name: 'Add reaction' } ) ).toBeFocused(); } ); @@ -1588,7 +1590,9 @@ test.describe( 'Block Notes', () => { .set( 'core', 'emojiPickerFrequentEmojis', [] ) ); - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); await blockNoteUtils.waitForFullPicker(); // Seeded with the curated set, so it has content before any picks. @@ -1625,7 +1629,9 @@ test.describe( 'Block Notes', () => { .click(); // On reopening, the pick has joined the Frequently used section. - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); await blockNoteUtils.waitForFullPicker(); await expect( frequentSection.getByRole( 'gridcell', { @@ -1645,7 +1651,9 @@ test.describe( 'Block Notes', () => { comment: 'Pick a toned thumbs up', } ); - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); await blockNoteUtils.waitForFullPicker(); // The persistent toggle next to the search field shows the @@ -1711,7 +1719,9 @@ test.describe( 'Block Notes', () => { } ); await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); await blockNoteUtils.waitForFullPicker(); // Focus has moved into the portaled popover, but the note's From 4596159e00f6c4cfd35fea013b903135ce326801 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 15:49:15 -0700 Subject: [PATCH 336/362] Notes: Build the skin tone picker with Menu The skin tone flyout was a Dropdown around a Composite listbox, with its own focus-on-mount effect and focus-return wiring. Menu's radio group covers the same job, single selection with focus handling and keyboard navigation built in, so use it instead. Menu lays out vertically, so each tone shows its swatch beside its name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8d57dBsBFSa51D1Dk46vU --- .../src/components/collab-sidebar/README.md | 6 +- .../reactions/skin-tone-picker.tsx | 189 +++++------------- .../src/components/collab-sidebar/style.scss | 27 +-- .../specs/editor/various/block-notes.spec.js | 45 +++-- 4 files changed, 82 insertions(+), 185 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/README.md b/packages/editor/src/components/collab-sidebar/README.md index d92a8f6c028925..6145cd1535d3e0 100644 --- a/packages/editor/src/components/collab-sidebar/README.md +++ b/packages/editor/src/components/collab-sidebar/README.md @@ -27,9 +27,9 @@ collab-sidebar/ ├── reactions/ emoji reactions on a note │ ├── use-reaction.ts useReaction( note ) - the note's `reaction_summary` + toggleReaction │ ├── reaction-display.tsx ReactionDisplay - reaction pills with reactor-name tooltips -│ ├── add-reaction-button.tsx AddReactionButton - the picker's popover trigger -│ ├── emoji-picker.tsx EmojiPicker - Autocomplete grid over the Emojibase dataset -│ ├── skin-tone-picker.tsx SkinTonePicker - default skin tone preference flyout +│ ├── add-reaction-button.tsx AddReactionButton - the add-reaction trigger for the picker +│ ├── emoji-picker.tsx EmojiPicker - Autocomplete trigger + popup grid over the Emojibase dataset +│ ├── skin-tone-picker.tsx SkinTonePicker - default skin tone preference Menu │ ├── emojibase-data.ts Emojibase dataset loading, labels, and settings │ ├── frequent-emojis.ts useFrequentEmojis - persisted "Frequently used" section │ └── reaction-emojis.ts named reaction set, storage keys, and the site's emoji rules diff --git a/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx index 93d679ae432bf3..c47f1b9b624ff1 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx @@ -1,7 +1,7 @@ import { __, sprintf } from '@wordpress/i18n'; -import { Button, Composite, Dropdown } from '@wordpress/components'; -import { useFocusReturn, useInstanceId } from '@wordpress/compose'; -import { useEffect } from '@wordpress/element'; +import { Button } from '@wordpress/components'; +// eslint-disable-next-line @wordpress/use-recommended-components -- Intentional early adoption of the new Menu, pending WordPress/gutenberg#76135. +import { Menu } from '@wordpress/ui'; import type { EmojibaseEntry, EmojibaseSkin } from './emojibase-data'; /** @@ -18,11 +18,6 @@ interface SkinTonePickerProps { onChange: ( tone: number ) => void; } -interface SkinToneMenuProps extends SkinTonePickerProps { - baseId: string; - onClose: () => void; -} - /** * The six skin tones in display order. Tone `0` is the default yellow * presentation, listed first so it is an explicit choice rather than the @@ -58,92 +53,8 @@ export function applySkinTone( } /** - * The flyout body: a heading and the six-swatch listbox. - * - * @param props Component props. - * @param props.value The selected tone, 0–5. - * @param props.onChange Called with the newly selected tone. - * @param props.baseId Unique ID prefix for the heading and options. - * @param props.onClose Closes the flyout. - */ -function SkinToneMenu( { - value, - onChange, - baseId, - onClose, -}: SkinToneMenuProps ) { - const headingId = `${ baseId }-heading`; - const optionId = ( tone: number ) => `${ baseId }-option-${ tone }`; - const current = - SKIN_TONES.find( ( option ) => option.tone === value ) || - SKIN_TONES[ 0 ]; - const selectedOptionId = optionId( current.tone ); - - /* - * Per the APG listbox pattern focus lands on the selected option. The - * popover's focus-on-mount and the composite's `defaultActiveId` race - * against item registration, so move focus explicitly on mount. - */ - useEffect( () => { - document.getElementById( selectedOptionId )?.focus(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [] ); - - // Disabling the popover's focus-on-mount also disables its focus return. - const focusReturnRef = useFocusReturn(); - - return ( -
-
- { __( 'Choose your default skin tone' ) } -
- - { SKIN_TONES.map( ( { tone, emoji, label } ) => ( - { - onChange( tone ); - onClose(); - } } - /> - } - > - { emoji } - - ) ) } - -
- ); -} - -/** - * Skin tone selector: a toggle showing the selected tone, opening a flyout - * of six swatches under a heading. + * Skin tone selector: a toggle showing the selected tone, opening a menu + * of the six tones as a radio group. * * @param props Component props. * @param props.value The selected tone, 0–5. @@ -153,54 +64,58 @@ export default function SkinTonePicker( { value, onChange, }: SkinTonePickerProps ) { - const baseId = useInstanceId( - SkinTonePicker, - 'editor-collab-sidebar-panel__skin-tone' - ); - // Mirrors `SkinToneMenu`'s heading id, to name the popup container. - const headingId = `${ baseId }-heading`; const current = SKIN_TONES.find( ( option ) => option.tone === value ) || SKIN_TONES[ 0 ]; return ( - ( - - ) } - renderContent={ ( { onClose } ) => ( - - ) } - /> + + { __( 'Choose your default skin tone' ) } + + { SKIN_TONES.map( ( { tone, emoji, label } ) => ( + + ) ) } + + + ); } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 32d9e4bce80184..354bdac22ff7e5 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -413,32 +413,9 @@ mark.wp-note { justify-content: center; } -.editor-collab-sidebar-panel__skin-tone-menu { - padding: var(--wpds-dimension-padding-sm); - font-family: var(--wpds-typography-font-family-body); -} - -.editor-collab-sidebar-panel__skin-tone-heading { - padding-bottom: var(--wpds-dimension-padding-sm); - color: var(--wpds-color-foreground-content-neutral-weak); - font-size: var(--wpds-typography-font-size-xs); - font-weight: var(--wpds-typography-font-weight-emphasis); -} - -.editor-collab-sidebar-panel__skin-tone-options { - display: flex; - gap: var(--wpds-dimension-gap-xs); -} - -.editor-collab-sidebar-panel__skin-tone-option { - // In its own flyout, outside the picker's scope, so read the token here. - width: var(--wpds-dimension-size-md); +.editor-collab-sidebar-panel__skin-tone-swatch { font-size: 18px; - justify-content: center; - - &[aria-selected="true"] { - background-color: var(--wpds-color-background-interactive-neutral-weak-active); - } + line-height: 1; } .editor-collab-sidebar-panel__picker-viewport { diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 6556a828a41f5e..407cde6a5311ad 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1528,7 +1528,7 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toContainText( '👍' ); } ); - test( 'Escape in the skin-tone flyout closes only that popup', async ( { + test( 'Escape in the skin-tone menu closes only that popup', async ( { page, blockNoteUtils, } ) => { @@ -1538,24 +1538,26 @@ test.describe( 'Block Notes', () => { comment: 'Escape unwinds one layer at a time', } ); - await page.getByRole( 'button', { name: 'Add reaction' } ).click(); + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); await blockNoteUtils.waitForFullPicker(); - // Open the nested skin-tone flyout; focus moves into its - // listbox (onto the selected swatch). + // Open the nested skin-tone menu. const skinToneToggle = page.getByRole( 'button', { name: /^Skin tone:/, } ); await skinToneToggle.click(); - const skinToneListbox = page.getByRole( 'listbox', { - name: 'Choose your default skin tone', - } ); - await expect( skinToneListbox ).toBeVisible(); - - // The first Escape closes only the flyout, returns focus to - // its toggle, and leaves the full picker open. + // The notes' Actions menus stay mounted, so pick out this one. + const skinToneMenu = page + .getByRole( 'menu' ) + .filter( { hasText: 'Choose your default skin tone' } ); + await expect( skinToneMenu ).toBeVisible(); + + // The first Escape closes only the menu, returns focus to its + // toggle, and leaves the full picker open. await page.keyboard.press( 'Escape' ); - await expect( skinToneListbox ).toBeHidden(); + await expect( skinToneMenu ).toBeHidden(); await expect( skinToneToggle ).toBeFocused(); await expect( page.getByPlaceholder( 'Search emoji' ) @@ -1662,22 +1664,25 @@ test.describe( 'Block Notes', () => { .getByRole( 'button', { name: 'Skin tone: Default skin tone' } ) .click(); - // The flyout has an explicit heading and six swatches, with - // the default tone selected. + // The menu has an explicit heading and six tones, with the + // default tone checked. await expect( page.getByText( 'Choose your default skin tone' ) ).toBeVisible(); - const swatches = page.getByRole( 'option' ); - await expect( swatches ).toHaveCount( 6 ); + const tones = page.getByRole( 'menuitemradio' ); + await expect( tones ).toHaveCount( 6 ); await expect( - page.getByRole( 'option', { name: 'Default skin tone' } ) - ).toHaveAttribute( 'aria-selected', 'true' ); + page.getByRole( 'menuitemradio', { name: 'Default skin tone' } ) + ).toHaveAttribute( 'aria-checked', 'true' ); await page - .getByRole( 'option', { name: 'Dark skin tone', exact: true } ) + .getByRole( 'menuitemradio', { + name: 'Dark skin tone', + exact: true, + } ) .click(); - // The toggle reflects the new tone and the flyout closes. + // The toggle reflects the new tone and the menu closes. await expect( page.getByRole( 'button', { name: 'Skin tone: Dark skin tone', From 1e03b5532e0db38c8e355c2862ae8b0af572c356 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 16:45:10 -0700 Subject: [PATCH 337/362] Notes: Pass noteReactionEmojiRules through to the block editor The server sets noteReactionEmojiRules, but the block editor settings allowlist dropped it, so useReactionEmojiRules() always saw the defaults. The picker then offered emoji the REST API rejects, and the add-reaction button never hid when a site limited reactions to an emptied named list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8d57dBsBFSa51D1Dk46vU --- .../editor/src/components/provider/use-block-editor-settings.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index ff83a773b077d0..9e53c6ec6105fd 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -89,6 +89,7 @@ const BLOCK_EDITOR_SETTINGS = [ 'noteEmojibaseUrl', 'noteEmojiLabelOverrides', 'noteReactionEmojis', + 'noteReactionEmojiRules', 'postContentAttributes', 'postsPerPage', 'readOnly', From b72a5327319347d89a3a4854948f5fc107a4c7d4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 16:45:11 -0700 Subject: [PATCH 338/362] Notes: Cap Frequently used at five full grid rows The cap was sized for an 8-column grid. The picker has 6 columns, so 32 left a partial last row; 30 fills five rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8d57dBsBFSa51D1Dk46vU --- .../components/collab-sidebar/reactions/frequent-emojis.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts index 192925d0d93869..42261b6d4b27d1 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts @@ -23,8 +23,8 @@ export interface FrequentEmojiEntry { */ export const FREQUENT_EMOJIS_PREFERENCE_KEY = 'emojiPickerFrequentEmojis'; -// Four rows of the picker's 8-column grid. -export const MAX_FREQUENT_EMOJIS = 32; +// Five full rows of the picker's 6-column grid. +export const MAX_FREQUENT_EMOJIS = 30; /** * Seeds the frequently-used section before any picks. Never persisted: From efa57533776154c66c72452b83cb9de823084229 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 16:45:11 -0700 Subject: [PATCH 339/362] Notes: Bring reaction comments in line with the current picker Comments, docblocks, and test titles still described the removed quick-reactions row, the lazy picker chunk, the Dropdown popover, the 6.9 compat functions, and a renamed file. Point them at what the code does now, and drop the stylesheet rule and remove_filter/remove_action calls that only served code that no longer exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8d57dBsBFSa51D1Dk46vU --- ...-gutenberg-rest-comment-controller-7-2.php | 6 +-- lib/compat/wordpress-7.2/emojibase-data.php | 7 ++-- lib/compat/wordpress-7.2/note-reactions.php | 41 ++++++++++--------- packages/editor/CHANGELOG.md | 4 +- .../collab-sidebar/reactions/emoji-picker.tsx | 4 +- .../reactions/emojibase-data.ts | 5 +-- .../src/components/collab-sidebar/style.scss | 17 ++------ phpunit/emoji-picker-data-test.php | 10 ++--- ...est-comments-controller-gutenberg-test.php | 12 +++--- .../specs/editor/various/block-notes-utils.js | 6 +-- .../specs/editor/various/block-notes.spec.js | 23 ++++++----- tools/build-scripts/copy-emojibase-data.mjs | 8 ++-- 12 files changed, 70 insertions(+), 73 deletions(-) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index c5c34ce30f9956..9c1e3a3977c973 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -494,11 +494,11 @@ public function create_item( $request ) { } // Validate the reaction content. We accept either: - // - a curated slug (e.g. "heart") from the allowed emoji list + // - a named slug (e.g. "heart") from the allowed emoji list // (filterable via `gutenberg_note_reaction_emoji_settings`), or // - a lowercase hex-codepoint sequence joined by `-` (e.g. // "1f44d" for 👍 or "1f468-200d-1f4bb" for 👨‍💻), which is how - // the full emoji picker stores a pick outside that list. + // the picker stores a pick from the Emojibase dataset. // Raw emoji bytes are rejected because the comments table is // not guaranteed to be utf8mb4 across all WordPress installs; // clients normalize before submitting. @@ -547,7 +547,7 @@ public function create_item( $request ) { } // Enforce uniqueness: prevent duplicate emoji per user per note. - // Limit to active reactions — trashed reactions are invisible and + // Limit to active reactions - trashed reactions are invisible and // must not block the user from re-adding the same emoji. $existing = get_comments( array( diff --git a/lib/compat/wordpress-7.2/emojibase-data.php b/lib/compat/wordpress-7.2/emojibase-data.php index 26b24a394d783f..a491786eb7cc32 100644 --- a/lib/compat/wordpress-7.2/emojibase-data.php +++ b/lib/compat/wordpress-7.2/emojibase-data.php @@ -22,7 +22,7 @@ */ function gutenberg_add_emojibase_settings( $settings ) { // Without the copied data the picker would 404 on every open; leaving - // the URL unset makes it fall back to the quick reactions instead. + // the URL unset makes the grid offer the named reaction set instead. if ( is_dir( gutenberg_dir_path() . 'build/emojibase-data' ) ) { $settings['noteEmojibaseUrl'] = gutenberg_url( 'build/emojibase-data' ); } @@ -91,7 +91,8 @@ function gutenberg_emoji_to_hexcode( $emoji ) { * * Emojibase translates labels for 28 locales only; the filter below lets * sites fill the gap for the emojis they care about. Seeded with the - * curated reactions so both rows show the same label. + * named reactions so an emoji keeps its named label when the grid shows + * the Emojibase dataset. * * @since 7.2.0 * @@ -127,7 +128,7 @@ function gutenberg_get_emoji_picker_label_overrides() { $defaults ); - // A non-string label would crash `label.toLowerCase()` in searchEmojis(). + // A non-string label would crash `override.toLowerCase()` in searchEmojis(). $overrides = is_array( $overrides ) ? $overrides : array(); return array_filter( $overrides, 'is_string' ); } diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index ca103c43c3b127..56d36d3bba853c 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -61,8 +61,8 @@ function gutenberg_get_internal_comment_types() { /** * Updates the comment type for avatars to include internal comment types. * - * Replaces the 6.9 implementation to also add the 'reaction' type - * to the list of comment types for which avatars should be retrieved. + * Adds the 'reaction' type to core's default 'comment' and 'note' avatar + * comment types. * * @param array $comment_type The array of comment types. * @return array The updated array of comment types. @@ -70,13 +70,14 @@ function gutenberg_get_internal_comment_types() { function gutenberg_update_get_avatar_comment_type_7_2( $comment_type ) { return array_values( array_unique( array_merge( $comment_type, gutenberg_get_internal_comment_types() ) ) ); } -remove_filter( 'get_avatar_comment_types', 'update_get_avatar_comment_type' ); add_filter( 'get_avatar_comment_types', 'gutenberg_update_get_avatar_comment_type_7_2' ); /** - * Excludes block comments and reactions from the admin comments query. + * Excludes notes and reactions from comment queries that request no + * specific type. * - * Replaces the 6.9 implementation to also exclude 'reaction' type. + * Core's WP_Comment_Query already excludes 'note'; this also excludes + * 'reaction'. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -98,7 +99,6 @@ function gutenberg_exclude_block_comments_from_admin_7_2( $clauses, $query ) { return $clauses; } -remove_action( 'comments_clauses', 'exclude_block_comments_from_admin', 10 ); add_action( 'comments_clauses', 'gutenberg_exclude_block_comments_from_admin_7_2', 10, 2 ); /** @@ -125,7 +125,8 @@ function gutenberg_exclude_internal_comment_types_from_feed_7_2( $cwhere ) { /** * Filter the comment count query to exclude notes and reactions. * - * Replaces the 6.9 implementation to also exclude 'reaction' type. + * Core hardcodes the 'note' exclusion in this query; this also excludes + * 'reaction'. * * @param string $query The SQL query string. * @return string The modified SQL query string. @@ -133,10 +134,10 @@ function gutenberg_exclude_internal_comment_types_from_feed_7_2( $cwhere ) { function gutenberg_filter_comment_count_query_exclude_block_comments_7_2( $query ) { if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { // Add an exclusion clause for each internal type not already present. - // Core (and older versions of this filter) may have already injected - // the note-only exclusion, so expanding per type - rather than bailing - // when any exclusion exists - ensures reactions are excluded too and - // keeps the filter idempotent if it runs more than once. + // Core's query already hardcodes the note-only exclusion, so expanding + // per type - rather than bailing when any exclusion exists - ensures + // reactions are excluded too and keeps the filter idempotent if it + // runs more than once. $type_clauses = array(); foreach ( gutenberg_get_internal_comment_types() as $internal_type ) { $clause = "comment_type != '" . esc_sql( $internal_type ) . "'"; @@ -155,7 +156,8 @@ function gutenberg_filter_comment_count_query_exclude_block_comments_7_2( $query /** * Adjusts the comments list table query so notes and reactions never display. * - * Replaces the 6.9 implementation to also handle 'reaction' type. + * Extends core's 'note' guard in WP_Comments_List_Table to the 'reaction' + * type. * * @param array $args An array of get_comments() arguments. * @return array Possibly modified arguments for get_comments(). @@ -171,7 +173,7 @@ function gutenberg_hide_note_from_comment_list_table_7_2( $args ) { /** * Override comment_count to exclude notes and reactions from the comment count. * - * Replaces the 6.9 implementation to also exclude 'reaction' type. + * Core's default count excludes only 'note'; this also excludes 'reaction'. * * @param int|null $new_count The new comment count. Default null. * @param int $old_count The old comment count. @@ -198,7 +200,8 @@ function gutenberg_exclude_notes_from_comment_count_7_2( $new_count, $old_count, /** * Returns the note reaction emoji settings: the named emoji list plus the - * rules for emoji picked from the full picker, which are stored as hex keys. + * rules for emoji picked from the Emojibase dataset, which are stored as + * hex keys. * * @since 7.2.0 * @@ -242,7 +245,7 @@ function gutenberg_get_note_reaction_emoji_settings() { * Filters which emoji note reactions accept. * * Named emoji are always accepted. `allow_unlisted` and `exclude` only - * apply to emoji picked from the full picker. + * apply to emoji picked from the Emojibase dataset. * * @since 7.2.0 * @@ -415,7 +418,7 @@ function gutenberg_delete_note_reactions( $comment_id, $comment ) { return; } - // Every status: trashing the note, or the user removing a reaction, + // Every status: trashing the note, or a REST delete without `force`, // leaves reactions in the trash. foreach ( gutenberg_get_note_reaction_ids( $comment ) as $reaction_id ) { wp_delete_comment( $reaction_id, true ); @@ -441,7 +444,7 @@ function gutenberg_trash_note_reactions( $comment_id, $comment ) { } // Flag each one so restoring the note brings back only these, not - // reactions the user had already removed. + // reactions already in the trash (a REST delete without `force`). foreach ( gutenberg_get_note_reaction_ids( $comment, 'approve' ) as $reaction_id ) { if ( wp_trash_comment( $reaction_id ) ) { add_comment_meta( $reaction_id, '_wp_trash_meta_with_note', '1', true ); @@ -454,8 +457,8 @@ function gutenberg_trash_note_reactions( $comment_id, $comment ) { * Restores a note's reactions along with the note. * * The counterpart to gutenberg_trash_note_reactions(): only reactions - * flagged as trashed along with the note come back. Ones the user removed - * beforehand stay in the trash. + * flagged as trashed along with the note come back. Ones already in the + * trash beforehand stay there. * * @since 7.2.0 * diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 97ee8bf36586f3..a6c2e36b7e1e3a 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -4,8 +4,8 @@ ### New Features -- Notes: allow reacting to a note with an emoji. Reactions display as pill buttons on the note with per-emoji counts, backed by a new `reaction` comment type. The add-reaction trigger fades in on hover or focus in the note's top corner, so a note with no reactions costs no vertical space for the option ([#76767](https://github.com/WordPress/gutenberg/pull/76767)). -- Notes: the add-reaction popover now offers a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via the `noteEmojibaseUrl` editor setting (with optional `noteEmojiLabelOverrides`) ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). +- Notes: allow reacting to a note with an emoji. Reactions display as pill buttons on the note with per-emoji counts, backed by a new `reaction` comment type. The add-reaction trigger shows on a selected note, fading in on hover or focus in its top corner, so a note with no reactions costs no vertical space for the option ([#76767](https://github.com/WordPress/gutenberg/pull/76767)). +- Notes: the add-reaction button now opens a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via the `noteEmojibaseUrl` editor setting (with optional `noteEmojiLabelOverrides`) ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). ### Enhancements diff --git a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx index e94be9db9f66e6..b96ce8130573be 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -258,8 +258,8 @@ export default function EmojiPicker( { const [ isOpen, setIsOpen ] = useState( false ); /* * Every note renders a trigger, so hold the dataset fetch until the - * user reaches for one: hovering or focusing it warms the data before - * the popup opens. + * user reaches for one: hovering, focusing, or opening it warms the + * data, ideally before the popup opens. */ const [ isWarm, setIsWarm ] = useState( false ); const warm = () => setIsWarm( true ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts index 5c0421e5e22633..a7f513fc85f4b4 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts @@ -5,8 +5,7 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; /* * Emojibase loading shared by the picker and the reaction pills (labels - * only). Kept dependency-light so importing it does not pull the picker - * UI along. + * only). */ /** @@ -277,7 +276,7 @@ export function useEmojibaseData( /** * Normalize an Emojibase hexcode (`2764-FE0F`) to the reaction storage key * form: lowercase, four-digit padded, U+FE0F stripped (`2764`). Must agree - * with `emojiToHexKey()` in `reaction-emoji-picker.tsx`. + * with `emojiToHexKey()` in `reaction-emojis.ts`. * * @param hexcode Emojibase hexcode. * @return Normalized hex key. diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 354bdac22ff7e5..218bda870764e3 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -335,7 +335,7 @@ mark.wp-note { .editor-collab-sidebar-panel__reactions .editor-collab-sidebar-panel__add-reaction-button:focus, // While the picker is open the pointer is over the popup, not the - // thread, and focus has moved into the popup's own portal. + // note, and focus has moved into the popup's own portal. .editor-collab-sidebar-panel__reactions .editor-collab-sidebar-panel__add-reaction-button[data-popup-open] { opacity: 1; @@ -360,7 +360,7 @@ mark.wp-note { .editor-collab-sidebar-panel__reaction-button-emoji { // Render the emoji glyph larger than the button's text size so it - // visually balances with the 24px reaction icon next to it. + // visually balances with the add-reaction icon that trails the pills. font-size: 16px; line-height: 1; } @@ -429,18 +429,9 @@ mark.wp-note { scroll-padding-block: var(--wpds-dimension-size-md) 0; } -// Loading state shown while the lazy picker chunk downloads. -.editor-collab-sidebar-panel__picker-status { - display: grid; - place-items: center; - height: 100%; - color: var(--wpds-color-foreground-content-neutral-weak); -} - // Hold the grid's width even while it is empty (loading, or no search -// results) so the popover doesn't resize and reposition. -.editor-collab-sidebar-panel__picker-list, -.editor-collab-sidebar-panel__picker-status { +// results) so the popup doesn't resize and reposition. +.editor-collab-sidebar-panel__picker-list { min-inline-size: var(--emoji-picker-grid-width); } diff --git a/phpunit/emoji-picker-data-test.php b/phpunit/emoji-picker-data-test.php index 46f992f7838c5f..76a6c5d8972ae6 100644 --- a/phpunit/emoji-picker-data-test.php +++ b/phpunit/emoji-picker-data-test.php @@ -43,15 +43,15 @@ public function data_gutenberg_emoji_to_hexcode() { } /** - * The default override map seeds the curated reaction emojis so the - * full picker shows the same translated label as the curated row. + * The default override map seeds the named reaction emojis so the + * picker shows their translated labels on the Emojibase entries. * * @covers ::gutenberg_get_emoji_picker_label_overrides */ public function test_default_overrides_seeded_from_curated_reactions() { $overrides = gutenberg_get_emoji_picker_label_overrides(); - // Heart, Celebration, Smile, Eyes, Rocket — the five curated + // Heart, Celebration, Smile, Eyes, Rocket - the five curated // reactions defined in gutenberg_get_note_reaction_emojis(). $this->assertArrayHasKey( '2764', $overrides ); $this->assertArrayHasKey( '1F389', $overrides ); @@ -120,8 +120,8 @@ public function test_filter_returning_non_array_is_treated_as_empty() { } /** - * The picker configuration rides on the block editor settings — no - * page globals — so the editor package reads it through its normal + * The picker configuration rides on the block editor settings - no + * page globals - so the editor package reads it through its normal * settings boundary. * * @covers ::gutenberg_add_emojibase_settings diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 795f70eccd598c..45f25751e51a61 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -697,7 +697,7 @@ public function test_cannot_create_note_without_edit_post_capability() { /** * @dataProvider data_invalid_reaction_inputs * - * @param string $parent_kind One of 'none', 'note', or 'comment' — what the + * @param string $parent_kind One of 'none', 'note', or 'comment' - what the * reaction's `parent` field references. * @param string|array $content The reaction storage key (slug) to submit, * as a string or as `{ raw }`. @@ -766,9 +766,10 @@ public function data_invalid_reaction_inputs() { } /** - * A pick from the full emoji picker that is not in the curated list is - * submitted as a lowercase hex code-point sequence, which the picker - * decodes back into the emoji. Those keys must round-trip through create. + * A pick from the emoji picker that is not in the named list is + * submitted as a lowercase hex code-point sequence, which the reaction + * pill decodes back into the emoji. Those keys must round-trip through + * create. * * @dataProvider data_valid_reaction_hex_keys * @@ -1361,7 +1362,8 @@ public function test_permanently_deleting_note_reply_deletes_its_reactions() { } /** - * Core cascades a trashed note to its `note` children only. + * Core cascades a trashed note to its `note` children only, so + * reactions need their own trash and restore cascade. */ public function test_trashing_note_trashes_and_restores_its_reactions() { if ( ! EMPTY_TRASH_DAYS ) { diff --git a/test/e2e/specs/editor/various/block-notes-utils.js b/test/e2e/specs/editor/various/block-notes-utils.js index 0e1c091845b019..f22814b2b605fd 100644 --- a/test/e2e/specs/editor/various/block-notes-utils.js +++ b/test/e2e/specs/editor/various/block-notes-utils.js @@ -168,9 +168,9 @@ class BlockNoteUtils { } /** - * Wait for the full emoji picker to finish loading its Emojibase - * data and render at least one emoji button. The grid / gridcell - * roles are stable across className changes. + * Wait for the emoji picker popup to open and its grid to render + * at least one cell. Until the Emojibase data loads the grid shows + * the named set, so this does not wait for the full dataset. */ async waitForFullPicker() { await expect( diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 407cde6a5311ad..d3f140dc019014 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1185,7 +1185,7 @@ test.describe( 'Block Notes', () => { await expect( reactionButton ).toContainText( '❤' ); await expect( reactionButton ).toContainText( '1' ); - // The duplicate-reaction error must never appear — pins both + // The duplicate-reaction error must never appear - pins both // fixes (client refetch + server status='approve' query) // against regression. await expect( @@ -1269,7 +1269,7 @@ test.describe( 'Block Notes', () => { ).toBeVisible(); } ); - test( 'reaction buttons are keyboard accessible', async ( { + test( 'the emoji picker is keyboard accessible', async ( { page, blockNoteUtils, } ) => { @@ -1280,10 +1280,10 @@ test.describe( 'Block Notes', () => { } ); // Open the emoji picker with keyboard. - const addReactionButton = page.getByRole( 'combobox', { + const addReactionTrigger = page.getByRole( 'combobox', { name: 'Add reaction', } ); - await addReactionButton.focus(); + await addReactionTrigger.focus(); await page.keyboard.press( 'Enter' ); await blockNoteUtils.waitForFullPicker(); @@ -1485,8 +1485,9 @@ test.describe( 'Block Notes', () => { // Open the full picker and click the plain heart specifically. /* - * "Heart" is label-overridden, and the helper's regex lookup - * would otherwise match "smiling face with hearts" first. + * Search first so the click lands on the Emojibase entry, not + * the Frequently used seed. "Heart" is a label override, so the + * exact match skips "smiling face with hearts". */ await page .getByRole( 'combobox', { name: 'Add reaction' } ) @@ -1498,8 +1499,8 @@ test.describe( 'Block Notes', () => { .click(); /* - * The same button the curated row produces. Broken storage - * normalization would leave a stray hex-key button instead. + * One pill showing the heart and a count of 1, as a named-set + * pick produces. */ const reactionButton = page.locator( '.editor-collab-sidebar-panel__reaction-button' @@ -1729,9 +1730,9 @@ test.describe( 'Block Notes', () => { .click(); await blockNoteUtils.waitForFullPicker(); - // Focus has moved into the portaled popover, but the note's - // onBlur handler exempts `.components-popover` so the thread - // stays selected and the trigger stays mounted. + // Focus has moved into the portaled popup, but its focus events + // still bubble to the thread's `useFocusOutside` through the + // React tree, so the thread stays selected and the trigger mounted. await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); } ); diff --git a/tools/build-scripts/copy-emojibase-data.mjs b/tools/build-scripts/copy-emojibase-data.mjs index 19fd840ec3bb5c..a316538dbc9ab7 100644 --- a/tools/build-scripts/copy-emojibase-data.mjs +++ b/tools/build-scripts/copy-emojibase-data.mjs @@ -6,8 +6,8 @@ * is not a network cost. * * Runs from `tools/build-scripts/build.mjs` and `dev.mjs` after wp-build - * populates `build/`. Exits 0 when emojibase-data is missing: the editor degrades - * by hiding the "More emojis" trigger. + * populates `build/`. Exits 0 when emojibase-data is missing: the picker + * degrades to offering the site's named reaction set. */ import { mkdir, copyFile } from 'fs/promises'; @@ -47,7 +47,7 @@ const FILES = [ 'data.json' ]; /* * Every locale Emojibase translates. Must stay in sync with * `EMOJIBASE_LOCALES` in - * `packages/editor/src/components/collab-sidebar/emojibase-data.ts`. + * `packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts`. * Each locale costs ~780KB on disk (~22MB total), ~95KB gzipped over the * wire, and only the active one is fetched per editor session. */ @@ -87,7 +87,7 @@ async function copyEmojibaseData() { console.warn( '⚠️ emojibase-data not found', SRC_DIR ? `at ${ SRC_DIR }` : '', - '— skipping. Run `npm install` to fetch it.' + '- skipping. Run `npm install` to fetch it.' ); return; } From b84d968bd69836bb4cc9bb1c12a53df3d82cdb10 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 16:57:12 -0700 Subject: [PATCH 340/362] Notes: Remove the emoji reaction customization filters The filters for changing the reaction emoji set, limiting or excluding full-picker emoji, and overriding picker labels make up a large share of the reactions diff while being an optional extension point. Split them out to a follow-up PR so the core reactions change stays reviewable on its own. The five quick reactions are now a fixed constant, the server accepts a curated slug or any valid Emojibase hex key, and the full picker takes curated labels straight from the constant instead of a server-built override map. That also drops the hand-written UTF-8 decoder, which only existed to key that map. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- ...-gutenberg-rest-comment-controller-7-2.php | 9 +- lib/compat/wordpress-7.2/emojibase-data.php | 112 +----------- lib/compat/wordpress-7.2/note-reactions.php | 149 +--------------- .../plugins/note-reaction-emojis.php | 126 -------------- packages/editor/CHANGELOG.md | 2 +- .../src/components/collab-sidebar/README.md | 2 +- .../reactions/add-reaction-button.tsx | 19 +- .../collab-sidebar/reactions/emoji-picker.tsx | 56 ++---- .../reactions/emojibase-data.ts | 97 ++++------- .../reactions/frequent-emojis.ts | 16 +- .../reactions/reaction-display.tsx | 14 +- .../reactions/reaction-emojis.ts | 141 +-------------- .../provider/use-block-editor-settings.js | 3 - phpunit/emoji-picker-data-test.php | 113 +----------- ...est-comments-controller-gutenberg-test.php | 163 ------------------ .../specs/editor/various/block-notes-utils.js | 6 +- .../specs/editor/various/block-notes.spec.js | 39 +---- 17 files changed, 80 insertions(+), 987 deletions(-) delete mode 100644 packages/e2e-tests/plugins/note-reaction-emojis.php diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index 9c1e3a3977c973..3e1dc135218439 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -494,8 +494,7 @@ public function create_item( $request ) { } // Validate the reaction content. We accept either: - // - a named slug (e.g. "heart") from the allowed emoji list - // (filterable via `gutenberg_note_reaction_emoji_settings`), or + // - a named slug (e.g. "heart") from the curated emoji list, or // - a lowercase hex-codepoint sequence joined by `-` (e.g. // "1f44d" for 👍 or "1f468-200d-1f4bb" for 👨‍💻), which is how // the picker stores a pick from the Emojibase dataset. @@ -532,12 +531,6 @@ public function create_item( $request ) { } } - // Sites can exclude emoji or limit reactions to the named list - // via `gutenberg_note_reaction_emoji_settings`. - if ( $is_hex_key && ! $is_curated_slug ) { - $is_hex_key = gutenberg_is_note_reaction_hex_key_allowed( $emoji_slug ); - } - if ( ! $is_curated_slug && ! $is_hex_key ) { return new WP_Error( 'rest_comment_invalid_reaction', diff --git a/lib/compat/wordpress-7.2/emojibase-data.php b/lib/compat/wordpress-7.2/emojibase-data.php index a491786eb7cc32..cdf4c66c9551cd 100644 --- a/lib/compat/wordpress-7.2/emojibase-data.php +++ b/lib/compat/wordpress-7.2/emojibase-data.php @@ -11,9 +11,9 @@ */ /** - * Injects the Emojibase dataset URL and per-emoji label overrides into the - * block editor settings, where the Notes picker reads them. npm consumers - * of `@wordpress/editor` opt in by supplying the same two settings. + * Injects the Emojibase dataset URL into the block editor settings, where + * the Notes picker reads it. npm consumers of `@wordpress/editor` opt in by + * supplying the same setting. * * @since 7.2.0 * @@ -21,114 +21,10 @@ * @return array Updated block editor settings. */ function gutenberg_add_emojibase_settings( $settings ) { - // Without the copied data the picker would 404 on every open; leaving - // the URL unset makes the grid offer the named reaction set instead. + // Without the copied data the picker would 404 on every open. if ( is_dir( gutenberg_dir_path() . 'build/emojibase-data' ) ) { $settings['noteEmojibaseUrl'] = gutenberg_url( 'build/emojibase-data' ); } - $settings['noteEmojiLabelOverrides'] = gutenberg_get_emoji_picker_label_overrides(); return $settings; } add_filter( 'block_editor_settings_all', 'gutenberg_add_emojibase_settings' ); - -/** - * Convert an emoji character to Emojibase's uppercase `hexcode` form: - * Variation Selector-16 stripped and each code point padded to four - * digits (`00A9`, not `A9`). - * - * Decodes UTF-8 by hand because `mbstring` is recommended but not - * required by WordPress, and `mb_ord()` is not polyfilled on all - * supported versions. - * - * @since 7.2.0 - * - * @param string $emoji Emoji character. - * @return string Uppercase four-digit-padded hex code-points joined by - * `-`, or empty string. - */ -function gutenberg_emoji_to_hexcode( $emoji ) { - if ( ! is_string( $emoji ) || '' === $emoji ) { - return ''; - } - $length = strlen( $emoji ); - $parts = array(); - for ( $i = 0; $i < $length; ) { - $byte = ord( $emoji[ $i ] ); - if ( $byte < 0x80 ) { - $codepoint = $byte; - $size = 1; - } elseif ( 0xC0 === ( $byte & 0xE0 ) ) { - $codepoint = $byte & 0x1F; - $size = 2; - } elseif ( 0xE0 === ( $byte & 0xF0 ) ) { - $codepoint = $byte & 0x0F; - $size = 3; - } elseif ( 0xF0 === ( $byte & 0xF8 ) ) { - $codepoint = $byte & 0x07; - $size = 4; - } else { - return ''; - } - for ( $j = 1; $j < $size; $j++ ) { - if ( $i + $j >= $length || 0x80 !== ( ord( $emoji[ $i + $j ] ) & 0xC0 ) ) { - return ''; - } - $codepoint = ( $codepoint << 6 ) | ( ord( $emoji[ $i + $j ] ) & 0x3F ); - } - $i += $size; - // Skip Variation Selector-16 so qualified `❤️` (2764 FE0F) - // matches Emojibase's unqualified `2764` entry. - if ( 0xFE0F === $codepoint ) { - continue; - } - $parts[] = str_pad( strtoupper( dechex( $codepoint ) ), 4, '0', STR_PAD_LEFT ); - } - return implode( '-', $parts ); -} - -/** - * Builds the per-emoji label override map exposed to the editor's picker. - * - * Emojibase translates labels for 28 locales only; the filter below lets - * sites fill the gap for the emojis they care about. Seeded with the - * named reactions so an emoji keeps its named label when the grid shows - * the Emojibase dataset. - * - * @since 7.2.0 - * - * @return array Map of `hexcode => translated label`. - */ -function gutenberg_get_emoji_picker_label_overrides() { - $defaults = array(); - if ( function_exists( 'gutenberg_get_note_reaction_emojis' ) ) { - foreach ( gutenberg_get_note_reaction_emojis() as $entry ) { - if ( empty( $entry['emoji'] ) || empty( $entry['label'] ) ) { - continue; - } - $hex = gutenberg_emoji_to_hexcode( $entry['emoji'] ); - if ( '' !== $hex ) { - $defaults[ $hex ] = $entry['label']; - } - } - } - - /** - * Filters the emoji label overrides exposed to the Notes picker. - * - * Keys are uppercase Emojibase `hexcode` values: each code point - * zero-padded to four digits with U+FE0F stripped, e.g. `2764` for - * ❤️ and `00A9` for ©️. - * - * @since 7.2.0 - * - * @param array $overrides Map of `hexcode => translated label`. - */ - $overrides = apply_filters( - 'gutenberg_emoji_picker_label_overrides', - $defaults - ); - - // A non-string label would crash `override.toLowerCase()` in searchEmojis(). - $overrides = is_array( $overrides ) ? $overrides : array(); - return array_filter( $overrides, 'is_string' ); -} diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index 56d36d3bba853c..4a9207f96a34e4 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -199,21 +199,15 @@ function gutenberg_exclude_notes_from_comment_count_7_2( $new_count, $old_count, add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_2', 10, 3 ); /** - * Returns the note reaction emoji settings: the named emoji list plus the - * rules for emoji picked from the Emojibase dataset, which are stored as - * hex keys. + * Returns the named emojis for note reactions. * * @since 7.2.0 * - * @return array { - * @type array[] $emojis Named emoji definitions, each with `emoji`, - * `label`, and `value` keys. - * @type bool $allow_unlisted Whether emoji outside the named list are accepted. - * @type string[] $exclude Normalized hex keys that are never accepted. - * } + * @return array[] List of emoji definitions, each with `emoji`, `label`, + * and `value` keys. */ -function gutenberg_get_note_reaction_emoji_settings() { - $default_emojis = array( +function gutenberg_get_note_reaction_emojis() { + return array( array( 'emoji' => '❤️', 'label' => _x( 'Heart', 'emoji reaction', 'gutenberg' ), @@ -240,139 +234,6 @@ function gutenberg_get_note_reaction_emoji_settings() { 'value' => 'rocket', ), ); - - /** - * Filters which emoji note reactions accept. - * - * Named emoji are always accepted. `allow_unlisted` and `exclude` only - * apply to emoji picked from the Emojibase dataset. - * - * @since 7.2.0 - * - * @param array $settings { - * @type array[] $emojis Named emoji definitions. Each item has - * `emoji`, `label`, and `value` keys. - * @type bool $allow_unlisted Whether any emoji from the full picker is - * accepted. False limits reactions to the - * named list. Default true. - * @type string[] $exclude Emojibase hexcodes to reject, such as - * `1F595`. Excluding an emoji also excludes - * its skin-tone variants. Default empty. - * } - */ - $defaults = array( - 'emojis' => $default_emojis, - 'allow_unlisted' => true, - 'exclude' => array(), - ); - $settings = apply_filters( 'gutenberg_note_reaction_emoji_settings', $defaults ); - // Keys a callback leaves out keep their defaults. - $settings = is_array( $settings ) ? wp_parse_args( $settings, $defaults ) : $defaults; - - $exclude = array(); - if ( is_array( $settings['exclude'] ) ) { - foreach ( $settings['exclude'] as $hexcode ) { - $hex_key = gutenberg_normalize_note_reaction_hex_key( $hexcode ); - if ( '' !== $hex_key ) { - $exclude[] = $hex_key; - } - } - } - - return array( - 'emojis' => is_array( $settings['emojis'] ) ? array_values( $settings['emojis'] ) : array(), - 'allow_unlisted' => (bool) $settings['allow_unlisted'], - 'exclude' => array_values( array_unique( $exclude ) ), - ); -} - -/** - * Returns the named emojis for note reactions. - * - * @since 7.2.0 - * - * @return array[] List of emoji definitions, each with `emoji`, `label`, - * and `value` keys. - */ -function gutenberg_get_note_reaction_emojis() { - $settings = gutenberg_get_note_reaction_emoji_settings(); - return $settings['emojis']; -} - -/** - * Injects the note reaction emoji list into block editor settings so the - * reaction picker offers the same (filterable) set the REST API accepts. - * - * @since 7.2.0 - * - * @param array $settings Existing block editor settings. - * @return array Updated block editor settings. - */ -function gutenberg_add_note_reaction_emojis_setting( $settings ) { - $emoji_settings = gutenberg_get_note_reaction_emoji_settings(); - - $settings['noteReactionEmojis'] = $emoji_settings['emojis']; - $settings['noteReactionEmojiRules'] = array( - 'allowUnlisted' => $emoji_settings['allow_unlisted'], - 'exclude' => $emoji_settings['exclude'], - ); - return $settings; -} -add_filter( 'block_editor_settings_all', 'gutenberg_add_note_reaction_emojis_setting' ); - -/** - * Normalizes an emoji hex key to the reaction storage form: lowercase, - * each code point padded to four digits, U+FE0F and skin-tone modifiers - * stripped. Accepts Emojibase hexcodes (`1F44D-1F3FB`) and stored keys. - * - * @since 7.2.0 - * - * @param string $hex_key Hex code points joined by `-`. - * @return string The base emoji's hex key, or empty string when invalid. - */ -function gutenberg_normalize_note_reaction_hex_key( $hex_key ) { - if ( ! is_string( $hex_key ) || ! preg_match( '/^[0-9a-f]{1,6}(-[0-9a-f]{1,6})*$/i', $hex_key ) ) { - return ''; - } - $parts = array(); - foreach ( explode( '-', strtolower( $hex_key ) ) as $part ) { - $value = hexdec( $part ); - // Variation Selector-16 and the five Fitzpatrick skin-tone modifiers. - if ( 0xFE0F === $value || ( $value >= 0x1F3FB && $value <= 0x1F3FF ) ) { - continue; - } - $parts[] = str_pad( $part, 4, '0', STR_PAD_LEFT ); - } - return implode( '-', $parts ); -} - -/** - * Whether a hex-key reaction is accepted under the emoji rules. An emoji - * from the named list is always accepted, including its skin-tone variants. - * - * @since 7.2.0 - * - * @param string $hex_key The submitted hex key. - * @return bool Whether the reaction is allowed. - */ -function gutenberg_is_note_reaction_hex_key_allowed( $hex_key ) { - $base = gutenberg_normalize_note_reaction_hex_key( $hex_key ); - if ( '' === $base ) { - return false; - } - - $settings = gutenberg_get_note_reaction_emoji_settings(); - foreach ( $settings['emojis'] as $entry ) { - if ( - is_array( $entry ) && - ! empty( $entry['emoji'] ) && - gutenberg_normalize_note_reaction_hex_key( gutenberg_emoji_to_hexcode( $entry['emoji'] ) ) === $base - ) { - return true; - } - } - - return $settings['allow_unlisted'] && ! in_array( $base, $settings['exclude'], true ); } /** diff --git a/packages/e2e-tests/plugins/note-reaction-emojis.php b/packages/e2e-tests/plugins/note-reaction-emojis.php deleted file mode 100644 index db424cf420af03..00000000000000 --- a/packages/e2e-tests/plugins/note-reaction-emojis.php +++ /dev/null @@ -1,126 +0,0 @@ - '👍', - 'label' => 'Thumbs up', - 'value' => 'thumbs-up', - ), - array( - 'emoji' => '👎', - 'label' => 'Thumbs down', - 'value' => 'thumbs-down', - ), - array( - 'emoji' => '🔥', - 'label' => 'Fire', - 'value' => 'fire', - ), - array( - 'emoji' => '⭐', - 'label' => 'Star', - 'value' => 'star', - ), - array( - 'emoji' => '👏', - 'label' => 'Clap', - 'value' => 'clap', - ), - array( - 'emoji' => '😂', - 'label' => 'Joy', - 'value' => 'joy', - ), - array( - 'emoji' => '😢', - 'label' => 'Cry', - 'value' => 'cry', - ), - array( - 'emoji' => '😮', - 'label' => 'Wow', - 'value' => 'wow', - ), - array( - 'emoji' => '🤔', - 'label' => 'Thinking', - 'value' => 'thinking', - ), - array( - 'emoji' => '🙏', - 'label' => 'Pray', - 'value' => 'pray', - ), - array( - 'emoji' => '💯', - 'label' => 'Hundred', - 'value' => 'hundred', - ), - array( - 'emoji' => '🎈', - 'label' => 'Balloon', - 'value' => 'balloon', - ), - array( - 'emoji' => '🍕', - 'label' => 'Pizza', - 'value' => 'pizza', - ), - array( - 'emoji' => '🌈', - 'label' => 'Rainbow', - 'value' => 'rainbow', - ), - array( - 'emoji' => '⚡', - 'label' => 'Lightning', - 'value' => 'lightning', - ), - array( - 'emoji' => '🐛', - 'label' => 'Bug', - 'value' => 'bug', - ), - array( - 'emoji' => '🚢', - 'label' => 'Ship', - 'value' => 'ship', - ), - array( - 'emoji' => '🧠', - 'label' => 'Brain', - 'value' => 'brain', - ), - array( - 'emoji' => '🦄', - 'label' => 'Unicorn', - 'value' => 'unicorn', - ), - array( - 'emoji' => '🏆', - 'label' => 'Trophy', - 'value' => 'trophy', - ), - ); - - $settings['emojis'] = array_merge( $settings['emojis'], $extra ); - return $settings; -} -add_filter( 'gutenberg_note_reaction_emoji_settings', 'gutenberg_test_note_reaction_emojis' ); diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index a6c2e36b7e1e3a..4205f3ad063f5b 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -5,7 +5,7 @@ ### New Features - Notes: allow reacting to a note with an emoji. Reactions display as pill buttons on the note with per-emoji counts, backed by a new `reaction` comment type. The add-reaction trigger shows on a selected note, fading in on hover or focus in its top corner, so a note with no reactions costs no vertical space for the option ([#76767](https://github.com/WordPress/gutenberg/pull/76767)). -- Notes: the add-reaction button now opens a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via the `noteEmojibaseUrl` editor setting (with optional `noteEmojiLabelOverrides`) ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). +- Notes: the add-reaction button now opens a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via the `noteEmojibaseUrl` editor setting ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). ### Enhancements diff --git a/packages/editor/src/components/collab-sidebar/README.md b/packages/editor/src/components/collab-sidebar/README.md index 6145cd1535d3e0..80c69302908d6c 100644 --- a/packages/editor/src/components/collab-sidebar/README.md +++ b/packages/editor/src/components/collab-sidebar/README.md @@ -32,7 +32,7 @@ collab-sidebar/ │ ├── skin-tone-picker.tsx SkinTonePicker - default skin tone preference Menu │ ├── emojibase-data.ts Emojibase dataset loading, labels, and settings │ ├── frequent-emojis.ts useFrequentEmojis - persisted "Frequently used" section -│ └── reaction-emojis.ts named reaction set, storage keys, and the site's emoji rules +│ └── reaction-emojis.ts curated reaction set and storage keys │ ├── hooks.js useNoteThreads, useNoteActions, useNoteSelection, useFloatingBoard, useEnableFloatingSidebar ├── utils.js focusNoteThread, getNoteExcerpt, sanitizeNoteContent, calculateNotePositions, getAvatarBorderColor diff --git a/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx index fd8ab721187b1c..d51997273f13cc 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx @@ -7,12 +7,7 @@ import { __ } from '@wordpress/i18n'; import { IconButton } from '@wordpress/ui'; import { reaction as reactionIcon } from '@wordpress/icons'; import EmojiPicker from './emoji-picker'; -import { - emojiToStorageKey, - useReactionEmojiRules, - useReactionEmojis, -} from './reaction-emojis'; -import { useEmojibaseConfig } from './emojibase-data'; +import { emojiToStorageKey } from './reaction-emojis'; interface AddReactionButtonProps { disabled?: boolean; @@ -32,16 +27,6 @@ export function AddReactionButton( { disabled = false, onToggleReaction, }: AddReactionButtonProps ) { - const emojis = useReactionEmojis(); - const rules = useReactionEmojiRules(); - const { baseUrl } = useEmojibaseConfig(); - - // With an emptied named list and no dataset, or a dataset limited to - // that list, there is nothing to pick. - if ( ! emojis.length && ( ! baseUrl || ! rules.allowUnlisted ) ) { - return null; - } - return ( - onToggleReaction( emojiToStorageKey( emoji, emojis ) ) + onToggleReaction( emojiToStorageKey( emoji ) ) } /> ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx index b96ce8130573be..ff60b6e52ca27e 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -14,7 +14,6 @@ import { useSelect, useDispatch } from '@wordpress/data'; import { store as preferencesStore } from '@wordpress/preferences'; import { detectLocale, - getOverrideLabel, normalizeHexcode, useEmojibaseConfig, useEmojibaseData, @@ -22,11 +21,9 @@ import { import type { EmojibaseEntry } from './emojibase-data'; import { useFrequentEmojis } from './frequent-emojis'; import { + REACTION_EMOJIS, emojiToHexKey, - getNamedHexKeys, - isReactionEmojiAllowed, - useReactionEmojiRules, - useReactionEmojis, + getCuratedLabel, } from './reaction-emojis'; import type { CuratedEmoji } from './reaction-emojis'; import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; @@ -181,15 +178,13 @@ export function chunkRows< T >( emojis: T[] ): T[][] { * Case-insensitive search over emoji labels and Emojibase tags. Returns * the unfiltered list when the query is empty. * - * @param emojis Emoji records. - * @param query Search query. - * @param overrides Map of `hexcode => translated label`. + * @param emojis Emoji records. + * @param query Search query. * @return Matching emoji records. */ export function searchEmojis( emojis: EmojibaseEntry[], - query: string, - overrides: Record< string, string > | null + query: string ): EmojibaseEntry[] { const trimmed = query.trim().toLowerCase(); if ( ! trimmed ) { @@ -197,11 +192,11 @@ export function searchEmojis( } return emojis.filter( ( entry ) => { /* - * Match both the override and the original Emojibase label, so - * searching either name finds the emoji. + * Match both the curated and the Emojibase label, so searching + * either name finds the emoji. */ - const override = getOverrideLabel( overrides, entry.hexcode ); - if ( override && override.toLowerCase().includes( trimmed ) ) { + const curated = getCuratedLabel( normalizeHexcode( entry.hexcode ) ); + if ( curated && curated.toLowerCase().includes( trimmed ) ) { return true; } if ( entry.label && entry.label.toLowerCase().includes( trimmed ) ) { @@ -263,30 +258,18 @@ export default function EmojiPicker( { */ const [ isWarm, setIsWarm ] = useState( false ); const warm = () => setIsWarm( true ); - const { baseUrl, labelOverrides } = useEmojibaseConfig(); + const { baseUrl } = useEmojibaseConfig(); const [ locale ] = useState( detectLocale ); const { data: dataset, isLoading, error, } = useEmojibaseData( isWarm ? baseUrl : null, locale ); - const namedEmojis = useReactionEmojis(); - const rules = useReactionEmojiRules(); const hasDataset = !! dataset; - // Offer only what the REST API accepts under the site's emoji rules. - const data = useMemo( () => { - if ( ! dataset ) { - return namedEmojisToEntries( namedEmojis ); - } - const namedKeys = getNamedHexKeys( namedEmojis ); - return dataset.filter( ( entry ) => - isReactionEmojiAllowed( - normalizeHexcode( entry.hexcode ), - rules, - namedKeys - ) - ); - }, [ dataset, namedEmojis, rules ] ); + const data = useMemo( + () => dataset ?? namedEmojisToEntries( REACTION_EMOJIS ), + [ dataset ] + ); const [ query, setQuery ] = useState( '' ); /* @@ -337,7 +320,7 @@ export default function EmojiPicker( { /* * The items handed to `Autocomplete.Root`: category groups while * browsing, a flat list while searching. Filtering stays ours - * (`filter={ null }`), since it matches label overrides and Emojibase + * (`filter={ null }`), since it matches curated labels and Emojibase * tags, so these are already the visible results. */ const items = useMemo( (): EmojiOptionGroup[] | EmojiOption[] => { @@ -354,7 +337,7 @@ export default function EmojiPicker( { key: `${ prefix }-${ entry.hexcode }`, value: display.emoji, label: - getOverrideLabel( labelOverrides, display.hexcode ) || + getCuratedLabel( normalizeHexcode( display.hexcode ) ) || display.label || '', hexKey: normalizeHexcode( entry.hexcode ), @@ -362,7 +345,7 @@ export default function EmojiPicker( { }; if ( ! hasDataset ) { - return searchEmojis( data, query, labelOverrides ).map( ( entry ) => + return searchEmojis( data, query ).map( ( entry ) => toOption( entry, 'named' ) ); } @@ -374,9 +357,7 @@ export default function EmojiPicker( { * mostly-empty headers. */ return groups - .flatMap( ( group ) => - searchEmojis( group.emojis, query, labelOverrides ) - ) + .flatMap( ( group ) => searchEmojis( group.emojis, query ) ) .map( ( entry ) => toOption( entry, 'search' ) ); } @@ -407,7 +388,6 @@ export default function EmojiPicker( { groups, isSearching, query, - labelOverrides, frequentKeys, recordByHexKey, skinTone, diff --git a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts index a7f513fc85f4b4..b7e2682398908e 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; // @ts-expect-error - No type declarations available for @wordpress/block-editor import { store as blockEditorStore } from '@wordpress/block-editor'; +import { getCuratedLabel } from './reaction-emojis'; /* * Emojibase loading shared by the picker and the reaction pills (labels @@ -12,32 +13,28 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; * Emojibase configuration read from the block editor settings. */ export interface EmojibaseConfig { - // Null when the site serves no dataset; the picker then offers the - // named reaction set only. + // Null when the site serves no dataset. baseUrl: string | null; - labelOverrides: Record< string, string > | null; } /** * Read the Emojibase configuration from the block editor settings. The - * plugin sets these server-side; npm consumers supply them themselves. + * plugin sets it server-side; npm consumers supply it themselves. * - * @return The Emojibase base URL and label overrides. + * @return The Emojibase base URL. */ export function useEmojibaseConfig(): EmojibaseConfig { - return useSelect( ( select ) => { - const settings: Record< string, unknown > = - select( blockEditorStore ).getSettings(); - const baseUrl = settings.noteEmojibaseUrl; - const labelOverrides = settings.noteEmojiLabelOverrides; - return { - baseUrl: typeof baseUrl === 'string' && baseUrl ? baseUrl : null, - labelOverrides: - labelOverrides && typeof labelOverrides === 'object' - ? ( labelOverrides as Record< string, string > ) - : null, - }; - }, [] ); + const baseUrl = useSelect( + ( select ) => + ( + select( blockEditorStore ).getSettings() as Record< + string, + unknown + > + ).noteEmojibaseUrl, + [] + ); + return { baseUrl: typeof baseUrl === 'string' && baseUrl ? baseUrl : null }; } /** @@ -294,43 +291,16 @@ export function normalizeHexcode( hexcode: string ): string { const labelMapCache = new Map< string, Map< string, string > >(); /** - * Look up a site's label override for an Emojibase entry. - * - * Emojibase hexcodes may keep U+FE0F (`2764-FE0F-200D-1F525`) while the - * server writes keys normalized without it, so try the raw hexcode first - * and the normalized form second. + * Build (and cache) a normalized-hex-key to label Map for a dataset, with + * the curated reactions keeping their own labels. * - * @param overrides Map of `hexcode => translated label`, or null. - * @param hexcode The entry's Emojibase hexcode. - * @return The override label, or undefined when there is none. - */ -export function getOverrideLabel( - overrides: Record< string, string > | null, - hexcode: string -): string | undefined { - if ( ! overrides ) { - return undefined; - } - return ( - overrides[ hexcode ] ?? - overrides[ normalizeHexcode( hexcode ).toUpperCase() ] - ); -} - -/** - * Build (and cache) a normalized-hex-key to label Map for a dataset, - * applying per-site overrides. Overrides come from editor settings and are - * page-static, so the cache is keyed by dataset alone. - * - * @param cacheKey `baseUrl|locale` cache key. - * @param data Emojibase emoji records. - * @param overrides Map of `hexcode => translated label`, or null. + * @param cacheKey `baseUrl|locale` cache key. + * @param data Emojibase emoji records. * @return Map from hex key to label. */ function buildLabelMap( cacheKey: string, - data: EmojibaseEntry[], - overrides: Record< string, string > | null + data: EmojibaseEntry[] ): Map< string, string > { const existing = labelMapCache.get( cacheKey ); if ( existing ) { @@ -338,10 +308,8 @@ function buildLabelMap( } const map = new Map< string, string >(); const indexEntry = ( hexcode: string, label: string ) => { - map.set( - normalizeHexcode( hexcode ), - getOverrideLabel( overrides, hexcode ) || label - ); + const hexKey = normalizeHexcode( hexcode ); + map.set( hexKey, getCuratedLabel( hexKey ) || label ); }; for ( const entry of data ) { if ( ! entry.hexcode || ! entry.label ) { @@ -365,15 +333,13 @@ function buildLabelMap( * Look up the label for a stored hex-key reaction from the already * loaded dataset, without triggering a fetch. * - * @param hexKey Normalized reaction hex key, e.g. `1f44d`. - * @param baseUrl Same-origin URL of the Emojibase dataset directory. - * @param overrides Map of `hexcode => translated label`, or null. + * @param hexKey Normalized reaction hex key, e.g. `1f44d`. + * @param baseUrl Same-origin URL of the Emojibase dataset directory. * @return The label, or null when unknown/not loaded. */ export function getCachedEmojiLabel( hexKey: string, - baseUrl: string | null, - overrides: Record< string, string > | null + baseUrl: string | null ): string | null { if ( ! baseUrl ) { return null; @@ -383,9 +349,7 @@ export function getCachedEmojiLabel( if ( ! cached ) { return null; } - return ( - buildLabelMap( cacheKey, cached.data, overrides ).get( hexKey ) || null - ); + return buildLabelMap( cacheKey, cached.data ).get( hexKey ) || null; } /** @@ -407,9 +371,9 @@ export function useEmojiLabel( enabled: boolean, load: boolean ): string | null { - const { baseUrl, labelOverrides } = useEmojibaseConfig(); + const { baseUrl } = useEmojibaseConfig(); const [ label, setLabel ] = useState< string | null >( () => - enabled ? getCachedEmojiLabel( hexKey, baseUrl, labelOverrides ) : null + enabled ? getCachedEmojiLabel( hexKey, baseUrl ) : null ); useEffect( () => { @@ -425,8 +389,7 @@ export function useEmojiLabel( } const resolved = buildLabelMap( `${ baseUrl }|${ locale }`, - data, - labelOverrides + data ).get( hexKey ); if ( resolved ) { setLabel( resolved ); @@ -438,7 +401,7 @@ export function useEmojiLabel( return () => { cancelled = true; }; - }, [ hexKey, enabled, load, label, baseUrl, labelOverrides ] ); + }, [ hexKey, enabled, load, label, baseUrl ] ); return label; } diff --git a/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts index 42261b6d4b27d1..240dd3ce2ac519 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts @@ -1,11 +1,7 @@ import { useCallback } from '@wordpress/element'; import { useSelect, useRegistry } from '@wordpress/data'; import { store as preferencesStore } from '@wordpress/preferences'; -import { - REACTION_EMOJIS, - emojiToHexKey, - useReactionEmojis, -} from './reaction-emojis'; +import { REACTION_EMOJIS, emojiToHexKey } from './reaction-emojis'; /** * A recorded frequently-used emoji: its normalized hex key and how many @@ -123,21 +119,15 @@ export function useFrequentEmojis(): { recordUse: ( key: string ) => void; } { const registry = useRegistry(); - /* - * Seed from the filtered list, not the shipped defaults, so a site - * using `gutenberg_note_reaction_emoji_settings` sees its own set. - */ - const emojis = useReactionEmojis(); const frequentKeys = useSelect( ( select ) => getFrequentEmojiKeys( select( preferencesStore ).get( 'core', FREQUENT_EMOJIS_PREFERENCE_KEY - ), - emojis.map( ( entry ) => emojiToHexKey( entry.emoji ) ) + ) ), - [ emojis ] + [] ); const recordUse = useCallback( ( key: string ) => { diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx index 3ce15d5666b6db..6a9d0d5ab17e12 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx @@ -9,11 +9,7 @@ import { Button, Stack, Tooltip } from '@wordpress/ui'; import { useState, useCallback, useMemo } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; -import { - hexKeyToEmoji, - buildEmojiBySlugMap, - useReactionEmojis, -} from './reaction-emojis'; +import { hexKeyToEmoji, buildEmojiBySlugMap } from './reaction-emojis'; import { useEmojiLabel } from './emojibase-data'; interface ReactionSummaryEntry { @@ -388,13 +384,7 @@ export default function ReactionDisplay( { onToggleReaction, children, }: ReactionDisplayProps ) { - // The list is filterable server-side (and static per page load), - // so index it once per list identity. - const emojis = useReactionEmojis(); - const emojiBySlug = useMemo( - () => buildEmojiBySlugMap( emojis ), - [ emojis ] - ); + const emojiBySlug = useMemo( () => buildEmojiBySlugMap(), [] ); const reactedSlugs = getReactedSlugs( reactions ); if ( reactedSlugs.length === 0 && ! children ) { diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts index e6a29fea6037c3..1a592c437ab372 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts @@ -1,8 +1,4 @@ import { _x } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; -import { useMemo } from '@wordpress/element'; -// @ts-expect-error - No type declarations available for @wordpress/block-editor -import { store as blockEditorStore } from '@wordpress/block-editor'; /** * A single curated reaction emoji. @@ -143,137 +139,16 @@ export function emojiToStorageKey( } /** - * The reaction emoji list from editor settings, falling back to the curated - * defaults only when the setting is absent. The server injects it via - * `gutenberg_note_reaction_emoji_settings`, so the picker offers the set - * the REST API accepts. Malformed entries drop, and an empty result stays - * empty: the server rejects the defaults' slugs once a filter removes them. + * The translated label of a curated reaction, so the full picker and the + * pills name it the same way the quick reactions do. * - * @return The emoji list to offer in the picker. + * @param hexKey Normalized hex key, e.g. `2764`. + * @return The curated label, or undefined for any other emoji. */ -export function useReactionEmojis(): CuratedEmoji[] { - return useSelect( ( select ) => { - const settings: Record< string, unknown > = - select( blockEditorStore ).getSettings(); - const emojis = settings.noteReactionEmojis; - if ( ! Array.isArray( emojis ) ) { - return REACTION_EMOJIS; - } - const valid = emojis.filter( - ( entry ): entry is CuratedEmoji => - !! entry && - typeof entry.emoji === 'string' && - typeof entry.label === 'string' && - typeof entry.value === 'string' - ); - return valid; - }, [] ); -} - -/** - * Which emoji the full picker offers beyond the named list. Mirrors the - * `allow_unlisted` and `exclude` from the server's - * `gutenberg_note_reaction_emoji_settings`. - */ -export interface ReactionEmojiRules { - allowUnlisted: boolean; - // Normalized hex keys that are never accepted. - exclude: string[]; -} - -const DEFAULT_REACTION_EMOJI_RULES: ReactionEmojiRules = { - allowUnlisted: true, - exclude: [], -}; - -/** - * Drop skin-tone modifiers so a variant resolves to its base emoji. - * - * @param hexKey Normalized hex key. - * @return The base emoji's hex key. - */ -function stripSkinTones( hexKey: string ): string { - return hexKey - .split( '-' ) - .filter( ( part ) => ! /^1f3f[b-f]$/.test( part ) ) - .join( '-' ); -} - -/** - * Read the emoji rules from the raw editor setting, defaulting to any emoji - * when it is absent or malformed. - * - * @param raw The `noteReactionEmojiRules` setting. - * @return The parsed rules. - */ -export function parseReactionEmojiRules( raw: unknown ): ReactionEmojiRules { - if ( ! raw || typeof raw !== 'object' ) { - return DEFAULT_REACTION_EMOJI_RULES; - } - const { allowUnlisted, exclude } = raw as Record< string, unknown >; - return { - allowUnlisted: allowUnlisted !== false, - exclude: Array.isArray( exclude ) - ? exclude - .filter( ( key ): key is string => typeof key === 'string' ) - .map( ( key ) => key.toLowerCase() ) - : [], - }; -} - -/** - * The emoji rules from editor settings. - * - * @return The rules the REST API applies to hex-key reactions. - */ -export function useReactionEmojiRules(): ReactionEmojiRules { - const raw = useSelect( - ( select ) => - ( - select( blockEditorStore ).getSettings() as Record< - string, - unknown - > - ).noteReactionEmojiRules, - [] - ); - return useMemo( () => parseReactionEmojiRules( raw ), [ raw ] ); -} - -/** - * Hex keys of the named emoji, which the rules always accept. - * - * @param emojis The named emoji list. - * @return Set of base hex keys. - */ -export function getNamedHexKeys( emojis: CuratedEmoji[] ): Set< string > { - return new Set( - emojis.map( ( entry ) => - stripSkinTones( emojiToHexKey( entry.emoji ) ) - ) - ); -} - -/** - * Whether the rules accept an emoji. Named emoji are always accepted, and - * skin-tone variants follow their base emoji. Must agree with - * `gutenberg_is_note_reaction_hex_key_allowed()`. - * - * @param hexKey Normalized hex key of the emoji. - * @param rules The emoji rules. - * @param namedKeys Hex keys from `getNamedHexKeys()`. - * @return Whether the emoji can be used as a reaction. - */ -export function isReactionEmojiAllowed( - hexKey: string, - rules: ReactionEmojiRules, - namedKeys: Set< string > -): boolean { - const base = stripSkinTones( hexKey ); - if ( namedKeys.has( base ) ) { - return true; - } - return rules.allowUnlisted && ! rules.exclude.includes( base ); +export function getCuratedLabel( hexKey: string ): string | undefined { + return REACTION_EMOJIS.find( + ( entry ) => emojiToHexKey( entry.emoji ) === hexKey + )?.label; } /** diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 9e53c6ec6105fd..416f19b09ba7a8 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -87,9 +87,6 @@ const BLOCK_EDITOR_SETTINGS = [ 'locale', 'maxWidth', 'noteEmojibaseUrl', - 'noteEmojiLabelOverrides', - 'noteReactionEmojis', - 'noteReactionEmojiRules', 'postContentAttributes', 'postsPerPage', 'readOnly', diff --git a/phpunit/emoji-picker-data-test.php b/phpunit/emoji-picker-data-test.php index 76a6c5d8972ae6..89e699a2e8a8f2 100644 --- a/phpunit/emoji-picker-data-test.php +++ b/phpunit/emoji-picker-data-test.php @@ -1,6 +1,6 @@ assertSame( $expected, gutenberg_emoji_to_hexcode( $input ) ); - } - - public function data_gutenberg_emoji_to_hexcode() { - return array( - // One code point per UTF-8 byte length, zero-padded to four digits. - '1 byte' => array( '#', '0023' ), - '2 bytes' => array( "\u{00A9}", '00A9' ), - '3 bytes' => array( "\u{2764}", '2764' ), - '4 bytes' => array( "\u{1F600}", '1F600' ), - // VS16 is stripped to match Emojibase's unqualified keys. - 'VS16 stripped' => array( "\u{2764}\u{FE0F}", '2764' ), - 'ZWJ sequence' => array( "\u{1F468}\u{200D}\u{1F4BB}", '1F468-200D-1F4BB' ), - 'keycap sequence' => array( "\u{0030}\u{FE0F}\u{20E3}", '0030-20E3' ), - 'empty string' => array( '', '' ), - 'non-string' => array( 42, '' ), - // Malformed UTF-8 never yields a garbage key. - 'lone continuation byte' => array( "\x80", '' ), - 'truncated sequence' => array( "\xF0\x9F\x98a", '' ), - 'invalid lead byte' => array( "\xF8\x88\x80\x80\x80", '' ), - ); - } - - /** - * The default override map seeds the named reaction emojis so the - * picker shows their translated labels on the Emojibase entries. - * - * @covers ::gutenberg_get_emoji_picker_label_overrides - */ - public function test_default_overrides_seeded_from_curated_reactions() { - $overrides = gutenberg_get_emoji_picker_label_overrides(); - - // Heart, Celebration, Smile, Eyes, Rocket - the five curated - // reactions defined in gutenberg_get_note_reaction_emojis(). - $this->assertArrayHasKey( '2764', $overrides ); - $this->assertArrayHasKey( '1F389', $overrides ); - $this->assertArrayHasKey( '1F604', $overrides ); - $this->assertArrayHasKey( '1F440', $overrides ); - $this->assertArrayHasKey( '1F680', $overrides ); - } - - /** - * @covers ::gutenberg_get_emoji_picker_label_overrides - */ - public function test_filter_can_extend_overrides() { - $callback = static function ( $overrides ) { - $overrides['1F44D'] = 'Custom thumbs up'; - return $overrides; - }; - add_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - - $overrides = gutenberg_get_emoji_picker_label_overrides(); - - $this->assertSame( 'Custom thumbs up', $overrides['1F44D'] ); - - remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - } - - /** - * @covers ::gutenberg_get_emoji_picker_label_overrides - */ - public function test_filter_drops_non_string_values() { - $callback = static function () { - return array( - '1F600' => 'Grin', // Kept. - '1F44D' => array( 'array value' ), // Dropped. - '1F389' => 42, // Dropped. - '1F680' => 'Rocket', // Kept. - ); - }; - add_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - - $this->assertSame( - array( - '1F600' => 'Grin', - '1F680' => 'Rocket', - ), - gutenberg_get_emoji_picker_label_overrides() - ); - - remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - } - - /** - * @covers ::gutenberg_get_emoji_picker_label_overrides - */ - public function test_filter_returning_non_array_is_treated_as_empty() { - $callback = static function () { - return 'not an array'; - }; - add_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - - $this->assertSame( - array(), - gutenberg_get_emoji_picker_label_overrides() - ); - - remove_filter( 'gutenberg_emoji_picker_label_overrides', $callback ); - } - /** * The picker configuration rides on the block editor settings - no * page globals - so the editor package reads it through its normal @@ -139,7 +30,5 @@ public function test_emojibase_settings_injected() { // No copied data, so the picker must not be pointed at a 404. $this->assertArrayNotHasKey( 'noteEmojibaseUrl', $settings ); } - $this->assertIsArray( $settings['noteEmojiLabelOverrides'] ); - $this->assertArrayHasKey( '2764', $settings['noteEmojiLabelOverrides'] ); } } diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 45f25751e51a61..0a56fe88990de5 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -942,169 +942,6 @@ public function data_valid_reaction_emojis() { return $data; } - public function test_reaction_emojis_filter_affects_validation() { - $custom_emoji = array( - array( - 'emoji' => '👍', - 'label' => 'Thumbs Up', - 'value' => 'thumbsup', - ), - ); - - $filter = function ( $settings ) use ( $custom_emoji ) { - $settings['emojis'] = $custom_emoji; - return $settings; - }; - add_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); - - try { - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create(); - $note_id = $this->create_note( $post_id, self::$editor_id ); - - // The custom emoji should be accepted. - $params = array( - 'post' => $post_id, - 'type' => 'reaction', - 'parent' => $note_id, - 'content' => 'thumbsup', - 'author' => self::$editor_id, - ); - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 201, $response->get_status() ); - - // A previously-default emoji should now be rejected. - $params['content'] = 'heart'; - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); - } finally { - // Always remove the filter so a failed assertion above does not - // leak it into the rest of the suite. - remove_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); - } - } - - /** - * `gutenberg_note_reaction_emoji_settings` controls which hex-key reactions - * (full picker picks outside the named list) the controller accepts. - * - * @dataProvider data_reaction_emoji_rules - * - * @param mixed $rules The filtered settings. - * @param string $content The reaction storage key to submit. - * @param int $status Expected response status. - */ - public function test_reaction_emoji_rules_affect_validation( $rules, $content, $status ) { - $filter = function ( $settings ) use ( $rules ) { - return is_array( $rules ) ? array_merge( $settings, $rules ) : $rules; - }; - add_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); - - try { - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create(); - $note_id = $this->create_note( $post_id, self::$editor_id ); - - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( - wp_json_encode( - array( - 'post' => $post_id, - 'type' => 'reaction', - 'parent' => $note_id, - 'content' => $content, - ) - ) - ); - $response = rest_get_server()->dispatch( $request ); - - if ( 201 === $status ) { - $this->assertSame( 201, $response->get_status() ); - } else { - $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, $status ); - } - } finally { - remove_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); - } - } - - public function data_reaction_emoji_rules() { - $exclude_thumbs = array( 'exclude' => array( '1F44D' ) ); - $named_only = array( 'allow_unlisted' => false ); - - return array( - 'excluded emoji' => array( $exclude_thumbs, '1f44d', 400 ), - 'skin-tone variant of excluded' => array( $exclude_thumbs, '1f44d-1f3fd', 400 ), - 'emoji that is not excluded' => array( $exclude_thumbs, '1f389', 201 ), - 'exclusion written with VS-16' => array( array( 'exclude' => array( '2763-FE0F' ) ), '2763', 400 ), - 'legacy unpadded key' => array( array( 'exclude' => array( '00A9' ) ), 'a9', 400 ), - 'named-only rejects unlisted' => array( $named_only, '1f44d', 400 ), - 'named-only accepts named slug' => array( $named_only, 'heart', 201 ), - 'named-only accepts named as hex' => array( $named_only, '2764', 201 ), - 'named emoji wins over an exclusion' => array( array( 'exclude' => array( '2764' ) ), '2764', 201 ), - 'malformed rules fall back' => array( 'not-an-array', '1f389', 201 ), - ); - } - - /** - * A callback that returns only some keys keeps the defaults for the rest, - * and a non-array return falls back to the defaults entirely. - */ - public function test_reaction_emoji_settings_fill_missing_keys_from_defaults() { - $defaults = gutenberg_get_note_reaction_emoji_settings(); - - $partial = function () { - return array( 'allow_unlisted' => false ); - }; - add_filter( 'gutenberg_note_reaction_emoji_settings', $partial ); - try { - $settings = gutenberg_get_note_reaction_emoji_settings(); - } finally { - remove_filter( 'gutenberg_note_reaction_emoji_settings', $partial ); - } - $this->assertFalse( $settings['allow_unlisted'] ); - $this->assertSame( $defaults['emojis'], $settings['emojis'] ); - $this->assertSame( array(), $settings['exclude'] ); - - add_filter( 'gutenberg_note_reaction_emoji_settings', '__return_false' ); - try { - $settings = gutenberg_get_note_reaction_emoji_settings(); - } finally { - remove_filter( 'gutenberg_note_reaction_emoji_settings', '__return_false' ); - } - $this->assertSame( $defaults, $settings ); - } - - public function test_reaction_emoji_rules_reach_editor_settings() { - $filter = function ( $settings ) { - $settings['allow_unlisted'] = false; - $settings['exclude'] = array( '1F595', 'not hex', 42 ); - return $settings; - }; - add_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); - - try { - $settings = gutenberg_add_note_reaction_emojis_setting( array() ); - } finally { - remove_filter( 'gutenberg_note_reaction_emoji_settings', $filter ); - } - - $this->assertSame( - array( - 'allowUnlisted' => false, - 'exclude' => array( '1f595' ), - ), - $settings['noteReactionEmojiRules'] - ); - } - public function test_note_response_includes_reaction_summary() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); diff --git a/test/e2e/specs/editor/various/block-notes-utils.js b/test/e2e/specs/editor/various/block-notes-utils.js index f22814b2b605fd..b3500e4493978d 100644 --- a/test/e2e/specs/editor/various/block-notes-utils.js +++ b/test/e2e/specs/editor/various/block-notes-utils.js @@ -156,9 +156,9 @@ class BlockNoteUtils { .click(); await this.waitForFullPicker(); - // Curated and filter-provided reactions carry exact label - // overrides (e.g. "Heart") and are seeded into the "Frequently - // used" section, so an exact-name gridcell lookup finds them + // Curated reactions carry their own exact labels (e.g. "Heart") + // and are seeded into the "Frequently used" section, so an + // exact-name gridcell lookup finds them // without matching Emojibase labels that merely contain the name // (e.g. "smiling face with hearts"). await this.#page diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index d3f140dc019014..7ad66781eea7df 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1486,7 +1486,7 @@ test.describe( 'Block Notes', () => { // Open the full picker and click the plain heart specifically. /* * Search first so the click lands on the Emojibase entry, not - * the Frequently used seed. "Heart" is a label override, so the + * the Frequently used seed. "Heart" is a curated label, so the * exact match skips "smiling face with hearts". */ await page @@ -1735,43 +1735,6 @@ test.describe( 'Block Notes', () => { // React tree, so the thread stays selected and the trigger mounted. await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); } ); - - test.describe( 'Filtered emoji list', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activatePlugin( - 'gutenberg-test-note-reaction-emojis' - ); - } ); - - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.deactivatePlugin( - 'gutenberg-test-note-reaction-emojis' - ); - } ); - - test( 'can react with a filter-added emoji', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Testing filtered reaction' }, - comment: 'Filtered reaction', - } ); - - // Exercises the whole path: the picker offers the custom - // entry, the REST API accepts its slug, and the pill - // resolves the slug back to the filtered emoji and label. - await blockNoteUtils.addReactionToComment( 'Unicorn' ); - - const reactionButton = page.getByRole( 'button', { - name: /Unicorn/, - } ); - await expect( reactionButton ).toBeVisible(); - await expect( reactionButton ).toContainText( '🦄' ); - await expect( reactionButton ).toContainText( '1' ); - } ); - } ); } ); test.describe( 'Multiple notes per block', () => { From a0209ea496b22f81b00d1d717b98d76996d9c2eb Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 16:59:22 -0700 Subject: [PATCH 341/362] Notes: Store every emoji reaction as its hex key Curated reactions were stored as named slugs (`heart`, `rocket`) while every other pick was stored as an Emojibase hex key, a remnant of the first version that only offered the curated set. Two formats meant a slug-to-emoji map on the client, a separate validation path on the server, and normalization so a full-picker heart still landed on `heart`. Nothing has shipped yet, so drop the slugs instead of migrating them. Every reaction, including the five quick ones, is now stored as its lowercase, four-digit-padded hex key (`2764`, `1f680`), and `reaction_summary` is keyed the same way. Pills decode the emoji from the key and take the curated label first, then the Emojibase one. The server accepts a valid hex key and nothing else, so unpadded keys such as `a9` are now rejected too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- ...-gutenberg-rest-comment-controller-7-2.php | 63 +++++------ lib/compat/wordpress-7.2/note-reactions.php | 38 ------- .../src/components/collab-sidebar/note.jsx | 6 +- .../reactions/add-reaction-button.tsx | 8 +- .../reactions/emojibase-data.ts | 4 +- .../reactions/reaction-display.tsx | 98 ++++++++-------- .../reactions/reaction-emojis.ts | 66 +++-------- .../collab-sidebar/reactions/use-reaction.ts | 26 ++--- ...est-comments-controller-gutenberg-test.php | 105 +++++++++--------- phpunit/note-reactions-feed-test.php | 2 +- .../specs/editor/various/block-notes.spec.js | 42 ++++--- 11 files changed, 193 insertions(+), 265 deletions(-) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index 3e1dc135218439..8fe82c521f5a1f 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -25,7 +25,7 @@ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['properties']['reaction_summary'] = array( - 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji slug.', 'gutenberg' ), + 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji hex key.', 'gutenberg' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, @@ -246,11 +246,11 @@ protected function check_read_permission( $comment, $request ) { * Checks if a given request has access to update a comment. * * Reactions are immutable: they carry an author, a parent note and a - * canonical emoji slug that `create_item()` validates as a set, and the + * canonical emoji hex key that `create_item()` validates as a set, and the * generic update route re-validates none of it. Allowing updates would * let anyone who can edit the note's post reattribute a reaction to * another user, move it to a note on a post they cannot edit, or store a - * duplicate or invalid slug. Toggling a reaction off is a delete. + * duplicate or invalid key. Toggling a reaction off is a delete. * * @since 7.2.0 * @@ -426,7 +426,7 @@ public function create_item_permissions_check( $request ) { * Creates a comment. * * Extends the 6.9 implementation to support 'reaction' comment type - * with validation for parent note, valid emoji slugs, and uniqueness. + * with validation for parent note, valid emoji hex keys, and uniqueness. * * @since 7.2.0 * @@ -451,9 +451,9 @@ public function create_item( $request ) { ); } - // The canonical reaction slug, populated once validated below so the + // The canonical reaction key, populated once validated below so the // stored content matches what was validated (not the raw input). - $reaction_slug = null; + $reaction_key = null; // Validate reaction-specific requirements. if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { @@ -493,36 +493,31 @@ public function create_item( $request ) { ); } - // Validate the reaction content. We accept either: - // - a named slug (e.g. "heart") from the curated emoji list, or - // - a lowercase hex-codepoint sequence joined by `-` (e.g. - // "1f44d" for 👍 or "1f468-200d-1f4bb" for 👨‍💻), which is how - // the picker stores a pick from the Emojibase dataset. + // Validate the reaction content: the emoji's Emojibase hex key, + // lowercase code points padded to four digits and joined by `-` + // (e.g. "2764" for ❤️ or "1f468-200d-1f4bb" for 👨‍💻). // Raw emoji bytes are rejected because the comments table is // not guaranteed to be utf8mb4 across all WordPress installs; // clients normalize before submitting. - $emojis = gutenberg_get_note_reaction_emojis(); - $valid_slugs = wp_list_pluck( $emojis, 'value' ); - $emoji_slug = ''; + $emoji_key = ''; // Accept `content` as a string or `{ raw }`, like prepare_item_for_database(). if ( isset( $request['content'] ) && is_string( $request['content'] ) ) { - $emoji_slug = wp_strip_all_tags( $request['content'] ); + $emoji_key = wp_strip_all_tags( $request['content'] ); } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) { - $emoji_slug = wp_strip_all_tags( $request['content']['raw'] ); + $emoji_key = wp_strip_all_tags( $request['content']['raw'] ); } - $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); - $is_hex_key = (bool) preg_match( - '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', - $emoji_slug + $is_hex_key = (bool) preg_match( + '/^[0-9a-f]{4,6}(-[0-9a-f]{4,6}){0,15}$/', + $emoji_key ); - // A hex-shaped slug must still be made of assignable Unicode code + // A hex key must still be made of assignable Unicode code // points: reject anything above U+10FFFF or in the UTF-16 surrogate // range (U+D800–U+DFFF). if ( $is_hex_key ) { - foreach ( explode( '-', $emoji_slug ) as $codepoint ) { + foreach ( explode( '-', $emoji_key ) as $codepoint ) { $value = hexdec( $codepoint ); if ( $value > 0x10FFFF || ( $value >= 0xD800 && $value <= 0xDFFF ) ) { $is_hex_key = false; @@ -531,7 +526,7 @@ public function create_item( $request ) { } } - if ( ! $is_curated_slug && ! $is_hex_key ) { + if ( ! $is_hex_key ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.', 'gutenberg' ), @@ -552,7 +547,7 @@ public function create_item( $request ) { ); foreach ( $existing as $existing_reaction ) { - if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { + if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_key ) { return new WP_Error( 'rest_comment_duplicate_reaction', __( 'You have already reacted with this emoji.', 'gutenberg' ), @@ -561,7 +556,7 @@ public function create_item( $request ) { } } - $reaction_slug = $emoji_slug; + $reaction_key = $emoji_key; } $prepared_comment = $this->prepare_item_for_database( $request ); @@ -571,11 +566,11 @@ public function create_item( $request ) { $prepared_comment['comment_type'] = $request['type']; - // Persist the validated, canonical reaction slug rather than the raw + // Persist the validated, canonical reaction key rather than the raw // request content, so stored values stay consistent for grouping and - // counting (e.g. "heart" is stored as "heart"). - if ( null !== $reaction_slug ) { - $prepared_comment['comment_content'] = $reaction_slug; + // counting (e.g. "2764" is stored as "2764"). + if ( null !== $reaction_key ) { + $prepared_comment['comment_content'] = $reaction_key; } if ( ! isset( $prepared_comment['comment_content'] ) ) { @@ -618,7 +613,7 @@ public function create_item( $request ) { // Pin a reaction to the current user, whatever author details the // request carried. Author fields alone leave `user_id` at 0, which // the uniqueness check and the reaction summary both key on. - if ( null !== $reaction_slug ) { + if ( null !== $reaction_key ) { $user = wp_get_current_user(); $prepared_comment['user_id'] = $user->ID; @@ -713,7 +708,7 @@ public function create_item( $request ) { // duplicates. Every concurrent request applies the same rule, so they // all settle on the same surviving row. If this request's own row lost // the race, repoint the response to the survivor. - if ( null !== $reaction_slug ) { + if ( null !== $reaction_key ) { $matching = get_comments( array( 'parent' => $request['parent'], @@ -726,7 +721,7 @@ public function create_item( $request ) { ); $duplicates = array(); foreach ( $matching as $candidate ) { - if ( wp_strip_all_tags( $candidate->comment_content ) === $reaction_slug ) { + if ( wp_strip_all_tags( $candidate->comment_content ) === $reaction_key ) { $duplicates[] = (int) $candidate->comment_ID; } } @@ -962,7 +957,7 @@ public function prepare_item_for_response( $item, $request ) { * Checks if comment content is allowed. * * Extends the 6.9 implementation to also allow reaction content - * (emoji slugs are always valid content). + * (emoji hex keys are always valid content). * * @since 7.2.0 * @@ -970,7 +965,7 @@ public function prepare_item_for_response( $item, $request ) { * @return bool True if the content is allowed, false otherwise. */ protected function check_is_comment_content_allowed( $prepared_comment ) { - // Note reactions always have content (the emoji slug). + // Note reactions always have content (the emoji hex key). if ( isset( $prepared_comment['comment_type'] ) && 'reaction' === $prepared_comment['comment_type'] ) { return true; } diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index 4a9207f96a34e4..f63a7da3e91531 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -198,44 +198,6 @@ function gutenberg_exclude_notes_from_comment_count_7_2( $new_count, $old_count, } add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_2', 10, 3 ); -/** - * Returns the named emojis for note reactions. - * - * @since 7.2.0 - * - * @return array[] List of emoji definitions, each with `emoji`, `label`, - * and `value` keys. - */ -function gutenberg_get_note_reaction_emojis() { - return array( - array( - 'emoji' => '❤️', - 'label' => _x( 'Heart', 'emoji reaction', 'gutenberg' ), - 'value' => 'heart', - ), - array( - 'emoji' => '🎉', - 'label' => _x( 'Celebration', 'emoji reaction', 'gutenberg' ), - 'value' => 'celebration', - ), - array( - 'emoji' => '😄', - 'label' => _x( 'Smile', 'emoji reaction', 'gutenberg' ), - 'value' => 'smile', - ), - array( - 'emoji' => '👀', - 'label' => _x( 'Eyes', 'emoji reaction', 'gutenberg' ), - 'value' => 'eyes', - ), - array( - 'emoji' => '🚀', - 'label' => _x( 'Rocket', 'emoji reaction', 'gutenberg' ), - 'value' => 'rocket', - ), - ); -} - /** * Returns the reaction children of a note. * diff --git a/packages/editor/src/components/collab-sidebar/note.jsx b/packages/editor/src/components/collab-sidebar/note.jsx index 0d5af646bac19a..8e99f6d0db1e6f 100644 --- a/packages/editor/src/components/collab-sidebar/note.jsx +++ b/packages/editor/src/components/collab-sidebar/note.jsx @@ -11,7 +11,9 @@ import { __, _x, sprintf } from '@wordpress/i18n'; import { moreVertical, published } from '@wordpress/icons'; import { NoteCard } from './note-card'; import { NoteForm } from './note-form'; -import ReactionDisplay, { getReactedSlugs } from './reactions/reaction-display'; +import ReactionDisplay, { + getReactedHexKeys, +} from './reactions/reaction-display'; import { AddReactionButton } from './reactions/add-reaction-button'; import { useReaction } from './reactions/use-reaction'; @@ -83,7 +85,7 @@ export function Note( { }, [ rawContent ] ); const canResolve = note.parent === 0; - const hasReactions = getReactedSlugs( reactions ).length > 0; + const hasReactions = getReactedHexKeys( reactions ).length > 0; // Not while editing: the trigger floats over the note's corner, which // during an edit is the form's own text field. const canReact = isSelected && actionState !== 'edit'; diff --git a/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx index d51997273f13cc..88fe62521ae7c7 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx @@ -7,11 +7,11 @@ import { __ } from '@wordpress/i18n'; import { IconButton } from '@wordpress/ui'; import { reaction as reactionIcon } from '@wordpress/icons'; import EmojiPicker from './emoji-picker'; -import { emojiToStorageKey } from './reaction-emojis'; +import { emojiToHexKey } from './reaction-emojis'; interface AddReactionButtonProps { disabled?: boolean; - onToggleReaction: ( slug: string ) => void; + onToggleReaction: ( hexKey: string ) => void; } /** @@ -42,9 +42,7 @@ export function AddReactionButton( { /> } disabled={ disabled } - onSelect={ ( emoji ) => - onToggleReaction( emojiToStorageKey( emoji ) ) - } + onSelect={ ( emoji ) => onToggleReaction( emojiToHexKey( emoji ) ) } /> ); } diff --git a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts index b7e2682398908e..19e5d0e491ce86 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts @@ -356,13 +356,13 @@ export function getCachedEmojiLabel( * Resolve the label for a hex-key reaction so its pill tooltip reads like a * curated one. A loaded dataset resolves from the module cache; otherwise * the fetch waits for `load`, since the dataset is ~775KB and should not be - * pulled just because a note carries a full-picker reaction. Until then the + * pulled just because a note carries a reaction. Until then the * emoji character stands in, which assistive technology announces by its * Unicode name. * * @param hexKey Normalized reaction hex key, e.g. `1f44d`. * @param enabled Whether resolution should run (false for curated - * slugs, which have their own labels). + * emoji, which have their own labels). * @param load Whether to fetch the dataset when it isn't cached. * @return The resolved label, or null while unresolved. */ diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx index 6a9d0d5ab17e12..038927eda71e7d 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx @@ -6,10 +6,10 @@ import { __, sprintf, _n } from '@wordpress/i18n'; */ // eslint-disable-next-line @wordpress/use-recommended-components import { Button, Stack, Tooltip } from '@wordpress/ui'; -import { useState, useCallback, useMemo } from '@wordpress/element'; +import { useState, useCallback } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; -import { hexKeyToEmoji, buildEmojiBySlugMap } from './reaction-emojis'; +import { getCuratedLabel, hexKeyToEmoji } from './reaction-emojis'; import { useEmojiLabel } from './emojibase-data'; interface ReactionSummaryEntry { @@ -20,7 +20,7 @@ interface ReactionSummaryEntry { } /** - * The reaction summary keyed by storage slug (curated slug or hex key). + * The reaction summary keyed by emoji hex key. */ export type ReactionSummary = Record< string, ReactionSummaryEntry >; @@ -33,47 +33,47 @@ interface ReactionComment { } /** - * Get the count of reactions for a specific slug. + * Get the count of reactions for a specific emoji. * - * @param reactions The reactions summary (keyed by slug). - * @param slug The reaction slug to count. + * @param reactions The reactions summary (keyed by hex key). + * @param hexKey The reaction hex key to count. * @return The count of reactions. */ function getReactionCount( reactions: ReactionSummary | null | undefined, - slug: string + hexKey: string ): number { - return reactions?.[ slug ]?.count || 0; + return reactions?.[ hexKey ]?.count || 0; } /** - * Check if the current user has reacted with a specific slug. + * Check if the current user has reacted with a specific emoji. * - * @param reactions The reactions summary (keyed by slug). - * @param slug The reaction slug to check. + * @param reactions The reactions summary (keyed by hex key). + * @param hexKey The reaction hex key to check. * @return Whether the user has reacted. */ function hasUserReacted( reactions: ReactionSummary | null | undefined, - slug: string + hexKey: string ): boolean { - return reactions?.[ slug ]?.reacted || false; + return reactions?.[ hexKey ]?.reacted || false; } /** - * Get all reaction slugs that have reactions. + * Get all reaction hex keys that have reactions. * - * @param reactions The reactions summary (keyed by slug). - * @return Array of slugs with reactions. + * @param reactions The reactions summary (keyed by hex key). + * @return Array of hex keys with reactions. */ -export function getReactedSlugs( +export function getReactedHexKeys( reactions: ReactionSummary | null | undefined ): string[] { if ( ! reactions ) { return []; } return Object.keys( reactions ).filter( - ( slug ) => reactions[ slug ]?.count > 0 + ( hexKey ) => reactions[ hexKey ]?.count > 0 ); } @@ -128,7 +128,7 @@ const MAX_REACTION_PAGES = 10; /** * Fetches every reaction on a note, across every emoji. * - * The REST collection cannot be filtered by reaction slug, so the whole set + * The REST collection cannot be filtered by reaction hex key, so the whole set * has to come back before it can be grouped. Walks the pages rather than * reading only the first one, which would drop reactors on a busy note. * @@ -177,29 +177,32 @@ async function fetchNoteReactions( return null; } -// Module-level cache for reaction details: { "noteId:slug": string[] } +// Module-level cache for reaction details: { "noteId:hexKey": string[] } const reactionNamesCache: Record< string, string[] > = {}; /** - * Drop the cached reactor names for a note/slug pair, so the next tooltip + * Drop the cached reactor names for a note/hex key pair, so the next tooltip * refetches them. * * @param noteId The parent note comment ID. - * @param slug The reaction slug. + * @param hexKey The reaction hex key. */ -export function invalidateReactionNames( noteId: number, slug: string ): void { - delete reactionNamesCache[ `${ noteId }:${ slug }` ]; +export function invalidateReactionNames( + noteId: number, + hexKey: string +): void { + delete reactionNamesCache[ `${ noteId }:${ hexKey }` ]; } interface ReactionButtonProps { noteId: number; - slug: string; + hexKey: string; count: number; isActive: boolean; emoji: string; emojiLabel?: string; disabled?: boolean; - onToggleReaction: ( slug: string ) => void; + onToggleReaction: ( hexKey: string ) => void; } /** @@ -207,7 +210,7 @@ interface ReactionButtonProps { * * @param props Component props. * @param props.noteId The parent note comment ID. - * @param props.slug The emoji slug. + * @param props.hexKey The emoji hex key. * @param props.count The reaction count. * @param props.isActive Whether the current user reacted. * @param props.emoji The emoji character. @@ -219,7 +222,7 @@ interface ReactionButtonProps { */ function ReactionButton( { noteId, - slug, + hexKey, count, isActive, emoji, @@ -232,11 +235,10 @@ function ReactionButton( { // Hover or keyboard focus, which gates the Emojibase fetch below. const [ isReached, setIsReached ] = useState( false ); /* - * Full-picker reactions are stored as hex keys with no curated label, - * so resolve one from Emojibase; until it arrives the emoji character - * stands in. + * An emoji outside the curated set has no label of its own, so resolve + * one from Emojibase; until it arrives the emoji character stands in. */ - const resolvedLabel = useEmojiLabel( slug, ! emojiLabel, isReached ); + const resolvedLabel = useEmojiLabel( hexKey, ! emojiLabel, isReached ); const label = emojiLabel || resolvedLabel || emoji; /* * Derived, not stored: the names and label requests race, and a stored @@ -247,7 +249,7 @@ function ReactionButton( { const fetchReactionNames = useCallback( () => { setIsReached( true ); - const cacheKey = `${ noteId }:${ slug }`; + const cacheKey = `${ noteId }:${ hexKey }`; if ( reactionNamesCache[ cacheKey ] ) { setNames( reactionNamesCache[ cacheKey ] ); return; @@ -281,7 +283,7 @@ function ReactionButton( { const clean = content ?.replace?.( /<[^>]*>/g, '' ) ?.trim(); - return clean === slug; + return clean === hexKey; } ) .map( ( r ) => r.author_name ); @@ -294,7 +296,7 @@ function ReactionButton( { .finally( () => { setIsFetching( false ); } ); - }, [ noteId, slug, isFetching ] ); + }, [ noteId, hexKey, isFetching ] ); const defaultLabel = sprintf( /* translators: 1: emoji label, 2: count of reactions */ @@ -339,7 +341,7 @@ function ReactionButton( { ?.focus(); } setNames( null ); - onToggleReaction( slug ); + onToggleReaction( hexKey ); } } onMouseEnter={ fetchReactionNames } onFocus={ fetchReactionNames } @@ -360,7 +362,7 @@ interface ReactionDisplayProps { noteId: number; reactions: ReactionSummary | null | undefined; disabled?: boolean; - onToggleReaction: ( slug: string ) => void; + onToggleReaction: ( hexKey: string ) => void; children?: ReactNode; } @@ -369,7 +371,7 @@ interface ReactionDisplayProps { * * @param props Component props. * @param props.noteId The parent note comment ID. - * @param props.reactions The reaction summary (keyed by slug). + * @param props.reactions The reaction summary (keyed by hex key). * @param props.disabled Whether reactions can no longer be toggled * (the thread is resolved). * @param props.onToggleReaction Callback to toggle a reaction. @@ -384,10 +386,9 @@ export default function ReactionDisplay( { onToggleReaction, children, }: ReactionDisplayProps ) { - const emojiBySlug = useMemo( () => buildEmojiBySlugMap(), [] ); - const reactedSlugs = getReactedSlugs( reactions ); + const reactedHexKeys = getReactedHexKeys( reactions ); - if ( reactedSlugs.length === 0 && ! children ) { + if ( reactedHexKeys.length === 0 && ! children ) { return null; } @@ -400,20 +401,19 @@ export default function ReactionDisplay( { justify="flex-start" wrap="wrap" > - { reactedSlugs.map( ( slug ) => { - const count = getReactionCount( reactions, slug ); - const isActive = hasUserReacted( reactions, slug ); - const entry = emojiBySlug.get( slug ); + { reactedHexKeys.map( ( hexKey ) => { + const count = getReactionCount( reactions, hexKey ); + const isActive = hasUserReacted( reactions, hexKey ); return ( diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts index 1a592c437ab372..d8cd0b9ef46077 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts @@ -6,42 +6,32 @@ import { _x } from '@wordpress/i18n'; export interface CuratedEmoji { emoji: string; label: string; - value: string; } /** - * Curated emoji set for reactions. - * The `value` slug is used as the storage key in the database to avoid - * potential encoding issues with emoji characters. + * Curated emoji set for reactions, seeding "Frequently used" and naming + * these emoji with their own translated labels. */ export const REACTION_EMOJIS: CuratedEmoji[] = [ - { emoji: '❤️', label: _x( 'Heart', 'emoji reaction' ), value: 'heart' }, - { - emoji: '🎉', - label: _x( 'Celebration', 'emoji reaction' ), - value: 'celebration', - }, - { emoji: '😄', label: _x( 'Smile', 'emoji reaction' ), value: 'smile' }, - { emoji: '👀', label: _x( 'Eyes', 'emoji reaction' ), value: 'eyes' }, - { emoji: '🚀', label: _x( 'Rocket', 'emoji reaction' ), value: 'rocket' }, + { emoji: '❤️', label: _x( 'Heart', 'emoji reaction' ) }, + { emoji: '🎉', label: _x( 'Celebration', 'emoji reaction' ) }, + { emoji: '😄', label: _x( 'Smile', 'emoji reaction' ) }, + { emoji: '👀', label: _x( 'Eyes', 'emoji reaction' ) }, + { emoji: '🚀', label: _x( 'Rocket', 'emoji reaction' ) }, ]; /** - * Reactions storage format: - * - * - Curated picks are stored as their slug, e.g. `heart`. - * - Full-picker picks are stored as lowercase hex code points joined by - * `-`, e.g. `1f44d`. U+FE0F is stripped so `2764-fe0f` collapses into - * the curated `heart` slug, and each code point is padded to four digits - * to match the Emojibase `hexcode` field. + * Reactions storage format: every reaction, curated or not, is stored as + * lowercase hex code points joined by `-`, e.g. `2764` or `1f44d`. U+FE0F + * is stripped so both presentations of an emoji share one key, and each + * code point is padded to four digits to match the Emojibase `hexcode` + * field. * * ASCII keys sidestep utf8/utf8mb4 portability issues on the comments * table and group stably in the `reaction_summary` aggregation. */ -// Keys written before the padding rule can be two digits wide, so reading -// stays lenient even though writing always pads. -const HEX_KEY_RE = /^[0-9a-f]{2,6}(-[0-9a-f]{2,6})*$/; +const HEX_KEY_RE = /^[0-9a-f]{4,6}(-[0-9a-f]{4,6})*$/; const VARIATION_SELECTOR = '\u{FE0F}'; const EMOJI_RE = /^\p{Emoji}$/u; @@ -120,24 +110,6 @@ export function hexKeyToEmoji( hexKey: string ): string { } } -/** - * Map a chosen emoji character to its storage key. If the emoji matches - * a curated reaction (after stripping VS-16) returns its slug, otherwise - * returns the hex-codepoint key. - * - * @param emoji The emoji character. - * @param emojis Curated emoji list to match against. - * @return The storage key (slug or hex codepoints). - */ -export function emojiToStorageKey( - emoji: string, - emojis: CuratedEmoji[] = REACTION_EMOJIS -): string { - const hex = emojiToHexKey( emoji ); - const curated = emojis.find( ( r ) => emojiToHexKey( r.emoji ) === hex ); - return curated ? curated.value : hex; -} - /** * The translated label of a curated reaction, so the full picker and the * pills name it the same way the quick reactions do. @@ -150,15 +122,3 @@ export function getCuratedLabel( hexKey: string ): string | undefined { ( entry ) => emojiToHexKey( entry.emoji ) === hexKey )?.label; } - -/** - * Build a Map keyed by slug for O(1) emoji and label lookups. - * - * @param emojis The emoji list to index. - * @return Map from slug to `{ emoji, label, value }` entry. - */ -export function buildEmojiBySlugMap( - emojis: CuratedEmoji[] = REACTION_EMOJIS -): Map< string, CuratedEmoji > { - return new Map( emojis.map( ( entry ) => [ entry.value, entry ] ) ); -} diff --git a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts index c68b595500a4e4..99dc59fa53d5bc 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts @@ -26,18 +26,18 @@ export interface ReactionTarget { * `reacted` / `my_reaction_id` pair and takes the wrong branch. * * @param note The cached note record. - * @param slug The reaction storage slug that changed. + * @param hexKey The reaction hex key that changed. * @param addedReactionId The new reaction's comment ID when one was added; * omitted when one was removed. * @return The note with an updated `reaction_summary`. */ export function applyReactionDelta< T extends ReactionTarget >( note: T, - slug: string, + hexKey: string, addedReactionId?: number ): T { const summary: ReactionSummary = { ...( note.reaction_summary || {} ) }; - const entry = summary[ slug ]; + const entry = summary[ hexKey ]; if ( addedReactionId ) { // Concurrent adds converge server-side on one surviving row, so a @@ -45,7 +45,7 @@ export function applyReactionDelta< T extends ReactionTarget >( if ( entry?.my_reaction_id === addedReactionId ) { return note; } - summary[ slug ] = { + summary[ hexKey ] = { count: ( entry?.count || 0 ) + 1, reacted: true, my_reaction_id: addedReactionId, @@ -53,9 +53,9 @@ export function applyReactionDelta< T extends ReactionTarget >( } else if ( entry ) { const count = entry.count - 1; if ( count > 0 ) { - summary[ slug ] = { count, reacted: false }; + summary[ hexKey ] = { count, reacted: false }; } else { - delete summary[ slug ]; + delete summary[ hexKey ]; } } @@ -75,7 +75,7 @@ const reactionMutationCounts = new Map< number, number >(); * * `reaction_summary` is computed server-side and cached on the note's * entity record, so the summary the note carries is the source of truth - * for whether the current user has already reacted with a slug. + * for whether the current user has already reacted with an emoji. * * @param note The note comment record. * @return The note's reaction summary and the toggle callback. @@ -89,8 +89,8 @@ export function useReaction( note: ReactionTarget ) { const { getCurrentPostId } = useSelect( editorStore ); const toggleReaction = useCallback( - async ( slug: string ) => { - const entry = reactions?.[ slug ]; + async ( hexKey: string ) => { + const entry = reactions?.[ hexKey ]; const myReactionId = entry?.reacted ? entry.my_reaction_id : undefined; @@ -120,7 +120,7 @@ export function useReaction( note: ReactionTarget ) { post: getCurrentPostId(), type: 'reaction', parent: noteId, - content: slug, + content: hexKey, status: 'approve', }, { throwOnError: true } @@ -142,8 +142,8 @@ export function useReaction( note: ReactionTarget ) { return; } - // The slug's reactor list changed, so the pill tooltip refetches. - invalidateReactionNames( noteId, slug ); + // The emoji's reactor list changed, so the pill tooltip refetches. + invalidateReactionNames( noteId, hexKey ); // Mutating a reaction comment doesn't invalidate the cached // `reaction_summary`, so a subsequent toggle would read stale @@ -163,7 +163,7 @@ export function useReaction( note: ReactionTarget ) { receiveEntityRecords( 'root', 'comment', [ applyReactionDelta( cached, - slug, + hexKey, isRemoving ? undefined : addedReactionId ), ] ); diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 0a56fe88990de5..f36334fa3ec2ae 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -489,7 +489,7 @@ public function test_create_reaction() { 'post' => $post_id, 'type' => 'reaction', 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'author' => self::$editor_id, ); @@ -503,14 +503,14 @@ public function test_create_reaction() { $data = $response->get_data(); $new_comment = get_comment( $data['id'] ); $this->assertSame( 'reaction', $new_comment->comment_type ); - $this->assertSame( 'heart', $new_comment->comment_content ); + $this->assertSame( '2764', $new_comment->comment_content ); $this->assertSame( (string) $note_id, $new_comment->comment_parent ); $this->assertSame( '1', $new_comment->comment_approved ); } /** * The comments API accepts `content` as a string or as `{ raw }`, so a - * reaction submitted either way must validate and store the same slug. + * reaction submitted either way must validate and store the same key. * * @dataProvider data_reaction_raw_content_slugs * @@ -544,8 +544,8 @@ public function test_create_reaction_with_raw_content( $slug ) { public function data_reaction_raw_content_slugs() { return array( - 'curated slug' => array( 'heart' ), - 'hex key' => array( '1f44d' ), + 'curated emoji' => array( '2764' ), + 'other emoji' => array( '1f44d' ), ); } @@ -568,7 +568,7 @@ public function test_reaction_not_publicly_readable() { 'comment_type' => 'reaction', 'comment_approved' => 1, 'comment_parent' => $note_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', 'user_id' => self::$editor_id, ) ); @@ -615,7 +615,7 @@ public function test_cannot_create_reaction_on_note_from_different_post() { 'post' => $post_id, 'type' => 'reaction', 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'author' => self::$editor_id, ); @@ -650,7 +650,7 @@ public function test_cannot_create_reaction_on_hidden_note( $status ) { 'post' => $post_id, 'type' => 'reaction', 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'author' => self::$editor_id, ) ) @@ -699,7 +699,7 @@ public function test_cannot_create_note_without_edit_post_capability() { * * @param string $parent_kind One of 'none', 'note', or 'comment' - what the * reaction's `parent` field references. - * @param string|array $content The reaction storage key (slug) to submit, + * @param string|array $content The reaction storage key to submit, * as a string or as `{ raw }`. * @param bool $authenticate Whether to set the current user before posting. * @param string $error_code Expected WP_Error code on the REST response. @@ -749,8 +749,8 @@ public function test_cannot_create_reaction_with_invalid_input( $parent_kind, $c public function data_invalid_reaction_inputs() { return array( - 'no parent' => array( 'none', 'heart', true, 'rest_comment_invalid_parent', 400 ), - 'parent is a regular comment' => array( 'comment', 'heart', true, 'rest_comment_invalid_parent', 400 ), + 'no parent' => array( 'none', '2764', true, 'rest_comment_invalid_parent', 400 ), + 'parent is a regular comment' => array( 'comment', '2764', true, 'rest_comment_invalid_parent', 400 ), 'content is not in emoji list' => array( 'note', 'invalid_emoji', true, 'rest_comment_invalid_reaction', 400 ), // Hex storage keys are accepted (see // `test_can_create_reaction_with_hex_key()`), but only when every @@ -759,17 +759,18 @@ public function data_invalid_reaction_inputs() { 'hex in surrogate range' => array( 'note', 'd800', true, 'rest_comment_invalid_reaction', 400 ), 'hex with a bad segment' => array( 'note', '1f468-200d-dfff', true, 'rest_comment_invalid_reaction', 400 ), 'uppercase hex key' => array( 'note', '1F44D', true, 'rest_comment_invalid_reaction', 400 ), - 'anonymous user' => array( 'none', 'heart', false, 'rest_comment_login_required', 401 ), - 'raw content not in list' => array( 'note', array( 'raw' => 'invalid_emoji' ), true, 'rest_comment_invalid_reaction', 400 ), - 'content without raw' => array( 'note', array( 'rendered' => 'heart' ), true, 'rest_comment_invalid_reaction', 400 ), + 'unpadded hex key' => array( 'note', 'a9', true, 'rest_comment_invalid_reaction', 400 ), + 'emoji name instead of hex' => array( 'note', 'heart', true, 'rest_comment_invalid_reaction', 400 ), + 'anonymous user' => array( 'none', '2764', false, 'rest_comment_login_required', 401 ), + 'raw content not a key' => array( 'note', array( 'raw' => 'invalid_emoji' ), true, 'rest_comment_invalid_reaction', 400 ), + 'content without raw' => array( 'note', array( 'rendered' => '2764' ), true, 'rest_comment_invalid_reaction', 400 ), ); } /** - * A pick from the emoji picker that is not in the named list is - * submitted as a lowercase hex code-point sequence, which the reaction - * pill decodes back into the emoji. Those keys must round-trip through - * create. + * Every reaction is submitted as a lowercase hex code-point sequence, + * which the reaction pill decodes back into the emoji. Those keys must + * round-trip through create. * * @dataProvider data_valid_reaction_hex_keys * @@ -805,7 +806,7 @@ public function data_valid_reaction_hex_keys() { return array( 'single code point' => array( '1f44d' ), 'ZWJ sequence' => array( '1f468-200d-1f4bb' ), - 'two-digit key' => array( 'a9' ), + 'padded key' => array( '00a9' ), ); } @@ -818,7 +819,7 @@ public function test_cannot_create_duplicate_reaction() { 'post' => $post_id, 'type' => 'reaction', 'parent' => $note_id, - 'content' => 'rocket', + 'content' => '1f680', 'author' => self::$editor_id, ); @@ -859,7 +860,7 @@ public function test_concurrent_duplicate_reaction_converges_to_single_row() { 'comment_post_ID' => $post_id, 'comment_parent' => $note_id, 'comment_type' => 'reaction', - 'comment_content' => 'heart', + 'comment_content' => '2764', 'comment_approved' => 1, 'user_id' => self::$editor_id, ) @@ -874,7 +875,7 @@ public function test_concurrent_duplicate_reaction_converges_to_single_row() { 'post' => $post_id, 'type' => 'reaction', 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'author' => self::$editor_id, ); $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); @@ -899,7 +900,7 @@ public function test_concurrent_duplicate_reaction_converges_to_single_row() { array_filter( $remaining, function ( $comment ) { - return 'heart' === wp_strip_all_tags( $comment->comment_content ); + return '2764' === wp_strip_all_tags( $comment->comment_content ); } ) ); @@ -934,12 +935,14 @@ public function test_create_reaction_valid_emojis( $emoji ) { } public function data_valid_reaction_emojis() { - $emojis = gutenberg_get_note_reaction_emojis(); - $data = array(); - foreach ( $emojis as $emoji ) { - $data[ $emoji['value'] ] = array( $emoji['value'] ); - } - return $data; + // The five quick reactions: heart, celebration, smile, eyes, rocket. + return array( + 'heart' => array( '2764' ), + 'celebration' => array( '1f389' ), + 'smile' => array( '1f604' ), + 'eyes' => array( '1f440' ), + 'rocket' => array( '1f680' ), + ); } public function test_note_response_includes_reaction_summary() { @@ -952,7 +955,7 @@ public function test_note_response_includes_reaction_summary() { 'post' => $post_id, 'type' => 'reaction', 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'author' => self::$editor_id, ); $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); @@ -968,10 +971,10 @@ public function test_note_response_includes_reaction_summary() { $data = $response->get_data(); $this->assertArrayHasKey( 'reaction_summary', $data ); - $this->assertArrayHasKey( 'heart', $data['reaction_summary'] ); - $this->assertSame( 1, $data['reaction_summary']['heart']['count'] ); - $this->assertTrue( $data['reaction_summary']['heart']['reacted'] ); - $this->assertSame( $reaction_id, $data['reaction_summary']['heart']['my_reaction_id'] ); + $this->assertArrayHasKey( '2764', $data['reaction_summary'] ); + $this->assertSame( 1, $data['reaction_summary']['2764']['count'] ); + $this->assertTrue( $data['reaction_summary']['2764']['reacted'] ); + $this->assertSame( $reaction_id, $data['reaction_summary']['2764']['my_reaction_id'] ); } public function test_reaction_summary_shows_not_reacted_for_other_user() { @@ -984,7 +987,7 @@ public function test_reaction_summary_shows_not_reacted_for_other_user() { 'post' => $post_id, 'type' => 'reaction', 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'author' => self::$editor_id, ); $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); @@ -1000,9 +1003,9 @@ public function test_reaction_summary_shows_not_reacted_for_other_user() { $data = $response->get_data(); $this->assertArrayHasKey( 'reaction_summary', $data ); - $this->assertSame( 1, $data['reaction_summary']['heart']['count'] ); - $this->assertFalse( $data['reaction_summary']['heart']['reacted'] ); - $this->assertSame( 0, $data['reaction_summary']['heart']['my_reaction_id'] ); + $this->assertSame( 1, $data['reaction_summary']['2764']['count'] ); + $this->assertFalse( $data['reaction_summary']['2764']['reacted'] ); + $this->assertSame( 0, $data['reaction_summary']['2764']['my_reaction_id'] ); } /** @@ -1043,8 +1046,8 @@ public function test_second_page_of_notes_keeps_reaction_summary_queries_bounded $data = $response->get_data(); $this->assertCount( 2, $data ); foreach ( $data as $note ) { - $this->assertSame( 1, $note['reaction_summary']['heart']['count'] ); - $this->assertTrue( $note['reaction_summary']['heart']['reacted'] ); + $this->assertSame( 1, $note['reaction_summary']['2764']['count'] ); + $this->assertTrue( $note['reaction_summary']['2764']['reacted'] ); } // One counts query and one current-user query for the whole page. $this->assertSame( 2, $summary_queries ); @@ -1066,7 +1069,7 @@ public function test_notes_and_reactions_excluded_from_comment_count() { 'comment_post_ID' => $post_id, 'comment_type' => 'reaction', 'comment_parent' => $note_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', 'comment_approved' => 1, 'user_id' => self::$editor_id, ) @@ -1094,7 +1097,7 @@ public function test_notes_and_reactions_excluded_from_comment_count() { * @param string $slug Reaction storage slug. * @return int Reaction comment ID. */ - protected function create_reaction( $post_id, $note_id, $user_id, $slug = 'heart' ) { + protected function create_reaction( $post_id, $note_id, $user_id, $slug = '2764' ) { return wp_insert_comment( array( 'comment_post_ID' => $post_id, @@ -1120,11 +1123,11 @@ public function test_cannot_update_reaction() { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array( 'content' => 'rocket' ) ) ); + $request->set_body( wp_json_encode( array( 'content' => '1f680' ) ) ); $response = rest_get_server()->dispatch( $request ); $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); - $this->assertSame( 'heart', get_comment( $reaction_id )->comment_content ); + $this->assertSame( '2764', get_comment( $reaction_id )->comment_content ); } /** @@ -1153,7 +1156,7 @@ public function test_permanently_deleting_note_deletes_its_reactions() { $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); $note_id = $this->create_note( $post_id, self::$editor_id ); $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); - $other_id = $this->create_reaction( $post_id, $note_id, self::$author_id, 'rocket' ); + $other_id = $this->create_reaction( $post_id, $note_id, self::$author_id, '1f680' ); wp_delete_comment( $note_id, true ); @@ -1233,7 +1236,7 @@ public function test_untrashing_note_does_not_restore_removed_reactions() { $note_id = $this->create_note( $post_id, self::$editor_id ); $removed_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); wp_trash_comment( $removed_id ); - $live_id = $this->create_reaction( $post_id, $note_id, self::$author_id, 'rocket' ); + $live_id = $this->create_reaction( $post_id, $note_id, self::$author_id, '1f680' ); wp_trash_comment( $note_id ); wp_untrash_comment( $note_id ); @@ -1275,7 +1278,7 @@ public function test_permanently_deleting_trashed_note_deletes_its_reactions() { $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); $note_id = $this->create_note( $post_id, self::$editor_id ); $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); - $removed_id = $this->create_reaction( $post_id, $note_id, self::$author_id, 'rocket' ); + $removed_id = $this->create_reaction( $post_id, $note_id, self::$author_id, '1f680' ); wp_trash_comment( $removed_id ); wp_trash_comment( $note_id ); @@ -1301,7 +1304,7 @@ public function test_cannot_create_reaction_for_another_user() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -1335,7 +1338,7 @@ public function test_create_reaction_ignores_request_author_fields() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author_name' => 'Someone Else', 'author_email' => 'someone@example.com', @@ -1391,7 +1394,7 @@ public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor( 'comment_post_ID' => $post_id, 'comment_parent' => $note_id, 'comment_type' => 'reaction', - 'comment_content' => 'heart', + 'comment_content' => '2764', 'comment_approved' => 1, 'user_id' => self::$editor_id, ) @@ -1407,7 +1410,7 @@ public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor( 'post' => $post_id, 'type' => 'reaction', 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'author' => self::$editor_id, ); $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); diff --git a/phpunit/note-reactions-feed-test.php b/phpunit/note-reactions-feed-test.php index 1ecca9b05adfd1..0bb3b5b4d3ab5b 100644 --- a/phpunit/note-reactions-feed-test.php +++ b/phpunit/note-reactions-feed-test.php @@ -41,7 +41,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { 'comment_post_ID' => self::$post_id, 'comment_parent' => $note_id, 'comment_type' => 'reaction', - 'comment_content' => 'heart', + 'comment_content' => '2764', 'comment_approved' => '1', ) ); diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 7ad66781eea7df..f2dd57963b58f1 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1473,20 +1473,38 @@ test.describe( 'Block Notes', () => { await expect( reactionPill ).toBeEnabled(); } ); - test( 'a full-picker pick that matches a curated emoji stores as the curated slug', async ( { + test( 'a curated pick and the same emoji from search share one hex key', async ( { page, blockNoteUtils, } ) => { await blockNoteUtils.addBlockWithNote( { type: 'core/paragraph', - attributes: { content: 'Curated normalization' }, - comment: 'Pick heart from full picker', + attributes: { content: 'Curated hex key' }, + comment: 'Pick heart twice', } ); - // Open the full picker and click the plain heart specifically. + // The Frequently used seed stores the heart by its hex key. + const created = page.waitForRequest( + ( request ) => + request.method() === 'POST' && + /\/wp\/v2\/comments/.test( + decodeURIComponent( request.url() ) + ) + ); + await blockNoteUtils.addReactionToComment( 'Heart' ); + expect( ( await created ).postDataJSON().content ).toBe( '2764' ); + + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( '❤' ); + await expect( reactionButton ).toContainText( '1' ); + /* - * Search first so the click lands on the Emojibase entry, not - * the Frequently used seed. "Heart" is a curated label, so the + * Picking the same heart from the search results resolves to + * the same key, so it toggles the existing reaction off rather + * than adding a second pill. "Heart" is a curated label, so the * exact match skips "smiling face with hearts". */ await page @@ -1497,17 +1515,7 @@ test.describe( 'Block Notes', () => { await page .getByRole( 'gridcell', { name: 'Heart', exact: true } ) .click(); - - /* - * One pill showing the heart and a count of 1, as a named-set - * pick produces. - */ - const reactionButton = page.locator( - '.editor-collab-sidebar-panel__reaction-button' - ); - await expect( reactionButton ).toHaveCount( 1 ); - await expect( reactionButton ).toContainText( '❤' ); - await expect( reactionButton ).toContainText( '1' ); + await expect( reactionButton ).toHaveCount( 0 ); } ); test( 'a full-picker pick that is not curated renders the chosen emoji', async ( { From ee6eea5342f6398c7a5db71e30b89c540eae3e92 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 17:00:41 -0700 Subject: [PATCH 342/362] Notes: Show an error instead of a fallback when emojis fail to load When the Emojibase data could not load, the picker swapped in the five quick reactions as a stand-in grid, with its own search path, a flat layout, an e2e plugin to fake the outage, and a test for it. The data ships in the plugin's own build, so a failed load is rare, and a plain "Couldn't load emojis." message covers it without a second picker mode to maintain. A missing `noteEmojibaseUrl` setting now shows the same message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- .../plugins/note-emojibase-unavailable.php | 21 ------ .../reactions/add-reaction-button.tsx | 2 +- .../collab-sidebar/reactions/emoji-picker.tsx | 72 ++++--------------- .../specs/editor/various/block-notes-utils.js | 5 +- .../specs/editor/various/block-notes.spec.js | 48 ------------- 5 files changed, 16 insertions(+), 132 deletions(-) delete mode 100644 packages/e2e-tests/plugins/note-emojibase-unavailable.php diff --git a/packages/e2e-tests/plugins/note-emojibase-unavailable.php b/packages/e2e-tests/plugins/note-emojibase-unavailable.php deleted file mode 100644 index 3fe8e7480e0f15..00000000000000 --- a/packages/e2e-tests/plugins/note-emojibase-unavailable.php +++ /dev/null @@ -1,21 +0,0 @@ - ( { - hexcode: emojiToHexKey( emoji ), - emoji, - label, - } ) ); -} - /** * Searchable emoji picker: a trigger button opening an autocomplete popup * with the search field on top of the emoji grid. Emoji data and labels * come from the per-locale Emojibase files at `noteEmojibaseUrl`; UI chrome * strings go through `@wordpress/i18n`. Without a dataset (no URL - * configured, or the fetch failed) the grid offers the named reaction set - * instead, so reacting keeps working. + * configured, or the fetch failed) the popup shows an error message. * * @param props Component props. * @param props.trigger The button that opens the picker. @@ -265,11 +242,7 @@ export default function EmojiPicker( { isLoading, error, } = useEmojibaseData( isWarm ? baseUrl : null, locale ); - const hasDataset = !! dataset; - const data = useMemo( - () => dataset ?? namedEmojisToEntries( REACTION_EMOJIS ), - [ dataset ] - ); + const data = useMemo( () => dataset ?? [], [ dataset ] ); const [ query, setQuery ] = useState( '' ); /* @@ -297,10 +270,7 @@ export default function EmojiPicker( { ); const { set: setPreference } = useDispatch( preferencesStore ); - const groups = useMemo( - () => ( hasDataset ? groupEmojis( data ) : [] ), - [ data, hasDataset ] - ); + const groups = useMemo( () => groupEmojis( data ), [ data ] ); // Resolves stored frequently-used hex keys back to full records. const recordByHexKey = useMemo( () => { @@ -314,8 +284,6 @@ export default function EmojiPicker( { }, [ data ] ); const isSearching = !! query.trim(); - // The named set is a handful of emoji, so it needs no category headings. - const isFlat = isSearching || ! hasDataset; /* * The items handed to `Autocomplete.Root`: category groups while @@ -344,12 +312,6 @@ export default function EmojiPicker( { }; }; - if ( ! hasDataset ) { - return searchEmojis( data, query ).map( ( entry ) => - toOption( entry, 'named' ) - ); - } - if ( isSearching ) { /* * One flat grid of results, as in the macOS picker: @@ -382,16 +344,7 @@ export default function EmojiPicker( { ), } ) ), ].filter( ( group ) => group.items.length > 0 ); - }, [ - data, - hasDataset, - groups, - isSearching, - query, - frequentKeys, - recordByHexKey, - skinTone, - ] ); + }, [ groups, isSearching, query, frequentKeys, recordByHexKey, skinTone ] ); /** * Render grid rows of emoji cells, recording usage on selection. @@ -426,13 +379,12 @@ export default function EmojiPicker( { ) ); + const loadFailed = !! error || ! baseUrl; let status: ReactNode = null; if ( isLoading ) { status = __( 'Loading…' ); - } else if ( error ) { - status = __( - 'Couldn’t load the full emoji picker. Basic reactions are available.' - ); + } else if ( loadFailed ) { + status = __( 'Couldn’t load emojis.' ); } else if ( hasJustLoaded && ! isSearching && groups.length > 0 ) { // An empty dataset is left to `Autocomplete.Empty`. const emojiCount = groups.reduce( @@ -540,16 +492,18 @@ export default function EmojiPicker( {
{ status } - { isLoading ? null : __( 'No emoji found.' ) } + { isLoading || loadFailed + ? null + : __( 'No emoji found.' ) } - { isFlat + { isSearching ? renderRows( items as EmojiOption[] ) : ( group: EmojiOptionGroup ) => ( { await expect( reactionButton ).toBeVisible(); } ); - test.describe( 'Emojibase dataset unavailable', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activatePlugin( - 'gutenberg-test-note-emojibase-unavailable' - ); - } ); - - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.deactivatePlugin( - 'gutenberg-test-note-emojibase-unavailable' - ); - } ); - - test( 'the picker offers the named set and a pick still adds a reaction', async ( { - page, - blockNoteUtils, - } ) => { - await blockNoteUtils.addBlockWithNote( { - type: 'core/paragraph', - attributes: { content: 'Named pick' }, - comment: 'Test comment for named pick', - } ); - - await page - .getByRole( 'combobox', { name: 'Add reaction' } ) - .click(); - await blockNoteUtils.waitForFullPicker(); - - // Only the named set, with no category headings. - const grid = page.getByRole( 'grid' ); - await expect( grid.getByRole( 'gridcell' ) ).toHaveCount( 5 ); - await expect( - page.locator( - '.editor-collab-sidebar-panel__picker-category' - ) - ).toHaveCount( 0 ); - - // Search still filters the named set. - await page.getByPlaceholder( 'Search emoji' ).fill( 'heart' ); - await expect( grid.getByRole( 'gridcell' ) ).toHaveCount( 1 ); - await grid.getByRole( 'gridcell', { name: 'Heart' } ).click(); - - await expect( - page.getByRole( 'button', { name: /Heart/ } ) - ).toContainText( '1' ); - } ); - } ); - test( 'resolving a thread locks its reactions', async ( { page, blockNoteUtils, From 1c1c94a9c1db100e1e889106ffa93966b0e09325 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 2 Oct 2026 18:12:31 -0700 Subject: [PATCH 343/362] Storybook: Add Autocomplete.Trigger to the components manifest The manifest snapshot check failed because the new Trigger subcomponent was missing from the committed snapshot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8d57dBsBFSa51D1Dk46vU --- storybook/components-manifest.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/storybook/components-manifest.yml b/storybook/components-manifest.yml index eef63ca4ed1327..3d6e0452d30fdb 100644 --- a/storybook/components-manifest.yml +++ b/storybook/components-manifest.yml @@ -168,6 +168,13 @@ - className - render - style + - name: Autocomplete.Trigger + props: + - className + - disabled + - nativeButton + - render + - style - name: Autocomplete.Value props: - children From 50587a162982af06072ec799d03c3b9a5c96ebf4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 3 Oct 2026 09:46:28 -0700 Subject: [PATCH 344/362] Notes: Recover the emoji picker from a failed or malformed dataset load A failed fetch left the picker's error in place for the life of the component, since reopening the popup reruns nothing, so the picker stayed broken until a page reload. Reopening it after a failure now retries. A JSON response that is not an array also reached groupEmojis() and threw during render; reject it at the load boundary so the picker shows its error state instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- .../collab-sidebar/reactions/emoji-picker.tsx | 5 ++ .../reactions/emojibase-data.ts | 27 +++++--- .../test/emojibase-data.jsdom.test.ts | 62 +++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/reactions/test/emojibase-data.jsdom.test.ts diff --git a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx index f6135e4e8ad7ad..dec3f03e6fdbed 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -241,6 +241,7 @@ export default function EmojiPicker( { data: dataset, isLoading, error, + retry, } = useEmojibaseData( isWarm ? baseUrl : null, locale ); const data = useMemo( () => dataset ?? [], [ dataset ] ); const [ query, setQuery ] = useState( '' ); @@ -414,6 +415,10 @@ export default function EmojiPicker( { setIsOpen( nextOpen ); if ( nextOpen ) { warm(); + // A failed load would otherwise stick until reload. + if ( error ) { + retry(); + } } } } // Start each opening from the full grid. diff --git a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts index 19e5d0e491ce86..e271ed9a00d7ef 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from '@wordpress/element'; +import { useCallback, useEffect, useState } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; // @ts-expect-error - No type declarations available for @wordpress/block-editor import { store as blockEditorStore } from '@wordpress/block-editor'; @@ -77,6 +77,11 @@ export interface EmojibaseDataState { error: Error | null; } +export interface EmojibaseDataResult extends EmojibaseDataState { + // Refetch after a failed load. + retry: () => void; +} + /* * Locales Emojibase ships translated labels for. Must stay in sync with * `LOCALES` in `tools/build-scripts/copy-emojibase-data.mjs`. @@ -189,10 +194,16 @@ export function loadEmojibaseData( if ( ! r.ok ) { throw new Error( `Failed to load ${ locale }/data.json` ); } - return r.json() as Promise< EmojibaseEntry[] >; + return r.json(); } ) - .then( ( data ) => { - const value: EmojibaseDataset = { data }; + .then( ( data: unknown ) => { + // Anything but an array would throw in the picker's render. + if ( ! Array.isArray( data ) ) { + throw new Error( `Invalid ${ locale }/data.json` ); + } + const value: EmojibaseDataset = { + data: data as EmojibaseEntry[], + }; dataCache.set( cacheKey, value ); inflight.delete( cacheKey ); return value; @@ -215,7 +226,9 @@ export function loadEmojibaseData( export function useEmojibaseData( baseUrl: string | null, locale: string -): EmojibaseDataState { +): EmojibaseDataResult { + const [ attempt, setAttempt ] = useState( 0 ); + const retry = useCallback( () => setAttempt( ( n ) => n + 1 ), [] ); const [ state, setState ] = useState< EmojibaseDataState >( () => { const cached = dataCache.get( `${ baseUrl }|${ locale }` ); return { @@ -265,9 +278,9 @@ export function useEmojibaseData( return () => { cancelled = true; }; - }, [ baseUrl, locale ] ); + }, [ baseUrl, locale, attempt ] ); - return state; + return { ...state, retry }; } /** diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/emojibase-data.jsdom.test.ts b/packages/editor/src/components/collab-sidebar/reactions/test/emojibase-data.jsdom.test.ts new file mode 100644 index 00000000000000..aaca8793cf343b --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/test/emojibase-data.jsdom.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { loadEmojibaseData, useEmojibaseData } from '../emojibase-data'; + +const ENTRY = { hexcode: '1F600', emoji: '😀', label: 'grinning face' }; + +function mockFetchResponses( ...bodies: Array< unknown | Error > ) { + const fetchMock = vi.fn(); + for ( const body of bodies ) { + fetchMock.mockImplementationOnce( () => + body instanceof Error + ? Promise.reject( body ) + : Promise.resolve( { + ok: true, + json: () => Promise.resolve( body ), + } ) + ); + } + vi.stubGlobal( 'fetch', fetchMock ); + return fetchMock; +} + +describe( 'loadEmojibaseData', () => { + afterEach( () => { + vi.unstubAllGlobals(); + } ); + + it( 'rejects a response that is not an array', async () => { + mockFetchResponses( { not: 'an array' } ); + + await expect( + loadEmojibaseData( 'https://example.com/shape', 'en' ) + ).rejects.toThrow( 'Invalid en/data.json' ); + } ); +} ); + +describe( 'useEmojibaseData', () => { + afterEach( () => { + vi.unstubAllGlobals(); + } ); + + it( 'loads the dataset again when retried after a failure', async () => { + const fetchMock = mockFetchResponses( new Error( 'Network down' ), [ + ENTRY, + ] ); + + const { result } = renderHook( () => + useEmojibaseData( 'https://example.com/retry', 'en' ) + ); + + await waitFor( () => expect( result.current.error ).not.toBeNull() ); + expect( result.current.data ).toBeNull(); + + act( () => result.current.retry() ); + + await waitFor( () => + expect( result.current.data ).toEqual( [ ENTRY ] ) + ); + expect( result.current.error ).toBeNull(); + expect( fetchMock ).toHaveBeenCalledTimes( 2 ); + } ); +} ); From 250fb4e1e2f84621e8dc270a33c2ea0ec6731dc5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 3 Oct 2026 09:52:41 -0700 Subject: [PATCH 345/362] Notes: Fetch a note's reactor names once for all its pills Every pill's tooltip fetched every reaction on the note, then cached only the names for its own emoji, so focusing each pill on a note repeated the same paginated walk. Cache the grouped names per note, share the in-flight request between pills, and invalidate the note as a whole after a toggle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- .../reactions/reaction-display.tsx | 122 +++++++++++------- .../test/reaction-display.jsdom.test.tsx | 67 ++++++++++ .../collab-sidebar/reactions/use-reaction.ts | 4 +- 3 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx index 038927eda71e7d..03138b0b567330 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx @@ -177,21 +177,78 @@ async function fetchNoteReactions( return null; } -// Module-level cache for reaction details: { "noteId:hexKey": string[] } -const reactionNamesCache: Record< string, string[] > = {}; +/* + * Module-level cache of reactor names per note, grouped by hex key. One fetch + * returns every emoji on the note, so all its pills share it, along with any + * request still in flight. Resolves to `null` when the note has more + * reactions than the walk will fetch. + */ +const reactionNamesCache = new Map< + number, + Promise< Record< string, string[] > | null > +>(); + +/** + * Groups reactor names by the hex key each reaction stores. + * + * @param reactions Reaction comments on one note. + * @return Reactor names keyed by hex key. + */ +function groupReactionNames( + reactions: ReactionComment[] +): Record< string, string[] > { + const grouped: Record< string, string[] > = {}; + for ( const reaction of reactions ) { + const content = + typeof reaction.content === 'object' + ? reaction.content?.raw || reaction.content?.rendered + : reaction.content; + const hexKey = content?.replace?.( /<[^>]*>/g, '' )?.trim(); + if ( hexKey ) { + grouped[ hexKey ] = [ + ...( grouped[ hexKey ] ?? [] ), + reaction.author_name, + ]; + } + } + return grouped; +} /** - * Drop the cached reactor names for a note/hex key pair, so the next tooltip - * refetches them. + * Resolves the reactor names on a note, fetching them at most once. * * @param noteId The parent note comment ID. - * @param hexKey The reaction hex key. + * @return Reactor names keyed by hex key, or `null` for a note too busy to + * name every reactor. */ -export function invalidateReactionNames( - noteId: number, - hexKey: string -): void { - delete reactionNamesCache[ `${ noteId }:${ hexKey }` ]; +function getReactionNames( + noteId: number +): Promise< Record< string, string[] > | null > { + let names = reactionNamesCache.get( noteId ); + if ( ! names ) { + const request = fetchNoteReactions( noteId ).then( + ( reactions ) => reactions && groupReactionNames( reactions ) + ); + // A failed request is not worth keeping; let the next hover retry. + request.catch( () => { + if ( reactionNamesCache.get( noteId ) === request ) { + reactionNamesCache.delete( noteId ); + } + } ); + reactionNamesCache.set( noteId, request ); + names = request; + } + return names; +} + +/** + * Drop the cached reactor names for a note, so the next tooltip refetches + * them. + * + * @param noteId The parent note comment ID. + */ +export function invalidateReactionNames( noteId: number ): void { + reactionNamesCache.delete( noteId ); } interface ReactionButtonProps { @@ -231,7 +288,6 @@ function ReactionButton( { onToggleReaction, }: ReactionButtonProps ) { const [ names, setNames ] = useState< string[] | null >( null ); - const [ isFetching, setIsFetching ] = useState( false ); // Hover or keyboard focus, which gates the Emojibase fetch below. const [ isReached, setIsReached ] = useState( false ); /* @@ -249,54 +305,26 @@ function ReactionButton( { const fetchReactionNames = useCallback( () => { setIsReached( true ); - const cacheKey = `${ noteId }:${ hexKey }`; - if ( reactionNamesCache[ cacheKey ] ) { - setNames( reactionNamesCache[ cacheKey ] ); - return; - } - - if ( isFetching ) { - return; - } - /* * A miss on a pill that already listed names means it was * invalidated; drop the stale list rather than show it against * the new count while refetching. */ - setNames( null ); - setIsFetching( true ); - fetchNoteReactions( noteId ) - .then( ( reactions ) => { + if ( ! reactionNamesCache.has( noteId ) ) { + setNames( null ); + } + getReactionNames( noteId ) + .then( ( grouped ) => { // A truncated walk would drop reactors, and a partial name // list reads as complete. Keep the count-based label instead. - if ( ! reactions ) { - return; + if ( grouped ) { + setNames( grouped[ hexKey ] ?? [] ); } - - const fetchedNames = reactions - .filter( ( r ) => { - const content = - typeof r.content === 'object' - ? r.content?.raw || r.content?.rendered - : r.content; - const clean = content - ?.replace?.( /<[^>]*>/g, '' ) - ?.trim(); - return clean === hexKey; - } ) - .map( ( r ) => r.author_name ); - - reactionNamesCache[ cacheKey ] = fetchedNames; - setNames( fetchedNames ); } ) .catch( () => { // Silently fall back to count-based label. - } ) - .finally( () => { - setIsFetching( false ); } ); - }, [ noteId, hexKey, isFetching ] ); + }, [ noteId, hexKey ] ); const defaultLabel = sprintf( /* translators: 1: emoji label, 2: count of reactions */ diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx new file mode 100644 index 00000000000000..d067c3bda8d11f --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +import ReactionDisplay, { invalidateReactionNames } from '../reaction-display'; + +vi.mock( import( '@wordpress/api-fetch' ) ); + +const NOTE_ID = 7; + +const REACTIONS = [ + { author_name: 'Ada', content: { raw: '2764' } }, + { author_name: 'Grace', content: { raw: '1f44d' } }, + { author_name: 'Linus', content: { raw: '2764' } }, +]; + +function renderPills() { + render( + {} } + /> + ); + return screen.getAllByRole( 'button' ); +} + +describe( 'ReactionDisplay', () => { + beforeEach( () => { + invalidateReactionNames( NOTE_ID ); + vi.mocked( apiFetch ).mockReset(); + vi.mocked( apiFetch ).mockResolvedValue( REACTIONS ); + } ); + + it( 'fetches the reactions on a note once for all of its pills', async () => { + const [ heart, thumbsUp ] = renderPills(); + + fireEvent.focus( heart ); + fireEvent.focus( thumbsUp ); + + await waitFor( () => + expect( heart ).toHaveAccessibleName( + expect.stringContaining( 'Ada and Linus' ) + ) + ); + await waitFor( () => + expect( thumbsUp ).toHaveAccessibleName( + expect.stringContaining( 'Grace' ) + ) + ); + expect( apiFetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'refetches after the note is invalidated', async () => { + const [ heart ] = renderPills(); + + fireEvent.focus( heart ); + await waitFor( () => expect( apiFetch ).toHaveBeenCalledTimes( 1 ) ); + + invalidateReactionNames( NOTE_ID ); + fireEvent.focus( heart ); + + await waitFor( () => expect( apiFetch ).toHaveBeenCalledTimes( 2 ) ); + } ); +} ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts index 99dc59fa53d5bc..37409dfcfb8734 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts @@ -142,8 +142,8 @@ export function useReaction( note: ReactionTarget ) { return; } - // The emoji's reactor list changed, so the pill tooltip refetches. - invalidateReactionNames( noteId, hexKey ); + // The note's reactor lists changed, so its pill tooltips refetch. + invalidateReactionNames( noteId ); // Mutating a reaction comment doesn't invalidate the cached // `reaction_summary`, so a subsequent toggle would read stale From 6b56b7bb9027d0d6b8142710691d3e21bb25c631 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 4 Oct 2026 07:56:26 -0700 Subject: [PATCH 346/362] Notes: Lowercase the curated reaction emoji names The five curated reactions were the only capitalized names in the picker, next to the lowercase Unicode names Emojibase supplies, and the difference shows in the new name footer (#84051). Lowercase also reads better in the pill tooltip, where the name sits mid-sentence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZXqJi4dn3qcNsYZHa579m --- .../reactions/reaction-emojis.ts | 14 ++++--- .../specs/editor/various/block-notes-utils.js | 2 +- .../specs/editor/various/block-notes.spec.js | 38 +++++++++---------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts index d8cd0b9ef46077..82e5eb5e51f35f 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts @@ -10,14 +10,16 @@ export interface CuratedEmoji { /** * Curated emoji set for reactions, seeding "Frequently used" and naming - * these emoji with their own translated labels. + * these emoji with their own translated labels. Lowercase, like the + * Emojibase names around them, since they also appear mid-sentence + * ("Adam reacted with heart"). */ export const REACTION_EMOJIS: CuratedEmoji[] = [ - { emoji: '❤️', label: _x( 'Heart', 'emoji reaction' ) }, - { emoji: '🎉', label: _x( 'Celebration', 'emoji reaction' ) }, - { emoji: '😄', label: _x( 'Smile', 'emoji reaction' ) }, - { emoji: '👀', label: _x( 'Eyes', 'emoji reaction' ) }, - { emoji: '🚀', label: _x( 'Rocket', 'emoji reaction' ) }, + { emoji: '❤️', label: _x( 'heart', 'emoji reaction' ) }, + { emoji: '🎉', label: _x( 'celebration', 'emoji reaction' ) }, + { emoji: '😄', label: _x( 'smile', 'emoji reaction' ) }, + { emoji: '👀', label: _x( 'eyes', 'emoji reaction' ) }, + { emoji: '🚀', label: _x( 'rocket', 'emoji reaction' ) }, ]; /** diff --git a/test/e2e/specs/editor/various/block-notes-utils.js b/test/e2e/specs/editor/various/block-notes-utils.js index 0f5c1c53df70f4..9a94a425d30f7d 100644 --- a/test/e2e/specs/editor/various/block-notes-utils.js +++ b/test/e2e/specs/editor/various/block-notes-utils.js @@ -156,7 +156,7 @@ class BlockNoteUtils { .click(); await this.waitForFullPicker(); - // Curated reactions carry their own exact labels (e.g. "Heart") + // Curated reactions carry their own exact labels (e.g. "heart") // and are seeded into the "Frequently used" section, so an // exact-name gridcell lookup finds them // without matching Emojibase labels that merely contain the name diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index fccc44a5b5c3c1..b2d55c503d1471 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1140,11 +1140,11 @@ test.describe( 'Block Notes', () => { comment: 'Test comment for reactions', } ); - await blockNoteUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'heart' ); // Verify the reaction button appears with count. const reactionButton = page.getByRole( 'button', { - name: /Heart/, + name: /heart/, } ); await expect( reactionButton ).toBeVisible(); await expect( reactionButton ).toContainText( '1' ); @@ -1160,9 +1160,9 @@ test.describe( 'Block Notes', () => { comment: 'Re-add reaction', } ); - await blockNoteUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'heart' ); const reactionButton = page.getByRole( 'button', { - name: /Heart/, + name: /heart/, } ); await expect( reactionButton ).toBeVisible(); await expect( reactionButton ).toContainText( '1' ); @@ -1180,7 +1180,7 @@ test.describe( 'Block Notes', () => { // so the just-removed reaction blocked the re-add with // `rest_comment_duplicate_reaction` ("You have already // reacted with this emoji"). - await blockNoteUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'heart' ); await expect( reactionButton ).toBeVisible(); await expect( reactionButton ).toContainText( '❤' ); await expect( reactionButton ).toContainText( '1' ); @@ -1227,10 +1227,10 @@ test.describe( 'Block Notes', () => { response.request().method() === 'GET' && singleNote.test( decodeURIComponent( response.url() ) ) ); - await blockNoteUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'heart' ); await refetch; await expect( - page.getByRole( 'button', { name: /Heart/ } ) + page.getByRole( 'button', { name: /heart/ } ) ).toContainText( '1' ); // The post-toggle refetch must not replace the cached note's @@ -1252,11 +1252,11 @@ test.describe( 'Block Notes', () => { } ); // Add a reaction. - await blockNoteUtils.addReactionToComment( 'Celebration' ); + await blockNoteUtils.addReactionToComment( 'celebration' ); // Hover over the reaction button to trigger tooltip. const reactionButton = page.getByRole( 'button', { - name: /Celebration/, + name: /celebration/, } ); await expect( reactionButton ).toBeVisible(); await reactionButton.hover(); @@ -1265,7 +1265,7 @@ test.describe( 'Block Notes', () => { // match its text. The pill's own label is an `aria-label`, not // text, so this only matches the popup. await expect( - page.getByText( /reacted with Celebration/ ) + page.getByText( /reacted with celebration/ ) ).toBeVisible(); } ); @@ -1352,9 +1352,9 @@ test.describe( 'Block Notes', () => { comment: 'Test comment for deselected reactions', } ); - await blockNoteUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'heart' ); const reactionButton = page.getByRole( 'button', { - name: /Heart/, + name: /heart/, } ); await expect( reactionButton ).toBeVisible(); @@ -1392,9 +1392,9 @@ test.describe( 'Block Notes', () => { await thread.click(); await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); - await blockNoteUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'heart' ); const reactionPill = sidebar.getByRole( 'button', { - name: /Heart/, + name: /heart/, } ); await expect( reactionPill ).toBeVisible(); @@ -1443,7 +1443,7 @@ test.describe( 'Block Notes', () => { decodeURIComponent( request.url() ) ) ); - await blockNoteUtils.addReactionToComment( 'Heart' ); + await blockNoteUtils.addReactionToComment( 'heart' ); expect( ( await created ).postDataJSON().content ).toBe( '2764' ); const reactionButton = page.locator( @@ -1456,16 +1456,16 @@ test.describe( 'Block Notes', () => { /* * Picking the same heart from the search results resolves to * the same key, so it toggles the existing reaction off rather - * than adding a second pill. "Heart" is a curated label, so the + * than adding a second pill. "heart" is a curated label, so the * exact match skips "smiling face with hearts". */ await page .getByRole( 'combobox', { name: 'Add reaction' } ) .click(); await blockNoteUtils.waitForFullPicker(); - await page.getByPlaceholder( 'Search emoji' ).fill( 'Heart' ); + await page.getByPlaceholder( 'Search emoji' ).fill( 'heart' ); await page - .getByRole( 'gridcell', { name: 'Heart', exact: true } ) + .getByRole( 'gridcell', { name: 'heart', exact: true } ) .click(); await expect( reactionButton ).toHaveCount( 0 ); } ); @@ -1570,7 +1570,7 @@ test.describe( 'Block Notes', () => { ).toHaveText( 'Frequently used' ); await expect( frequentSection.getByRole( 'gridcell', { - name: 'Heart', + name: 'heart', exact: true, } ) ).toBeVisible(); From 1de738fb6eea319f1a892c00d1746e9d7b91f196 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 6 Oct 2026 12:25:20 +0200 Subject: [PATCH 347/362] Backport changelog: List only this PR for wpd #10930 Reviewer asked for the 10930 entry to reference the Gutenberg PR that actually carries the backported code, rather than the earlier PRs it superseded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012dpDEqJD9APinkNrMN7Kem --- backport-changelog/7.2/10930.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/backport-changelog/7.2/10930.md b/backport-changelog/7.2/10930.md index 6ef53f6ebf6d38..60b8f4be8bdd88 100644 --- a/backport-changelog/7.2/10930.md +++ b/backport-changelog/7.2/10930.md @@ -1,5 +1,3 @@ https://github.com/WordPress/wordpress-develop/pull/10930 -* https://github.com/WordPress/gutenberg/pull/76767 -* https://github.com/WordPress/gutenberg/pull/78176 * https://github.com/WordPress/gutenberg/pull/83538 From 3c9014ac95be31b828414044a9c75ccb97e047d2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 6 Oct 2026 12:25:57 +0200 Subject: [PATCH 348/362] Notes: Drop the reaction restore cascade on untrash wp_untrash_comment() restores no child comments of any type, so a reaction-only restore cascade would be an inconsistent special case. Restoring children is better handled for every child type together in a follow-up. The trash and delete cascades stay, since leaving reactions approved under a trashed note, or orphaned after a delete, is a bug. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012dpDEqJD9APinkNrMN7Kem --- ...-gutenberg-rest-comment-controller-7-2.php | 2 +- lib/compat/wordpress-7.2/note-reactions.php | 53 ++----------------- ...est-comments-controller-gutenberg-test.php | 52 ++---------------- 3 files changed, 9 insertions(+), 98 deletions(-) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index 8fe82c521f5a1f..8cbcd37a58414e 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -475,7 +475,7 @@ public function create_item( $request ) { ); } - // A reaction under a hidden note would escape the trash/restore cascade. + // A reaction under a hidden note would escape the trash cascade. if ( in_array( $parent_comment->comment_approved, array( 'trash', 'spam' ), true ) ) { return new WP_Error( 'rest_comment_invalid_parent', diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index f63a7da3e91531..c829c702e681a7 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -256,6 +256,10 @@ function gutenberg_delete_note_reactions( $comment_id, $comment ) { * would otherwise stay approved under a trashed note. Replies are covered * because core trashes each one, which fires this action again. * + * Restoring the note does not bring its reactions back: core's + * `wp_untrash_comment()` restores no children of any type, so restoring + * children is left to a cascade that covers every child type together. + * * @since 7.2.0 * * @param string $comment_id The comment ID as a numeric string. @@ -266,55 +270,8 @@ function gutenberg_trash_note_reactions( $comment_id, $comment ) { return; } - // Flag each one so restoring the note brings back only these, not - // reactions already in the trash (a REST delete without `force`). foreach ( gutenberg_get_note_reaction_ids( $comment, 'approve' ) as $reaction_id ) { - if ( wp_trash_comment( $reaction_id ) ) { - add_comment_meta( $reaction_id, '_wp_trash_meta_with_note', '1', true ); - } + wp_trash_comment( $reaction_id ); } } add_action( 'trashed_comment', 'gutenberg_trash_note_reactions', 10, 2 ); - -/** - * Restores a note's reactions along with the note. - * - * The counterpart to gutenberg_trash_note_reactions(): only reactions - * flagged as trashed along with the note come back. Ones already in the - * trash beforehand stay there. - * - * @since 7.2.0 - * - * @param string $comment_id The comment ID as a numeric string. - * @param WP_Comment $comment The untrashed comment. - */ -function gutenberg_untrash_note_reactions( $comment_id, $comment ) { - if ( ! $comment instanceof WP_Comment || 'note' !== $comment->comment_type ) { - return; - } - - foreach ( gutenberg_get_note_reaction_ids( $comment, 'trash' ) as $reaction_id ) { - if ( get_comment_meta( $reaction_id, '_wp_trash_meta_with_note', true ) ) { - wp_untrash_comment( $reaction_id ); - } - } -} -add_action( 'untrashed_comment', 'gutenberg_untrash_note_reactions', 10, 2 ); - -/** - * Clears the flag gutenberg_trash_note_reactions() sets, however a comment - * leaves the trash, so a later note restore can't resurrect it. - * - * @since 7.2.0 - * - * @param string $comment_id The comment ID as a numeric string. - * @param WP_Comment $comment The untrashed comment. - */ -function gutenberg_clear_note_reaction_trash_flag( $comment_id, $comment ) { - if ( ! $comment instanceof WP_Comment || 'reaction' !== $comment->comment_type ) { - return; - } - - delete_comment_meta( $comment_id, '_wp_trash_meta_with_note' ); -} -add_action( 'untrashed_comment', 'gutenberg_clear_note_reaction_trash_flag', 5, 2 ); diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index f36334fa3ec2ae..71eb5963a7914e 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -629,7 +629,7 @@ public function test_cannot_create_reaction_on_note_from_different_post() { /** * A reaction created under a hidden note would never be reached by the - * trash/restore cascade, so the note must be live. + * trash cascade, so the note must be live. * * @dataProvider data_hidden_note_statuses * @@ -1203,9 +1203,9 @@ public function test_permanently_deleting_note_reply_deletes_its_reactions() { /** * Core cascades a trashed note to its `note` children only, so - * reactions need their own trash and restore cascade. + * reactions need their own trash cascade. */ - public function test_trashing_note_trashes_and_restores_its_reactions() { + public function test_trashing_note_trashes_its_reactions() { if ( ! EMPTY_TRASH_DAYS ) { $this->markTestSkipped( 'Trash is disabled; trashing force-deletes.' ); } @@ -1217,52 +1217,6 @@ public function test_trashing_note_trashes_and_restores_its_reactions() { wp_trash_comment( $note_id ); $this->assertSame( 'trash', wp_get_comment_status( $reaction_id ), 'Reaction stayed approved under a trashed note.' ); - - wp_untrash_comment( $note_id ); - $this->assertSame( 'approved', wp_get_comment_status( $reaction_id ), 'Reaction was not restored with its note.' ); - } - - /** - * Restoring a note brings back only the reactions trashed along with it, - * not ones the user had already removed. - */ - public function test_untrashing_note_does_not_restore_removed_reactions() { - if ( ! EMPTY_TRASH_DAYS ) { - $this->markTestSkipped( 'Trash is disabled; trashing force-deletes.' ); - } - - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); - $note_id = $this->create_note( $post_id, self::$editor_id ); - $removed_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); - wp_trash_comment( $removed_id ); - $live_id = $this->create_reaction( $post_id, $note_id, self::$author_id, '1f680' ); - - wp_trash_comment( $note_id ); - wp_untrash_comment( $note_id ); - - $this->assertSame( 'trash', wp_get_comment_status( $removed_id ), 'A reaction the user removed was restored with its note.' ); - $this->assertSame( 'approved', wp_get_comment_status( $live_id ), 'The live reaction was not restored with its note.' ); - $this->assertSame( '', get_comment_meta( $live_id, '_wp_trash_meta_with_note', true ), 'The restored reaction kept its cascade flag.' ); - } - - /** - * The cascade flag only ever sits on reactions, so restoring any other - * comment must leave its meta alone. - */ - public function test_untrashing_regular_comment_keeps_its_meta() { - if ( ! EMPTY_TRASH_DAYS ) { - $this->markTestSkipped( 'Trash is disabled; trashing force-deletes.' ); - } - - $post_id = self::factory()->post->create(); - $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $post_id ) ); - add_comment_meta( $comment_id, '_wp_trash_meta_with_note', '1', true ); - - wp_trash_comment( $comment_id ); - wp_untrash_comment( $comment_id ); - - $this->assertSame( '1', get_comment_meta( $comment_id, '_wp_trash_meta_with_note', true ) ); } /** From 5c022419775d91dd151d6f4cb3f60c7d66e01981 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 6 Oct 2026 12:28:50 +0200 Subject: [PATCH 349/362] Notes: Collapse reaction_summary to count and current_user_reaction The summary carried both a boolean `reacted` and `my_reaction_id`, which say the same thing twice and can disagree. A single `current_user_reaction` field, holding the user's reaction comment ID or 0, answers both whether the user reacted and which comment to delete, with a name that reads naturally in the REST response. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012dpDEqJD9APinkNrMN7Kem --- ...-gutenberg-rest-comment-controller-7-2.php | 22 +++----- .../reactions/reaction-display.tsx | 8 +-- .../test/reaction-display.jsdom.test.tsx | 11 +++- .../reactions/test/use-reaction.jsdom.test.ts | 50 +++++++++++++++++++ .../collab-sidebar/reactions/use-reaction.ts | 15 +++--- ...est-comments-controller-gutenberg-test.php | 10 ++-- .../specs/editor/various/block-notes.spec.js | 2 +- 7 files changed, 83 insertions(+), 35 deletions(-) create mode 100644 packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index 8cbcd37a58414e..36b99a14d52449 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -32,16 +32,12 @@ public function get_item_schema() { 'additionalProperties' => array( 'type' => 'object', 'properties' => array( - 'count' => array( + 'count' => array( 'description' => __( 'Total number of reactions with this emoji.', 'gutenberg' ), 'type' => 'integer', ), - 'reacted' => array( - 'description' => __( 'Whether the current user reacted with this emoji.', 'gutenberg' ), - 'type' => 'boolean', - ), - 'my_reaction_id' => array( - 'description' => __( 'The current user\'s reaction comment ID, or 0 if not reacted.', 'gutenberg' ), + 'current_user_reaction' => array( + 'description' => __( 'The current user\'s reaction comment ID for this emoji, or 0 if they have not reacted.', 'gutenberg' ), 'type' => 'integer', ), ), @@ -906,15 +902,13 @@ protected function prefetch_reaction_summaries( $note_ids ) { } foreach ( $counts as $row ) { - $note_id = (int) $row->comment_parent; - $slug = wp_strip_all_tags( $row->comment_content ); - $key = $note_id . ':' . $slug; - $my_reaction_id = $my_reactions[ $key ] ?? 0; + $note_id = (int) $row->comment_parent; + $slug = wp_strip_all_tags( $row->comment_content ); + $key = $note_id . ':' . $slug; $this->reaction_summaries[ $note_id ][ $slug ] = array( - 'count' => (int) $row->reaction_count, - 'reacted' => $my_reaction_id > 0, - 'my_reaction_id' => $my_reaction_id, + 'count' => (int) $row->reaction_count, + 'current_user_reaction' => $my_reactions[ $key ] ?? 0, ); } } diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx index 03138b0b567330..d429360b12168d 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx @@ -14,9 +14,9 @@ import { useEmojiLabel } from './emojibase-data'; interface ReactionSummaryEntry { count: number; - reacted?: boolean; - // The current user's reaction comment ID, used to delete it again. - my_reaction_id?: number; + // The current user's reaction comment ID, used to delete it again; + // 0 when they have not reacted with this emoji. + current_user_reaction: number; } /** @@ -57,7 +57,7 @@ function hasUserReacted( reactions: ReactionSummary | null | undefined, hexKey: string ): boolean { - return reactions?.[ hexKey ]?.reacted || false; + return ( reactions?.[ hexKey ]?.current_user_reaction ?? 0 ) > 0; } /** diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx index d067c3bda8d11f..02e72497470843 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx @@ -18,8 +18,8 @@ function renderPills() { {} } /> @@ -34,6 +34,13 @@ describe( 'ReactionDisplay', () => { vi.mocked( apiFetch ).mockResolvedValue( REACTIONS ); } ); + it( 'marks a pill pressed only when the current user reacted with it', () => { + const [ heart, thumbsUp ] = renderPills(); + + expect( heart ).toHaveAttribute( 'aria-pressed', 'false' ); + expect( thumbsUp ).toHaveAttribute( 'aria-pressed', 'true' ); + } ); + it( 'fetches the reactions on a note once for all of its pills', async () => { const [ heart, thumbsUp ] = renderPills(); diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts b/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts new file mode 100644 index 00000000000000..08ff140fe04128 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { applyReactionDelta } from '../use-reaction'; + +// The editor store pulls in viewport listeners that jsdom can't run, and +// `applyReactionDelta` never touches it. +vi.mock( import( '../../../../store' ), () => ( { store: {} } ) as never ); + +describe( 'applyReactionDelta', () => { + it( 'records the added reaction as the current user reaction', () => { + const note = { + id: 1, + reaction_summary: { 2764: { count: 1, current_user_reaction: 0 } }, + }; + + expect( + applyReactionDelta( note, '2764', 99 ).reaction_summary + ).toEqual( { 2764: { count: 2, current_user_reaction: 99 } } ); + } ); + + it( 'ignores an added reaction that is already counted', () => { + const note = { + id: 1, + reaction_summary: { 2764: { count: 2, current_user_reaction: 99 } }, + }; + + expect( applyReactionDelta( note, '2764', 99 ) ).toBe( note ); + } ); + + it( 'clears the current user reaction when one is removed', () => { + const note = { + id: 1, + reaction_summary: { 2764: { count: 2, current_user_reaction: 99 } }, + }; + + expect( applyReactionDelta( note, '2764' ).reaction_summary ).toEqual( { + 2764: { count: 1, current_user_reaction: 0 }, + } ); + } ); + + it( 'drops the entry when the last reaction is removed', () => { + const note = { + id: 1, + reaction_summary: { 2764: { count: 1, current_user_reaction: 99 } }, + }; + + expect( applyReactionDelta( note, '2764' ).reaction_summary ).toEqual( + {} + ); + } ); +} ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts index 37409dfcfb8734..7a3a5548ec4d1b 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts @@ -23,7 +23,7 @@ export interface ReactionTarget { * * Used to keep `reaction_summary` usable when the refetch that would * normally replace it fails: without it, the next toggle reads a stale - * `reacted` / `my_reaction_id` pair and takes the wrong branch. + * `current_user_reaction` and takes the wrong branch. * * @param note The cached note record. * @param hexKey The reaction hex key that changed. @@ -42,18 +42,17 @@ export function applyReactionDelta< T extends ReactionTarget >( if ( addedReactionId ) { // Concurrent adds converge server-side on one surviving row, so a // repeated ID is already counted. - if ( entry?.my_reaction_id === addedReactionId ) { + if ( entry?.current_user_reaction === addedReactionId ) { return note; } summary[ hexKey ] = { count: ( entry?.count || 0 ) + 1, - reacted: true, - my_reaction_id: addedReactionId, + current_user_reaction: addedReactionId, }; } else if ( entry ) { const count = entry.count - 1; if ( count > 0 ) { - summary[ hexKey ] = { count, reacted: false }; + summary[ hexKey ] = { count, current_user_reaction: 0 }; } else { delete summary[ hexKey ]; } @@ -91,9 +90,7 @@ export function useReaction( note: ReactionTarget ) { const toggleReaction = useCallback( async ( hexKey: string ) => { const entry = reactions?.[ hexKey ]; - const myReactionId = entry?.reacted - ? entry.my_reaction_id - : undefined; + const myReactionId = entry?.current_user_reaction || undefined; const isRemoving = !! myReactionId; let addedReactionId: number | undefined; @@ -147,7 +144,7 @@ export function useReaction( note: ReactionTarget ) { // Mutating a reaction comment doesn't invalidate the cached // `reaction_summary`, so a subsequent toggle would read stale - // `reacted` / `my_reaction_id` data and route into the wrong + // `current_user_reaction` data and route into the wrong // branch (deleting an already-removed comment). // // The mutation has landed, so fold its known effect into the diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 71eb5963a7914e..f385ba407589d6 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -973,8 +973,9 @@ public function test_note_response_includes_reaction_summary() { $this->assertArrayHasKey( 'reaction_summary', $data ); $this->assertArrayHasKey( '2764', $data['reaction_summary'] ); $this->assertSame( 1, $data['reaction_summary']['2764']['count'] ); - $this->assertTrue( $data['reaction_summary']['2764']['reacted'] ); - $this->assertSame( $reaction_id, $data['reaction_summary']['2764']['my_reaction_id'] ); + $this->assertSame( $reaction_id, $data['reaction_summary']['2764']['current_user_reaction'] ); + $this->assertArrayNotHasKey( 'reacted', $data['reaction_summary']['2764'] ); + $this->assertArrayNotHasKey( 'my_reaction_id', $data['reaction_summary']['2764'] ); } public function test_reaction_summary_shows_not_reacted_for_other_user() { @@ -1004,8 +1005,7 @@ public function test_reaction_summary_shows_not_reacted_for_other_user() { $this->assertArrayHasKey( 'reaction_summary', $data ); $this->assertSame( 1, $data['reaction_summary']['2764']['count'] ); - $this->assertFalse( $data['reaction_summary']['2764']['reacted'] ); - $this->assertSame( 0, $data['reaction_summary']['2764']['my_reaction_id'] ); + $this->assertSame( 0, $data['reaction_summary']['2764']['current_user_reaction'] ); } /** @@ -1047,7 +1047,7 @@ public function test_second_page_of_notes_keeps_reaction_summary_queries_bounded $this->assertCount( 2, $data ); foreach ( $data as $note ) { $this->assertSame( 1, $note['reaction_summary']['2764']['count'] ); - $this->assertTrue( $note['reaction_summary']['2764']['reacted'] ); + $this->assertGreaterThan( 0, $note['reaction_summary']['2764']['current_user_reaction'] ); } // One counts query and one current-user query for the whole page. $this->assertSame( 2, $summary_queries ); diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 11f961ed00e278..222b343b030664 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1175,7 +1175,7 @@ test.describe( 'Block Notes', () => { // Add the same reaction again. This used to fail two ways: // 1) the parent note's cached `reaction_summary` still - // reported the removed heart as `reacted`, so the toggle + // carried the removed heart's `current_user_reaction`, so the toggle // attempted to delete a now-missing comment record // instead of routing to add; and 2) the server's // duplicate-reaction guard included trashed comments, From d9600a9fe7ba2282a86dbc715e1295806d2c8bbc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 6 Oct 2026 12:30:08 +0200 Subject: [PATCH 350/362] Notes: Use the ui Tooltip on the skin tone toggle The toggle sits inside the @wordpress/ui emoji picker popover, and the @wordpress/components tooltip from showTooltip layered behind it, so the label was hidden. Composing the @wordpress/ui Tooltip into the Menu trigger keeps both in the same layering system. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012dpDEqJD9APinkNrMN7Kem --- .../reactions/skin-tone-picker.tsx | 45 ++++++++++++------- .../specs/editor/various/block-notes.spec.js | 14 +++++- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx index c47f1b9b624ff1..8ab5d1f50a5d50 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx @@ -1,7 +1,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; // eslint-disable-next-line @wordpress/use-recommended-components -- Intentional early adoption of the new Menu, pending WordPress/gutenberg#76135. -import { Menu } from '@wordpress/ui'; +import { Menu, Tooltip } from '@wordpress/ui'; import type { EmojibaseEntry, EmojibaseSkin } from './emojibase-data'; /** @@ -67,25 +67,36 @@ export default function SkinTonePicker( { const current = SKIN_TONES.find( ( option ) => option.tone === value ) || SKIN_TONES[ 0 ]; + const triggerLabel = sprintf( + // translators: %s: the selected skin tone, e.g. "Medium skin tone". + __( 'Skin tone: %s' ), + current.label + ); return ( - - } - > - { current.emoji } - + { /* + * A `@wordpress/components` tooltip renders behind this + * `@wordpress/ui` popover, so the toggle uses the ui Tooltip. + */ } + + + } + /> + } + > + { current.emoji } + + { triggerLabel } + } > diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 222b343b030664..f5d641c7bcd4f8 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1526,7 +1526,19 @@ test.describe( 'Block Notes', () => { page.getByPlaceholder( 'Search emoji' ) ).toBeVisible(); - // The second Escape closes the full picker and returns focus + // Keyboard focus back on the toggle shows its tooltip, the next + // layer an Escape dismisses, again leaving the picker open. + const skinToneTooltip = page.getByText( + 'Skin tone: Default skin tone' + ); + await expect( skinToneTooltip ).toBeVisible(); + await page.keyboard.press( 'Escape' ); + await expect( skinToneTooltip ).toBeHidden(); + await expect( + page.getByPlaceholder( 'Search emoji' ) + ).toBeVisible(); + + // The next Escape closes the full picker and returns focus // to the add-reaction trigger. await page.keyboard.press( 'Escape' ); await expect( From 61e250df15ce780e24905c08a323a5faf79c98ef Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 6 Oct 2026 12:30:58 +0200 Subject: [PATCH 351/362] Notes: Keep emoji glyphs in color under forced colors Windows High Contrast Mode repaints text in a system color, which renders the reaction, picker, and skin tone emoji as flat monochrome glyphs and hides the skin tones the picker is meant to show. Opting the glyph spans out of forced-color-adjust keeps them intact, while the buttons around them still pick up system colors for their borders. The skin tone toggle's emoji gets its own span for that reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012dpDEqJD9APinkNrMN7Kem --- .../collab-sidebar/reactions/skin-tone-picker.tsx | 7 ++++++- .../editor/src/components/collab-sidebar/style.scss | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx index 8ab5d1f50a5d50..494ebd52cd89f2 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx @@ -93,7 +93,12 @@ export default function SkinTonePicker( { /> } > - { current.emoji } + { triggerLabel } diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 75a9f3fe086337..29a16d767469c8 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -545,6 +545,16 @@ mark.wp-note { font-size: 24px; } +// Forced colors repaint text in a system color, which turns color emoji into +// flat monochrome glyphs and drops skin tones. Opt the glyphs out, but not +// the buttons around them, so their borders still follow the system colors. +.editor-collab-sidebar-panel__picker-emoji-label, +.editor-collab-sidebar-panel__skin-tone-toggle-emoji, +.editor-collab-sidebar-panel__skin-tone-swatch, +.editor-collab-sidebar-panel__reaction-button-emoji { + forced-color-adjust: none; +} + // Mention chips, both in the note input and the rendered note content. A // mention is a non-interactive `span` carrying the mentioned user's ID in a // `user-N` class, styled as a chip. From b29a486687bb10065dfb0688fb768cac6072ea8c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 6 Oct 2026 12:34:37 +0200 Subject: [PATCH 352/362] Notes: Keep the emoji grid scroller out of the Tab order None of the grid cells are tabbable, since the search field drives them with aria-activedescendant, so browsers made the scrolling viewport a Tab stop of its own. Tabbing from the search field past the skin tone toggle landed there, and a screen reader read out every emoji in the grid. The arrow keys from the search field already reach every cell, so the scroller does not need to be a Tab stop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012dpDEqJD9APinkNrMN7Kem --- .../collab-sidebar/reactions/emoji-picker.tsx | 11 ++++- .../specs/editor/various/block-notes.spec.js | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx index dec3f03e6fdbed..c8c383f724bea7 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -494,7 +494,16 @@ export default function EmojiPicker( { } />
-
+ { /* + * The arrow keys in the search field drive the grid, so + * keep its scroller out of the Tab order. Browsers would + * otherwise make it a Tab stop, since none of the cells are + * tabbable, and a screen reader would read out every emoji. + */ } +
{ status } { isLoading || loadFailed diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index f5d641c7bcd4f8..98ad3b1f63c88d 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1312,6 +1312,51 @@ test.describe( 'Block Notes', () => { ).toBeVisible(); } ); + test( 'Tab from the emoji search skips the grid', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Tabbing through the picker' }, + comment: 'Tab order in the emoji picker', + } ); + + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .focus(); + await page.keyboard.press( 'Enter' ); + await blockNoteUtils.waitForFullPicker(); + + const searchField = page.getByRole( 'combobox', { + name: 'Search emoji', + } ); + await expect( searchField ).toBeFocused(); + + // The grid is reached with the arrow keys from the search + // field, so Tab moves on to the skin tone toggle. + await page.keyboard.press( 'Tab' ); + await expect( + page.getByRole( 'button', { name: /^Skin tone:/ } ) + ).toBeFocused(); + + // The scrolling grid is never a Tab stop of its own, which + // would read out every emoji in it. + await page.keyboard.press( 'Tab' ); + await expect + .poll( () => + page.evaluate( + () => + !! document.activeElement?.closest( + '.editor-collab-sidebar-panel__picker-viewport' + ) + ) + ) + .toBe( false ); + // Tabbing past the toggle leaves the non-modal picker, closing it. + await expect( searchField ).toBeHidden(); + } ); + test( 'the add-reaction trigger is revealed on hover and focus', async ( { page, blockNoteUtils, From a7d2fba1460399f95195d79358d514e76911469a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 11:05:36 +0200 Subject: [PATCH 353/362] Notes: Skip the reply composer in the reaction hover e2e test Trunk's draft-preserving reply form (#84110) carries the note class, so the hover locator matched two elements. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L7rwHtVAa37GWJPC8P3Cwd --- test/e2e/specs/editor/various/block-notes.spec.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index c0e7319e7c40d5..59ff0160785ce1 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1370,7 +1370,11 @@ test.describe( 'Block Notes', () => { const trigger = page.getByRole( 'combobox', { name: 'Add reaction', } ); - const note = page.locator( '.editor-collab-sidebar-panel__note' ); + // The reply composer shares the note class, so match only the + // note that carries the trigger. + const note = page + .locator( '.editor-collab-sidebar-panel__note' ) + .filter( { has: trigger } ); // Park the pointer outside the sidebar: adding the note leaves it // over the thread, which would hold the trigger open. From 97843a87f546bbbf6754e65a8542fae8eba03656 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 10:52:31 +0200 Subject: [PATCH 354/362] Notes: Enforce reaction ownership and resolved threads on the server Core's delete check lets anyone who can edit the note's post remove a reaction, but #75144 makes removal author-only. Reactions on a resolved thread were only blocked by disabled controls, so a stale tab could still add one. Reject both in the REST controller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L7rwHtVAa37GWJPC8P3Cwd (cherry picked from commit c0df5e3285de4b17b20522b41fbaf8ed445333ce) --- ...-gutenberg-rest-comment-controller-7-2.php | 47 +++++++++ ...est-comments-controller-gutenberg-test.php | 96 +++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index 36b99a14d52449..a43116ed35519a 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -270,6 +270,35 @@ public function update_item_permissions_check( $request ) { return parent::update_item_permissions_check( $request ); } + /** + * Checks if a given request has access to delete a comment. + * + * Anyone who can edit a note's post can edit the note, and core's delete + * check follows that, but a reaction belongs to the user who added it: + * only they can take it back. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to delete the item, error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + if ( 'reaction' === $comment->comment_type && get_current_user_id() !== (int) $comment->user_id ) { + return new WP_Error( + 'rest_cannot_delete', + __( 'Sorry, you can only remove your own reactions.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return parent::delete_item_permissions_check( $request ); + } + public function create_item_permissions_check( $request ) { $is_note = ! empty( $request['type'] ) && $this->is_note_or_reaction( $request['type'] ); @@ -480,6 +509,24 @@ public function create_item( $request ) { ); } + // Resolving a thread approves its root note, and the editor + // disables reactions from then on; hold stale tabs to that too. + $thread_root = $parent_comment; + while ( $thread_root->comment_parent ) { + $ancestor = get_comment( $thread_root->comment_parent ); + if ( ! $ancestor ) { + break; + } + $thread_root = $ancestor; + } + if ( '1' === $thread_root->comment_approved ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction cannot be added to a resolved note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + // The parent note must belong to the post the reaction targets. if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { return new WP_Error( diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index f385ba407589d6..5a3b8ba22f11f6 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -470,6 +470,8 @@ protected function create_note( $post_id, $user_id ) { 'content' => 'Test note for reactions', 'author' => $user_id, 'type' => 'note', + // Open, like the editor creates it: an approved note is resolved. + 'status' => 'hold', ); $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); $request->add_header( 'Content-Type', 'application/json' ); @@ -1130,6 +1132,100 @@ public function test_cannot_update_reaction() { $this->assertSame( '2764', get_comment( $reaction_id )->comment_content ); } + /** + * Only the user who added a reaction can remove it, even though other + * editors of the post can edit the note it belongs to. + */ + public function test_cannot_delete_another_users_reaction() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + $this->assertNotNull( get_comment( $reaction_id ), 'Another user removed the reaction.' ); + } + + /** + * The reaction's author can still remove it. + */ + public function test_can_delete_own_reaction() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$admin_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_comment( $reaction_id ) ); + } + + /** + * Resolving approves the thread's root note and the editor disables + * reactions from then on, so the server rejects them too, on the root + * note and on its replies. + * + * @dataProvider data_resolved_thread_targets + * + * @param bool $on_reply Whether to react to a reply rather than the root note. + */ + public function test_cannot_create_reaction_on_resolved_thread( $on_reply ) { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $target = $note_id; + if ( $on_reply ) { + $target = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + ) + ); + } + wp_set_comment_status( $note_id, 'approve' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $target, + 'content' => '2764', + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + /** + * Data provider for test_cannot_create_reaction_on_resolved_thread(). + * + * @return array[] + */ + public function data_resolved_thread_targets() { + return array( + 'root note' => array( false ), + 'reply' => array( true ), + ); + } + /** * Notes remain editable - only reactions are locked down. */ From 912d09cefb6c28987423927ec81bda30d0c0342f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 10:52:45 +0200 Subject: [PATCH 355/362] Notes: Let forced colors repaint reaction emoji The curated reaction set reads by shape alone, so honor the user's high-contrast preference instead of opting the glyph out of it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L7rwHtVAa37GWJPC8P3Cwd (cherry picked from commit a6b8e41e9f14165582abb5866a0d4f88e3046237) --- packages/editor/src/components/collab-sidebar/style.scss | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 021a71c0537737..990fef346c9cb9 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -550,12 +550,13 @@ mark.wp-note { } // Forced colors repaint text in a system color, which turns color emoji into -// flat monochrome glyphs and drops skin tones. Opt the glyphs out, but not -// the buttons around them, so their borders still follow the system colors. +// flat monochrome glyphs and drops skin tones. Opt the picker glyphs out, but +// not the buttons around them, so their borders still follow the system +// colors; skin tones can't be told apart by shape. Reaction pills keep the +// user's preference. .editor-collab-sidebar-panel__picker-emoji-label, .editor-collab-sidebar-panel__skin-tone-toggle-emoji, -.editor-collab-sidebar-panel__skin-tone-swatch, -.editor-collab-sidebar-panel__reaction-button-emoji { +.editor-collab-sidebar-panel__skin-tone-swatch { forced-color-adjust: none; } From fc57daa426c7e18a263db145d02dba4dc7c70a67 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 10:54:53 +0200 Subject: [PATCH 356/362] Notes: Stop the thread root walk on a cyclic parent chain A corrupt comment_parent loop would otherwise spin the request forever. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L7rwHtVAa37GWJPC8P3Cwd (cherry picked from commit 9d7274c7e9968760f41f9b8ef2a23031d8f0474c) --- .../class-gutenberg-rest-comment-controller-7-2.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index a43116ed35519a..04984297699362 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -512,12 +512,15 @@ public function create_item( $request ) { // Resolving a thread approves its root note, and the editor // disables reactions from then on; hold stale tabs to that too. $thread_root = $parent_comment; + $visited = array( (int) $thread_root->comment_ID => true ); while ( $thread_root->comment_parent ) { $ancestor = get_comment( $thread_root->comment_parent ); - if ( ! $ancestor ) { + // Stop at a missing ancestor or a corrupt, cyclic chain. + if ( ! $ancestor || isset( $visited[ (int) $ancestor->comment_ID ] ) ) { break; } - $thread_root = $ancestor; + $visited[ (int) $ancestor->comment_ID ] = true; + $thread_root = $ancestor; } if ( '1' === $thread_root->comment_approved ) { return new WP_Error( From 80ac9badaab5da8c021c2f1a06761e9d017cf03a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 10:54:53 +0200 Subject: [PATCH 357/362] Notes: Guard reaction toggles against stale and repeated clicks A refresh from another emoji's toggle can clear current_user_reaction while a delete is pending, so only fold the removal in when the cached entry still holds that reaction. A second click while a toggle is in flight read the same snapshot and repeated the request, surfacing a 404 or 409 even though the first one landed; ignore it until the first one settles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L7rwHtVAa37GWJPC8P3Cwd (cherry picked from commit 0eecd1ac01781c4eef194f4969c5080f0ad5a8a9) --- .../reactions/test/use-reaction.jsdom.test.ts | 76 +++++++++++++++++-- .../collab-sidebar/reactions/use-reaction.ts | 52 +++++++++---- 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts b/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts index 08ff140fe04128..db76fa6a3e840c 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts @@ -1,5 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; -import { applyReactionDelta } from '../use-reaction'; +import { act, renderHook } from '@testing-library/react'; +import { applyReactionDelta, useReaction } from '../use-reaction'; + +const mockDispatch = vi.hoisted( () => vi.fn() ); +vi.mock( import( '@wordpress/data' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + useDispatch: mockDispatch, + useSelect: ( () => ( { + getEntityRecord: () => undefined, + getCurrentPostId: () => 1, + } ) ) as never, +} ) ); +vi.mock( import( '@wordpress/api-fetch' ), () => ( { + default: vi.fn( () => Promise.reject( new Error() ) ) as never, +} ) ); // The editor store pulls in viewport listeners that jsdom can't run, and // `applyReactionDelta` never touches it. @@ -13,7 +27,7 @@ describe( 'applyReactionDelta', () => { }; expect( - applyReactionDelta( note, '2764', 99 ).reaction_summary + applyReactionDelta( note, '2764', { added: 99 } ).reaction_summary ).toEqual( { 2764: { count: 2, current_user_reaction: 99 } } ); } ); @@ -23,7 +37,9 @@ describe( 'applyReactionDelta', () => { reaction_summary: { 2764: { count: 2, current_user_reaction: 99 } }, }; - expect( applyReactionDelta( note, '2764', 99 ) ).toBe( note ); + expect( applyReactionDelta( note, '2764', { added: 99 } ) ).toBe( + note + ); } ); it( 'clears the current user reaction when one is removed', () => { @@ -32,7 +48,9 @@ describe( 'applyReactionDelta', () => { reaction_summary: { 2764: { count: 2, current_user_reaction: 99 } }, }; - expect( applyReactionDelta( note, '2764' ).reaction_summary ).toEqual( { + expect( + applyReactionDelta( note, '2764', { removed: 99 } ).reaction_summary + ).toEqual( { 2764: { count: 1, current_user_reaction: 0 }, } ); } ); @@ -43,8 +61,54 @@ describe( 'applyReactionDelta', () => { reaction_summary: { 2764: { count: 1, current_user_reaction: 99 } }, }; - expect( applyReactionDelta( note, '2764' ).reaction_summary ).toEqual( - {} + expect( + applyReactionDelta( note, '2764', { removed: 99 } ).reaction_summary + ).toEqual( {} ); + } ); + + it( 'ignores a removal another refresh already folded in', () => { + // Someone else's reaction remains after a refresh cleared ours. + const note = { + id: 1, + reaction_summary: { 2764: { count: 1, current_user_reaction: 0 } }, + }; + + expect( applyReactionDelta( note, '2764', { removed: 99 } ) ).toBe( + note ); } ); } ); + +describe( 'useReaction', () => { + it( 'ignores a repeat toggle while the first is still pending', async () => { + let resolveSave: ( value: { id: number } ) => void = () => {}; + const saveEntityRecord = vi.fn( + () => + new Promise< { id: number } >( ( resolve ) => { + resolveSave = resolve; + } ) + ); + mockDispatch.mockReturnValue( { + createNotice: vi.fn(), + saveEntityRecord, + deleteEntityRecord: vi.fn(), + receiveEntityRecords: vi.fn(), + } ); + + const { result } = renderHook( () => + useReaction( { id: 7, reaction_summary: {} } ) + ); + + let first: Promise< void > = Promise.resolve(); + await act( async () => { + first = result.current.toggleReaction( '2764' ); + await result.current.toggleReaction( '2764' ); + } ); + expect( saveEntityRecord ).toHaveBeenCalledTimes( 1 ); + + await act( async () => { + resolveSave( { id: 99 } ); + await first; + } ); + } ); +} ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts index 7a3a5548ec4d1b..453e73a77ebf2c 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts @@ -25,31 +25,36 @@ export interface ReactionTarget { * normally replace it fails: without it, the next toggle reads a stale * `current_user_reaction` and takes the wrong branch. * - * @param note The cached note record. - * @param hexKey The reaction hex key that changed. - * @param addedReactionId The new reaction's comment ID when one was added; - * omitted when one was removed. + * @param note The cached note record. + * @param hexKey The reaction hex key that changed. + * @param change The comment ID of the current user's reaction that was + * added or removed. * @return The note with an updated `reaction_summary`. */ export function applyReactionDelta< T extends ReactionTarget >( note: T, hexKey: string, - addedReactionId?: number + change: { added: number } | { removed: number } ): T { const summary: ReactionSummary = { ...( note.reaction_summary || {} ) }; const entry = summary[ hexKey ]; - if ( addedReactionId ) { + if ( 'added' in change ) { // Concurrent adds converge server-side on one surviving row, so a // repeated ID is already counted. - if ( entry?.current_user_reaction === addedReactionId ) { + if ( entry?.current_user_reaction === change.added ) { return note; } summary[ hexKey ] = { count: ( entry?.count || 0 ) + 1, - current_user_reaction: addedReactionId, + current_user_reaction: change.added, }; - } else if ( entry ) { + } else { + // Another toggle's refresh may already have dropped the reaction; + // decrementing again would hide someone else's. + if ( entry?.current_user_reaction !== change.removed ) { + return note; + } const count = entry.count - 1; if ( count > 0 ) { summary[ hexKey ] = { count, current_user_reaction: 0 }; @@ -69,6 +74,13 @@ export function applyReactionDelta< T extends ReactionTarget >( */ const reactionMutationCounts = new Map< number, number >(); +/* + * Toggles still in flight, keyed by note and emoji. A second click reads + * the same summary snapshot and would repeat the request, so it is + * ignored until the first one lands. Shared by the picker and the pills. + */ +const pendingToggles = new Set< string >(); + /** * A note's reactions and a callback to toggle one. * @@ -89,9 +101,14 @@ export function useReaction( note: ReactionTarget ) { const toggleReaction = useCallback( async ( hexKey: string ) => { + const toggleKey = `${ noteId }:${ hexKey }`; + if ( pendingToggles.has( toggleKey ) ) { + return; + } + pendingToggles.add( toggleKey ); + const entry = reactions?.[ hexKey ]; const myReactionId = entry?.current_user_reaction || undefined; - const isRemoving = !! myReactionId; let addedReactionId: number | undefined; try { @@ -136,6 +153,7 @@ export function useReaction( note: ReactionTarget ) { : __( 'An error occurred while performing an update.' ), { type: 'snackbar', isDismissible: true } ); + pendingToggles.delete( toggleKey ); return; } @@ -156,15 +174,17 @@ export function useReaction( note: ReactionTarget ) { const cached = getEntityRecord( 'root', 'comment', noteId ) as ReactionTarget | undefined; - if ( cached ) { + const change = myReactionId + ? { removed: myReactionId } + : addedReactionId && { added: addedReactionId }; + if ( cached && change ) { receiveEntityRecords( 'root', 'comment', [ - applyReactionDelta( - cached, - hexKey, - isRemoving ? undefined : addedReactionId - ), + applyReactionDelta( cached, hexKey, change ), ] ); } + // The cached summary now reflects this toggle, so the next one + // reads the right state. + pendingToggles.delete( toggleKey ); // Then refetch the parent note (1 record) for the authoritative // summary, which also picks up other users' reactions. From a251c4b32a3779f26892c5c0acf86d65e180bace Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 16:35:53 +0200 Subject: [PATCH 358/362] Notes: Inline the internal comment types instead of a helper Reviewers on the matching core change prefer not to define what counts as an "internal" comment type before a custom comment types API exists, since reactions may later apply to public comments too. Drop the gutenberg_get_internal_comment_types() helper and its filter, and list 'note' and 'reaction' at each call site, as on #76767. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01THCU4aj11hnJP5ZbUegTRh --- ...-gutenberg-rest-comment-controller-7-2.php | 4 +- lib/compat/wordpress-7.2/note-reactions.php | 46 +++---------------- phpunit/note-reactions-feed-test.php | 9 ---- 3 files changed, 8 insertions(+), 51 deletions(-) diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php index 04984297699362..001fc2bb2fa28a 100644 --- a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -54,7 +54,7 @@ public function get_item_schema() { * @return bool True if the type is one of the internal comment types. */ protected function is_note_or_reaction( $type ) { - return in_array( $type, gutenberg_get_internal_comment_types(), true ); + return in_array( $type, array( 'note', 'reaction' ), true ); } /** @@ -468,7 +468,7 @@ public function create_item( $request ) { } // Allow 'comment' plus internal comment types (note, reaction). - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array_merge( array( 'comment' ), gutenberg_get_internal_comment_types() ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array_merge( array( 'comment' ), array( 'note', 'reaction' ) ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.', 'gutenberg' ), diff --git a/lib/compat/wordpress-7.2/note-reactions.php b/lib/compat/wordpress-7.2/note-reactions.php index c829c702e681a7..29ab5adb438d2c 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -24,40 +24,6 @@ * @since 7.2.0 */ -/** - * Returns the list of internal comment types used by core features. - * - * Internal comment types (currently 'note' and 'reaction') back editor - * functionality such as block notes and emoji reactions, and should be - * excluded from front-end comment listings, counts, and similar contexts - * that target user discussion. Centralizing the list keeps every guard - * in sync when new internal types are added. - * - * Mirrors the planned `wp_get_internal_comment_types()` core helper - * (see https://github.com/WordPress/wordpress-develop/pull/10930). - * - * @since 7.2.0 - * - * @return string[] List of internal comment type slugs. - */ -function gutenberg_get_internal_comment_types() { - /** - * Filters the list of internal comment types. - * - * @since 7.2.0 - * - * @param string[] $types List of internal comment type slugs. - */ - $types = apply_filters( 'gutenberg_internal_comment_types', array( 'note', 'reaction' ) ); - - // Callers build `NOT IN ( ... )` from this list, which is invalid SQL when empty. - if ( ! is_array( $types ) || empty( $types ) ) { - return array( 'note', 'reaction' ); - } - - return array_values( $types ); -} - /** * Updates the comment type for avatars to include internal comment types. * @@ -68,7 +34,7 @@ function gutenberg_get_internal_comment_types() { * @return array The updated array of comment types. */ function gutenberg_update_get_avatar_comment_type_7_2( $comment_type ) { - return array_values( array_unique( array_merge( $comment_type, gutenberg_get_internal_comment_types() ) ) ); + return array_values( array_unique( array_merge( $comment_type, array( 'note', 'reaction' ) ) ) ); } add_filter( 'get_avatar_comment_types', 'gutenberg_update_get_avatar_comment_type_7_2' ); @@ -91,7 +57,7 @@ function gutenberg_exclude_block_comments_from_admin_7_2( $clauses, $query ) { $query->set( 'type', '' ); global $wpdb; - $internal_types = gutenberg_get_internal_comment_types(); + $internal_types = array( 'note', 'reaction' ); $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $clauses['where'] .= ' AND ' . $wpdb->prepare( "{$wpdb->comments}.comment_type NOT IN ( $type_placeholders )", $internal_types ); @@ -115,7 +81,7 @@ function gutenberg_exclude_block_comments_from_admin_7_2( $clauses, $query ) { */ function gutenberg_exclude_internal_comment_types_from_feed_7_2( $cwhere ) { global $wpdb; - $internal_types = gutenberg_get_internal_comment_types(); + $internal_types = array( 'note', 'reaction' ); $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare return $cwhere . ' AND ' . $wpdb->prepare( "{$wpdb->comments}.comment_type NOT IN ( $type_placeholders )", $internal_types ); @@ -139,7 +105,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments_7_2( $query // reactions are excluded too and keeps the filter idempotent if it // runs more than once. $type_clauses = array(); - foreach ( gutenberg_get_internal_comment_types() as $internal_type ) { + foreach ( array( 'note', 'reaction' ) as $internal_type ) { $clause = "comment_type != '" . esc_sql( $internal_type ) . "'"; if ( ! str_contains( $query, $clause ) ) { $type_clauses[] = $clause; @@ -163,7 +129,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments_7_2( $query * @return array Possibly modified arguments for get_comments(). */ function gutenberg_hide_note_from_comment_list_table_7_2( $args ) { - if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], gutenberg_get_internal_comment_types(), true ) ) { + if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { unset( $args['type'] ); } return $args; @@ -185,7 +151,7 @@ function gutenberg_exclude_notes_from_comment_count_7_2( $new_count, $old_count, if ( null !== $new_count ) { return $new_count; } - $internal_types = gutenberg_get_internal_comment_types(); + $internal_types = array( 'note', 'reaction' ); $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); $new_count = (int) $wpdb->get_var( $wpdb->prepare( diff --git a/phpunit/note-reactions-feed-test.php b/phpunit/note-reactions-feed-test.php index 0bb3b5b4d3ab5b..c61cdd2f259f68 100644 --- a/phpunit/note-reactions-feed-test.php +++ b/phpunit/note-reactions-feed-test.php @@ -72,13 +72,4 @@ public function test_post_comment_feed_excludes_reactions() { $this->assertContains( self::$comment_id, $ids ); $this->assertNotContains( self::$reaction_id, $ids ); } - - public function test_feed_query_survives_empty_internal_types_filter() { - add_filter( 'gutenberg_internal_comment_types', '__return_empty_array' ); - $this->go_to( '/?feed=comments-rss2' ); - - $ids = $this->get_feed_comment_ids(); - $this->assertContains( self::$comment_id, $ids ); - $this->assertNotContains( self::$reaction_id, $ids ); - } } From f6a32e250e246b9dd5d075ffdbfb5cf96405e3c2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 16:41:13 +0200 Subject: [PATCH 359/362] Notes: Localize the reaction pill count Format the count for the user's locale in both the pill and its accessible label, as on #76767. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01THCU4aj11hnJP5ZbUegTRh --- .../collab-sidebar/reactions/reaction-display.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx index d429360b12168d..9850f88a5e303b 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx @@ -53,7 +53,7 @@ function getReactionCount( * @param hexKey The reaction hex key to check. * @return Whether the user has reacted. */ -function hasUserReacted( +export function hasUserReacted( reactions: ReactionSummary | null | undefined, hexKey: string ): boolean { @@ -328,9 +328,9 @@ function ReactionButton( { const defaultLabel = sprintf( /* translators: 1: emoji label, 2: count of reactions */ - _n( '%1$s, %2$d reaction', '%1$s, %2$d reactions', count ), + _n( '%1$s, %2$s reaction', '%1$s, %2$s reactions', count ), label, - count + count.toLocaleString() ); const accessibleLabel = tooltipText || defaultLabel; @@ -379,7 +379,7 @@ function ReactionButton( { { emoji } - { count } + { count.toLocaleString() } { accessibleLabel } From 7e95aebac427630ac7a96921946f5f9a4d45e329 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 16:41:23 +0200 Subject: [PATCH 360/362] Notes: Mark own reactions in the picker and hide it on resolved threads The picker now outlines the emoji the user already reacted with, and names them as such, so picking one again reads as a toggle. On a resolved thread the add-reaction trigger was shown disabled on some notes and hidden until hover on others; hide it entirely, as on #76767. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01THCU4aj11hnJP5ZbUegTRh --- .../src/components/collab-sidebar/note.jsx | 7 +- .../reactions/add-reaction-button.tsx | 16 ++-- .../collab-sidebar/reactions/emoji-picker.tsx | 73 +++++++++++++------ .../src/components/collab-sidebar/style.scss | 5 ++ .../specs/editor/various/block-notes.spec.js | 29 ++++---- 5 files changed, 85 insertions(+), 45 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/note.jsx b/packages/editor/src/components/collab-sidebar/note.jsx index 13226a85e20051..d4cd8b7b2d914b 100644 --- a/packages/editor/src/components/collab-sidebar/note.jsx +++ b/packages/editor/src/components/collab-sidebar/note.jsx @@ -87,8 +87,9 @@ export function Note( { const canResolve = note.parent === 0; const hasReactions = getReactedHexKeys( reactions ).length > 0; // Not while editing: the trigger floats over the note's corner, which - // during an edit is the form's own text field. - const canReact = isSelected && actionState !== 'edit'; + // during an edit is the form's own text field. Not on a resolved thread + // either, where reactions can no longer be toggled. + const canReact = isSelected && actionState !== 'edit' && ! isThreadResolved; const metaStatus = note.meta?._wp_note_status; const isResolutionNote = metaStatus === 'resolved' || metaStatus === 'reopen'; @@ -264,7 +265,7 @@ export function Note( { */ } { canReact && ( ) } diff --git a/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx index c85f973b30d7d4..9b035bd73cf1ee 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/add-reaction-button.tsx @@ -8,9 +8,11 @@ import { IconButton } from '@wordpress/ui'; import { reaction as reactionIcon } from '@wordpress/icons'; import EmojiPicker from './emoji-picker'; import { emojiToHexKey } from './reaction-emojis'; +import { hasUserReacted } from './reaction-display'; +import type { ReactionSummary } from './reaction-display'; interface AddReactionButtonProps { - disabled?: boolean; + reactions?: ReactionSummary | null; onToggleReaction: ( hexKey: string ) => void; } @@ -19,14 +21,18 @@ interface AddReactionButtonProps { * with its "Frequently used" section seeded from the curated set. * * @param props Component props. - * @param props.disabled Whether the button is disabled (e.g. on a - * resolved note thread). + * @param props.reactions The note's reaction summary, used to mark the + * emoji the current user has already reacted with. * @param props.onToggleReaction Callback to toggle a reaction. */ export function AddReactionButton( { - disabled = false, + reactions, onToggleReaction, }: AddReactionButtonProps ) { + const reactedHexKeys = Object.keys( reactions ?? {} ).filter( ( hexKey ) => + hasUserReacted( reactions, hexKey ) + ); + return ( } - disabled={ disabled } + reactedHexKeys={ reactedHexKeys } onSelect={ ( emoji ) => onToggleReaction( emojiToHexKey( emoji ) ) } /> ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx index c8c383f724bea7..bba54605c907ba 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -20,7 +20,7 @@ import { } from './emojibase-data'; import type { EmojibaseEntry } from './emojibase-data'; import { useFrequentEmojis } from './frequent-emojis'; -import { getCuratedLabel } from './reaction-emojis'; +import { emojiToHexKey, getCuratedLabel } from './reaction-emojis'; import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; /** @@ -67,6 +67,11 @@ interface EmojiPickerProps { * Accessible name for the picker popup. */ label: string; + /** + * Hex keys of the emoji the current user has already reacted with, + * marked in the grid. + */ + reactedHexKeys?: string[]; onSelect: ( emoji: string ) => void; } @@ -79,6 +84,8 @@ export const SKIN_TONE_PREFERENCE_KEY = 'emojiPickerSkinTone'; const COLUMNS = 6; +const EMPTY_HEX_KEYS: string[] = []; + /* * Unicode's Component group holds the skin-tone swatches and hair * modifiers, which only ever combine with another emoji. They are not @@ -215,16 +222,18 @@ export function searchEmojis( * strings go through `@wordpress/i18n`. Without a dataset (no URL * configured, or the fetch failed) the popup shows an error message. * - * @param props Component props. - * @param props.trigger The button that opens the picker. - * @param props.disabled Whether the trigger is disabled. - * @param props.label Accessible name for the popup. - * @param props.onSelect Called with the selected emoji character. + * @param props Component props. + * @param props.trigger The button that opens the picker. + * @param props.disabled Whether the trigger is disabled. + * @param props.label Accessible name for the popup. + * @param props.reactedHexKeys Hex keys of the user's own reactions. + * @param props.onSelect Called with the selected emoji character. */ export default function EmojiPicker( { trigger, disabled = false, label, + reactedHexKeys = EMPTY_HEX_KEYS, onSelect, }: EmojiPickerProps ) { const [ isOpen, setIsOpen ] = useState( false ); @@ -359,24 +368,40 @@ export default function EmojiPicker( { key={ rowIndex } className="editor-collab-sidebar-panel__picker-row" > - { row.map( ( option ) => ( - { - recordUse( option.hexKey ); - setIsOpen( false ); - onSelect( option.value ); - } } - > - - - - - ) ) } + { row.map( ( option ) => { + const isReacted = reactedHexKeys.includes( + emojiToHexKey( option.value ) + ); + return ( + { + recordUse( option.hexKey ); + setIsOpen( false ); + onSelect( option.value ); + } } + > + + + + + ); + } ) } ) ); diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 0cf843154953fd..a3ecee5d00db29 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -540,6 +540,11 @@ mark.wp-note { justify-content: center; height: var(--emoji-picker-cell-size); padding: 0; + + // The brand outline marks the user's own reactions, as on the pills. + &.is-reacted { + box-shadow: inset 0 0 0 var(--wpds-border-width-xs) var(--wpds-color-stroke-interactive-brand); + } } .editor-collab-sidebar-panel__picker-emoji-label { diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 59ff0160785ce1..29acb131442206 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1449,11 +1449,9 @@ test.describe( 'Block Notes', () => { } ); await expect( reactionPill ).toBeVisible(); - // Resolving posts a "Marked as resolved" reply that carries its - // own add trigger, so the root note's is the first of the two. - const addReaction = sidebar - .getByRole( 'combobox', { name: 'Add reaction' } ) - .first(); + const addReaction = sidebar.getByRole( 'combobox', { + name: 'Add reaction', + } ); const resolveButton = sidebar.getByRole( 'button', { name: 'Resolve', } ); @@ -1464,15 +1462,15 @@ test.describe( 'Block Notes', () => { await thread.click(); await expect( resolveButton ).toBeDisabled(); - // A resolved thread is an archived conversation, so neither the - // add trigger nor the existing pill may still mutate reactions. - await expect( addReaction ).toBeDisabled(); + // A resolved thread is an archived conversation: the add trigger + // is gone and the existing pill can no longer mutate reactions. + await expect( addReaction ).toHaveCount( 0 ); await expect( reactionPill ).toBeDisabled(); // Reopening the thread unlocks them again. await blockNoteUtils.clickBlockNoteActionMenuItem( 'Reopen' ); await expect( resolveButton ).toBeEnabled(); - await expect( addReaction ).toBeEnabled(); + await expect( addReaction.first() ).toBeEnabled(); await expect( reactionPill ).toBeEnabled(); } ); @@ -1508,7 +1506,8 @@ test.describe( 'Block Notes', () => { * Picking the same heart from the search results resolves to * the same key, so it toggles the existing reaction off rather * than adding a second pill. "heart" is a curated label, so the - * exact match skips "smiling face with hearts". + * exact match skips "smiling face with hearts", and the cell is + * marked as the user's own reaction. */ await page .getByRole( 'combobox', { name: 'Add reaction' } ) @@ -1516,7 +1515,10 @@ test.describe( 'Block Notes', () => { await blockNoteUtils.waitForFullPicker(); await page.getByPlaceholder( 'Search emoji' ).fill( 'heart' ); await page - .getByRole( 'gridcell', { name: 'heart', exact: true } ) + .getByRole( 'gridcell', { + name: 'heart, your reaction', + exact: true, + } ) .click(); await expect( reactionButton ).toHaveCount( 0 ); } ); @@ -1654,14 +1656,15 @@ test.describe( 'Block Notes', () => { .getByRole( 'gridcell', { name: 'avocado', exact: true } ) .click(); - // On reopening, the pick has joined the Frequently used section. + // On reopening, the pick has joined the Frequently used section, + // marked as the user's own reaction. await page .getByRole( 'combobox', { name: 'Add reaction' } ) .click(); await blockNoteUtils.waitForFullPicker(); await expect( frequentSection.getByRole( 'gridcell', { - name: 'avocado', + name: 'avocado, your reaction', exact: true, } ) ).toBeVisible(); From 74c7340b743198fae345a8daf3ae4d0967de5624 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 16:48:31 +0200 Subject: [PATCH 361/362] Notes: Fix emoji picker type errors The typecheck rejected the picker's trigger element, whose props were typed as unknown, and the grouped-or-flat items union, which matches neither of Base UI's Autocomplete.Root overloads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01THCU4aj11hnJP5ZbUegTRh --- .../components/collab-sidebar/reactions/emoji-picker.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx index bba54605c907ba..5fa5be49d9f55e 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -58,7 +58,7 @@ interface EmojiPickerProps { * The button that opens the picker, passed to `Autocomplete.Trigger` * as its `render` element. */ - trigger: ReactElement; + trigger: ReactElement< Record< string, unknown > >; /** * Whether the trigger is disabled. */ @@ -452,7 +452,9 @@ export default function EmojiPicker( { setQuery( '' ); } } } - items={ items } + // Groups while browsing, a flat list while searching. Base UI takes + // either at runtime, but its overloads can't take the union. + items={ items as readonly EmojiOption[] } filter={ null } // Enter picks the top hit once the user has typed. autoHighlight From a5f79408e2381e35755f443c6119b43ade0e6125 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 12 Oct 2026 12:29:18 +0200 Subject: [PATCH 362/362] Editor changelog: Order New Features before Enhancements The changelog validator requires Unreleased subsections in a fixed order, and the trunk merge put Enhancements first. Claude-Session: https://claude.ai/code/session_01RmMwj4CN9cF8b1Sx58Pvik --- packages/editor/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index f3933cde5b208f..592b5a569a7ef8 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -6,15 +6,15 @@ - Raise the minimum required Node.js version to `>=22.13.0`, for stable `require(esm)` support ([#84313](https://github.com/WordPress/gutenberg/pull/84313)). -### Enhancements - -- Migrate the classic-theme template selector to `@wordpress/ui` `SelectControl` ([#84345](https://github.com/WordPress/gutenberg/pull/84345)). - ### New Features - Notes: allow reacting to a note with an emoji. Reactions display as pill buttons on the note with per-emoji counts, backed by a new `reaction` comment type. The add-reaction trigger shows on a selected note, fading in on hover or focus in its top corner, so a note with no reactions costs no vertical space for the option ([#76767](https://github.com/WordPress/gutenberg/pull/76767)). - Notes: the add-reaction button now opens a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via the `noteEmojibaseUrl` editor setting ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). +### Enhancements + +- Migrate the classic-theme template selector to `@wordpress/ui` `SelectControl` ([#84345](https://github.com/WordPress/gutenberg/pull/84345)). + ### Bug Fixes - Notes: Highlight the text an unsent note is about, and keep the note anchored to it when the selection changes or the text is edited before the note is sent ([#84125](https://github.com/WordPress/gutenberg/pull/84125)).