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 001fc2bb2fa28a..2830057f43bc5a 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 @@ -572,6 +572,12 @@ 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_hex_key = gutenberg_is_note_reaction_hex_key_allowed( $emoji_key ); + } + if ( ! $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 cdf4c66c9551cd..13aed966d047b7 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 into the block editor settings, where - * the Notes picker reads it. npm consumers of `@wordpress/editor` opt in by - * supplying the same setting. + * 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. * * @since 7.2.0 * @@ -25,6 +25,48 @@ function gutenberg_add_emojibase_settings( $settings ) { 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' ); + +/** + * 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 in the picker. + * + * @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_emoji_settings' ) ) { + $emoji_settings = gutenberg_get_note_reaction_emoji_settings(); + foreach ( $emoji_settings['emojis'] as $entry ) { + $defaults[ strtoupper( $entry['hexcode'] ) ] = $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 29ab5adb438d2c..d45cdd9c359f95 100644 --- a/lib/compat/wordpress-7.2/note-reactions.php +++ b/lib/compat/wordpress-7.2/note-reactions.php @@ -164,6 +164,203 @@ 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 ); +/** + * Formats an emoji hex key in the reaction storage form: lowercase, each + * code point padded to four digits, U+FE0F stripped. Accepts Emojibase + * hexcodes (`2764-FE0F`) and stored keys. + * + * @since 7.2.0 + * + * @param string $hex_key Hex code points joined by `-`. + * @return string The formatted hex key, or empty string when invalid. + */ +function gutenberg_format_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 ) { + if ( 0xFE0F === hexdec( $part ) ) { + continue; + } + $parts[] = str_pad( $part, 4, '0', STR_PAD_LEFT ); + } + return implode( '-', $parts ); +} + +/** + * Reduces an emoji hex key to its base emoji: formatted as in + * gutenberg_format_note_reaction_hex_key(), with skin-tone modifiers + * stripped, so a rule for an emoji covers its skin-tone variants. + * + * @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 ) { + $formatted = gutenberg_format_note_reaction_hex_key( $hex_key ); + if ( '' === $formatted ) { + return ''; + } + $parts = array(); + foreach ( explode( '-', $formatted ) as $part ) { + $value = hexdec( $part ); + // The five Fitzpatrick skin-tone modifiers. + if ( $value >= 0x1F3FB && $value <= 0x1F3FF ) { + continue; + } + $parts[] = $part; + } + return implode( '-', $parts ); +} + +/** + * Returns the note reaction emoji settings: the named emoji list plus the + * rules for which other emoji the picker offers and the REST API accepts. + * + * @since 7.2.0 + * + * @return array { + * @type array[] $emojis Named emoji, each with a `hexcode` key + * in storage form and a `label`. + * @type bool $allow_unlisted Whether emoji outside the named list are accepted. + * @type string[] $exclude Base hex keys that are never accepted. + * } + */ +function gutenberg_get_note_reaction_emoji_settings() { + $default_emojis = array( + array( + 'hexcode' => '2764', + 'label' => _x( 'heart', 'emoji reaction', 'gutenberg' ), + ), + array( + 'hexcode' => '1F389', + 'label' => _x( 'celebration', 'emoji reaction', 'gutenberg' ), + ), + array( + 'hexcode' => '1F604', + 'label' => _x( 'smile', 'emoji reaction', 'gutenberg' ), + ), + array( + 'hexcode' => '1F440', + 'label' => _x( 'eyes', 'emoji reaction', 'gutenberg' ), + ), + array( + 'hexcode' => '1F680', + 'label' => _x( 'rocket', 'emoji reaction', 'gutenberg' ), + ), + ); + + /** + * Filters which emoji note reactions offer and accept. + * + * Named emoji seed the picker's "Frequently used" section, carry their + * own labels, and are always accepted. `allow_unlisted` and `exclude` + * apply to every other emoji in the picker. + * + * @since 7.2.0 + * + * @param array $settings { + * @type array[] $emojis Named emoji. Each item has a `hexcode` + * (Emojibase form, such as `1F984`) and + * a `label`. + * @type bool $allow_unlisted Whether any emoji from the 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; + + $emojis = array(); + if ( is_array( $settings['emojis'] ) ) { + foreach ( $settings['emojis'] as $entry ) { + if ( ! is_array( $entry ) || ! isset( $entry['hexcode'], $entry['label'] ) || ! is_string( $entry['label'] ) ) { + continue; + } + $hex_key = gutenberg_format_note_reaction_hex_key( $entry['hexcode'] ); + if ( '' !== $hex_key ) { + $emojis[] = array( + 'hexcode' => $hex_key, + 'label' => $entry['label'], + ); + } + } + } + + $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' => $emojis, + 'allow_unlisted' => (bool) $settings['allow_unlisted'], + 'exclude' => array_values( array_unique( $exclude ) ), + ); +} + +/** + * Injects the note reaction emoji settings into the block editor settings + * so the picker offers the same 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' ); + +/** + * Whether a reaction hex key 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 ( gutenberg_normalize_note_reaction_hex_key( $entry['hexcode'] ) === $base ) { + return true; + } + } + + return $settings['allow_unlisted'] && ! in_array( $base, $settings['exclude'], true ); +} + /** * Returns the reaction children of a note. * 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..8e7fb520f843ac --- /dev/null +++ b/packages/e2e-tests/plugins/note-reaction-emojis.php @@ -0,0 +1,31 @@ + '1F984', + 'label' => 'Unicorn', + ); + $settings['emojis'][] = array( + 'hexcode' => '1F3C6', + 'label' => 'Trophy', + ); + // Thumbs down, along with its skin-tone variants. + $settings['exclude'][] = '1F44E'; + 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 592b5a569a7ef8..35fea729a10cbf 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -10,6 +10,7 @@ - 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)). +- Notes: sites can customize emoji reactions through the `noteReactionEmojis`, `noteReactionEmojiRules`, and `noteEmojiLabelOverrides` editor settings, which the plugin fills from the `gutenberg_note_reaction_emoji_settings` and `gutenberg_emoji_picker_label_overrides` filters ([#84043](https://github.com/WordPress/gutenberg/pull/84043)). ### Enhancements diff --git a/packages/editor/src/components/collab-sidebar/README.md b/packages/editor/src/components/collab-sidebar/README.md index b36da71328e3e4..9c4928c53cc542 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 curated reaction set and storage keys +│ └── reaction-emojis.ts curated reaction set, storage keys, and the site's emoji rules │ ├── hooks.js useNoteThreads, useNoteActions, useNoteSelection, usePickNote, useNoteFocus, 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 9b035bd73cf1ee..4629be4fbd2f59 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,7 +7,11 @@ import { __ } from '@wordpress/i18n'; import { IconButton } from '@wordpress/ui'; import { reaction as reactionIcon } from '@wordpress/icons'; import EmojiPicker from './emoji-picker'; -import { emojiToHexKey } from './reaction-emojis'; +import { + emojiToHexKey, + useReactionEmojiRules, + useReactionEmojis, +} from './reaction-emojis'; import { hasUserReacted } from './reaction-display'; import type { ReactionSummary } from './reaction-display'; @@ -18,7 +22,7 @@ interface AddReactionButtonProps { /** * Standalone add-reaction button, opening the searchable emoji picker - * with its "Frequently used" section seeded from the curated set. + * with its "Frequently used" section seeded from the named set. * * @param props Component props. * @param props.reactions The note's reaction summary, used to mark the @@ -29,6 +33,15 @@ export function AddReactionButton( { reactions, onToggleReaction, }: AddReactionButtonProps ) { + const emojis = useReactionEmojis(); + const rules = useReactionEmojiRules(); + + // A site that empties the named list and allows nothing else leaves + // nothing to pick. + if ( ! emojis.length && ! rules.allowUnlisted ) { + return null; + } + const reactedHexKeys = Object.keys( reactions ?? {} ).filter( ( hexKey ) => hasUserReacted( reactions, hexKey ) ); 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 aafc468132db11..b9701426171768 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -14,13 +14,21 @@ import { useSelect, useDispatch } from '@wordpress/data'; import { store as preferencesStore } from '@wordpress/preferences'; import { detectLocale, + getOverrideLabel, normalizeHexcode, useEmojibaseConfig, useEmojibaseData, } from './emojibase-data'; import type { EmojibaseEntry } from './emojibase-data'; import { useFrequentEmojis } from './frequent-emojis'; -import { emojiToHexKey, getCuratedLabel } from './reaction-emojis'; +import { + emojiToHexKey, + getCuratedLabel, + getNamedHexKeys, + isReactionEmojiAllowed, + useReactionEmojiRules, + useReactionEmojis, +} from './reaction-emojis'; import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; /** @@ -180,13 +188,15 @@ 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 emojis Emoji records. + * @param query Search query. + * @param overrides Map of `hexcode => translated label`. * @return Matching emoji records. */ export function searchEmojis( emojis: EmojibaseEntry[], - query: string + query: string, + overrides: Record< string, string > | null ): EmojibaseEntry[] { const trimmed = query.trim().toLowerCase(); if ( ! trimmed ) { @@ -194,11 +204,13 @@ export function searchEmojis( } return emojis.filter( ( entry ) => { /* - * Match both the curated and the Emojibase label, so searching - * either name finds the emoji. + * Match both the site's or curated label and the Emojibase one, so + * searching either name finds the emoji. */ - const curated = getCuratedLabel( normalizeHexcode( entry.hexcode ) ); - if ( curated && curated.toLowerCase().includes( trimmed ) ) { + const named = + getOverrideLabel( overrides, entry.hexcode ) || + getCuratedLabel( normalizeHexcode( entry.hexcode ) ); + if ( named && named.toLowerCase().includes( trimmed ) ) { return true; } if ( entry.label && entry.label.toLowerCase().includes( trimmed ) ) { @@ -244,7 +256,7 @@ export default function EmojiPicker( { */ const [ isWarm, setIsWarm ] = useState( false ); const warm = () => setIsWarm( true ); - const { baseUrl } = useEmojibaseConfig(); + const { baseUrl, labelOverrides } = useEmojibaseConfig(); const [ locale ] = useState( detectLocale ); const { data: dataset, @@ -252,7 +264,22 @@ export default function EmojiPicker( { error, retry, } = useEmojibaseData( isWarm ? baseUrl : null, locale ); - const data = useMemo( () => dataset ?? [], [ dataset ] ); + 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 []; + } + const namedKeys = getNamedHexKeys( namedEmojis ); + return dataset.filter( ( entry ) => + isReactionEmojiAllowed( + normalizeHexcode( entry.hexcode ), + rules, + namedKeys + ) + ); + }, [ dataset, namedEmojis, rules ] ); const [ query, setQuery ] = useState( '' ); /* @@ -314,6 +341,7 @@ export default function EmojiPicker( { key: `${ prefix }-${ entry.hexcode }`, value: display.emoji, label: + getOverrideLabel( labelOverrides, display.hexcode ) || getCuratedLabel( normalizeHexcode( display.hexcode ) ) || display.label || '', @@ -328,7 +356,9 @@ export default function EmojiPicker( { * mostly-empty headers. */ return groups - .flatMap( ( group ) => searchEmojis( group.emojis, query ) ) + .flatMap( ( group ) => + searchEmojis( group.emojis, query, labelOverrides ) + ) .map( ( entry ) => toOption( entry, 'search' ) ); } @@ -353,7 +383,15 @@ export default function EmojiPicker( { ), } ) ), ].filter( ( group ) => group.items.length > 0 ); - }, [ groups, isSearching, query, frequentKeys, recordByHexKey, skinTone ] ); + }, [ + groups, + isSearching, + query, + labelOverrides, + frequentKeys, + recordByHexKey, + skinTone, + ] ); /** * Render grid rows of emoji cells, recording usage on selection. 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 e271ed9a00d7ef..18dd7314a131fd 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts @@ -15,26 +15,29 @@ import { getCuratedLabel } from './reaction-emojis'; export interface EmojibaseConfig { // 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 it server-side; npm consumers supply it themselves. + * plugin sets these server-side; npm consumers supply them themselves. * - * @return The Emojibase base URL. + * @return The Emojibase base URL and label overrides. */ export function useEmojibaseConfig(): EmojibaseConfig { - const baseUrl = useSelect( - ( select ) => - ( - select( blockEditorStore ).getSettings() as Record< - string, - unknown - > - ).noteEmojibaseUrl, - [] - ); - return { baseUrl: typeof baseUrl === 'string' && baseUrl ? baseUrl : null }; + 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, + }; + }, [] ); } /** @@ -303,17 +306,45 @@ export function normalizeHexcode( hexcode: string ): string { // One label map per `baseUrl|locale`, built lazily from the dataset. const labelMapCache = new Map< string, Map< string, string > >(); +/** + * Look up a site's label override for an emoji. + * + * 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. + * + * @param overrides Map of `hexcode => translated label`, or null. + * @param hexcode An Emojibase hexcode or a stored hex key. + * @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, with - * the curated reactions keeping their own labels. + * per-site overrides first and the curated labels next. 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 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 ) { @@ -322,7 +353,12 @@ function buildLabelMap( const map = new Map< string, string >(); const indexEntry = ( hexcode: string, label: string ) => { const hexKey = normalizeHexcode( hexcode ); - map.set( hexKey, getCuratedLabel( hexKey ) || label ); + map.set( + hexKey, + getOverrideLabel( overrides, hexcode ) || + getCuratedLabel( hexKey ) || + label + ); }; for ( const entry of data ) { if ( ! entry.hexcode || ! entry.label ) { @@ -346,13 +382,15 @@ 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 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, - baseUrl: string | null + baseUrl: string | null, + overrides: Record< string, string > | null ): string | null { if ( ! baseUrl ) { return null; @@ -362,7 +400,9 @@ export function getCachedEmojiLabel( if ( ! cached ) { return null; } - return buildLabelMap( cacheKey, cached.data ).get( hexKey ) || null; + return ( + buildLabelMap( cacheKey, cached.data, overrides ).get( hexKey ) || null + ); } /** @@ -384,9 +424,9 @@ export function useEmojiLabel( enabled: boolean, load: boolean ): string | null { - const { baseUrl } = useEmojibaseConfig(); + const { baseUrl, labelOverrides } = useEmojibaseConfig(); const [ label, setLabel ] = useState< string | null >( () => - enabled ? getCachedEmojiLabel( hexKey, baseUrl ) : null + enabled ? getCachedEmojiLabel( hexKey, baseUrl, labelOverrides ) : null ); useEffect( () => { @@ -402,7 +442,8 @@ export function useEmojiLabel( } const resolved = buildLabelMap( `${ baseUrl }|${ locale }`, - data + data, + labelOverrides ).get( hexKey ); if ( resolved ) { setLabel( resolved ); @@ -414,7 +455,7 @@ export function useEmojiLabel( return () => { cancelled = true; }; - }, [ hexKey, enabled, load, label, baseUrl ] ); + }, [ hexKey, enabled, load, label, baseUrl, labelOverrides ] ); 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 240dd3ce2ac519..63711f739b235a 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts @@ -1,7 +1,11 @@ import { useCallback } from '@wordpress/element'; import { useSelect, useRegistry } from '@wordpress/data'; import { store as preferencesStore } from '@wordpress/preferences'; -import { REACTION_EMOJIS, emojiToHexKey } from './reaction-emojis'; +import { + REACTION_EMOJIS, + emojiToHexKey, + useReactionEmojis, +} from './reaction-emojis'; /** * A recorded frequently-used emoji: its normalized hex key and how many @@ -119,15 +123,21 @@ export function useFrequentEmojis(): { recordUse: ( key: string ) => void; } { const registry = useRegistry(); + /* + * Seed from the named 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 ) => entry.hexKey ) ), - [] + [ 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 9850f88a5e303b..ed17670e38807c 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx @@ -10,7 +10,11 @@ import { useState, useCallback } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; import { getCuratedLabel, hexKeyToEmoji } from './reaction-emojis'; -import { useEmojiLabel } from './emojibase-data'; +import { + getOverrideLabel, + useEmojibaseConfig, + useEmojiLabel, +} from './emojibase-data'; interface ReactionSummaryEntry { count: number; @@ -414,6 +418,7 @@ export default function ReactionDisplay( { onToggleReaction, children, }: ReactionDisplayProps ) { + const { labelOverrides } = useEmojibaseConfig(); const reactedHexKeys = getReactedHexKeys( reactions ); if ( reactedHexKeys.length === 0 && ! children ) { @@ -441,7 +446,10 @@ export default function ReactionDisplay( { count={ count } isActive={ isActive } emoji={ hexKeyToEmoji( hexKey ) } - emojiLabel={ getCuratedLabel( hexKey ) } + emojiLabel={ + getOverrideLabel( labelOverrides, hexKey ) || + getCuratedLabel( hexKey ) + } disabled={ disabled } onToggleReaction={ onToggleReaction } /> 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 82e5eb5e51f35f..0b30261429cce2 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts @@ -1,4 +1,8 @@ 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. @@ -124,3 +128,163 @@ export function getCuratedLabel( hexKey: string ): string | undefined { ( entry ) => emojiToHexKey( entry.emoji ) === hexKey )?.label; } + +/** + * A named reaction emoji from the `noteReactionEmojis` setting. + */ +export interface NamedEmoji { + hexKey: string; + label: string; +} + +/** + * Read the named emoji list from the raw editor setting, falling back to + * the curated set only when the setting is absent. Malformed entries drop, + * and an emptied list stays empty. + * + * @param raw The `noteReactionEmojis` setting. + * @return The named emoji. + */ +export function parseReactionEmojis( raw: unknown ): NamedEmoji[] { + if ( ! Array.isArray( raw ) ) { + return REACTION_EMOJIS.map( ( { emoji, label } ) => ( { + hexKey: emojiToHexKey( emoji ), + label, + } ) ); + } + return raw + .filter( + ( entry ): entry is { hexcode: string; label: string } => + !! entry && + typeof entry.hexcode === 'string' && + typeof entry.label === 'string' + ) + .map( ( { hexcode, label } ) => ( { + hexKey: hexcode.toLowerCase(), + label, + } ) ) + .filter( ( entry ) => HEX_KEY_RE.test( entry.hexKey ) ); +} + +/** + * The named emoji from editor settings. The server injects them via + * `gutenberg_note_reaction_emoji_settings`, so "Frequently used" starts + * from the set the site chose. + * + * @return The named emoji. + */ +export function useReactionEmojis(): NamedEmoji[] { + const raw = useSelect( + ( select ) => + ( + select( blockEditorStore ).getSettings() as Record< + string, + unknown + > + ).noteReactionEmojis, + [] + ); + return useMemo( () => parseReactionEmojis( raw ), [ raw ] ); +} + +/** + * Which emoji the 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; + // Base 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 reactions. + */ +export function useReactionEmojiRules(): ReactionEmojiRules { + const raw = useSelect( + ( select ) => + ( + select( blockEditorStore ).getSettings() as Record< + string, + unknown + > + ).noteReactionEmojiRules, + [] + ); + return useMemo( () => parseReactionEmojiRules( raw ), [ raw ] ); +} + +/** + * Base hex keys of the named emoji, which the rules always accept. + * + * @param emojis The named emoji. + * @return Set of base hex keys. + */ +export function getNamedHexKeys( emojis: NamedEmoji[] ): Set< string > { + return new Set( emojis.map( ( entry ) => stripSkinTones( entry.hexKey ) ) ); +} + +/** + * 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 ); +} diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/emoji-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/reactions/test/emoji-picker.jsdom.test.tsx index a61a17ebae6a08..d9378ab03bf15c 100644 --- a/packages/editor/src/components/collab-sidebar/reactions/test/emoji-picker.jsdom.test.tsx +++ b/packages/editor/src/components/collab-sidebar/reactions/test/emoji-picker.jsdom.test.tsx @@ -19,7 +19,10 @@ vi.mock( import( '../emojibase-data' ), async ( importOriginal ) => { const actual = ( await importOriginal() ) as typeof EmojibaseData; return { ...actual, - useEmojibaseConfig: () => ( { baseUrl: 'https://example.org/' } ), + useEmojibaseConfig: () => ( { + baseUrl: 'https://example.org/', + labelOverrides: null, + } ), useEmojibaseData: () => ( { data: DATASET, isLoading: false, 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 49fd04fac6a7db..5209ea39bf4be5 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -87,6 +87,9 @@ 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 89e699a2e8a8f2..2aca299a6cf912 100644 --- a/phpunit/emoji-picker-data-test.php +++ b/phpunit/emoji-picker-data-test.php @@ -1,6 +1,6 @@ assertSame( 'heart', $overrides['2764'] ); + $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 @@ -30,5 +106,7 @@ 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 5a3b8ba22f11f6..d25ec1dda58e9f 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -947,6 +947,190 @@ public function data_valid_reaction_emojis() { ); } + public function test_reaction_emojis_filter_affects_validation() { + $filter = function ( $settings ) { + $settings['emojis'] = array( + array( + 'hexcode' => '1F44D', + 'label' => 'Thumbs Up', + ), + ); + $settings['allow_unlisted'] = false; + 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 named emoji should be accepted. + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => '1f44d', + '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 named emoji is now unlisted, so it is rejected. + $params['content'] = '2764'; + $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 reactions + * outside the named list the controller accepts. + * + * @dataProvider data_reaction_emoji_rules + * + * @param mixed $rules The filtered settings. + * @param string $content The reaction hex 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 ), + 'unpadded exclusion' => array( array( 'exclude' => array( 'A9' ) ), '00a9', 400 ), + 'named-only rejects unlisted' => array( $named_only, '1f44d', 400 ), + 'named-only accepts named' => 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 ); + } + + /** + * Named emoji reach the editor as formatted hex keys, and malformed + * entries drop. + */ + public function test_reaction_emojis_reach_editor_settings() { + $filter = function ( $settings ) { + $settings['emojis'] = array( + array( + 'hexcode' => '2764-FE0F', + 'label' => 'Heart', + ), + array( + 'hexcode' => 'not hex', + 'label' => 'Broken', + ), + array( 'label' => 'No hexcode' ), + ); + $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( + array( + 'hexcode' => '2764', + 'label' => 'Heart', + ), + ), + $settings['noteReactionEmojis'] + ); + $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.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index b1e13a14e1fc19..6eb95128b3b689 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1761,6 +1761,72 @@ 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 named + // entry under its own label, the REST API accepts its hex + // key, and the pill resolves the key back to the 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( 'the picker does not offer an excluded emoji', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing excluded emoji' }, + comment: 'Excluded emoji', + } ); + + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await blockNoteUtils.waitForFullPicker(); + const search = page.getByPlaceholder( 'Search emoji' ); + + // The REST API rejects the excluded emoji, so the picker + // must not offer it, while its sibling stays available. + await search.fill( 'thumbs up' ); + await expect( + page.getByRole( 'gridcell', { name: /thumbs up/i } ) + ).not.toHaveCount( 0 ); + await search.fill( 'thumbs down' ); + await expect( + page.getByRole( 'gridcell', { name: /thumbs down/i } ) + ).toHaveCount( 0 ); + } ); + } ); } ); test.describe( 'Multiple notes per block', () => {