From f5ece323435bf8da748e55e006553cbe9face4bb Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 14:30:11 +0200 Subject: [PATCH 01/36] feat(emojis): add built-in emoji dataset and search index Vendor a snapshot of @emoji-mart/data (native set 15, sheet stripped) into src/plugins/Emojis/data and add an in-house search index that replicates emoji-mart's SearchIndex.search ranking, so ":shortcode" autocomplete and emoticon replacement work without the emoji-mart packages. - scripts/vendor-emoji-data.mjs regenerates the vendored JSON + MIT LICENSE - loadEmojiData() lazily code-splits the dataset (never in the core bundle) - createTextComposerEmojiMiddleware() now defaults to the built-in index - deprecated no-op init() shim eases migration off emoji-mart --- .prettierignore | 4 + scripts/vendor-emoji-data.mjs | 49 +++++++++ src/plugins/Emojis/compat.ts | 9 ++ src/plugins/Emojis/data/LICENSE | 21 ++++ .../Emojis/data/__tests__/emoji-data.test.ts | 34 ++++++ src/plugins/Emojis/data/emoji-data.json | 1 + src/plugins/Emojis/data/index.ts | 27 +++++ src/plugins/Emojis/data/types.ts | 28 +++++ src/plugins/Emojis/index.ts | 3 + .../textComposerEmojiMiddleware.test.ts | 82 ++++++++++++++ .../middleware/textComposerEmojiMiddleware.ts | 24 ++-- src/plugins/Emojis/search/EmojiSearchIndex.ts | 39 +++++++ .../search/__tests__/EmojiSearchIndex.test.ts | 34 ++++++ .../Emojis/search/__tests__/search.test.ts | 103 ++++++++++++++++++ .../Emojis/search/buildEmojiSearchData.ts | 58 ++++++++++ src/plugins/Emojis/search/index.ts | 3 + src/plugins/Emojis/search/search.ts | 61 +++++++++++ 17 files changed, 569 insertions(+), 11 deletions(-) create mode 100644 scripts/vendor-emoji-data.mjs create mode 100644 src/plugins/Emojis/compat.ts create mode 100644 src/plugins/Emojis/data/LICENSE create mode 100644 src/plugins/Emojis/data/__tests__/emoji-data.test.ts create mode 100644 src/plugins/Emojis/data/emoji-data.json create mode 100644 src/plugins/Emojis/data/index.ts create mode 100644 src/plugins/Emojis/data/types.ts create mode 100644 src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts create mode 100644 src/plugins/Emojis/search/EmojiSearchIndex.ts create mode 100644 src/plugins/Emojis/search/__tests__/EmojiSearchIndex.test.ts create mode 100644 src/plugins/Emojis/search/__tests__/search.test.ts create mode 100644 src/plugins/Emojis/search/buildEmojiSearchData.ts create mode 100644 src/plugins/Emojis/search/index.ts create mode 100644 src/plugins/Emojis/search/search.ts diff --git a/.prettierignore b/.prettierignore index 4b02ec540d..7307d99ba9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,7 @@ examples/**/serviceWorker.ts .agents/ .claude/skills/ skills-lock.json + +# Vendored emoji dataset (snapshot of @emoji-mart/data โ€” kept minified; regenerate +# via `node scripts/vendor-emoji-data.mjs`) +src/plugins/Emojis/data/emoji-data.json diff --git a/scripts/vendor-emoji-data.mjs b/scripts/vendor-emoji-data.mjs new file mode 100644 index 0000000000..0619c741bb --- /dev/null +++ b/scripts/vendor-emoji-data.mjs @@ -0,0 +1,49 @@ +// Regenerates the vendored emoji dataset used by the built-in emoji picker and +// search index (src/plugins/Emojis/data/emoji-data.json). +// +// We vendor a snapshot of @emoji-mart/data's *native* set (no spritesheet) so the +// SDK ships its own emoji data and does not depend on the unmaintained emoji-mart +// packages at runtime. @emoji-mart/data stays a pinned devDependency used ONLY by +// this script. +// +// Usage: node scripts/vendor-emoji-data.mjs +// +// The dataset is MIT-licensed (Copyright (c) Missive); its license is copied +// verbatim to src/plugins/Emojis/data/LICENSE. + +import { createRequire } from 'node:module'; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +// @emoji-mart/data's "main" resolves to sets/15/native.json (its newest set), so +// pinning 15 matches a bare `import data from '@emoji-mart/data'`. +const SET = 15; + +const require = createRequire(import.meta.url); + +const sourcePath = require.resolve(`@emoji-mart/data/sets/${SET}/native.json`); +const pkgPath = require.resolve('@emoji-mart/data/package.json'); +const pkg = require('@emoji-mart/data/package.json'); +const licensePath = join(dirname(pkgPath), 'LICENSE'); + +const outDir = resolve(process.cwd(), 'src/plugins/Emojis/data'); +const outDataPath = join(outDir, 'emoji-data.json'); +const outLicensePath = join(outDir, 'LICENSE'); + +const data = JSON.parse(readFileSync(sourcePath, 'utf8')); + +// `sheet` only describes spritesheet geometry (cols/rows); we render native unicode +// exclusively, so it is dead weight. Everything else (categories, emojis, aliases) +// is preserved verbatim so mapEmojiMartData and the search index keep working. +delete data.sheet; + +mkdirSync(outDir, { recursive: true }); +writeFileSync(outDataPath, JSON.stringify(data)); +copyFileSync(licensePath, outLicensePath); + +const emojiCount = Object.keys(data.emojis).length; +console.log( + `Vendored @emoji-mart/data@${pkg.version} (set ${SET}, native): ` + + `${emojiCount} emoji, ${data.categories.length} categories -> ${outDataPath}`, +); +console.log(`Copied upstream MIT LICENSE -> ${outLicensePath}`); diff --git a/src/plugins/Emojis/compat.ts b/src/plugins/Emojis/compat.ts new file mode 100644 index 0000000000..2f270ceed8 --- /dev/null +++ b/src/plugins/Emojis/compat.ts @@ -0,0 +1,9 @@ +/** + * @deprecated No longer required. The built-in emoji search index self-initializes, + * so this is a no-op kept only so existing `init({ data })` calls keep compiling and + * running while migrating away from `emoji-mart`. Safe to remove from your app. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const init = (_options?: unknown): void => { + // intentionally empty โ€” kept for backwards compatibility only +}; diff --git a/src/plugins/Emojis/data/LICENSE b/src/plugins/Emojis/data/LICENSE new file mode 100644 index 0000000000..a82512e980 --- /dev/null +++ b/src/plugins/Emojis/data/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Missive. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/plugins/Emojis/data/__tests__/emoji-data.test.ts b/src/plugins/Emojis/data/__tests__/emoji-data.test.ts new file mode 100644 index 0000000000..202f9c40e9 --- /dev/null +++ b/src/plugins/Emojis/data/__tests__/emoji-data.test.ts @@ -0,0 +1,34 @@ +import emojiData from '../emoji-data.json'; +import type { EmojiData } from '../types'; + +const data = emojiData as unknown as EmojiData; + +describe('vendored emoji-data.json', () => { + it('has exactly the aliases/categories/emojis top-level keys (no spritesheet)', () => { + expect(Object.keys(data).sort()).toEqual(['aliases', 'categories', 'emojis']); + expect((data as Record).sheet).toBeUndefined(); + }); + + it('has the 8 standard categories in the expected order', () => { + expect(data.categories.map((category) => category.id)).toEqual([ + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', + ]); + }); + + it('every emoji has an id, a name and at least one native skin', () => { + const emojis = Object.values(data.emojis); + expect(emojis.length).toBeGreaterThan(1800); + for (const emoji of emojis) { + expect(typeof emoji.id).toBe('string'); + expect(typeof emoji.name).toBe('string'); + expect(typeof emoji.skins?.[0]?.native).toBe('string'); + } + }); +}); diff --git a/src/plugins/Emojis/data/emoji-data.json b/src/plugins/Emojis/data/emoji-data.json new file mode 100644 index 0000000000..ce24d75195 --- /dev/null +++ b/src/plugins/Emojis/data/emoji-data.json @@ -0,0 +1 @@ +{"categories":[{"id":"people","emojis":["grinning","smiley","smile","grin","laughing","sweat_smile","rolling_on_the_floor_laughing","joy","slightly_smiling_face","upside_down_face","melting_face","wink","blush","innocent","smiling_face_with_3_hearts","heart_eyes","star-struck","kissing_heart","kissing","relaxed","kissing_closed_eyes","kissing_smiling_eyes","smiling_face_with_tear","yum","stuck_out_tongue","stuck_out_tongue_winking_eye","zany_face","stuck_out_tongue_closed_eyes","money_mouth_face","hugging_face","face_with_hand_over_mouth","face_with_open_eyes_and_hand_over_mouth","face_with_peeking_eye","shushing_face","thinking_face","saluting_face","zipper_mouth_face","face_with_raised_eyebrow","neutral_face","expressionless","no_mouth","dotted_line_face","face_in_clouds","smirk","unamused","face_with_rolling_eyes","grimacing","face_exhaling","lying_face","shaking_face","relieved","pensive","sleepy","drooling_face","sleeping","mask","face_with_thermometer","face_with_head_bandage","nauseated_face","face_vomiting","sneezing_face","hot_face","cold_face","woozy_face","dizzy_face","face_with_spiral_eyes","exploding_head","face_with_cowboy_hat","partying_face","disguised_face","sunglasses","nerd_face","face_with_monocle","confused","face_with_diagonal_mouth","worried","slightly_frowning_face","white_frowning_face","open_mouth","hushed","astonished","flushed","pleading_face","face_holding_back_tears","frowning","anguished","fearful","cold_sweat","disappointed_relieved","cry","sob","scream","confounded","persevere","disappointed","sweat","weary","tired_face","yawning_face","triumph","rage","angry","face_with_symbols_on_mouth","smiling_imp","imp","skull","skull_and_crossbones","hankey","clown_face","japanese_ogre","japanese_goblin","ghost","alien","space_invader","wave","raised_back_of_hand","raised_hand_with_fingers_splayed","hand","spock-hand","rightwards_hand","leftwards_hand","palm_down_hand","palm_up_hand","leftwards_pushing_hand","rightwards_pushing_hand","ok_hand","pinched_fingers","pinching_hand","v","crossed_fingers","hand_with_index_finger_and_thumb_crossed","i_love_you_hand_sign","the_horns","call_me_hand","point_left","point_right","point_up_2","middle_finger","point_down","point_up","index_pointing_at_the_viewer","+1","-1","fist","facepunch","left-facing_fist","right-facing_fist","clap","raised_hands","heart_hands","open_hands","palms_up_together","handshake","pray","writing_hand","nail_care","selfie","muscle","mechanical_arm","mechanical_leg","leg","foot","ear","ear_with_hearing_aid","nose","brain","anatomical_heart","lungs","tooth","bone","eyes","eye","tongue","lips","biting_lip","baby","child","boy","girl","adult","person_with_blond_hair","man","bearded_person","man_with_beard","woman_with_beard","red_haired_man","curly_haired_man","white_haired_man","bald_man","woman","red_haired_woman","red_haired_person","curly_haired_woman","curly_haired_person","white_haired_woman","white_haired_person","bald_woman","bald_person","blond-haired-woman","blond-haired-man","older_adult","older_man","older_woman","person_frowning","man-frowning","woman-frowning","person_with_pouting_face","man-pouting","woman-pouting","no_good","man-gesturing-no","woman-gesturing-no","ok_woman","man-gesturing-ok","woman-gesturing-ok","information_desk_person","man-tipping-hand","woman-tipping-hand","raising_hand","man-raising-hand","woman-raising-hand","deaf_person","deaf_man","deaf_woman","bow","man-bowing","woman-bowing","face_palm","man-facepalming","woman-facepalming","shrug","man-shrugging","woman-shrugging","health_worker","male-doctor","female-doctor","student","male-student","female-student","teacher","male-teacher","female-teacher","judge","male-judge","female-judge","farmer","male-farmer","female-farmer","cook","male-cook","female-cook","mechanic","male-mechanic","female-mechanic","factory_worker","male-factory-worker","female-factory-worker","office_worker","male-office-worker","female-office-worker","scientist","male-scientist","female-scientist","technologist","male-technologist","female-technologist","singer","male-singer","female-singer","artist","male-artist","female-artist","pilot","male-pilot","female-pilot","astronaut","male-astronaut","female-astronaut","firefighter","male-firefighter","female-firefighter","cop","male-police-officer","female-police-officer","sleuth_or_spy","male-detective","female-detective","guardsman","male-guard","female-guard","ninja","construction_worker","male-construction-worker","female-construction-worker","person_with_crown","prince","princess","man_with_turban","man-wearing-turban","woman-wearing-turban","man_with_gua_pi_mao","person_with_headscarf","person_in_tuxedo","man_in_tuxedo","woman_in_tuxedo","bride_with_veil","man_with_veil","woman_with_veil","pregnant_woman","pregnant_man","pregnant_person","breast-feeding","woman_feeding_baby","man_feeding_baby","person_feeding_baby","angel","santa","mrs_claus","mx_claus","superhero","male_superhero","female_superhero","supervillain","male_supervillain","female_supervillain","mage","male_mage","female_mage","fairy","male_fairy","female_fairy","vampire","male_vampire","female_vampire","merperson","merman","mermaid","elf","male_elf","female_elf","genie","male_genie","female_genie","zombie","male_zombie","female_zombie","troll","massage","man-getting-massage","woman-getting-massage","haircut","man-getting-haircut","woman-getting-haircut","walking","man-walking","woman-walking","standing_person","man_standing","woman_standing","kneeling_person","man_kneeling","woman_kneeling","person_with_probing_cane","man_with_probing_cane","woman_with_probing_cane","person_in_motorized_wheelchair","man_in_motorized_wheelchair","woman_in_motorized_wheelchair","person_in_manual_wheelchair","man_in_manual_wheelchair","woman_in_manual_wheelchair","runner","man-running","woman-running","dancer","man_dancing","man_in_business_suit_levitating","dancers","men-with-bunny-ears-partying","women-with-bunny-ears-partying","person_in_steamy_room","man_in_steamy_room","woman_in_steamy_room","person_climbing","man_climbing","woman_climbing","fencer","horse_racing","skier","snowboarder","golfer","man-golfing","woman-golfing","surfer","man-surfing","woman-surfing","rowboat","man-rowing-boat","woman-rowing-boat","swimmer","man-swimming","woman-swimming","person_with_ball","man-bouncing-ball","woman-bouncing-ball","weight_lifter","man-lifting-weights","woman-lifting-weights","bicyclist","man-biking","woman-biking","mountain_bicyclist","man-mountain-biking","woman-mountain-biking","person_doing_cartwheel","man-cartwheeling","woman-cartwheeling","wrestlers","man-wrestling","woman-wrestling","water_polo","man-playing-water-polo","woman-playing-water-polo","handball","man-playing-handball","woman-playing-handball","juggling","man-juggling","woman-juggling","person_in_lotus_position","man_in_lotus_position","woman_in_lotus_position","bath","sleeping_accommodation","people_holding_hands","two_women_holding_hands","man_and_woman_holding_hands","two_men_holding_hands","couplekiss","woman-kiss-man","man-kiss-man","woman-kiss-woman","couple_with_heart","woman-heart-man","man-heart-man","woman-heart-woman","family","man-woman-boy","man-woman-girl","man-woman-girl-boy","man-woman-boy-boy","man-woman-girl-girl","man-man-boy","man-man-girl","man-man-girl-boy","man-man-boy-boy","man-man-girl-girl","woman-woman-boy","woman-woman-girl","woman-woman-girl-boy","woman-woman-boy-boy","woman-woman-girl-girl","man-boy","man-boy-boy","man-girl","man-girl-boy","man-girl-girl","woman-boy","woman-boy-boy","woman-girl","woman-girl-boy","woman-girl-girl","speaking_head_in_silhouette","bust_in_silhouette","busts_in_silhouette","people_hugging","footprints","robot_face","smiley_cat","smile_cat","joy_cat","heart_eyes_cat","smirk_cat","kissing_cat","scream_cat","crying_cat_face","pouting_cat","see_no_evil","hear_no_evil","speak_no_evil","love_letter","cupid","gift_heart","sparkling_heart","heartpulse","heartbeat","revolving_hearts","two_hearts","heart_decoration","heavy_heart_exclamation_mark_ornament","broken_heart","heart_on_fire","mending_heart","heart","pink_heart","orange_heart","yellow_heart","green_heart","blue_heart","light_blue_heart","purple_heart","brown_heart","black_heart","grey_heart","white_heart","kiss","100","anger","boom","dizzy","sweat_drops","dash","hole","speech_balloon","eye-in-speech-bubble","left_speech_bubble","right_anger_bubble","thought_balloon","zzz"]},{"id":"nature","emojis":["monkey_face","monkey","gorilla","orangutan","dog","dog2","guide_dog","service_dog","poodle","wolf","fox_face","raccoon","cat","cat2","black_cat","lion_face","tiger","tiger2","leopard","horse","moose","donkey","racehorse","unicorn_face","zebra_face","deer","bison","cow","ox","water_buffalo","cow2","pig","pig2","boar","pig_nose","ram","sheep","goat","dromedary_camel","camel","llama","giraffe_face","elephant","mammoth","rhinoceros","hippopotamus","mouse","mouse2","rat","hamster","rabbit","rabbit2","chipmunk","beaver","hedgehog","bat","bear","polar_bear","koala","panda_face","sloth","otter","skunk","kangaroo","badger","feet","turkey","chicken","rooster","hatching_chick","baby_chick","hatched_chick","bird","penguin","dove_of_peace","eagle","duck","swan","owl","dodo","feather","flamingo","peacock","parrot","wing","black_bird","goose","frog","crocodile","turtle","lizard","snake","dragon_face","dragon","sauropod","t-rex","whale","whale2","dolphin","seal","fish","tropical_fish","blowfish","shark","octopus","shell","coral","jellyfish","snail","butterfly","bug","ant","bee","beetle","ladybug","cricket","cockroach","spider","spider_web","scorpion","mosquito","fly","worm","microbe","bouquet","cherry_blossom","white_flower","lotus","rosette","rose","wilted_flower","hibiscus","sunflower","blossom","tulip","hyacinth","seedling","potted_plant","evergreen_tree","deciduous_tree","palm_tree","cactus","ear_of_rice","herb","shamrock","four_leaf_clover","maple_leaf","fallen_leaf","leaves","empty_nest","nest_with_eggs","mushroom"]},{"id":"foods","emojis":["grapes","melon","watermelon","tangerine","lemon","banana","pineapple","mango","apple","green_apple","pear","peach","cherries","strawberry","blueberries","kiwifruit","tomato","olive","coconut","avocado","eggplant","potato","carrot","corn","hot_pepper","bell_pepper","cucumber","leafy_green","broccoli","garlic","onion","peanuts","beans","chestnut","ginger_root","pea_pod","bread","croissant","baguette_bread","flatbread","pretzel","bagel","pancakes","waffle","cheese_wedge","meat_on_bone","poultry_leg","cut_of_meat","bacon","hamburger","fries","pizza","hotdog","sandwich","taco","burrito","tamale","stuffed_flatbread","falafel","egg","fried_egg","shallow_pan_of_food","stew","fondue","bowl_with_spoon","green_salad","popcorn","butter","salt","canned_food","bento","rice_cracker","rice_ball","rice","curry","ramen","spaghetti","sweet_potato","oden","sushi","fried_shrimp","fish_cake","moon_cake","dango","dumpling","fortune_cookie","takeout_box","crab","lobster","shrimp","squid","oyster","icecream","shaved_ice","ice_cream","doughnut","cookie","birthday","cake","cupcake","pie","chocolate_bar","candy","lollipop","custard","honey_pot","baby_bottle","glass_of_milk","coffee","teapot","tea","sake","champagne","wine_glass","cocktail","tropical_drink","beer","beers","clinking_glasses","tumbler_glass","pouring_liquid","cup_with_straw","bubble_tea","beverage_box","mate_drink","ice_cube","chopsticks","knife_fork_plate","fork_and_knife","spoon","hocho","jar","amphora"]},{"id":"activity","emojis":["jack_o_lantern","christmas_tree","fireworks","sparkler","firecracker","sparkles","balloon","tada","confetti_ball","tanabata_tree","bamboo","dolls","flags","wind_chime","rice_scene","red_envelope","ribbon","gift","reminder_ribbon","admission_tickets","ticket","medal","trophy","sports_medal","first_place_medal","second_place_medal","third_place_medal","soccer","baseball","softball","basketball","volleyball","football","rugby_football","tennis","flying_disc","bowling","cricket_bat_and_ball","field_hockey_stick_and_ball","ice_hockey_stick_and_puck","lacrosse","table_tennis_paddle_and_ball","badminton_racquet_and_shuttlecock","boxing_glove","martial_arts_uniform","goal_net","golf","ice_skate","fishing_pole_and_fish","diving_mask","running_shirt_with_sash","ski","sled","curling_stone","dart","yo-yo","kite","gun","8ball","crystal_ball","magic_wand","video_game","joystick","slot_machine","game_die","jigsaw","teddy_bear","pinata","mirror_ball","nesting_dolls","spades","hearts","diamonds","clubs","chess_pawn","black_joker","mahjong","flower_playing_cards","performing_arts","frame_with_picture","art","thread","sewing_needle","yarn","knot"]},{"id":"places","emojis":["earth_africa","earth_americas","earth_asia","globe_with_meridians","world_map","japan","compass","snow_capped_mountain","mountain","volcano","mount_fuji","camping","beach_with_umbrella","desert","desert_island","national_park","stadium","classical_building","building_construction","bricks","rock","wood","hut","house_buildings","derelict_house_building","house","house_with_garden","office","post_office","european_post_office","hospital","bank","hotel","love_hotel","convenience_store","school","department_store","factory","japanese_castle","european_castle","wedding","tokyo_tower","statue_of_liberty","church","mosque","hindu_temple","synagogue","shinto_shrine","kaaba","fountain","tent","foggy","night_with_stars","cityscape","sunrise_over_mountains","sunrise","city_sunset","city_sunrise","bridge_at_night","hotsprings","carousel_horse","playground_slide","ferris_wheel","roller_coaster","barber","circus_tent","steam_locomotive","railway_car","bullettrain_side","bullettrain_front","train2","metro","light_rail","station","tram","monorail","mountain_railway","train","bus","oncoming_bus","trolleybus","minibus","ambulance","fire_engine","police_car","oncoming_police_car","taxi","oncoming_taxi","car","oncoming_automobile","blue_car","pickup_truck","truck","articulated_lorry","tractor","racing_car","racing_motorcycle","motor_scooter","manual_wheelchair","motorized_wheelchair","auto_rickshaw","bike","scooter","skateboard","roller_skate","busstop","motorway","railway_track","oil_drum","fuelpump","wheel","rotating_light","traffic_light","vertical_traffic_light","octagonal_sign","construction","anchor","ring_buoy","boat","canoe","speedboat","passenger_ship","ferry","motor_boat","ship","airplane","small_airplane","airplane_departure","airplane_arriving","parachute","seat","helicopter","suspension_railway","mountain_cableway","aerial_tramway","satellite","rocket","flying_saucer","bellhop_bell","luggage","hourglass","hourglass_flowing_sand","watch","alarm_clock","stopwatch","timer_clock","mantelpiece_clock","clock12","clock1230","clock1","clock130","clock2","clock230","clock3","clock330","clock4","clock430","clock5","clock530","clock6","clock630","clock7","clock730","clock8","clock830","clock9","clock930","clock10","clock1030","clock11","clock1130","new_moon","waxing_crescent_moon","first_quarter_moon","moon","full_moon","waning_gibbous_moon","last_quarter_moon","waning_crescent_moon","crescent_moon","new_moon_with_face","first_quarter_moon_with_face","last_quarter_moon_with_face","thermometer","sunny","full_moon_with_face","sun_with_face","ringed_planet","star","star2","stars","milky_way","cloud","partly_sunny","thunder_cloud_and_rain","mostly_sunny","barely_sunny","partly_sunny_rain","rain_cloud","snow_cloud","lightning","tornado","fog","wind_blowing_face","cyclone","rainbow","closed_umbrella","umbrella","umbrella_with_rain_drops","umbrella_on_ground","zap","snowflake","snowman","snowman_without_snow","comet","fire","droplet","ocean"]},{"id":"objects","emojis":["eyeglasses","dark_sunglasses","goggles","lab_coat","safety_vest","necktie","shirt","jeans","scarf","gloves","coat","socks","dress","kimono","sari","one-piece_swimsuit","briefs","shorts","bikini","womans_clothes","folding_hand_fan","purse","handbag","pouch","shopping_bags","school_satchel","thong_sandal","mans_shoe","athletic_shoe","hiking_boot","womans_flat_shoe","high_heel","sandal","ballet_shoes","boot","hair_pick","crown","womans_hat","tophat","mortar_board","billed_cap","military_helmet","helmet_with_white_cross","prayer_beads","lipstick","ring","gem","mute","speaker","sound","loud_sound","loudspeaker","mega","postal_horn","bell","no_bell","musical_score","musical_note","notes","studio_microphone","level_slider","control_knobs","microphone","headphones","radio","saxophone","accordion","guitar","musical_keyboard","trumpet","violin","banjo","drum_with_drumsticks","long_drum","maracas","flute","iphone","calling","phone","telephone_receiver","pager","fax","battery","low_battery","electric_plug","computer","desktop_computer","printer","keyboard","three_button_mouse","trackball","minidisc","floppy_disk","cd","dvd","abacus","movie_camera","film_frames","film_projector","clapper","tv","camera","camera_with_flash","video_camera","vhs","mag","mag_right","candle","bulb","flashlight","izakaya_lantern","diya_lamp","notebook_with_decorative_cover","closed_book","book","green_book","blue_book","orange_book","books","notebook","ledger","page_with_curl","scroll","page_facing_up","newspaper","rolled_up_newspaper","bookmark_tabs","bookmark","label","moneybag","coin","yen","dollar","euro","pound","money_with_wings","credit_card","receipt","chart","email","e-mail","incoming_envelope","envelope_with_arrow","outbox_tray","inbox_tray","package","mailbox","mailbox_closed","mailbox_with_mail","mailbox_with_no_mail","postbox","ballot_box_with_ballot","pencil2","black_nib","lower_left_fountain_pen","lower_left_ballpoint_pen","lower_left_paintbrush","lower_left_crayon","memo","briefcase","file_folder","open_file_folder","card_index_dividers","date","calendar","spiral_note_pad","spiral_calendar_pad","card_index","chart_with_upwards_trend","chart_with_downwards_trend","bar_chart","clipboard","pushpin","round_pushpin","paperclip","linked_paperclips","straight_ruler","triangular_ruler","scissors","card_file_box","file_cabinet","wastebasket","lock","unlock","lock_with_ink_pen","closed_lock_with_key","key","old_key","hammer","axe","pick","hammer_and_pick","hammer_and_wrench","dagger_knife","crossed_swords","bomb","boomerang","bow_and_arrow","shield","carpentry_saw","wrench","screwdriver","nut_and_bolt","gear","compression","scales","probing_cane","link","chains","hook","toolbox","magnet","ladder","alembic","test_tube","petri_dish","dna","microscope","telescope","satellite_antenna","syringe","drop_of_blood","pill","adhesive_bandage","crutch","stethoscope","x-ray","door","elevator","mirror","window","bed","couch_and_lamp","chair","toilet","plunger","shower","bathtub","mouse_trap","razor","lotion_bottle","safety_pin","broom","basket","roll_of_paper","bucket","soap","bubbles","toothbrush","sponge","fire_extinguisher","shopping_trolley","smoking","coffin","headstone","funeral_urn","nazar_amulet","hamsa","moyai","placard","identification_card"]},{"id":"symbols","emojis":["atm","put_litter_in_its_place","potable_water","wheelchair","mens","womens","restroom","baby_symbol","wc","passport_control","customs","baggage_claim","left_luggage","warning","children_crossing","no_entry","no_entry_sign","no_bicycles","no_smoking","do_not_litter","non-potable_water","no_pedestrians","no_mobile_phones","underage","radioactive_sign","biohazard_sign","arrow_up","arrow_upper_right","arrow_right","arrow_lower_right","arrow_down","arrow_lower_left","arrow_left","arrow_upper_left","arrow_up_down","left_right_arrow","leftwards_arrow_with_hook","arrow_right_hook","arrow_heading_up","arrow_heading_down","arrows_clockwise","arrows_counterclockwise","back","end","on","soon","top","place_of_worship","atom_symbol","om_symbol","star_of_david","wheel_of_dharma","yin_yang","latin_cross","orthodox_cross","star_and_crescent","peace_symbol","menorah_with_nine_branches","six_pointed_star","khanda","aries","taurus","gemini","cancer","leo","virgo","libra","scorpius","sagittarius","capricorn","aquarius","pisces","ophiuchus","twisted_rightwards_arrows","repeat","repeat_one","arrow_forward","fast_forward","black_right_pointing_double_triangle_with_vertical_bar","black_right_pointing_triangle_with_double_vertical_bar","arrow_backward","rewind","black_left_pointing_double_triangle_with_vertical_bar","arrow_up_small","arrow_double_up","arrow_down_small","arrow_double_down","double_vertical_bar","black_square_for_stop","black_circle_for_record","eject","cinema","low_brightness","high_brightness","signal_strength","wireless","vibration_mode","mobile_phone_off","female_sign","male_sign","transgender_symbol","heavy_multiplication_x","heavy_plus_sign","heavy_minus_sign","heavy_division_sign","heavy_equals_sign","infinity","bangbang","interrobang","question","grey_question","grey_exclamation","exclamation","wavy_dash","currency_exchange","heavy_dollar_sign","medical_symbol","recycle","fleur_de_lis","trident","name_badge","beginner","o","white_check_mark","ballot_box_with_check","heavy_check_mark","x","negative_squared_cross_mark","curly_loop","loop","part_alternation_mark","eight_spoked_asterisk","eight_pointed_black_star","sparkle","copyright","registered","tm","hash","keycap_star","zero","one","two","three","four","five","six","seven","eight","nine","keycap_ten","capital_abcd","abcd","1234","symbols","abc","a","ab","b","cl","cool","free","information_source","id","m","new","ng","o2","ok","parking","sos","up","vs","koko","sa","u6708","u6709","u6307","ideograph_advantage","u5272","u7121","u7981","accept","u7533","u5408","u7a7a","congratulations","secret","u55b6","u6e80","red_circle","large_orange_circle","large_yellow_circle","large_green_circle","large_blue_circle","large_purple_circle","large_brown_circle","black_circle","white_circle","large_red_square","large_orange_square","large_yellow_square","large_green_square","large_blue_square","large_purple_square","large_brown_square","black_large_square","white_large_square","black_medium_square","white_medium_square","black_medium_small_square","white_medium_small_square","black_small_square","white_small_square","large_orange_diamond","large_blue_diamond","small_orange_diamond","small_blue_diamond","small_red_triangle","small_red_triangle_down","diamond_shape_with_a_dot_inside","radio_button","white_square_button","black_square_button"]},{"id":"flags","emojis":["checkered_flag","cn","crossed_flags","de","es","flag-ac","flag-ad","flag-ae","flag-af","flag-ag","flag-ai","flag-al","flag-am","flag-ao","flag-aq","flag-ar","flag-as","flag-at","flag-au","flag-aw","flag-ax","flag-az","flag-ba","flag-bb","flag-bd","flag-be","flag-bf","flag-bg","flag-bh","flag-bi","flag-bj","flag-bl","flag-bm","flag-bn","flag-bo","flag-bq","flag-br","flag-bs","flag-bt","flag-bv","flag-bw","flag-by","flag-bz","flag-ca","flag-cc","flag-cd","flag-cf","flag-cg","flag-ch","flag-ci","flag-ck","flag-cl","flag-cm","flag-co","flag-cp","flag-cr","flag-cu","flag-cv","flag-cw","flag-cx","flag-cy","flag-cz","flag-dg","flag-dj","flag-dk","flag-dm","flag-do","flag-dz","flag-ea","flag-ec","flag-ee","flag-eg","flag-eh","flag-england","flag-er","flag-et","flag-eu","flag-fi","flag-fj","flag-fk","flag-fm","flag-fo","flag-ga","flag-gd","flag-ge","flag-gf","flag-gg","flag-gh","flag-gi","flag-gl","flag-gm","flag-gn","flag-gp","flag-gq","flag-gr","flag-gs","flag-gt","flag-gu","flag-gw","flag-gy","flag-hk","flag-hm","flag-hn","flag-hr","flag-ht","flag-hu","flag-ic","flag-id","flag-ie","flag-il","flag-im","flag-in","flag-io","flag-iq","flag-ir","flag-is","flag-je","flag-jm","flag-jo","flag-ke","flag-kg","flag-kh","flag-ki","flag-km","flag-kn","flag-kp","flag-kw","flag-ky","flag-kz","flag-la","flag-lb","flag-lc","flag-li","flag-lk","flag-lr","flag-ls","flag-lt","flag-lu","flag-lv","flag-ly","flag-ma","flag-mc","flag-md","flag-me","flag-mf","flag-mg","flag-mh","flag-mk","flag-ml","flag-mm","flag-mn","flag-mo","flag-mp","flag-mq","flag-mr","flag-ms","flag-mt","flag-mu","flag-mv","flag-mw","flag-mx","flag-my","flag-mz","flag-na","flag-nc","flag-ne","flag-nf","flag-ng","flag-ni","flag-nl","flag-no","flag-np","flag-nr","flag-nu","flag-nz","flag-om","flag-pa","flag-pe","flag-pf","flag-pg","flag-ph","flag-pk","flag-pl","flag-pm","flag-pn","flag-pr","flag-ps","flag-pt","flag-pw","flag-py","flag-qa","flag-re","flag-ro","flag-rs","flag-rw","flag-sa","flag-sb","flag-sc","flag-scotland","flag-sd","flag-se","flag-sg","flag-sh","flag-si","flag-sj","flag-sk","flag-sl","flag-sm","flag-sn","flag-so","flag-sr","flag-ss","flag-st","flag-sv","flag-sx","flag-sy","flag-sz","flag-ta","flag-tc","flag-td","flag-tf","flag-tg","flag-th","flag-tj","flag-tk","flag-tl","flag-tm","flag-tn","flag-to","flag-tr","flag-tt","flag-tv","flag-tw","flag-tz","flag-ua","flag-ug","flag-um","flag-un","flag-uy","flag-uz","flag-va","flag-vc","flag-ve","flag-vg","flag-vi","flag-vn","flag-vu","flag-wales","flag-wf","flag-ws","flag-xk","flag-ye","flag-yt","flag-za","flag-zm","flag-zw","fr","gb","it","jp","kr","pirate_flag","rainbow-flag","ru","transgender_flag","triangular_flag_on_post","us","waving_black_flag","waving_white_flag"]}],"emojis":{"100":{"id":"100","name":"Hundred Points","keywords":["100","score","perfect","numbers","century","exam","quiz","test","pass"],"skins":[{"unified":"1f4af","native":"๐Ÿ’ฏ"}],"version":1},"1234":{"id":"1234","name":"Input Numbers","keywords":["1234","blue","square","1","2","3","4"],"skins":[{"unified":"1f522","native":"๐Ÿ”ข"}],"version":1},"grinning":{"id":"grinning","name":"Grinning Face","emoticons":[":D"],"keywords":["smile","happy","joy",":D","grin"],"skins":[{"unified":"1f600","native":"๐Ÿ˜€"}],"version":1},"smiley":{"id":"smiley","name":"Grinning Face with Big Eyes","emoticons":[":)","=)","=-)"],"keywords":["smiley","happy","joy","haha",":D",":)","smile","funny"],"skins":[{"unified":"1f603","native":"๐Ÿ˜ƒ"}],"version":1},"smile":{"id":"smile","name":"Grinning Face with Smiling Eyes","emoticons":[":)","C:","c:",":D",":-D"],"keywords":["smile","happy","joy","funny","haha","laugh","like",":D",":)"],"skins":[{"unified":"1f604","native":"๐Ÿ˜„"}],"version":1},"grin":{"id":"grin","name":"Beaming Face with Smiling Eyes","keywords":["grin","happy","smile","joy","kawaii"],"skins":[{"unified":"1f601","native":"๐Ÿ˜"}],"version":1},"laughing":{"id":"laughing","name":"Grinning Squinting Face","emoticons":[":>",":->"],"keywords":["laughing","satisfied","happy","joy","lol","haha","glad","XD","laugh"],"skins":[{"unified":"1f606","native":"๐Ÿ˜†"}],"version":1},"sweat_smile":{"id":"sweat_smile","name":"Grinning Face with Sweat","keywords":["smile","hot","happy","laugh","relief"],"skins":[{"unified":"1f605","native":"๐Ÿ˜…"}],"version":1},"rolling_on_the_floor_laughing":{"id":"rolling_on_the_floor_laughing","name":"Rolling on the Floor Laughing","keywords":["face","lol","haha","rofl"],"skins":[{"unified":"1f923","native":"๐Ÿคฃ"}],"version":3},"joy":{"id":"joy","name":"Face with Tears of Joy","keywords":["cry","weep","happy","happytears","haha"],"skins":[{"unified":"1f602","native":"๐Ÿ˜‚"}],"version":1},"slightly_smiling_face":{"id":"slightly_smiling_face","name":"Slightly Smiling Face","emoticons":[":)","(:",":-)"],"keywords":["smile"],"skins":[{"unified":"1f642","native":"๐Ÿ™‚"}],"version":1},"upside_down_face":{"id":"upside_down_face","name":"Upside-Down Face","keywords":["upside","down","flipped","silly","smile"],"skins":[{"unified":"1f643","native":"๐Ÿ™ƒ"}],"version":1},"melting_face":{"id":"melting_face","name":"Melting Face","keywords":["hot","heat"],"skins":[{"unified":"1fae0","native":"๐Ÿซ "}],"version":14},"wink":{"id":"wink","name":"Winking Face","emoticons":[";)",";-)"],"keywords":["wink","happy","mischievous","secret",";)","smile","eye"],"skins":[{"unified":"1f609","native":"๐Ÿ˜‰"}],"version":1},"blush":{"id":"blush","name":"Smiling Face with Smiling Eyes","emoticons":[":)"],"keywords":["blush","smile","happy","flushed","crush","embarrassed","shy","joy"],"skins":[{"unified":"1f60a","native":"๐Ÿ˜Š"}],"version":1},"innocent":{"id":"innocent","name":"Smiling Face with Halo","keywords":["innocent","angel","heaven"],"skins":[{"unified":"1f607","native":"๐Ÿ˜‡"}],"version":1},"smiling_face_with_3_hearts":{"id":"smiling_face_with_3_hearts","name":"Smiling Face with Hearts","keywords":["3","love","like","affection","valentines","infatuation","crush","adore"],"skins":[{"unified":"1f970","native":"๐Ÿฅฐ"}],"version":11},"heart_eyes":{"id":"heart_eyes","name":"Smiling Face with Heart-Eyes","keywords":["heart","eyes","love","like","affection","valentines","infatuation","crush"],"skins":[{"unified":"1f60d","native":"๐Ÿ˜"}],"version":1},"star-struck":{"id":"star-struck","name":"Star-Struck","keywords":["star","struck","grinning","face","with","eyes","smile","starry"],"skins":[{"unified":"1f929","native":"๐Ÿคฉ"}],"version":5},"kissing_heart":{"id":"kissing_heart","name":"Face Blowing a Kiss","emoticons":[":*",":-*"],"keywords":["kissing","heart","love","like","affection","valentines","infatuation"],"skins":[{"unified":"1f618","native":"๐Ÿ˜˜"}],"version":1},"kissing":{"id":"kissing","name":"Kissing Face","keywords":["love","like","3","valentines","infatuation","kiss"],"skins":[{"unified":"1f617","native":"๐Ÿ˜—"}],"version":1},"relaxed":{"id":"relaxed","name":"Smiling Face","keywords":["relaxed","blush","massage","happiness"],"skins":[{"unified":"263a-fe0f","native":"โ˜บ๏ธ"}],"version":1},"kissing_closed_eyes":{"id":"kissing_closed_eyes","name":"Kissing Face with Closed Eyes","keywords":["love","like","affection","valentines","infatuation","kiss"],"skins":[{"unified":"1f61a","native":"๐Ÿ˜š"}],"version":1},"kissing_smiling_eyes":{"id":"kissing_smiling_eyes","name":"Kissing Face with Smiling Eyes","keywords":["affection","valentines","infatuation","kiss"],"skins":[{"unified":"1f619","native":"๐Ÿ˜™"}],"version":1},"smiling_face_with_tear":{"id":"smiling_face_with_tear","name":"Smiling Face with Tear","keywords":["sad","cry","pretend"],"skins":[{"unified":"1f972","native":"๐Ÿฅฒ"}],"version":13},"yum":{"id":"yum","name":"Face Savoring Food","keywords":["yum","happy","joy","tongue","smile","silly","yummy","nom","delicious","savouring"],"skins":[{"unified":"1f60b","native":"๐Ÿ˜‹"}],"version":1},"stuck_out_tongue":{"id":"stuck_out_tongue","name":"Face with Tongue","emoticons":[":p",":-p",":P",":-P",":b",":-b"],"keywords":["stuck","out","prank","childish","playful","mischievous","smile"],"skins":[{"unified":"1f61b","native":"๐Ÿ˜›"}],"version":1},"stuck_out_tongue_winking_eye":{"id":"stuck_out_tongue_winking_eye","name":"Winking Face with Tongue","emoticons":[";p",";-p",";b",";-b",";P",";-P"],"keywords":["stuck","out","eye","prank","childish","playful","mischievous","smile","wink"],"skins":[{"unified":"1f61c","native":"๐Ÿ˜œ"}],"version":1},"zany_face":{"id":"zany_face","name":"Zany Face","keywords":["grinning","with","one","large","and","small","eye","goofy","crazy"],"skins":[{"unified":"1f92a","native":"๐Ÿคช"}],"version":5},"stuck_out_tongue_closed_eyes":{"id":"stuck_out_tongue_closed_eyes","name":"Squinting Face with Tongue","keywords":["stuck","out","closed","eyes","prank","playful","mischievous","smile"],"skins":[{"unified":"1f61d","native":"๐Ÿ˜"}],"version":1},"money_mouth_face":{"id":"money_mouth_face","name":"Money-Mouth Face","keywords":["money","mouth","rich","dollar"],"skins":[{"unified":"1f911","native":"๐Ÿค‘"}],"version":1},"hugging_face":{"id":"hugging_face","name":"Hugging Face","keywords":["smile","hug"],"skins":[{"unified":"1f917","native":"๐Ÿค—"}],"version":1},"face_with_hand_over_mouth":{"id":"face_with_hand_over_mouth","name":"Face with Hand over Mouth","keywords":["smiling","eyes","and","covering","whoops","shock","surprise"],"skins":[{"unified":"1f92d","native":"๐Ÿคญ"}],"version":5},"face_with_open_eyes_and_hand_over_mouth":{"id":"face_with_open_eyes_and_hand_over_mouth","name":"Face with Open Eyes and Hand over Mouth","keywords":["silence","secret","shock","surprise"],"skins":[{"unified":"1fae2","native":"๐Ÿซข"}],"version":14},"face_with_peeking_eye":{"id":"face_with_peeking_eye","name":"Face with Peeking Eye","keywords":["scared","frightening","embarrassing","shy"],"skins":[{"unified":"1fae3","native":"๐Ÿซฃ"}],"version":14},"shushing_face":{"id":"shushing_face","name":"Shushing Face","keywords":["with","finger","covering","closed","lips","quiet","shhh"],"skins":[{"unified":"1f92b","native":"๐Ÿคซ"}],"version":5},"thinking_face":{"id":"thinking_face","name":"Thinking Face","keywords":["hmmm","think","consider"],"skins":[{"unified":"1f914","native":"๐Ÿค”"}],"version":1},"saluting_face":{"id":"saluting_face","name":"Saluting Face","keywords":["respect","salute"],"skins":[{"unified":"1fae1","native":"๐Ÿซก"}],"version":14},"zipper_mouth_face":{"id":"zipper_mouth_face","name":"Zipper-Mouth Face","keywords":["zipper","mouth","sealed","secret"],"skins":[{"unified":"1f910","native":"๐Ÿค"}],"version":1},"face_with_raised_eyebrow":{"id":"face_with_raised_eyebrow","name":"Face with Raised Eyebrow","keywords":["one","distrust","scepticism","disapproval","disbelief","surprise"],"skins":[{"unified":"1f928","native":"๐Ÿคจ"}],"version":5},"neutral_face":{"id":"neutral_face","name":"Neutral Face","emoticons":[":|",":-|"],"keywords":["indifference","meh",":",""],"skins":[{"unified":"1f610","native":"๐Ÿ˜"}],"version":1},"expressionless":{"id":"expressionless","name":"Expressionless Face","emoticons":["-_-"],"keywords":["indifferent","-","","meh","deadpan"],"skins":[{"unified":"1f611","native":"๐Ÿ˜‘"}],"version":1},"no_mouth":{"id":"no_mouth","name":"Face Without Mouth","keywords":["no","hellokitty"],"skins":[{"unified":"1f636","native":"๐Ÿ˜ถ"}],"version":1},"dotted_line_face":{"id":"dotted_line_face","name":"Dotted Line Face","keywords":["invisible","lonely","isolation","depression"],"skins":[{"unified":"1fae5","native":"๐Ÿซฅ"}],"version":14},"face_in_clouds":{"id":"face_in_clouds","name":"Face in Clouds","keywords":["shower","steam","dream"],"skins":[{"unified":"1f636-200d-1f32b-fe0f","native":"๐Ÿ˜ถโ€๐ŸŒซ๏ธ"}],"version":13.1},"smirk":{"id":"smirk","name":"Smirking Face","keywords":["smirk","smile","mean","prank","smug","sarcasm"],"skins":[{"unified":"1f60f","native":"๐Ÿ˜"}],"version":1},"unamused":{"id":"unamused","name":"Unamused Face","emoticons":[":("],"keywords":["indifference","bored","straight","serious","sarcasm","unimpressed","skeptical","dubious","side","eye"],"skins":[{"unified":"1f612","native":"๐Ÿ˜’"}],"version":1},"face_with_rolling_eyes":{"id":"face_with_rolling_eyes","name":"Face with Rolling Eyes","keywords":["eyeroll","frustrated"],"skins":[{"unified":"1f644","native":"๐Ÿ™„"}],"version":1},"grimacing":{"id":"grimacing","name":"Grimacing Face","keywords":["grimace","teeth"],"skins":[{"unified":"1f62c","native":"๐Ÿ˜ฌ"}],"version":1},"face_exhaling":{"id":"face_exhaling","name":"Face Exhaling","keywords":["relieve","relief","tired","sigh"],"skins":[{"unified":"1f62e-200d-1f4a8","native":"๐Ÿ˜ฎโ€๐Ÿ’จ"}],"version":13.1},"lying_face":{"id":"lying_face","name":"Lying Face","keywords":["lie","pinocchio"],"skins":[{"unified":"1f925","native":"๐Ÿคฅ"}],"version":3},"shaking_face":{"id":"shaking_face","name":"Shaking Face","keywords":["dizzy","shock","blurry","earthquake"],"skins":[{"unified":"1fae8","native":"๐Ÿซจ"}],"version":15},"relieved":{"id":"relieved","name":"Relieved Face","keywords":["relaxed","phew","massage","happiness"],"skins":[{"unified":"1f60c","native":"๐Ÿ˜Œ"}],"version":1},"pensive":{"id":"pensive","name":"Pensive Face","keywords":["sad","depressed","upset"],"skins":[{"unified":"1f614","native":"๐Ÿ˜”"}],"version":1},"sleepy":{"id":"sleepy","name":"Sleepy Face","keywords":["tired","rest","nap"],"skins":[{"unified":"1f62a","native":"๐Ÿ˜ช"}],"version":1},"drooling_face":{"id":"drooling_face","name":"Drooling Face","keywords":[],"skins":[{"unified":"1f924","native":"๐Ÿคค"}],"version":3},"sleeping":{"id":"sleeping","name":"Sleeping Face","keywords":["tired","sleepy","night","zzz"],"skins":[{"unified":"1f634","native":"๐Ÿ˜ด"}],"version":1},"mask":{"id":"mask","name":"Face with Medical Mask","keywords":["sick","ill","disease","covid"],"skins":[{"unified":"1f637","native":"๐Ÿ˜ท"}],"version":1},"face_with_thermometer":{"id":"face_with_thermometer","name":"Face with Thermometer","keywords":["sick","temperature","cold","fever","covid"],"skins":[{"unified":"1f912","native":"๐Ÿค’"}],"version":1},"face_with_head_bandage":{"id":"face_with_head_bandage","name":"Face with Head-Bandage","keywords":["head","bandage","injured","clumsy","hurt"],"skins":[{"unified":"1f915","native":"๐Ÿค•"}],"version":1},"nauseated_face":{"id":"nauseated_face","name":"Nauseated Face","keywords":["vomit","gross","green","sick","throw","up","ill"],"skins":[{"unified":"1f922","native":"๐Ÿคข"}],"version":3},"face_vomiting":{"id":"face_vomiting","name":"Face Vomiting","keywords":["with","open","mouth","sick"],"skins":[{"unified":"1f92e","native":"๐Ÿคฎ"}],"version":5},"sneezing_face":{"id":"sneezing_face","name":"Sneezing Face","keywords":["gesundheit","sneeze","sick","allergy"],"skins":[{"unified":"1f927","native":"๐Ÿคง"}],"version":3},"hot_face":{"id":"hot_face","name":"Hot Face","keywords":["feverish","heat","red","sweating"],"skins":[{"unified":"1f975","native":"๐Ÿฅต"}],"version":11},"cold_face":{"id":"cold_face","name":"Cold Face","keywords":["blue","freezing","frozen","frostbite","icicles"],"skins":[{"unified":"1f976","native":"๐Ÿฅถ"}],"version":11},"woozy_face":{"id":"woozy_face","name":"Woozy Face","keywords":["dizzy","intoxicated","tipsy","wavy"],"skins":[{"unified":"1f974","native":"๐Ÿฅด"}],"version":11},"dizzy_face":{"id":"dizzy_face","name":"Dizzy Face","keywords":["spent","unconscious","xox"],"skins":[{"unified":"1f635","native":"๐Ÿ˜ต"}],"version":1},"face_with_spiral_eyes":{"id":"face_with_spiral_eyes","name":"Face with Spiral Eyes","keywords":["sick","ill","confused","nauseous","nausea"],"skins":[{"unified":"1f635-200d-1f4ab","native":"๐Ÿ˜ตโ€๐Ÿ’ซ"}],"version":13.1},"exploding_head":{"id":"exploding_head","name":"Exploding Head","keywords":["shocked","face","with","mind","blown"],"skins":[{"unified":"1f92f","native":"๐Ÿคฏ"}],"version":5},"face_with_cowboy_hat":{"id":"face_with_cowboy_hat","name":"Cowboy Hat Face","keywords":["with","cowgirl"],"skins":[{"unified":"1f920","native":"๐Ÿค "}],"version":3},"partying_face":{"id":"partying_face","name":"Partying Face","keywords":["celebration","woohoo"],"skins":[{"unified":"1f973","native":"๐Ÿฅณ"}],"version":11},"disguised_face":{"id":"disguised_face","name":"Disguised Face","keywords":["pretent","brows","glasses","moustache"],"skins":[{"unified":"1f978","native":"๐Ÿฅธ"}],"version":13},"sunglasses":{"id":"sunglasses","name":"Smiling Face with Sunglasses","emoticons":["8)"],"keywords":["cool","smile","summer","beach","sunglass"],"skins":[{"unified":"1f60e","native":"๐Ÿ˜Ž"}],"version":1},"nerd_face":{"id":"nerd_face","name":"Nerd Face","keywords":["nerdy","geek","dork"],"skins":[{"unified":"1f913","native":"๐Ÿค“"}],"version":1},"face_with_monocle":{"id":"face_with_monocle","name":"Face with Monocle","keywords":["stuffy","wealthy"],"skins":[{"unified":"1f9d0","native":"๐Ÿง"}],"version":5},"confused":{"id":"confused","name":"Confused Face","emoticons":[":\\",":-\\",":/",":-/"],"keywords":["indifference","huh","weird","hmmm",":/"],"skins":[{"unified":"1f615","native":"๐Ÿ˜•"}],"version":1},"face_with_diagonal_mouth":{"id":"face_with_diagonal_mouth","name":"Face with Diagonal Mouth","keywords":["skeptic","confuse","frustrated","indifferent"],"skins":[{"unified":"1fae4","native":"๐Ÿซค"}],"version":14},"worried":{"id":"worried","name":"Worried Face","keywords":["concern","nervous",":("],"skins":[{"unified":"1f61f","native":"๐Ÿ˜Ÿ"}],"version":1},"slightly_frowning_face":{"id":"slightly_frowning_face","name":"Slightly Frowning Face","keywords":["disappointed","sad","upset"],"skins":[{"unified":"1f641","native":"๐Ÿ™"}],"version":1},"white_frowning_face":{"id":"white_frowning_face","name":"Frowning Face","keywords":["white","sad","upset","frown"],"skins":[{"unified":"2639-fe0f","native":"โ˜น๏ธ"}],"version":1},"open_mouth":{"id":"open_mouth","name":"Face with Open Mouth","emoticons":[":o",":-o",":O",":-O"],"keywords":["surprise","impressed","wow","whoa",":O"],"skins":[{"unified":"1f62e","native":"๐Ÿ˜ฎ"}],"version":1},"hushed":{"id":"hushed","name":"Hushed Face","keywords":["woo","shh"],"skins":[{"unified":"1f62f","native":"๐Ÿ˜ฏ"}],"version":1},"astonished":{"id":"astonished","name":"Astonished Face","keywords":["xox","surprised","poisoned"],"skins":[{"unified":"1f632","native":"๐Ÿ˜ฒ"}],"version":1},"flushed":{"id":"flushed","name":"Flushed Face","keywords":["blush","shy","flattered"],"skins":[{"unified":"1f633","native":"๐Ÿ˜ณ"}],"version":1},"pleading_face":{"id":"pleading_face","name":"Pleading Face","keywords":["begging","mercy","cry","tears","sad","grievance"],"skins":[{"unified":"1f97a","native":"๐Ÿฅบ"}],"version":11},"face_holding_back_tears":{"id":"face_holding_back_tears","name":"Face Holding Back Tears","keywords":["touched","gratitude","cry"],"skins":[{"unified":"1f979","native":"๐Ÿฅน"}],"version":14},"frowning":{"id":"frowning","name":"Frowning Face with Open Mouth","keywords":["aw","what"],"skins":[{"unified":"1f626","native":"๐Ÿ˜ฆ"}],"version":1},"anguished":{"id":"anguished","name":"Anguished Face","emoticons":["D:"],"keywords":["stunned","nervous"],"skins":[{"unified":"1f627","native":"๐Ÿ˜ง"}],"version":1},"fearful":{"id":"fearful","name":"Fearful Face","keywords":["scared","terrified","nervous"],"skins":[{"unified":"1f628","native":"๐Ÿ˜จ"}],"version":1},"cold_sweat":{"id":"cold_sweat","name":"Anxious Face with Sweat","keywords":["cold","nervous"],"skins":[{"unified":"1f630","native":"๐Ÿ˜ฐ"}],"version":1},"disappointed_relieved":{"id":"disappointed_relieved","name":"Sad but Relieved Face","keywords":["disappointed","phew","sweat","nervous"],"skins":[{"unified":"1f625","native":"๐Ÿ˜ฅ"}],"version":1},"cry":{"id":"cry","name":"Crying Face","emoticons":[":'("],"keywords":["cry","tears","sad","depressed","upset",":'("],"skins":[{"unified":"1f622","native":"๐Ÿ˜ข"}],"version":1},"sob":{"id":"sob","name":"Loudly Crying Face","emoticons":[":'("],"keywords":["sob","cry","tears","sad","upset","depressed"],"skins":[{"unified":"1f62d","native":"๐Ÿ˜ญ"}],"version":1},"scream":{"id":"scream","name":"Face Screaming in Fear","keywords":["scream","munch","scared","omg"],"skins":[{"unified":"1f631","native":"๐Ÿ˜ฑ"}],"version":1},"confounded":{"id":"confounded","name":"Confounded Face","keywords":["confused","sick","unwell","oops",":S"],"skins":[{"unified":"1f616","native":"๐Ÿ˜–"}],"version":1},"persevere":{"id":"persevere","name":"Persevering Face","keywords":["persevere","sick","no","upset","oops"],"skins":[{"unified":"1f623","native":"๐Ÿ˜ฃ"}],"version":1},"disappointed":{"id":"disappointed","name":"Disappointed Face","emoticons":["):",":(",":-("],"keywords":["sad","upset","depressed",":("],"skins":[{"unified":"1f61e","native":"๐Ÿ˜ž"}],"version":1},"sweat":{"id":"sweat","name":"Face with Cold Sweat","keywords":["downcast","hot","sad","tired","exercise"],"skins":[{"unified":"1f613","native":"๐Ÿ˜“"}],"version":1},"weary":{"id":"weary","name":"Weary Face","keywords":["tired","sleepy","sad","frustrated","upset"],"skins":[{"unified":"1f629","native":"๐Ÿ˜ฉ"}],"version":1},"tired_face":{"id":"tired_face","name":"Tired Face","keywords":["sick","whine","upset","frustrated"],"skins":[{"unified":"1f62b","native":"๐Ÿ˜ซ"}],"version":1},"yawning_face":{"id":"yawning_face","name":"Yawning Face","keywords":["tired","sleepy"],"skins":[{"unified":"1f971","native":"๐Ÿฅฑ"}],"version":12},"triumph":{"id":"triumph","name":"Face with Look of Triumph","keywords":["steam","from","nose","gas","phew","proud","pride"],"skins":[{"unified":"1f624","native":"๐Ÿ˜ค"}],"version":1},"rage":{"id":"rage","name":"Pouting Face","keywords":["rage","angry","mad","hate","despise"],"skins":[{"unified":"1f621","native":"๐Ÿ˜ก"}],"version":1},"angry":{"id":"angry","name":"Angry Face","emoticons":[">:(",">:-("],"keywords":["mad","annoyed","frustrated"],"skins":[{"unified":"1f620","native":"๐Ÿ˜ "}],"version":1},"face_with_symbols_on_mouth":{"id":"face_with_symbols_on_mouth","name":"Face with Symbols on Mouth","keywords":["serious","covering","swearing","cursing","cussing","profanity","expletive"],"skins":[{"unified":"1f92c","native":"๐Ÿคฌ"}],"version":5},"smiling_imp":{"id":"smiling_imp","name":"Smiling Face with Horns","keywords":["imp","devil"],"skins":[{"unified":"1f608","native":"๐Ÿ˜ˆ"}],"version":1},"imp":{"id":"imp","name":"Imp","keywords":["angry","face","with","horns","devil"],"skins":[{"unified":"1f47f","native":"๐Ÿ‘ฟ"}],"version":1},"skull":{"id":"skull","name":"Skull","keywords":["dead","skeleton","creepy","death"],"skins":[{"unified":"1f480","native":"๐Ÿ’€"}],"version":1},"skull_and_crossbones":{"id":"skull_and_crossbones","name":"Skull and Crossbones","keywords":["poison","danger","deadly","scary","death","pirate","evil"],"skins":[{"unified":"2620-fe0f","native":"โ˜ ๏ธ"}],"version":1},"hankey":{"id":"hankey","name":"Pile of Poo","keywords":["hankey","poop","shit","shitface","fail","turd"],"skins":[{"unified":"1f4a9","native":"๐Ÿ’ฉ"}],"version":1},"clown_face":{"id":"clown_face","name":"Clown Face","keywords":[],"skins":[{"unified":"1f921","native":"๐Ÿคก"}],"version":3},"japanese_ogre":{"id":"japanese_ogre","name":"Ogre","keywords":["japanese","monster","red","mask","halloween","scary","creepy","devil","demon"],"skins":[{"unified":"1f479","native":"๐Ÿ‘น"}],"version":1},"japanese_goblin":{"id":"japanese_goblin","name":"Goblin","keywords":["japanese","red","evil","mask","monster","scary","creepy"],"skins":[{"unified":"1f47a","native":"๐Ÿ‘บ"}],"version":1},"ghost":{"id":"ghost","name":"Ghost","keywords":["halloween","spooky","scary"],"skins":[{"unified":"1f47b","native":"๐Ÿ‘ป"}],"version":1},"alien":{"id":"alien","name":"Alien","keywords":["UFO","paul","weird","outer","space"],"skins":[{"unified":"1f47d","native":"๐Ÿ‘ฝ"}],"version":1},"space_invader":{"id":"space_invader","name":"Alien Monster","keywords":["space","invader","game","arcade","play"],"skins":[{"unified":"1f47e","native":"๐Ÿ‘พ"}],"version":1},"robot_face":{"id":"robot_face","name":"Robot","keywords":["face","computer","machine","bot"],"skins":[{"unified":"1f916","native":"๐Ÿค–"}],"version":1},"smiley_cat":{"id":"smiley_cat","name":"Grinning Cat","keywords":["smiley","animal","cats","happy","smile"],"skins":[{"unified":"1f63a","native":"๐Ÿ˜บ"}],"version":1},"smile_cat":{"id":"smile_cat","name":"Grinning Cat with Smiling Eyes","keywords":["smile","animal","cats"],"skins":[{"unified":"1f638","native":"๐Ÿ˜ธ"}],"version":1},"joy_cat":{"id":"joy_cat","name":"Cat with Tears of Joy","keywords":["animal","cats","haha","happy"],"skins":[{"unified":"1f639","native":"๐Ÿ˜น"}],"version":1},"heart_eyes_cat":{"id":"heart_eyes_cat","name":"Smiling Cat with Heart-Eyes","keywords":["heart","eyes","animal","love","like","affection","cats","valentines"],"skins":[{"unified":"1f63b","native":"๐Ÿ˜ป"}],"version":1},"smirk_cat":{"id":"smirk_cat","name":"Cat with Wry Smile","keywords":["smirk","animal","cats"],"skins":[{"unified":"1f63c","native":"๐Ÿ˜ผ"}],"version":1},"kissing_cat":{"id":"kissing_cat","name":"Kissing Cat","keywords":["animal","cats","kiss"],"skins":[{"unified":"1f63d","native":"๐Ÿ˜ฝ"}],"version":1},"scream_cat":{"id":"scream_cat","name":"Weary Cat","keywords":["scream","animal","cats","munch","scared"],"skins":[{"unified":"1f640","native":"๐Ÿ™€"}],"version":1},"crying_cat_face":{"id":"crying_cat_face","name":"Crying Cat","keywords":["face","animal","tears","weep","sad","cats","upset","cry"],"skins":[{"unified":"1f63f","native":"๐Ÿ˜ฟ"}],"version":1},"pouting_cat":{"id":"pouting_cat","name":"Pouting Cat","keywords":["animal","cats"],"skins":[{"unified":"1f63e","native":"๐Ÿ˜พ"}],"version":1},"see_no_evil":{"id":"see_no_evil","name":"See-No-Evil Monkey","keywords":["see","no","evil","animal","nature","haha"],"skins":[{"unified":"1f648","native":"๐Ÿ™ˆ"}],"version":1},"hear_no_evil":{"id":"hear_no_evil","name":"Hear-No-Evil Monkey","keywords":["hear","no","evil","animal","nature"],"skins":[{"unified":"1f649","native":"๐Ÿ™‰"}],"version":1},"speak_no_evil":{"id":"speak_no_evil","name":"Speak-No-Evil Monkey","keywords":["speak","no","evil","animal","nature","omg"],"skins":[{"unified":"1f64a","native":"๐Ÿ™Š"}],"version":1},"love_letter":{"id":"love_letter","name":"Love Letter","keywords":["email","like","affection","envelope","valentines"],"skins":[{"unified":"1f48c","native":"๐Ÿ’Œ"}],"version":1},"cupid":{"id":"cupid","name":"Heart with Arrow","keywords":["cupid","love","like","affection","valentines"],"skins":[{"unified":"1f498","native":"๐Ÿ’˜"}],"version":1},"gift_heart":{"id":"gift_heart","name":"Heart with Ribbon","keywords":["gift","love","valentines"],"skins":[{"unified":"1f49d","native":"๐Ÿ’"}],"version":1},"sparkling_heart":{"id":"sparkling_heart","name":"Sparkling Heart","keywords":["love","like","affection","valentines"],"skins":[{"unified":"1f496","native":"๐Ÿ’–"}],"version":1},"heartpulse":{"id":"heartpulse","name":"Growing Heart","keywords":["heartpulse","like","love","affection","valentines","pink"],"skins":[{"unified":"1f497","native":"๐Ÿ’—"}],"version":1},"heartbeat":{"id":"heartbeat","name":"Beating Heart","keywords":["heartbeat","love","like","affection","valentines","pink"],"skins":[{"unified":"1f493","native":"๐Ÿ’“"}],"version":1},"revolving_hearts":{"id":"revolving_hearts","name":"Revolving Hearts","keywords":["love","like","affection","valentines"],"skins":[{"unified":"1f49e","native":"๐Ÿ’ž"}],"version":1},"two_hearts":{"id":"two_hearts","name":"Two Hearts","keywords":["love","like","affection","valentines","heart"],"skins":[{"unified":"1f495","native":"๐Ÿ’•"}],"version":1},"heart_decoration":{"id":"heart_decoration","name":"Heart Decoration","keywords":["purple","square","love","like"],"skins":[{"unified":"1f49f","native":"๐Ÿ’Ÿ"}],"version":1},"heavy_heart_exclamation_mark_ornament":{"id":"heavy_heart_exclamation_mark_ornament","name":"Heart Exclamation","keywords":["heavy","mark","ornament","decoration","love"],"skins":[{"unified":"2763-fe0f","native":"โฃ๏ธ"}],"version":1},"broken_heart":{"id":"broken_heart","name":"Broken Heart","emoticons":[" | null = null; + +/** + * Lazily loads the vendored emoji dataset. The JSON is imported dynamically so + * bundlers emit it as a separate async chunk โ€” it is fetched only when the emoji + * picker or search actually runs, and never enters the main `stream-chat-react` + * bundle. The result is memoized, so repeated calls share a single load. + */ +export const loadEmojiData = (): Promise => { + if (!dataPromise) { + dataPromise = import('./emoji-data.json').then( + (mod) => + ((mod as unknown as { default?: EmojiData }).default ?? + mod) as unknown as EmojiData, + ); + } + return dataPromise; +}; diff --git a/src/plugins/Emojis/data/types.ts b/src/plugins/Emojis/data/types.ts new file mode 100644 index 0000000000..f4f4d2d3bb --- /dev/null +++ b/src/plugins/Emojis/data/types.ts @@ -0,0 +1,28 @@ +// Shape of the vendored emoji dataset (a snapshot of `@emoji-mart/data`'s native +// set). Kept structurally identical to the upstream data so that `mapEmojiMartData` +// and any consumer relying on the emoji-mart data shape keep working unchanged. + +export type EmojiDataSkin = { + native: string; + unified: string; +}; + +export type EmojiDataEmoji = { + id: string; + keywords: string[]; + name: string; + skins: EmojiDataSkin[]; + version: number; + emoticons?: string[]; +}; + +export type EmojiDataCategory = { + emojis: string[]; + id: string; +}; + +export type EmojiData = { + aliases: Record; + categories: EmojiDataCategory[]; + emojis: Record; +}; diff --git a/src/plugins/Emojis/index.ts b/src/plugins/Emojis/index.ts index 17d0119d3c..d68d65a68d 100644 --- a/src/plugins/Emojis/index.ts +++ b/src/plugins/Emojis/index.ts @@ -1,2 +1,5 @@ +export * from './compat'; +export * from './data'; export * from './EmojiPicker'; export * from './middleware'; +export * from './search'; diff --git a/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts b/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts new file mode 100644 index 0000000000..be4ccecfdd --- /dev/null +++ b/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts @@ -0,0 +1,82 @@ +import { createTextComposerEmojiMiddleware } from '../textComposerEmojiMiddleware'; + +// Minimal onChange harness: capture whatever state the handler completes/nexts with. +const runOnChange = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + middleware: ReturnType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: any, +) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let output: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const complete = (next: any) => { + output = next; + return { state: next, status: 'complete' }; + }; + const forward = vi.fn(() => ({ status: 'forward' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const next = (nextState: any) => { + output = nextState; + return { state: nextState, status: 'next' }; + }; + await middleware.handlers.onChange({ + complete, + forward, + next, + state, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + return { forward, output }; +}; + +describe('createTextComposerEmojiMiddleware', () => { + it('returns a middleware with the expected id and handlers when called with no arguments', () => { + const middleware = createTextComposerEmojiMiddleware(); + expect(middleware.id).toBe('stream-io/emoji-middleware'); + expect(typeof middleware.handlers.onChange).toBe('function'); + expect(typeof middleware.handlers.onSuggestionItemSelect).toBe('function'); + }); + + it('drives ":" shortcode autocomplete from the built-in index (no emoji-mart)', async () => { + const middleware = createTextComposerEmojiMiddleware(); + const { output } = await runOnChange(middleware, { + selection: { end: 4, start: 4 }, + suggestions: undefined, + text: ':smi', + }); + + expect(output?.suggestions?.trigger).toBe(':'); + expect(output?.suggestions?.query).toBe('smi'); + + const { items } = await output.suggestions.searchSource.query('smi'); + expect(items.length).toBeGreaterThan(0); + // e.g. "smile" / "smiley" โ€” proves the default search index is wired in + expect(items.some((item: { id: string }) => item.id.startsWith('smi'))).toBe(true); + }); + + it('forwards when there is no selection', async () => { + const middleware = createTextComposerEmojiMiddleware(); + const { forward } = await runOnChange(middleware, { + selection: null, + suggestions: undefined, + text: '', + }); + expect(forward).toHaveBeenCalled(); + }); + + it('accepts a custom EmojiSearchIndex override', async () => { + const search = vi.fn().mockResolvedValue([ + { emoticons: [], id: 'custom', name: 'Custom', native: '๐Ÿฆ„', skins: [{ native: '๐Ÿฆ„' }] }, + ]); + const middleware = createTextComposerEmojiMiddleware({ search }); + const { output } = await runOnChange(middleware, { + selection: { end: 4, start: 4 }, + suggestions: undefined, + text: ':uni', + }); + const { items } = await output.suggestions.searchSource.query('uni'); + expect(search).toHaveBeenCalled(); + expect(items[0]?.id).toBe('custom'); + }); +}); diff --git a/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts b/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts index 2a8ce8c004..0d66ea917c 100644 --- a/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts +++ b/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts @@ -18,6 +18,7 @@ import type { EmojiSearchIndex, EmojiSearchIndexResult, } from '../../../components/MessageComposer'; +import { defaultEmojiSearchIndex } from '../search'; export type EmojiSuggestion = TextComposerSuggestion; @@ -78,24 +79,25 @@ export type EmojiMiddleware; /** - * TextComposer middleware for mentions + * TextComposer middleware providing `:shortcode` emoji autocomplete and + * emoticon-to-emoji replacement (e.g. `:)` โ†’ ๐Ÿ™‚). + * * Usage: * - * const textComposer = new TextComposer(options); + * // uses the SDK's built-in emoji search index (no `emoji-mart` required) + * textComposer.middlewareExecutor.insert({ + * middleware: [createTextComposerEmojiMiddleware()], + * }); * - * textComposer.use(new createTextComposerEmojiMiddleware(emojiSearchIndex, { - * minChars: 2 - * })); + * // or provide a custom EmojiSearchIndex / options + * createTextComposerEmojiMiddleware(customEmojiSearchIndex, { minChars: 2 }); * - * @param emojiSearchIndex - * @param {{ - * minChars: number; - * trigger: string; - * }} options + * @param emojiSearchIndex Defaults to the SDK's built-in `defaultEmojiSearchIndex`. + * @param options `minChars` and `trigger` overrides. * @returns */ export const createTextComposerEmojiMiddleware = ( - emojiSearchIndex: EmojiSearchIndex, + emojiSearchIndex: EmojiSearchIndex = defaultEmojiSearchIndex, options?: Partial, ): EmojiMiddleware => { const finalOptions = mergeWith(DEFAULT_OPTIONS, options ?? {}); diff --git a/src/plugins/Emojis/search/EmojiSearchIndex.ts b/src/plugins/Emojis/search/EmojiSearchIndex.ts new file mode 100644 index 0000000000..b41f1ba399 --- /dev/null +++ b/src/plugins/Emojis/search/EmojiSearchIndex.ts @@ -0,0 +1,39 @@ +import type { + EmojiSearchIndex, + EmojiSearchIndexResult, +} from '../../../components/MessageComposer'; +import { loadEmojiData } from '../data'; +import { buildEmojiSearchData, type SearchableEmoji } from './buildEmojiSearchData'; +import { runSearch } from './search'; + +let indexPromise: Promise | null = null; + +const getIndex = (): Promise => { + if (!indexPromise) { + indexPromise = loadEmojiData().then(buildEmojiSearchData); + } + return indexPromise; +}; + +const toResult = (emoji: SearchableEmoji): EmojiSearchIndexResult => ({ + emoticons: emoji.emoticons, + id: emoji.id, + name: emoji.name, + native: emoji.native, + skins: emoji.skins, +}); + +/** + * The built-in, `emoji-mart`-free implementation of the {@link EmojiSearchIndex} + * interface consumed by `createTextComposerEmojiMiddleware` and the emoji picker. + * It self-initializes: the vendored dataset is loaded and the search index built + * lazily on first `search()` call, then memoized. An empty query resolves to `[]` + * (functionally equivalent to emoji-mart's `null`, which the middleware coerces). + */ +export const defaultEmojiSearchIndex: EmojiSearchIndex = { + search: async (query: string) => { + if (!query || !query.trim()) return []; + const results = runSearch(await getIndex(), query); + return results ? results.map(toResult) : []; + }, +}; diff --git a/src/plugins/Emojis/search/__tests__/EmojiSearchIndex.test.ts b/src/plugins/Emojis/search/__tests__/EmojiSearchIndex.test.ts new file mode 100644 index 0000000000..b7be9f36a7 --- /dev/null +++ b/src/plugins/Emojis/search/__tests__/EmojiSearchIndex.test.ts @@ -0,0 +1,34 @@ +import { defaultEmojiSearchIndex } from '../EmojiSearchIndex'; + +describe('defaultEmojiSearchIndex', () => { + it('satisfies the EmojiSearchIndex interface (async search)', () => { + expect(typeof defaultEmojiSearchIndex.search).toBe('function'); + }); + + it('resolves to [] for an empty query', async () => { + await expect(defaultEmojiSearchIndex.search('')).resolves.toEqual([]); + await expect(defaultEmojiSearchIndex.search(' ')).resolves.toEqual([]); + }); + + it('lazily builds the index and returns EmojiSearchIndexResult-shaped items', async () => { + const results = (await defaultEmojiSearchIndex.search('fire')) ?? []; + expect(results.length).toBeGreaterThan(0); + const [first] = results; + expect(first).toEqual( + expect.objectContaining({ + id: 'fire', + name: 'Fire', + native: '๐Ÿ”ฅ', + }), + ); + expect(Array.isArray(first.skins)).toBe(true); + expect(first.skins[0]).toHaveProperty('native'); + }); + + it('reuses the memoized index across calls', async () => { + const first = (await defaultEmojiSearchIndex.search('smile')) ?? []; + const second = (await defaultEmojiSearchIndex.search('smile')) ?? []; + expect(second.map((emoji) => emoji.id)).toEqual(first.map((emoji) => emoji.id)); + expect(first[0]?.id).toBe('smile'); + }); +}); diff --git a/src/plugins/Emojis/search/__tests__/search.test.ts b/src/plugins/Emojis/search/__tests__/search.test.ts new file mode 100644 index 0000000000..a92571717e --- /dev/null +++ b/src/plugins/Emojis/search/__tests__/search.test.ts @@ -0,0 +1,103 @@ +import emojiData from '../../data/emoji-data.json'; +import type { EmojiData } from '../../data/types'; +import { buildEmojiSearchData, type SearchableEmoji } from '../buildEmojiSearchData'; +import { runSearch } from '../search'; + +const index = buildEmojiSearchData(emojiData as unknown as EmojiData); +const ids = (results: SearchableEmoji[] | null) => + (results ?? []).map((emoji) => emoji.id); + +describe('runSearch (against the vendored dataset)', () => { + it('returns null for an empty or whitespace-only query', () => { + expect(runSearch(index, '')).toBeNull(); + expect(runSearch(index, ' ')).toBeNull(); + }); + + it('ranks an exact id match first', () => { + expect(ids(runSearch(index, 'fire'))[0]).toBe('fire'); + expect(ids(runSearch(index, 'smile'))[0]).toBe('smile'); + }); + + it('matches keywords by comma-anchored token prefix', () => { + const thumbs = ids(runSearch(index, 'thumb')); + expect(thumbs).toContain('+1'); // "Thumbs Up" via the "thumbsup" keyword + expect(thumbs).toContain('-1'); // "Thumbs Down" via the "thumbsdown" keyword + }); + + it('matches (lowercased) emoticons', () => { + const results = runSearch(index, ':)') ?? []; + expect(results.length).toBeGreaterThan(0); + expect(results.some((emoji) => emoji.emoticons?.includes(':)'))).toBe(true); + }); + + it('applies AND semantics across multiple words', () => { + const results = runSearch(index, 'red heart') ?? []; + expect(results.map((emoji) => emoji.id)).toContain('heart'); // "Red Heart" + // every returned emoji must have matched both words + expect( + results.every((emoji) => /red/.test(emoji.search) && /heart/.test(emoji.search)), + ).toBe(true); + }); + + it('resolves the native character on each result', () => { + const [first] = runSearch(index, 'fire') ?? []; + expect(first.native).toBe('๐Ÿ”ฅ'); + }); + + it('respects the maxResults cap', () => { + const capped = runSearch(index, 'face', { maxResults: 5 }) ?? []; + expect(capped.length).toBeLessThanOrEqual(5); + expect(capped.length).toBeGreaterThan(0); + }); +}); + +describe('runSearch (ranking details, synthetic data)', () => { + const tiny = buildEmojiSearchData({ + aliases: {}, + categories: [], + emojis: { + aaa: { + id: 'aaa', + keywords: [], + name: 'First', + skins: [{ native: 'โ‘ ', unified: '' }], + version: 1, + }, + bbb: { + id: 'bbb', + keywords: ['aaa'], + name: 'Second', + skins: [{ native: 'โ‘ก', unified: '' }], + version: 1, + }, + // both share the "tie" keyword at the same offset (equal-length ids) + ccc: { + id: 'ccc', + keywords: ['tie'], + name: 'Third', + skins: [{ native: 'โ‘ข', unified: '' }], + version: 1, + }, + ddd: { + id: 'ddd', + keywords: ['tie'], + name: 'Fourth', + skins: [{ native: 'โ‘ฃ', unified: '' }], + version: 1, + }, + }, + } as EmojiData); + + it('returns a single match unsorted when fewer than two results', () => { + expect(runSearch(tiny, 'first')?.map((emoji) => emoji.id)).toEqual(['aaa']); + }); + + it('scores an exact id match as 0 so it ranks before keyword matches', () => { + // "aaa" is the id of emoji aaa (score 0) and a keyword of emoji bbb (score > 0) + expect(runSearch(tiny, 'aaa')?.map((emoji) => emoji.id)).toEqual(['aaa', 'bbb']); + }); + + it('breaks score ties with id.localeCompare', () => { + expect(runSearch(tiny, 'tie')?.map((emoji) => emoji.id)).toEqual(['ccc', 'ddd']); + }); +}); diff --git a/src/plugins/Emojis/search/buildEmojiSearchData.ts b/src/plugins/Emojis/search/buildEmojiSearchData.ts new file mode 100644 index 0000000000..70f7fb3e75 --- /dev/null +++ b/src/plugins/Emojis/search/buildEmojiSearchData.ts @@ -0,0 +1,58 @@ +import type { EmojiData, EmojiDataEmoji, EmojiDataSkin } from '../data'; + +export type SearchableEmoji = { + id: string; + name: string; + native: string; + /** Comma-prefixed, lowercased haystack โ€” mirrors emoji-mart's `emoji.search`. */ + search: string; + skins: EmojiDataSkin[]; + emoticons?: string[]; +}; + +// Mirrors emoji-mart's SearchIndex haystack construction (module.js): id (not +// tokenized), name (tokenized on /[-|_|\s]+/), keywords and emoticons (not +// tokenized) โ€” all lowercased and comma-joined โ€” followed by each skin's native. +const buildHaystack = (emoji: EmojiDataEmoji): string => { + const fields: Array<[string | string[] | undefined, boolean]> = [ + [emoji.id, false], + [emoji.name, true], + [emoji.keywords, false], + [emoji.emoticons, false], + ]; + + const tokens = fields + .map(([strings, split]) => { + if (!strings) return []; + return (Array.isArray(strings) ? strings : [strings]) + .map((string) => + (split ? string.split(/[-|_|\s]+/) : [string]).map((part) => + part.toLowerCase(), + ), + ) + .flat(); + }) + .flat() + .filter((token) => token && token.trim()); + + let haystack = `,${tokens.join(',')}`; + for (const skin of emoji.skins) { + if (skin?.native) haystack += `,${skin.native}`; + } + return haystack; +}; + +/** + * Transforms the vendored emoji dataset into a flat, search-ready index. Pure and + * side-effect free โ€” the produced `search` haystack matches emoji-mart's format so + * that ranking parity with `emoji-mart`'s `SearchIndex` is preserved. + */ +export const buildEmojiSearchData = (data: EmojiData): SearchableEmoji[] => + Object.values(data.emojis).map((emoji) => ({ + emoticons: emoji.emoticons, + id: emoji.id, + name: emoji.name, + native: emoji.skins[0]?.native ?? '', + search: buildHaystack(emoji), + skins: emoji.skins, + })); diff --git a/src/plugins/Emojis/search/index.ts b/src/plugins/Emojis/search/index.ts new file mode 100644 index 0000000000..e59a35752b --- /dev/null +++ b/src/plugins/Emojis/search/index.ts @@ -0,0 +1,3 @@ +export * from './buildEmojiSearchData'; +export * from './EmojiSearchIndex'; +export * from './search'; diff --git a/src/plugins/Emojis/search/search.ts b/src/plugins/Emojis/search/search.ts new file mode 100644 index 0000000000..11be95dd4f --- /dev/null +++ b/src/plugins/Emojis/search/search.ts @@ -0,0 +1,61 @@ +import type { SearchableEmoji } from './buildEmojiSearchData'; + +export type RunSearchOptions = { + maxResults?: number; +}; + +/** + * Ranked emoji search replicating emoji-mart's `SearchIndex.search` (module.js): + * lowercase the query, turn the first `word-` into `word ` (space), split on + * whitespace/`|`/`,`, dedupe, then AND-intersect the pool across words โ€” scoring + * each emoji by the position of `,` in its haystack. Lower score (earlier + * match) ranks first; an exact id match scores 0; ties break by `id.localeCompare`. + * + * Returns `null` for an empty query (parity with emoji-mart), an empty array when + * the query yields no usable words, otherwise the ranked matches capped at + * `maxResults` (default 90). + */ +export const runSearch = ( + index: SearchableEmoji[], + value: string, + { maxResults = 90 }: RunSearchOptions = {}, +): SearchableEmoji[] | null => { + if (!value || !value.trim().length) return null; + + const words = value + .toLowerCase() + .replace(/(\w)-/, '$1 ') + .split(/[\s|,]+/) + .filter((word, position, all) => word.trim() && all.indexOf(word) === position); + + if (!words.length) return []; + + let pool = index; + let results: SearchableEmoji[] = []; + let scores: Record = {}; + + for (const word of words) { + if (!pool.length) break; + results = []; + scores = {}; + for (const emoji of pool) { + if (!emoji.search) continue; + const score = emoji.search.indexOf(`,${word}`); + if (score === -1) continue; + results.push(emoji); + scores[emoji.id] = (scores[emoji.id] ?? 0) + (emoji.id === word ? 0 : score + 1); + } + pool = results; + } + + if (results.length < 2) return results; + + results.sort((a, b) => { + const aScore = scores[a.id]; + const bScore = scores[b.id]; + if (aScore === bScore) return a.id.localeCompare(b.id); + return aScore - bScore; + }); + + return results.length > maxResults ? results.slice(0, maxResults) : results; +}; From 27eab235c51112ee02fcf7992577a79214fd75fd Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 14:37:23 +0200 Subject: [PATCH 02/36] feat(emojis): render a native React emoji picker panel Replace emoji-mart's web component with an in-house React panel that loads the vendored dataset and renders category navigation, a grouped emoji grid, and a preview pane. The EmojiPicker shell keeps its popover, toggle button, click-outside and insert behaviour and public props unchanged. Removes the @emoji-mart/react import and its CJS interop shim; no runtime emoji-mart imports remain in src. Styling, in-picker search, skin tones, virtualization and full keyboard a11y follow in subsequent phases. --- src/plugins/Emojis/EmojiPicker.tsx | 21 +--- src/plugins/Emojis/components/CategoryNav.tsx | 37 ++++++ src/plugins/Emojis/components/EmojiButton.tsx | 33 ++++++ src/plugins/Emojis/components/EmojiGrid.tsx | 41 +++++++ .../Emojis/components/EmojiPickerPanel.tsx | 112 ++++++++++++++++++ src/plugins/Emojis/components/PreviewPane.tsx | 28 +++++ src/plugins/Emojis/components/categories.ts | 15 +++ src/plugins/Emojis/components/index.ts | 1 + .../Emojis/context/EmojiPickerContext.tsx | 27 +++++ .../Emojis/hooks/useEmojiPickerState.ts | 26 ++++ src/plugins/Emojis/index.ts | 1 + .../textComposerEmojiMiddleware.test.ts | 17 +-- 12 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 src/plugins/Emojis/components/CategoryNav.tsx create mode 100644 src/plugins/Emojis/components/EmojiButton.tsx create mode 100644 src/plugins/Emojis/components/EmojiGrid.tsx create mode 100644 src/plugins/Emojis/components/EmojiPickerPanel.tsx create mode 100644 src/plugins/Emojis/components/PreviewPane.tsx create mode 100644 src/plugins/Emojis/components/categories.ts create mode 100644 src/plugins/Emojis/components/index.ts create mode 100644 src/plugins/Emojis/context/EmojiPickerContext.tsx create mode 100644 src/plugins/Emojis/hooks/useEmojiPickerState.ts diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 4972da647d..ac40ffcdfb 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from 'react'; -import PickerImport from '@emoji-mart/react'; import { useMessageComposerContext, useTranslationContext } from '../../context'; import { @@ -10,14 +9,7 @@ import { } from '../../components'; import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive'; - -// @emoji-mart/react ships as CJS with the component on `exports.default`. Under -// spec-strict ESM interop (e.g. Vite 8 / Rolldown, native Node ESM) a default -// import yields the module namespace `{ default }` instead of the component, -// which makes React throw "Element type is invalid ... got: object". Unwrap the -// default defensively so it works regardless of interop. -const Picker = - (PickerImport as unknown as { default?: typeof PickerImport }).default ?? PickerImport; +import { EmojiPickerPanel } from './components'; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; @@ -105,19 +97,18 @@ export const EmojiPicker = (props: EmojiPickerProps) => { ref={setPopperElement} style={{ left: x ?? 0, position: strategy, top: y ?? 0 }} > - (await import('@emoji-mart/data')).default} - onEmojiSelect={(e: { native: string }) => { + { const textarea = textareaRef.current; if (!textarea) return; - textComposer.insertText({ text: e.native }); + textComposer.insertText({ text: emoji.native }); textarea.focus(); if (props.closeOnEmojiSelect) { setDisplayPicker(false); } }} - {...props.pickerProps} - style={{ ...pickerStyle, '--shadow': 'none' }} + style={pickerStyle} + theme={props.pickerProps?.theme} /> )} diff --git a/src/plugins/Emojis/components/CategoryNav.tsx b/src/plugins/Emojis/components/CategoryNav.tsx new file mode 100644 index 0000000000..0d7bb6a437 --- /dev/null +++ b/src/plugins/Emojis/components/CategoryNav.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx'; +import { EMOJI_CATEGORY_META } from './categories'; +import type { EmojiPickerCategory } from './EmojiGrid'; + +export type CategoryNavProps = { + categories: EmojiPickerCategory[]; + onNavigate: (categoryId: string) => void; + activeCategoryId?: string; +}; + +/** + * Top navigation bar with one tab per category. Clicking a tab scrolls its section + * into view; the active tab reflects the currently visible section. + */ +export const CategoryNav = ({ + activeCategoryId, + categories, + onNavigate, +}: CategoryNavProps) => ( +
+ {categories.map(({ id, label }) => ( + + ))} +
+); diff --git a/src/plugins/Emojis/components/EmojiButton.tsx b/src/plugins/Emojis/components/EmojiButton.tsx new file mode 100644 index 0000000000..ca99a70717 --- /dev/null +++ b/src/plugins/Emojis/components/EmojiButton.tsx @@ -0,0 +1,33 @@ +import { memo } from 'react'; +import { useEmojiPickerContext } from '../context/EmojiPickerContext'; +import type { EmojiDataEmoji } from '../data'; + +export type EmojiButtonProps = { + emoji: EmojiDataEmoji; +}; + +/** + * A single selectable emoji cell rendering the native unicode glyph for the active + * skin tone. Memoized because the grid can render ~1800 of these. + */ +export const EmojiButton = memo(function EmojiButton({ emoji }: EmojiButtonProps) { + const { onSelectEmoji, setPreviewedEmoji, skinToneIndex } = + useEmojiPickerContext('EmojiButton'); + const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? ''; + + return ( + + ); +}); diff --git a/src/plugins/Emojis/components/EmojiGrid.tsx b/src/plugins/Emojis/components/EmojiGrid.tsx new file mode 100644 index 0000000000..6723f924b4 --- /dev/null +++ b/src/plugins/Emojis/components/EmojiGrid.tsx @@ -0,0 +1,41 @@ +import { memo } from 'react'; +import { EmojiButton } from './EmojiButton'; +import type { EmojiDataEmoji } from '../data'; + +export type EmojiPickerCategory = { + emojis: EmojiDataEmoji[]; + id: string; + label: string; +}; + +export type EmojiGridProps = { + categories: EmojiPickerCategory[]; +}; + +/** + * Renders every category as a labelled section of emoji cells. Non-virtualized โ€” + * virtualization is layered on in a later phase behind this same category model. + */ +export const EmojiGrid = memo(function EmojiGrid({ categories }: EmojiGridProps) { + return ( +
+ {categories.map((category) => ( +
+
+ {category.label} +
+
+ {category.emojis.map((emoji) => ( + + ))} +
+
+ ))} +
+ ); +}); diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx new file mode 100644 index 0000000000..302fb15c15 --- /dev/null +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -0,0 +1,112 @@ +import { type CSSProperties, useCallback, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; +import { CategoryNav } from './CategoryNav'; +import { EmojiGrid, type EmojiPickerCategory } from './EmojiGrid'; +import { EMOJI_CATEGORY_META } from './categories'; +import { PreviewPane } from './PreviewPane'; +import { + type EmojiPickerContextValue, + EmojiPickerProvider, +} from '../context/EmojiPickerContext'; +import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; +import type { EmojiDataEmoji } from '../data'; +import { useTranslationContext } from '../../../context'; + +export type EmojiSelection = { + id: string; + name: string; + native: string; +}; + +export type EmojiPickerPanelProps = { + onEmojiSelect: (emoji: EmojiSelection) => void; + className?: string; + style?: CSSProperties; + theme?: 'auto' | 'light' | 'dark'; +}; + +const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { + if (theme === 'light') return 'str-chat__theme-light'; + if (theme === 'dark') return 'str-chat__theme-dark'; + // 'auto' (default): inherit the ancestor `.str-chat__theme-*`. + return undefined; +}; + +/** + * The native React emoji picker panel that replaces emoji-mart's `` + * web component. Loads the vendored dataset, renders the category navigation, emoji + * grid and preview, and emits the resolved native emoji on selection. + */ +export const EmojiPickerPanel = ({ + className, + onEmojiSelect, + style, + theme, +}: EmojiPickerPanelProps) => { + const { t } = useTranslationContext('EmojiPickerPanel'); + const { data } = useEmojiPickerState(); + const [previewedEmoji, setPreviewedEmoji] = useState(null); + const [activeCategoryId, setActiveCategoryId] = useState(undefined); + const gridContainerRef = useRef(null); + const skinToneIndex = 0; // Wired to props in a later phase. + + const categories = useMemo(() => { + if (!data) return []; + return data.categories.map((category) => ({ + emojis: category.emojis.map((id) => data.emojis[id]).filter(Boolean), + id: category.id, + label: t(EMOJI_CATEGORY_META[category.id]?.labelKey ?? category.id), + })); + }, [data, t]); + + const onSelectEmoji = useCallback( + (emoji: EmojiDataEmoji) => { + const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? ''; + if (!native) return; + onEmojiSelect({ id: emoji.id, name: emoji.name, native }); + }, + [onEmojiSelect], + ); + + const contextValue = useMemo( + () => ({ onSelectEmoji, setPreviewedEmoji, skinToneIndex }), + [onSelectEmoji], + ); + + const handleNavigate = useCallback((categoryId: string) => { + setActiveCategoryId(categoryId); + gridContainerRef.current + ?.querySelector(`[data-category-id="${categoryId}"]`) + ?.scrollIntoView({ block: 'start' }); + }, []); + + return ( + +
+ {data ? ( + <> + +
+ +
+ + + ) : ( +
+ )} +
+ + ); +}; diff --git a/src/plugins/Emojis/components/PreviewPane.tsx b/src/plugins/Emojis/components/PreviewPane.tsx new file mode 100644 index 0000000000..8b42dcb87b --- /dev/null +++ b/src/plugins/Emojis/components/PreviewPane.tsx @@ -0,0 +1,28 @@ +import { useEmojiPickerContext } from '../context/EmojiPickerContext'; +import type { EmojiDataEmoji } from '../data'; + +export type PreviewPaneProps = { + emoji: EmojiDataEmoji | null; +}; + +/** + * Footer preview of the hovered/focused emoji: a large glyph plus its name. + * Receives the previewed emoji as a prop (kept out of context) so hovering does + * not re-render the emoji grid. + */ +export const PreviewPane = ({ emoji }: PreviewPaneProps) => { + const { skinToneIndex } = useEmojiPickerContext('PreviewPane'); + + return ( +
+ {emoji ? ( + <> + + {emoji.name} + + ) : null} +
+ ); +}; diff --git a/src/plugins/Emojis/components/categories.ts b/src/plugins/Emojis/components/categories.ts new file mode 100644 index 0000000000..b006a47dd3 --- /dev/null +++ b/src/plugins/Emojis/components/categories.ts @@ -0,0 +1,15 @@ +// Category metadata for the emoji picker navigation. We use representative native +// emoji glyphs for the nav tabs (avoids shipping ~8 additional SVG icon assets) +// plus an i18n label key per category. Keys match the vendored dataset's category +// ids, with `frequent` reserved for the synthetic "frequently used" section. +export const EMOJI_CATEGORY_META: Record = { + activity: { glyph: 'โšฝ', labelKey: 'Activities' }, + flags: { glyph: '๐Ÿ', labelKey: 'Flags' }, + foods: { glyph: '๐ŸŽ', labelKey: 'Food & Drink' }, + frequent: { glyph: '๐Ÿ•', labelKey: 'Frequently used' }, + nature: { glyph: '๐ŸŒธ', labelKey: 'Animals & Nature' }, + objects: { glyph: '๐Ÿ’ก', labelKey: 'Objects' }, + people: { glyph: '๐Ÿ˜€', labelKey: 'Smileys & People' }, + places: { glyph: 'โœˆ๏ธ', labelKey: 'Travel & Places' }, + symbols: { glyph: '๐Ÿ”ฃ', labelKey: 'Symbols' }, +}; diff --git a/src/plugins/Emojis/components/index.ts b/src/plugins/Emojis/components/index.ts new file mode 100644 index 0000000000..a8bf208ec6 --- /dev/null +++ b/src/plugins/Emojis/components/index.ts @@ -0,0 +1 @@ +export * from './EmojiPickerPanel'; diff --git a/src/plugins/Emojis/context/EmojiPickerContext.tsx b/src/plugins/Emojis/context/EmojiPickerContext.tsx new file mode 100644 index 0000000000..b74b683b30 --- /dev/null +++ b/src/plugins/Emojis/context/EmojiPickerContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; +import type { EmojiDataEmoji } from '../data'; + +export type EmojiPickerContextValue = { + onSelectEmoji: (emoji: EmojiDataEmoji) => void; + setPreviewedEmoji: (emoji: EmojiDataEmoji | null) => void; + skinToneIndex: number; +}; + +const EmojiPickerContext = createContext(undefined); + +export const EmojiPickerProvider = EmojiPickerContext.Provider; + +/** + * Shares the stable picker callbacks and the active skin tone with the picker's + * child components. Deliberately excludes transient state (like the previewed + * emoji) so consuming the context does not re-render the whole emoji grid. + */ +export const useEmojiPickerContext = (componentName = 'EmojiPicker') => { + const context = useContext(EmojiPickerContext); + if (!context) { + throw new Error( + `The ${componentName} component must be rendered within an EmojiPickerPanel.`, + ); + } + return context; +}; diff --git a/src/plugins/Emojis/hooks/useEmojiPickerState.ts b/src/plugins/Emojis/hooks/useEmojiPickerState.ts new file mode 100644 index 0000000000..ad602553b7 --- /dev/null +++ b/src/plugins/Emojis/hooks/useEmojiPickerState.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; +import { type EmojiData, loadEmojiData } from '../data'; + +/** + * Loads the vendored emoji dataset lazily (via the code-split dynamic import) and + * exposes it once resolved. Returns `null` while loading. + */ +export const useEmojiPickerState = () => { + const [data, setData] = useState(null); + + useEffect(() => { + let active = true; + loadEmojiData() + .then((loaded) => { + if (active) setData(loaded); + }) + .catch(() => { + // Swallow โ€” the picker stays in its loading state if data fails to load. + }); + return () => { + active = false; + }; + }, []); + + return { data }; +}; diff --git a/src/plugins/Emojis/index.ts b/src/plugins/Emojis/index.ts index d68d65a68d..92d4ca9abf 100644 --- a/src/plugins/Emojis/index.ts +++ b/src/plugins/Emojis/index.ts @@ -1,4 +1,5 @@ export * from './compat'; +export * from './components'; export * from './data'; export * from './EmojiPicker'; export * from './middleware'; diff --git a/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts b/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts index be4ccecfdd..0eef1cf59e 100644 --- a/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts +++ b/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts @@ -2,20 +2,18 @@ import { createTextComposerEmojiMiddleware } from '../textComposerEmojiMiddlewar // Minimal onChange harness: capture whatever state the handler completes/nexts with. const runOnChange = async ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any middleware: ReturnType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: any, ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any let output: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const complete = (next: any) => { output = next; return { state: next, status: 'complete' }; }; const forward = vi.fn(() => ({ status: 'forward' })); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const next = (nextState: any) => { output = nextState; return { state: nextState, status: 'next' }; @@ -25,7 +23,6 @@ const runOnChange = async ( forward, next, state, - // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); return { forward, output }; }; @@ -67,7 +64,13 @@ describe('createTextComposerEmojiMiddleware', () => { it('accepts a custom EmojiSearchIndex override', async () => { const search = vi.fn().mockResolvedValue([ - { emoticons: [], id: 'custom', name: 'Custom', native: '๐Ÿฆ„', skins: [{ native: '๐Ÿฆ„' }] }, + { + emoticons: [], + id: 'custom', + name: 'Custom', + native: '๐Ÿฆ„', + skins: [{ native: '๐Ÿฆ„' }], + }, ]); const middleware = createTextComposerEmojiMiddleware({ search }); const { output } = await runOnChange(middleware, { From 7d672bc6a521142afa6543913e9e2062e85de000 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 14:41:06 +0200 Subject: [PATCH 03/36] feat(emojis): style the native emoji picker panel Rewrite EmojiPicker.scss with class-based, tokenized styles for the React panel (category nav, grid, emoji cells, preview) using component tokens resolved from semantic tokens, so light/dark theming comes for free via the inherited .str-chat__theme-* ancestor. Drop the emoji-mart rules. Update _emoji-replacement.scss to apply the Windows flag-font replacement to the new .str-chat__emoji-picker__emoji / __preview-emoji glyphs instead of the dead .emoji-mart-emoji-native selector. --- src/plugins/Emojis/styling/EmojiPicker.scss | 153 +++++++++++++++++++- src/styling/_emoji-replacement.scss | 6 +- 2 files changed, 150 insertions(+), 9 deletions(-) diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index 2bd0aadea9..33193fe129 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -1,11 +1,150 @@ -$emoji-picker-border-radius: 10px; +@use '../../../styling/utils'; -.str-chat__message-textarea-emoji-picker-container { - border-radius: $emoji-picker-border-radius; - box-shadow: var(--str-chat__box-shadow-3); - overflow: hidden; +$emoji-picker-border-radius: 12px; - em-emoji-picker { - --border-radius: #{$emoji-picker-border-radius}; +.str-chat { + .str-chat__message-textarea-emoji-picker-container { + border-radius: $emoji-picker-border-radius; + box-shadow: var(--str-chat__box-shadow-3); + overflow: hidden; + } + + .str-chat__emoji-picker { + // Component tokens โ€” resolved from semantic tokens so light/dark theming comes + // for free via the inherited `.str-chat__theme-*` ancestor. + --str-chat__emoji-picker-background-color: var( + --str-chat__background-core-surface-card + ); + --str-chat__emoji-picker-text-color: var(--str-chat__text-primary); + --str-chat__emoji-picker-secondary-text-color: var(--str-chat__text-secondary); + --str-chat__emoji-picker-hover-background-color: var( + --str-chat__background-utility-hover + ); + --str-chat__emoji-picker-selected-background-color: var( + --str-chat__background-utility-selected + ); + --str-chat__emoji-picker-border-color: var(--str-chat__border-core-on-surface); + --str-chat__emoji-picker-width: 20rem; + --str-chat__emoji-picker-height: 22.5rem; + --str-chat__emoji-picker-emoji-size: 1.5rem; + + display: flex; + flex-direction: column; + inline-size: var(--str-chat__emoji-picker-width); + max-inline-size: 100%; + block-size: var(--str-chat__emoji-picker-height); + background-color: var(--str-chat__emoji-picker-background-color); + color: var(--str-chat__emoji-picker-text-color); + font: var(--str-chat__font-body-default); + + &__category-nav { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xxs); + padding: var(--str-chat__spacing-xs); + border-block-end: 1px solid var(--str-chat__emoji-picker-border-color); + } + + &__category-nav-button { + @include utils.button-reset; + cursor: pointer; + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + padding: var(--str-chat__spacing-xxs); + border-radius: var(--str-chat__radius-4); + font-size: 1.125rem; + line-height: 1; + opacity: 0.6; + + &:hover { + background-color: var(--str-chat__emoji-picker-hover-background-color); + opacity: 1; + } + + &:focus-visible { + @include utils.focusable; + } + + &--active { + background-color: var(--str-chat__emoji-picker-selected-background-color); + opacity: 1; + } + } + + &__grid-container { + @include utils.scrollable-y; + flex: 1 1 auto; + padding-inline: var(--str-chat__spacing-xs); + } + + &__category { + &-label { + position: sticky; + inset-block-start: 0; + z-index: 1; + padding-block: var(--str-chat__spacing-xs); + background-color: var(--str-chat__emoji-picker-background-color); + color: var(--str-chat__emoji-picker-secondary-text-color); + font: var(--str-chat__font-body-emphasis); + text-transform: capitalize; + } + + &-emojis { + display: grid; + grid-template-columns: repeat( + auto-fill, + minmax(var(--str-chat__emoji-picker-emoji-size), 1fr) + ); + gap: var(--str-chat__spacing-xxs); + padding-block-end: var(--str-chat__spacing-sm); + } + } + + &__emoji { + @include utils.button-reset; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1 / 1; + inline-size: 100%; + border-radius: var(--str-chat__radius-4); + font-size: var(--str-chat__emoji-picker-emoji-size); + line-height: 1; + + &:hover { + background-color: var(--str-chat__emoji-picker-hover-background-color); + } + + &:focus-visible { + @include utils.focusable; + } + } + + &__preview { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-sm); + min-block-size: 2.75rem; + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm); + border-block-start: 1px solid var(--str-chat__emoji-picker-border-color); + + &-emoji { + font-size: 1.5rem; + line-height: 1; + } + + &-name { + color: var(--str-chat__emoji-picker-secondary-text-color); + text-transform: capitalize; + } + } + + &__loading { + flex: 1 1 auto; + min-block-size: 12rem; + } } } diff --git a/src/styling/_emoji-replacement.scss b/src/styling/_emoji-replacement.scss index 5d6170dfb6..1c34a7b561 100644 --- a/src/styling/_emoji-replacement.scss +++ b/src/styling/_emoji-replacement.scss @@ -21,7 +21,8 @@ $emoji-flag-unicode-range: U+1F1E6-1F1FF; .str-chat__message-textarea, .str-chat__message-text-inner *, .str-chat__emoji-item--entity, - .emoji-mart-emoji-native * { + .str-chat__emoji-picker__emoji, + .str-chat__emoji-picker__preview-emoji { font-family: ReplaceFlagEmojiPNG, var(--str-chat__font-family), sans-serif; font-display: swap; } @@ -33,7 +34,8 @@ $emoji-flag-unicode-range: U+1F1E6-1F1FF; .str-chat__message-textarea, .str-chat__message-text-inner *, .str-chat__emoji-item--entity, - .emoji-mart-emoji-native * { + .str-chat__emoji-picker__emoji, + .str-chat__emoji-picker__preview-emoji { font-family: ReplaceFlagEmojiSVG, var(--str-chat__font-family), sans-serif; font-display: swap; } From 9362f637b29e7f3db1831c0adb57fd3efcf7bb81 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 14:45:06 +0200 Subject: [PATCH 04/36] feat(emojis): add in-picker search and empty state Add a SearchInput (icon + labelled input + clear button, mirroring the SDK SearchBar) and an EmptyResults state. The panel builds an in-memory search index from the loaded dataset and swaps the grouped category grid for a flat results grid while a query is active; navigating a category exits search. Styling for the search box and empty state added to EmojiPicker.scss. --- .../Emojis/components/EmojiPickerPanel.tsx | 51 ++++++++++++++++--- .../Emojis/components/EmptyResults.tsx | 14 +++++ src/plugins/Emojis/components/SearchInput.tsx | 49 ++++++++++++++++++ src/plugins/Emojis/styling/EmojiPicker.scss | 41 +++++++++++++++ 4 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 src/plugins/Emojis/components/EmptyResults.tsx create mode 100644 src/plugins/Emojis/components/SearchInput.tsx diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index 302fb15c15..c3afa7b613 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -1,14 +1,18 @@ import { type CSSProperties, useCallback, useMemo, useRef, useState } from 'react'; import clsx from 'clsx'; import { CategoryNav } from './CategoryNav'; +import { EmojiButton } from './EmojiButton'; import { EmojiGrid, type EmojiPickerCategory } from './EmojiGrid'; import { EMOJI_CATEGORY_META } from './categories'; +import { EmptyResults } from './EmptyResults'; import { PreviewPane } from './PreviewPane'; +import { SearchInput } from './SearchInput'; import { type EmojiPickerContextValue, EmojiPickerProvider, } from '../context/EmojiPickerContext'; import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; +import { buildEmojiSearchData, runSearch } from '../search'; import type { EmojiDataEmoji } from '../data'; import { useTranslationContext } from '../../../context'; @@ -34,8 +38,8 @@ const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { /** * The native React emoji picker panel that replaces emoji-mart's `` - * web component. Loads the vendored dataset, renders the category navigation, emoji - * grid and preview, and emits the resolved native emoji on selection. + * web component. Loads the vendored dataset, renders search, category navigation, + * the emoji grid and a preview, and emits the resolved native emoji on selection. */ export const EmojiPickerPanel = ({ className, @@ -47,6 +51,7 @@ export const EmojiPickerPanel = ({ const { data } = useEmojiPickerState(); const [previewedEmoji, setPreviewedEmoji] = useState(null); const [activeCategoryId, setActiveCategoryId] = useState(undefined); + const [query, setQuery] = useState(''); const gridContainerRef = useRef(null); const skinToneIndex = 0; // Wired to props in a later phase. @@ -59,6 +64,17 @@ export const EmojiPickerPanel = ({ })); }, [data, t]); + const searchIndex = useMemo(() => (data ? buildEmojiSearchData(data) : []), [data]); + + // `null` when not searching; otherwise the (possibly empty) list of matches. + const searchedEmojis = useMemo(() => { + const trimmed = query.trim(); + if (!trimmed || !data) return null; + return (runSearch(searchIndex, trimmed) ?? []) + .map((result) => data.emojis[result.id]) + .filter(Boolean); + }, [data, query, searchIndex]); + const onSelectEmoji = useCallback( (emoji: EmojiDataEmoji) => { const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? ''; @@ -74,12 +90,18 @@ export const EmojiPickerPanel = ({ ); const handleNavigate = useCallback((categoryId: string) => { + setQuery(''); // navigating a category exits search setActiveCategoryId(categoryId); - gridContainerRef.current - ?.querySelector(`[data-category-id="${categoryId}"]`) - ?.scrollIntoView({ block: 'start' }); + // Defer so the category view has re-rendered before we scroll to the section. + requestAnimationFrame(() => { + gridContainerRef.current + ?.querySelector(`[data-category-id="${categoryId}"]`) + ?.scrollIntoView({ block: 'start' }); + }); }, []); + const isSearching = searchedEmojis !== null; + return (
{data ? ( <> + @@ -99,7 +122,21 @@ export const EmojiPickerPanel = ({ className='str-chat__emoji-picker__grid-container' ref={gridContainerRef} > - + {isSearching ? ( + searchedEmojis.length ? ( +
+
+ {searchedEmojis.map((emoji) => ( + + ))} +
+
+ ) : ( + + ) + ) : ( + + )}
diff --git a/src/plugins/Emojis/components/EmptyResults.tsx b/src/plugins/Emojis/components/EmptyResults.tsx new file mode 100644 index 0000000000..c54d5f5dab --- /dev/null +++ b/src/plugins/Emojis/components/EmptyResults.tsx @@ -0,0 +1,14 @@ +import { useTranslationContext } from '../../../context'; + +/** + * Shown in place of the emoji grid when a search yields no matches. + */ +export const EmptyResults = () => { + const { t } = useTranslationContext('EmojiPicker'); + + return ( +
+ {t('No emoji found')} +
+ ); +}; diff --git a/src/plugins/Emojis/components/SearchInput.tsx b/src/plugins/Emojis/components/SearchInput.tsx new file mode 100644 index 0000000000..61a1531591 --- /dev/null +++ b/src/plugins/Emojis/components/SearchInput.tsx @@ -0,0 +1,49 @@ +import { Button, IconSearch, IconXCircle, VisuallyHidden } from '../../../components'; +import { useStableId } from '../../../components/UtilityComponents/useStableId'; +import { useTranslationContext } from '../../../context'; + +export type SearchInputProps = { + onChange: (value: string) => void; + value: string; +}; + +/** + * Search box for the emoji picker. Mirrors the SDK's SearchBar structure (icon + + * labelled input + clear button). + */ +export const SearchInput = ({ onChange, value }: SearchInputProps) => { + const { t } = useTranslationContext('EmojiPickerSearchInput'); + const inputId = useStableId(); + + return ( +
+ + + onChange(event.target.value)} + placeholder={t('Search emoji')} + type='text' + value={value} + /> + {value ? ( + + ) : null} +
+ ); +}; diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index 33193fe129..f19db6f319 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -37,6 +37,47 @@ $emoji-picker-border-radius: 12px; color: var(--str-chat__emoji-picker-text-color); font: var(--str-chat__font-body-default); + &__search { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-sm) 0; + + .str-chat__icon { + flex: none; + color: var(--str-chat__emoji-picker-secondary-text-color); + } + } + + &__search-input { + flex: 1 1 auto; + min-inline-size: 0; + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm); + border: 1px solid var(--str-chat__emoji-picker-border-color); + border-radius: var(--str-chat__radius-8); + background-color: var(--str-chat__background-core-surface-default); + color: inherit; + font: inherit; + + &:focus-visible { + @include utils.focusable; + } + } + + &__search-clear { + flex: none; + } + + &__empty { + display: flex; + align-items: center; + justify-content: center; + min-block-size: 12rem; + padding: var(--str-chat__spacing-md); + color: var(--str-chat__emoji-picker-secondary-text-color); + text-align: center; + } + &__category-nav { display: flex; align-items: center; From 9a30e5b4aab780e915827e190a65cd51765e1548 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 14:49:23 +0200 Subject: [PATCH 05/36] feat(emojis): add skin-tone selector and frequently-used section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a footer skin-tone selector and a synthetic "frequently used" category. Both are integrator-managed via new optional props โ€” skinTone/defaultSkinTone/ onSkinToneChange and frequentlyUsedEmoji/onFrequentlyUsedChange โ€” with ephemeral in-memory defaults. The SDK never touches browser storage; integrators own any persistence (demonstrated in the vite example in a later phase). useSkinTone and useFrequentlyUsedEmoji implement the controlled-or-uncontrolled pattern and are unit-tested. --- src/plugins/Emojis/EmojiPicker.tsx | 19 ++++++ .../Emojis/components/EmojiPickerPanel.tsx | 54 +++++++++++++++-- .../Emojis/components/SkinToneSelector.tsx | 59 +++++++++++++++++++ src/plugins/Emojis/components/skinTones.ts | 13 ++++ .../__tests__/useFrequentlyUsedEmoji.test.ts | 26 ++++++++ .../hooks/__tests__/useSkinTone.test.ts | 32 ++++++++++ .../Emojis/hooks/useFrequentlyUsedEmoji.ts | 39 ++++++++++++ src/plugins/Emojis/hooks/useSkinTone.ts | 39 ++++++++++++ src/plugins/Emojis/styling/EmojiPicker.scss | 46 ++++++++++++++- 9 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 src/plugins/Emojis/components/SkinToneSelector.tsx create mode 100644 src/plugins/Emojis/components/skinTones.ts create mode 100644 src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts create mode 100644 src/plugins/Emojis/hooks/__tests__/useSkinTone.test.ts create mode 100644 src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts create mode 100644 src/plugins/Emojis/hooks/useSkinTone.ts diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index ac40ffcdfb..ea1d472b3d 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -28,6 +28,20 @@ export type EmojiPickerProps = { * Floating UI placement (default: 'top-end') for the picker popover */ placement?: PopperLikePlacement; + /** Uncontrolled initial skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ + defaultSkinTone?: number; + /** + * Controlled ordered list of recently used emoji ids (most recent first). The SDK + * does not persist this โ€” provide it (and `onFrequentlyUsedChange`) to control the + * "frequently used" section; otherwise it is tracked in memory for the session. + */ + frequentlyUsedEmoji?: string[]; + /** Called with the updated recently-used list when an emoji is selected. */ + onFrequentlyUsedChange?: (emojiIds: string[]) => void; + /** Called with the new skin tone index when it changes. */ + onSkinToneChange?: (skinTone: number) => void; + /** Controlled skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ + skinTone?: number; }; const defaultButtonClassName = 'str-chat__emoji-picker-button'; @@ -98,6 +112,8 @@ export const EmojiPicker = (props: EmojiPickerProps) => { style={{ left: x ?? 0, position: strategy, top: y ?? 0 }} > { const textarea = textareaRef.current; if (!textarea) return; @@ -107,6 +123,9 @@ export const EmojiPicker = (props: EmojiPickerProps) => { setDisplayPicker(false); } }} + onFrequentlyUsedChange={props.onFrequentlyUsedChange} + onSkinToneChange={props.onSkinToneChange} + skinTone={props.skinTone} style={pickerStyle} theme={props.pickerProps?.theme} /> diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index c3afa7b613..9ae950b983 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -7,11 +7,14 @@ import { EMOJI_CATEGORY_META } from './categories'; import { EmptyResults } from './EmptyResults'; import { PreviewPane } from './PreviewPane'; import { SearchInput } from './SearchInput'; +import { SkinToneSelector } from './SkinToneSelector'; import { type EmojiPickerContextValue, EmojiPickerProvider, } from '../context/EmojiPickerContext'; import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; +import { useFrequentlyUsedEmoji } from '../hooks/useFrequentlyUsedEmoji'; +import { useSkinTone } from '../hooks/useSkinTone'; import { buildEmojiSearchData, runSearch } from '../search'; import type { EmojiDataEmoji } from '../data'; import { useTranslationContext } from '../../../context'; @@ -25,6 +28,16 @@ export type EmojiSelection = { export type EmojiPickerPanelProps = { onEmojiSelect: (emoji: EmojiSelection) => void; className?: string; + /** Uncontrolled initial skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ + defaultSkinTone?: number; + /** Controlled ordered list of recently used emoji ids (most recent first). */ + frequentlyUsedEmoji?: string[]; + /** Called with the updated recently-used list when an emoji is selected. */ + onFrequentlyUsedChange?: (emojiIds: string[]) => void; + /** Called with the new skin tone index when it changes. */ + onSkinToneChange?: (skinTone: number) => void; + /** Controlled skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ + skinTone?: number; style?: CSSProperties; theme?: 'auto' | 'light' | 'dark'; }; @@ -39,23 +52,38 @@ const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { /** * The native React emoji picker panel that replaces emoji-mart's `` * web component. Loads the vendored dataset, renders search, category navigation, - * the emoji grid and a preview, and emits the resolved native emoji on selection. + * the emoji grid, a preview and a skin-tone selector, and emits the resolved native + * emoji on selection. Skin tone and frequently-used are integrator-managed props + * (no browser storage in the SDK). */ export const EmojiPickerPanel = ({ className, + defaultSkinTone, + frequentlyUsedEmoji, onEmojiSelect, + onFrequentlyUsedChange, + onSkinToneChange, + skinTone, style, theme, }: EmojiPickerPanelProps) => { const { t } = useTranslationContext('EmojiPickerPanel'); const { data } = useEmojiPickerState(); + const [skinToneIndex, setSkinTone] = useSkinTone({ + defaultSkinTone, + onSkinToneChange, + skinTone, + }); + const { frequentlyUsedIds, recordUse } = useFrequentlyUsedEmoji({ + frequentlyUsedEmoji, + onFrequentlyUsedChange, + }); const [previewedEmoji, setPreviewedEmoji] = useState(null); const [activeCategoryId, setActiveCategoryId] = useState(undefined); const [query, setQuery] = useState(''); const gridContainerRef = useRef(null); - const skinToneIndex = 0; // Wired to props in a later phase. - const categories = useMemo(() => { + const baseCategories = useMemo(() => { if (!data) return []; return data.categories.map((category) => ({ emojis: category.emojis.map((id) => data.emojis[id]).filter(Boolean), @@ -64,6 +92,16 @@ export const EmojiPickerPanel = ({ })); }, [data, t]); + const categories = useMemo(() => { + if (!data || !frequentlyUsedIds.length) return baseCategories; + const frequent: EmojiPickerCategory = { + emojis: frequentlyUsedIds.map((id) => data.emojis[id]).filter(Boolean), + id: 'frequent', + label: t(EMOJI_CATEGORY_META.frequent.labelKey), + }; + return frequent.emojis.length ? [frequent, ...baseCategories] : baseCategories; + }, [baseCategories, data, frequentlyUsedIds, t]); + const searchIndex = useMemo(() => (data ? buildEmojiSearchData(data) : []), [data]); // `null` when not searching; otherwise the (possibly empty) list of matches. @@ -79,14 +117,15 @@ export const EmojiPickerPanel = ({ (emoji: EmojiDataEmoji) => { const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? ''; if (!native) return; + recordUse(emoji.id); onEmojiSelect({ id: emoji.id, name: emoji.name, native }); }, - [onEmojiSelect], + [onEmojiSelect, recordUse, skinToneIndex], ); const contextValue = useMemo( () => ({ onSelectEmoji, setPreviewedEmoji, skinToneIndex }), - [onSelectEmoji], + [onSelectEmoji, skinToneIndex], ); const handleNavigate = useCallback((categoryId: string) => { @@ -138,7 +177,10 @@ export const EmojiPickerPanel = ({ )}
- +
+ + +
) : (
diff --git a/src/plugins/Emojis/components/SkinToneSelector.tsx b/src/plugins/Emojis/components/SkinToneSelector.tsx new file mode 100644 index 0000000000..d4fc9349a1 --- /dev/null +++ b/src/plugins/Emojis/components/SkinToneSelector.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import clsx from 'clsx'; +import { SKIN_TONES } from './skinTones'; +import { useTranslationContext } from '../../../context'; + +export type SkinToneSelectorProps = { + onSelect: (skinToneIndex: number) => void; + skinToneIndex: number; +}; + +/** + * Skin-tone picker rendered in the footer. Collapsed to the active tone; expands to + * a radiogroup of all tones on activation. + */ +export const SkinToneSelector = ({ onSelect, skinToneIndex }: SkinToneSelectorProps) => { + const { t } = useTranslationContext('EmojiPickerSkinTone'); + const [expanded, setExpanded] = useState(false); + const activeTone = SKIN_TONES[skinToneIndex] ?? SKIN_TONES[0]; + + if (!expanded) { + return ( + + ); + } + + return ( +
+ {SKIN_TONES.map((tone, index) => ( + + ))} +
+ ); +}; diff --git a/src/plugins/Emojis/components/skinTones.ts b/src/plugins/Emojis/components/skinTones.ts new file mode 100644 index 0000000000..68838fd241 --- /dev/null +++ b/src/plugins/Emojis/components/skinTones.ts @@ -0,0 +1,13 @@ +// Skin-tone swatches for the picker. Index 0 is the default (no modifier); 1โ€“5 are +// light โ†’ dark. The glyph is a hand emoji carrying the matching Fitzpatrick skin +// tone modifier, mirroring emoji-mart's selector. `labelKey` is translated via t(). +export const SKIN_TONES: Array<{ glyph: string; labelKey: string }> = [ + { glyph: 'โœ‹', labelKey: 'Default' }, + { glyph: 'โœ‹๐Ÿป', labelKey: 'Light' }, + { glyph: 'โœ‹๐Ÿผ', labelKey: 'Medium-Light' }, + { glyph: 'โœ‹๐Ÿฝ', labelKey: 'Medium' }, + { glyph: 'โœ‹๐Ÿพ', labelKey: 'Medium-Dark' }, + { glyph: 'โœ‹๐Ÿฟ', labelKey: 'Dark' }, +]; + +export const MAX_SKIN_TONE_INDEX = SKIN_TONES.length - 1; diff --git a/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts b/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts new file mode 100644 index 0000000000..fd03a7a2e3 --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts @@ -0,0 +1,26 @@ +import { act, renderHook } from '@testing-library/react'; +import { useFrequentlyUsedEmoji } from '../useFrequentlyUsedEmoji'; + +describe('useFrequentlyUsedEmoji', () => { + it('tracks recents most-recent-first and de-duplicates (uncontrolled)', () => { + const { result } = renderHook(() => useFrequentlyUsedEmoji({})); + act(() => result.current.recordUse('a')); + act(() => result.current.recordUse('b')); + act(() => result.current.recordUse('a')); + expect(result.current.frequentlyUsedIds).toEqual(['a', 'b']); + }); + + it('notifies via onFrequentlyUsedChange and stays controlled', () => { + const onFrequentlyUsedChange = vi.fn(); + const { rerender, result } = renderHook( + ({ frequentlyUsedEmoji }) => + useFrequentlyUsedEmoji({ frequentlyUsedEmoji, onFrequentlyUsedChange }), + { initialProps: { frequentlyUsedEmoji: ['x'] } }, + ); + act(() => result.current.recordUse('y')); + expect(onFrequentlyUsedChange).toHaveBeenCalledWith(['y', 'x']); + expect(result.current.frequentlyUsedIds).toEqual(['x']); // controlled: reflects prop only + rerender({ frequentlyUsedEmoji: ['y', 'x'] }); + expect(result.current.frequentlyUsedIds).toEqual(['y', 'x']); + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/useSkinTone.test.ts b/src/plugins/Emojis/hooks/__tests__/useSkinTone.test.ts new file mode 100644 index 0000000000..adc4f510ef --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useSkinTone.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; +import { useSkinTone } from '../useSkinTone'; + +describe('useSkinTone', () => { + it('is uncontrolled from defaultSkinTone and updates internally', () => { + const { result } = renderHook(() => useSkinTone({ defaultSkinTone: 2 })); + expect(result.current[0]).toBe(2); + act(() => result.current[1](4)); + expect(result.current[0]).toBe(4); + }); + + it('clamps out-of-range values to 0..5', () => { + const { result } = renderHook(() => useSkinTone({ defaultSkinTone: 99 })); + expect(result.current[0]).toBe(5); + act(() => result.current[1](-3)); + expect(result.current[0]).toBe(0); + }); + + it('is controlled when skinTone is provided and does not self-update', () => { + const onSkinToneChange = vi.fn(); + const { rerender, result } = renderHook( + ({ skinTone }) => useSkinTone({ onSkinToneChange, skinTone }), + { initialProps: { skinTone: 1 } }, + ); + expect(result.current[0]).toBe(1); + act(() => result.current[1](3)); + expect(onSkinToneChange).toHaveBeenCalledWith(3); + expect(result.current[0]).toBe(1); // stays until the controlled prop changes + rerender({ skinTone: 3 }); + expect(result.current[0]).toBe(3); + }); +}); diff --git a/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts b/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts new file mode 100644 index 0000000000..0cd961042f --- /dev/null +++ b/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react'; + +export type UseFrequentlyUsedEmojiParams = { + /** Controlled ordered list of recently used emoji ids (most recent first). */ + frequentlyUsedEmoji?: string[]; + /** Called with the updated ordered list whenever an emoji is used. */ + onFrequentlyUsedChange?: (emojiIds: string[]) => void; +}; + +const MAX_FREQUENTLY_USED = 24; + +/** + * Tracks recently used emoji as an ordered, most-recent-first list. Controlled via + * `frequentlyUsedEmoji`/`onFrequentlyUsedChange`; otherwise kept ephemerally in + * memory for the current mount (reset on reload). The SDK never persists โ€” see the + * vite example for a localStorage-backed integrator pattern. + */ +export const useFrequentlyUsedEmoji = ({ + frequentlyUsedEmoji, + onFrequentlyUsedChange, +}: UseFrequentlyUsedEmojiParams) => { + const [internal, setInternal] = useState([]); + const isControlled = Array.isArray(frequentlyUsedEmoji); + const frequentlyUsedIds = isControlled ? frequentlyUsedEmoji : internal; + + const recordUse = useCallback( + (emojiId: string) => { + const next = [ + emojiId, + ...frequentlyUsedIds.filter((existing) => existing !== emojiId), + ].slice(0, MAX_FREQUENTLY_USED); + if (!isControlled) setInternal(next); + onFrequentlyUsedChange?.(next); + }, + [frequentlyUsedIds, isControlled, onFrequentlyUsedChange], + ); + + return { frequentlyUsedIds, recordUse }; +}; diff --git a/src/plugins/Emojis/hooks/useSkinTone.ts b/src/plugins/Emojis/hooks/useSkinTone.ts new file mode 100644 index 0000000000..b06b2c7e20 --- /dev/null +++ b/src/plugins/Emojis/hooks/useSkinTone.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react'; +import { MAX_SKIN_TONE_INDEX } from '../components/skinTones'; + +export type UseSkinToneParams = { + /** Uncontrolled initial skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ + defaultSkinTone?: number; + /** Called with the new skin tone index whenever it changes. */ + onSkinToneChange?: (skinTone: number) => void; + /** Controlled skin tone index. When provided, the picker does not hold its own. */ + skinTone?: number; +}; + +const clamp = (value: number) => + Math.min(MAX_SKIN_TONE_INDEX, Math.max(0, Math.floor(value))); + +/** + * Controlled-or-uncontrolled skin tone selection. The SDK never persists the value + * โ€” integrators own persistence by controlling `skinTone`/`onSkinToneChange`. + */ +export const useSkinTone = ({ + defaultSkinTone, + onSkinToneChange, + skinTone, +}: UseSkinToneParams) => { + const [internal, setInternal] = useState(() => clamp(defaultSkinTone ?? 0)); + const isControlled = typeof skinTone === 'number'; + const value = clamp(isControlled ? skinTone : internal); + + const setSkinTone = useCallback( + (next: number) => { + const clamped = clamp(next); + if (!isControlled) setInternal(clamped); + onSkinToneChange?.(clamped); + }, + [isControlled, onSkinToneChange], + ); + + return [value, setSkinTone] as const; +}; diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index f19db6f319..0b91e6e64b 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -164,13 +164,21 @@ $emoji-picker-border-radius: 12px; } } - &__preview { + &__footer { display: flex; align-items: center; gap: var(--str-chat__spacing-sm); min-block-size: 2.75rem; padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm); border-block-start: 1px solid var(--str-chat__emoji-picker-border-color); + } + + &__preview { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: var(--str-chat__spacing-sm); + min-inline-size: 0; &-emoji { font-size: 1.5rem; @@ -178,11 +186,47 @@ $emoji-picker-border-radius: 12px; } &-name { + overflow: hidden; color: var(--str-chat__emoji-picker-secondary-text-color); + white-space: nowrap; + text-overflow: ellipsis; text-transform: capitalize; } } + &__skin-tones { + display: flex; + flex: none; + align-items: center; + gap: var(--str-chat__spacing-xxs); + } + + &__skin-tone-toggle, + &__skin-tone { + @include utils.button-reset; + cursor: pointer; + display: flex; + flex: none; + align-items: center; + justify-content: center; + padding: var(--str-chat__spacing-xxs); + border-radius: var(--str-chat__radius-4); + font-size: 1.25rem; + line-height: 1; + + &:hover { + background-color: var(--str-chat__emoji-picker-hover-background-color); + } + + &:focus-visible { + @include utils.focusable; + } + } + + &__skin-tone--active { + background-color: var(--str-chat__emoji-picker-selected-background-color); + } + &__loading { flex: 1 1 auto; min-block-size: 12rem; From 3fd1f0d4d6a470e8f427f5956d47951f386e8402 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 14:54:09 +0200 Subject: [PATCH 06/36] perf(emojis): virtualize the emoji grid by category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the category-grouped grid with react-virtuoso (already a dependency), virtualizing at the category level so only categories in/near the viewport mount โ€” keeping picker open fast without giving up section headers, scroll-spy (rangeChanged) or per-category scrolling (scrollToIndex via an imperative scrollToCategory handle). Search results remain a small, non-virtualized grid. --- src/plugins/Emojis/components/EmojiGrid.tsx | 79 +++++++++++++------ .../Emojis/components/EmojiPickerPanel.tsx | 33 ++++---- src/plugins/Emojis/styling/EmojiPicker.scss | 9 ++- 3 files changed, 81 insertions(+), 40 deletions(-) diff --git a/src/plugins/Emojis/components/EmojiGrid.tsx b/src/plugins/Emojis/components/EmojiGrid.tsx index 6723f924b4..b0994521ef 100644 --- a/src/plugins/Emojis/components/EmojiGrid.tsx +++ b/src/plugins/Emojis/components/EmojiGrid.tsx @@ -1,4 +1,5 @@ -import { memo } from 'react'; +import { forwardRef, useImperativeHandle, useRef } from 'react'; +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { EmojiButton } from './EmojiButton'; import type { EmojiDataEmoji } from '../data'; @@ -8,34 +9,66 @@ export type EmojiPickerCategory = { label: string; }; +export type EmojiGridHandle = { + scrollToCategory: (categoryId: string) => void; +}; + export type EmojiGridProps = { categories: EmojiPickerCategory[]; + onActiveCategoryChange?: (categoryId: string) => void; }; +const CategorySection = ({ category }: { category: EmojiPickerCategory }) => ( +
+
+ {category.label} +
+
+ {category.emojis.map((emoji) => ( + + ))} +
+
+); + /** - * Renders every category as a labelled section of emoji cells. Non-virtualized โ€” - * virtualization is layered on in a later phase behind this same category model. + * The category-grouped emoji grid. Virtualized at the category level with + * react-virtuoso: only the categories in (and near) the viewport are mounted, which + * keeps opening the picker fast without giving up section headers, scroll-spy, or + * per-category scrolling. `scrollToCategory` is exposed imperatively for the nav. */ -export const EmojiGrid = memo(function EmojiGrid({ categories }: EmojiGridProps) { +export const EmojiGrid = forwardRef(function EmojiGrid( + { categories, onActiveCategoryChange }, + ref, +) { + const virtuosoRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + scrollToCategory: (categoryId: string) => { + const index = categories.findIndex((category) => category.id === categoryId); + if (index >= 0) virtuosoRef.current?.scrollToIndex({ align: 'start', index }); + }, + }), + [categories], + ); + return ( -
- {categories.map((category) => ( -
-
- {category.label} -
-
- {category.emojis.map((emoji) => ( - - ))} -
-
- ))} -
+ } + rangeChanged={({ startIndex }) => { + const category = categories[startIndex]; + if (category) onActiveCategoryChange?.(category.id); + }} + ref={virtuosoRef} + style={{ height: '100%' }} + /> ); }); diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index 9ae950b983..a96fb49f11 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -2,7 +2,7 @@ import { type CSSProperties, useCallback, useMemo, useRef, useState } from 'reac import clsx from 'clsx'; import { CategoryNav } from './CategoryNav'; import { EmojiButton } from './EmojiButton'; -import { EmojiGrid, type EmojiPickerCategory } from './EmojiGrid'; +import { EmojiGrid, type EmojiGridHandle, type EmojiPickerCategory } from './EmojiGrid'; import { EMOJI_CATEGORY_META } from './categories'; import { EmptyResults } from './EmptyResults'; import { PreviewPane } from './PreviewPane'; @@ -81,7 +81,7 @@ export const EmojiPickerPanel = ({ const [previewedEmoji, setPreviewedEmoji] = useState(null); const [activeCategoryId, setActiveCategoryId] = useState(undefined); const [query, setQuery] = useState(''); - const gridContainerRef = useRef(null); + const emojiGridRef = useRef(null); const baseCategories = useMemo(() => { if (!data) return []; @@ -131,11 +131,9 @@ export const EmojiPickerPanel = ({ const handleNavigate = useCallback((categoryId: string) => { setQuery(''); // navigating a category exits search setActiveCategoryId(categoryId); - // Defer so the category view has re-rendered before we scroll to the section. + // Defer so the (virtualized) category view has mounted before we scroll to it. requestAnimationFrame(() => { - gridContainerRef.current - ?.querySelector(`[data-category-id="${categoryId}"]`) - ?.scrollIntoView({ block: 'start' }); + emojiGridRef.current?.scrollToCategory(categoryId); }); }, []); @@ -157,24 +155,27 @@ export const EmojiPickerPanel = ({ categories={categories} onNavigate={handleNavigate} /> -
+
{isSearching ? ( searchedEmojis.length ? ( -
-
- {searchedEmojis.map((emoji) => ( - - ))} +
+
+
+ {searchedEmojis.map((emoji) => ( + + ))} +
) : ( ) ) : ( - + )}
diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index 0b91e6e64b..f4028865b3 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -114,13 +114,20 @@ $emoji-picker-border-radius: 12px; } } + &__body { + flex: 1 1 auto; + min-block-size: 0; + } + &__grid-container { @include utils.scrollable-y; - flex: 1 1 auto; + block-size: 100%; padding-inline: var(--str-chat__spacing-xs); } &__category { + padding-inline: var(--str-chat__spacing-xs); + &-label { position: sticky; inset-block-start: 0; From 61f0ac9f5d873a4be36751f99bc4330cc35f5d1b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 15:00:22 +0200 Subject: [PATCH 07/36] feat(emojis): keyboard navigation and accessibility for the picker - Escape closes the picker and returns focus to the toggle button (onClose) - the search input receives focus on open; ArrowDown moves into the grid - 2D roving-tabindex grid navigation (useGridKeyboardNav): Left/Right in reading order, Up/Down to the geometrically nearest cell on the adjacent row (robust across category headers and virtualization) - category tabs get roving Left/Right/Home/End navigation - roles/labels: dialog, tablist/tab, grid/row/gridcell, radiogroup skin tones, aria-live preview --- src/plugins/Emojis/EmojiPicker.tsx | 4 + src/plugins/Emojis/components/CategoryNav.tsx | 75 +++++++++---- .../Emojis/components/EmojiPickerPanel.tsx | 20 +++- src/plugins/Emojis/components/SearchInput.tsx | 19 +++- .../Emojis/hooks/useGridKeyboardNav.ts | 106 ++++++++++++++++++ 5 files changed, 199 insertions(+), 25 deletions(-) create mode 100644 src/plugins/Emojis/hooks/useGridKeyboardNav.ts diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index ea1d472b3d..6454582287 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -114,6 +114,10 @@ export const EmojiPicker = (props: EmojiPickerProps) => { { + setDisplayPicker(false); + referenceElement?.focus(); + }} onEmojiSelect={(emoji) => { const textarea = textareaRef.current; if (!textarea) return; diff --git a/src/plugins/Emojis/components/CategoryNav.tsx b/src/plugins/Emojis/components/CategoryNav.tsx index 0d7bb6a437..cea8cd3be8 100644 --- a/src/plugins/Emojis/components/CategoryNav.tsx +++ b/src/plugins/Emojis/components/CategoryNav.tsx @@ -1,3 +1,4 @@ +import { type KeyboardEvent, useRef } from 'react'; import clsx from 'clsx'; import { EMOJI_CATEGORY_META } from './categories'; import type { EmojiPickerCategory } from './EmojiGrid'; @@ -8,30 +9,62 @@ export type CategoryNavProps = { activeCategoryId?: string; }; +const NAV_KEYS = ['ArrowRight', 'ArrowLeft', 'Home', 'End']; + /** - * Top navigation bar with one tab per category. Clicking a tab scrolls its section - * into view; the active tab reflects the currently visible section. + * Top navigation bar with one tab per category (role="tablist"). Clicking a tab + * scrolls its section into view; Left/Right/Home/End move focus between tabs with a + * roving tabindex; the active tab reflects the currently visible section. */ export const CategoryNav = ({ activeCategoryId, categories, onNavigate, -}: CategoryNavProps) => ( -
- {categories.map(({ id, label }) => ( - - ))} -
-); +}: CategoryNavProps) => { + const navRef = useRef(null); + const rovingId = activeCategoryId ?? categories[0]?.id; + + const onKeyDown = (event: KeyboardEvent) => { + if (!NAV_KEYS.includes(event.key)) return; + const tabs = Array.from( + navRef.current?.querySelectorAll('[role="tab"]') ?? [], + ); + const index = tabs.findIndex((tab) => tab === document.activeElement); + if (index === -1) return; + event.preventDefault(); + const lastIndex = tabs.length - 1; + let next = index; + if (event.key === 'ArrowRight') next = Math.min(index + 1, lastIndex); + else if (event.key === 'ArrowLeft') next = Math.max(index - 1, 0); + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = lastIndex; + tabs[next]?.focus(); + }; + + return ( +
+ {categories.map(({ id, label }) => ( + + ))} +
+ ); +}; diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index a96fb49f11..ce09e3bbf3 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -14,6 +14,7 @@ import { } from '../context/EmojiPickerContext'; import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; import { useFrequentlyUsedEmoji } from '../hooks/useFrequentlyUsedEmoji'; +import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav'; import { useSkinTone } from '../hooks/useSkinTone'; import { buildEmojiSearchData, runSearch } from '../search'; import type { EmojiDataEmoji } from '../data'; @@ -30,6 +31,8 @@ export type EmojiPickerPanelProps = { className?: string; /** Uncontrolled initial skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ defaultSkinTone?: number; + /** Called when the panel requests to close (e.g. the Escape key). */ + onClose?: () => void; /** Controlled ordered list of recently used emoji ids (most recent first). */ frequentlyUsedEmoji?: string[]; /** Called with the updated recently-used list when an emoji is selected. */ @@ -60,6 +63,7 @@ export const EmojiPickerPanel = ({ className, defaultSkinTone, frequentlyUsedEmoji, + onClose, onEmojiSelect, onFrequentlyUsedChange, onSkinToneChange, @@ -82,6 +86,8 @@ export const EmojiPickerPanel = ({ const [activeCategoryId, setActiveCategoryId] = useState(undefined); const [query, setQuery] = useState(''); const emojiGridRef = useRef(null); + const bodyRef = useRef(null); + const { focusFirst, onKeyDown: onGridKeyDown } = useGridKeyboardNav(bodyRef); const baseCategories = useMemo(() => { if (!data) return []; @@ -144,18 +150,28 @@ export const EmojiPickerPanel = ({
{ + if (event.key === 'Escape') { + event.stopPropagation(); + onClose?.(); + } + }} role='dialog' style={style} > {data ? ( <> - + -
+
{isSearching ? ( searchedEmojis.length ? (
diff --git a/src/plugins/Emojis/components/SearchInput.tsx b/src/plugins/Emojis/components/SearchInput.tsx index 61a1531591..d6e766117b 100644 --- a/src/plugins/Emojis/components/SearchInput.tsx +++ b/src/plugins/Emojis/components/SearchInput.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { Button, IconSearch, IconXCircle, VisuallyHidden } from '../../../components'; import { useStableId } from '../../../components/UtilityComponents/useStableId'; import { useTranslationContext } from '../../../context'; @@ -5,15 +6,22 @@ import { useTranslationContext } from '../../../context'; export type SearchInputProps = { onChange: (value: string) => void; value: string; + /** Called when ArrowDown is pressed, to move focus into the emoji grid. */ + onArrowDown?: () => void; }; /** * Search box for the emoji picker. Mirrors the SDK's SearchBar structure (icon + - * labelled input + clear button). + * labelled input + clear button) and receives focus when the picker opens. */ -export const SearchInput = ({ onChange, value }: SearchInputProps) => { +export const SearchInput = ({ onArrowDown, onChange, value }: SearchInputProps) => { const { t } = useTranslationContext('EmojiPickerSearchInput'); const inputId = useStableId(); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); return (
@@ -26,7 +34,14 @@ export const SearchInput = ({ onChange, value }: SearchInputProps) => { className='str-chat__emoji-picker__search-input' id={inputId} onChange={(event) => onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + onArrowDown?.(); + } + }} placeholder={t('Search emoji')} + ref={inputRef} type='text' value={value} /> diff --git a/src/plugins/Emojis/hooks/useGridKeyboardNav.ts b/src/plugins/Emojis/hooks/useGridKeyboardNav.ts new file mode 100644 index 0000000000..f0da1dfa01 --- /dev/null +++ b/src/plugins/Emojis/hooks/useGridKeyboardNav.ts @@ -0,0 +1,106 @@ +import { type KeyboardEvent, useCallback, useEffect } from 'react'; + +type GridRef = { readonly current: HTMLElement | null }; + +const EMOJI_SELECTOR = '.str-chat__emoji-picker__emoji'; +const NAV_KEYS = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End']; + +/** + * Roving-tabindex keyboard navigation for the emoji grid. Left/Right move in reading + * order; Up/Down pick the geometrically nearest cell on the adjacent row (robust + * across category headers and virtualization, which a fixed column count is not). + * Operates on the currently rendered cells; focusing scrolls the next ones in. + */ +export const useGridKeyboardNav = (gridRef: GridRef) => { + const getButtons = useCallback( + () => + Array.from( + gridRef.current?.querySelectorAll(EMOJI_SELECTOR) ?? [], + ), + [gridRef], + ); + + const setRoving = useCallback( + (target: HTMLButtonElement) => { + for (const button of getButtons()) { + button.tabIndex = button === target ? 0 : -1; + } + }, + [getButtons], + ); + + // Keep exactly one cell tab-reachable across (virtualized) re-renders. + useEffect(() => { + const buttons = getButtons(); + if (buttons.length && !buttons.some((button) => button.tabIndex === 0)) { + buttons[0].tabIndex = 0; + } + }); + + const focusButton = useCallback( + (button: HTMLButtonElement | undefined) => { + if (!button) return; + setRoving(button); + button.focus(); + button.scrollIntoView({ block: 'nearest' }); + }, + [setRoving], + ); + + const focusFirst = useCallback(() => { + focusButton(getButtons()[0]); + }, [focusButton, getButtons]); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!NAV_KEYS.includes(event.key)) return; + const buttons = getButtons(); + const index = buttons.findIndex((button) => button === document.activeElement); + if (index === -1) return; + event.preventDefault(); + + if (event.key === 'Home') { + focusButton(buttons[0]); + return; + } + if (event.key === 'End') { + focusButton(buttons[buttons.length - 1]); + return; + } + if (event.key === 'ArrowRight') { + focusButton(buttons[Math.min(index + 1, buttons.length - 1)]); + return; + } + if (event.key === 'ArrowLeft') { + focusButton(buttons[Math.max(index - 1, 0)]); + return; + } + + // ArrowUp / ArrowDown โ€” nearest cell on the adjacent row, matched by center-x. + const current = buttons[index].getBoundingClientRect(); + const centerX = current.left + current.width / 2; + const centerY = current.top + current.height / 2; + const goingDown = event.key === 'ArrowDown'; + + let best: HTMLButtonElement | undefined; + let bestScore = Infinity; + for (const button of buttons) { + if (button === buttons[index]) continue; + const rect = button.getBoundingClientRect(); + const dy = rect.top + rect.height / 2 - centerY; + // Skip cells on the same row or the wrong direction. + if (goingDown ? dy <= current.height / 2 : dy >= -current.height / 2) continue; + const dx = Math.abs(rect.left + rect.width / 2 - centerX); + const score = dx + Math.abs(dy) * 2; // prefer same column, then nearest row + if (score < bestScore) { + bestScore = score; + best = button; + } + } + focusButton(best ?? buttons[index]); + }, + [focusButton, getButtons], + ); + + return { focusFirst, onKeyDown }; +}; From 1ae32059de7d1738da317de2eb93c033b201b1fa Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 15:05:11 +0200 Subject: [PATCH 08/36] feat(emojis): add emoji picker i18n strings for all 12 locales Add translations for the picker's search placeholder, clear/skin-tone aria labels, empty state, the eight category labels + "Frequently used", and the six skin-tone labels across de/en/es/fr/hi/it/ja/ko/nl/pt/ru/tr. Category and skin-tone labels are looked up via t() with dynamic keys, so they are declared directly here rather than via extraction. validate-translations passes. --- src/i18n/de.json | 21 ++++++++++++++++++++- src/i18n/en.json | 21 ++++++++++++++++++++- src/i18n/es.json | 21 ++++++++++++++++++++- src/i18n/fr.json | 21 ++++++++++++++++++++- src/i18n/hi.json | 21 ++++++++++++++++++++- src/i18n/it.json | 21 ++++++++++++++++++++- src/i18n/ja.json | 21 ++++++++++++++++++++- src/i18n/ko.json | 21 ++++++++++++++++++++- src/i18n/nl.json | 21 ++++++++++++++++++++- src/i18n/pt.json | 21 ++++++++++++++++++++- src/i18n/ru.json | 21 ++++++++++++++++++++- src/i18n/tr.json | 21 ++++++++++++++++++++- 12 files changed, 240 insertions(+), 12 deletions(-) diff --git a/src/i18n/de.json b/src/i18n/de.json index 438a858a08..b084f0269a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -628,5 +628,24 @@ "Wait until all attachments have uploaded": "Bitte warten, bis alle Anhรคnge hochgeladen wurden", "Waiting for networkโ€ฆ": "Warte auf Netzwerkโ€ฆ", "You": "Du", - "You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht" + "You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht", + "Search emoji": "Emoji suchen", + "aria/Clear emoji search": "Emoji-Suche lรถschen", + "No emoji found": "Keine Emojis gefunden", + "aria/Choose default skin tone": "Standard-Hautton wรคhlen", + "Frequently used": "Hรคufig verwendet", + "Smileys & People": "Smileys & Personen", + "Animals & Nature": "Tiere & Natur", + "Food & Drink": "Essen & Trinken", + "Activities": "Aktivitรคten", + "Travel & Places": "Reisen & Orte", + "Objects": "Objekte", + "Symbols": "Symbole", + "Flags": "Flaggen", + "Default": "Standard", + "Light": "Hell", + "Medium-Light": "Mittelhell", + "Medium": "Mittel", + "Medium-Dark": "Mitteldunkel", + "Dark": "Dunkel" } diff --git a/src/i18n/en.json b/src/i18n/en.json index 8e5c6f4427..e223b17934 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -628,5 +628,24 @@ "Wait until all attachments have uploaded": "Wait until all attachments have uploaded", "Waiting for networkโ€ฆ": "Waiting for networkโ€ฆ", "You": "You", - "You've reached the maximum number of files": "You've reached the maximum number of files" + "You've reached the maximum number of files": "You've reached the maximum number of files", + "Search emoji": "Search emoji", + "aria/Clear emoji search": "aria/Clear emoji search", + "No emoji found": "No emoji found", + "aria/Choose default skin tone": "aria/Choose default skin tone", + "Frequently used": "Frequently used", + "Smileys & People": "Smileys & People", + "Animals & Nature": "Animals & Nature", + "Food & Drink": "Food & Drink", + "Activities": "Activities", + "Travel & Places": "Travel & Places", + "Objects": "Objects", + "Symbols": "Symbols", + "Flags": "Flags", + "Default": "Default", + "Light": "Light", + "Medium-Light": "Medium-Light", + "Medium": "Medium", + "Medium-Dark": "Medium-Dark", + "Dark": "Dark" } diff --git a/src/i18n/es.json b/src/i18n/es.json index 7c0d7e176d..7ff537d67d 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -652,5 +652,24 @@ "Wait until all attachments have uploaded": "Espere hasta que se hayan cargado todos los archivos adjuntos", "Waiting for networkโ€ฆ": "Esperando redโ€ฆ", "You": "Tรบ", - "You've reached the maximum number of files": "Has alcanzado el nรบmero mรกximo de archivos" + "You've reached the maximum number of files": "Has alcanzado el nรบmero mรกximo de archivos", + "Search emoji": "Buscar emoji", + "aria/Clear emoji search": "Borrar bรบsqueda de emojis", + "No emoji found": "No se encontraron emojis", + "aria/Choose default skin tone": "Elegir tono de piel predeterminado", + "Frequently used": "Usados frecuentemente", + "Smileys & People": "Emoticonos y personas", + "Animals & Nature": "Animales y naturaleza", + "Food & Drink": "Comida y bebida", + "Activities": "Actividades", + "Travel & Places": "Viajes y lugares", + "Objects": "Objetos", + "Symbols": "Sรญmbolos", + "Flags": "Banderas", + "Default": "Predeterminado", + "Light": "Claro", + "Medium-Light": "Medio claro", + "Medium": "Medio", + "Medium-Dark": "Medio oscuro", + "Dark": "Oscuro" } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 62b88faf9d..30394da477 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -652,5 +652,24 @@ "Wait until all attachments have uploaded": "Attendez que toutes les piรจces jointes soient tรฉlรฉchargรฉes", "Waiting for networkโ€ฆ": "En attente du rรฉseauโ€ฆ", "You": "Vous", - "You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers" + "You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers", + "Search emoji": "Rechercher un emoji", + "aria/Clear emoji search": "Effacer la recherche d'emoji", + "No emoji found": "Aucun emoji trouvรฉ", + "aria/Choose default skin tone": "Choisir le teint par dรฉfaut", + "Frequently used": "Frรฉquemment utilisรฉs", + "Smileys & People": "ร‰moticรดnes et personnes", + "Animals & Nature": "Animaux et nature", + "Food & Drink": "Nourriture et boissons", + "Activities": "Activitรฉs", + "Travel & Places": "Voyages et lieux", + "Objects": "Objets", + "Symbols": "Symboles", + "Flags": "Drapeaux", + "Default": "Par dรฉfaut", + "Light": "Clair", + "Medium-Light": "Moyennement clair", + "Medium": "Moyen", + "Medium-Dark": "Moyennement foncรฉ", + "Dark": "Foncรฉ" } diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 839d362181..c69903f0e1 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -629,5 +629,24 @@ "Wait until all attachments have uploaded": "เคธเคญเฅ€ เค…เคŸเฅˆเคšเคฎเฅ‡เค‚เคŸ เค…เคชเคฒเฅ‹เคก เคนเฅ‹เคจเฅ‡ เคคเค• เคชเฅเคฐเคคเฅ€เค•เฅเคทเคพ เค•เคฐเฅ‡เค‚", "Waiting for networkโ€ฆ": "เคจเฅ‡เคŸเคตเคฐเฅเค• เค•เฅ€ เคชเฅเคฐเคคเฅ€เค•เฅเคทเคพโ€ฆ", "You": "เค†เคช", - "You've reached the maximum number of files": "เค†เคช เค…เคงเคฟเค•เคคเคฎ เคซเคผเคพเค‡เคฒเฅ‹เค‚ เคคเค• เคชเคนเฅเคเคš เค—เค เคนเฅˆเค‚" + "You've reached the maximum number of files": "เค†เคช เค…เคงเคฟเค•เคคเคฎ เคซเคผเคพเค‡เคฒเฅ‹เค‚ เคคเค• เคชเคนเฅเคเคš เค—เค เคนเฅˆเค‚", + "Search emoji": "เค‡เคฎเฅ‹เคœเฅ€ เค–เฅ‹เคœเฅ‡เค‚", + "aria/Clear emoji search": "เค‡เคฎเฅ‹เคœเฅ€ เค–เฅ‹เคœ เคธเคพเคซเคผ เค•เคฐเฅ‡เค‚", + "No emoji found": "เค•เฅ‹เคˆ เค‡เคฎเฅ‹เคœเฅ€ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", + "aria/Choose default skin tone": "เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ เคคเฅเคตเคšเคพ เคŸเฅ‹เคจ เคšเฅเคจเฅ‡เค‚", + "Frequently used": "เค…เค•เฅเคธเคฐ เค‰เคชเคฏเฅ‹เค— เค•เคฟเค เค—เค", + "Smileys & People": "เคธเฅเคฎเคพเค‡เคฒเฅ€ เค”เคฐ เคฒเฅ‹เค—", + "Animals & Nature": "เคœเคพเคจเคตเคฐ เค”เคฐ เคชเฅเคฐเค•เฅƒเคคเคฟ", + "Food & Drink": "เค–เคพเคจเคพ เค”เคฐ เคชเฅ‡เคฏ", + "Activities": "เค—เคคเคฟเคตเคฟเคงเคฟเคฏเคพเค", + "Travel & Places": "เคฏเคพเคคเฅเคฐเคพ เค”เคฐ เคธเฅเคฅเคพเคจ", + "Objects": "เคตเคธเฅเคคเฅเคเค", + "Symbols": "เคชเฅเคฐเคคเฅ€เค•", + "Flags": "เคเค‚เคกเฅ‡", + "Default": "เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ", + "Light": "เคนเคฒเฅเค•เคพ", + "Medium-Light": "เคฎเคงเฅเคฏเคฎ-เคนเคฒเฅเค•เคพ", + "Medium": "เคฎเคงเฅเคฏเคฎ", + "Medium-Dark": "เคฎเคงเฅเคฏเคฎ-เค—เคนเคฐเคพ", + "Dark": "เค—เคนเคฐเคพ" } diff --git a/src/i18n/it.json b/src/i18n/it.json index 385f128c0b..4d13b87e02 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -652,5 +652,24 @@ "Wait until all attachments have uploaded": "Attendi il caricamento di tutti gli allegati", "Waiting for networkโ€ฆ": "In attesa della reteโ€ฆ", "You": "Tu", - "You've reached the maximum number of files": "Hai raggiunto il numero massimo di file" + "You've reached the maximum number of files": "Hai raggiunto il numero massimo di file", + "Search emoji": "Cerca emoji", + "aria/Clear emoji search": "Cancella ricerca emoji", + "No emoji found": "Nessun emoji trovato", + "aria/Choose default skin tone": "Scegli la tonalitร  della pelle predefinita", + "Frequently used": "Usati di frequente", + "Smileys & People": "Faccine e persone", + "Animals & Nature": "Animali e natura", + "Food & Drink": "Cibo e bevande", + "Activities": "Attivitร ", + "Travel & Places": "Viaggi e luoghi", + "Objects": "Oggetti", + "Symbols": "Simboli", + "Flags": "Bandiere", + "Default": "Predefinito", + "Light": "Chiaro", + "Medium-Light": "Medio chiaro", + "Medium": "Medio", + "Medium-Dark": "Medio scuro", + "Dark": "Scuro" } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 7b0b93b481..a73af77750 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -615,5 +615,24 @@ "Wait until all attachments have uploaded": "ใ™ในใฆใฎๆทปไป˜ใƒ•ใ‚กใ‚คใƒซใŒใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ•ใ‚Œใ‚‹ใพใงใŠๅพ…ใกใใ ใ•ใ„", "Waiting for networkโ€ฆ": "ใƒใƒƒใƒˆใƒฏใƒผใ‚ฏใ‚’ๅพ…ๆฉŸไธญโ€ฆ", "You": "ใ‚ใชใŸ", - "You've reached the maximum number of files": "ใƒ•ใ‚กใ‚คใƒซใฎๆœ€ๅคงๆ•ฐใซ้”ใ—ใพใ—ใŸ" + "You've reached the maximum number of files": "ใƒ•ใ‚กใ‚คใƒซใฎๆœ€ๅคงๆ•ฐใซ้”ใ—ใพใ—ใŸ", + "Search emoji": "็ตตๆ–‡ๅญ—ใ‚’ๆคœ็ดข", + "aria/Clear emoji search": "็ตตๆ–‡ๅญ—ๆคœ็ดขใ‚’ใ‚ฏใƒชใ‚ข", + "No emoji found": "็ตตๆ–‡ๅญ—ใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", + "aria/Choose default skin tone": "ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใฎ่‚Œใฎ่‰ฒใ‚’้ธๆŠž", + "Frequently used": "ใ‚ˆใไฝฟใ†", + "Smileys & People": "ใ‚นใƒžใ‚คใƒชใƒผใจไบบใ€…", + "Animals & Nature": "ๅ‹•็‰ฉใจ่‡ช็„ถ", + "Food & Drink": "้ฃŸใน็‰ฉใจ้ฃฒใฟ็‰ฉ", + "Activities": "ใ‚ขใ‚ฏใƒ†ใ‚ฃใƒ“ใƒ†ใ‚ฃ", + "Travel & Places": "ๆ—…่กŒใจๅ ดๆ‰€", + "Objects": "็‰ฉ", + "Symbols": "่จ˜ๅท", + "Flags": "ๆ——", + "Default": "ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆ", + "Light": "ๆ˜Žใ‚‹ใ„", + "Medium-Light": "ใ‚„ใ‚„ๆ˜Žใ‚‹ใ„", + "Medium": "ๆ™ฎ้€š", + "Medium-Dark": "ใ‚„ใ‚„ๆš—ใ„", + "Dark": "ๆš—ใ„" } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 783de931e3..1b8ff7b285 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -615,5 +615,24 @@ "Wait until all attachments have uploaded": "๋ชจ๋“  ์ฒจ๋ถ€ ํŒŒ์ผ์ด ์—…๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฝ๋‹ˆ๋‹ค.", "Waiting for networkโ€ฆ": "๋„คํŠธ์›Œํฌ ๋Œ€๊ธฐ ์ค‘โ€ฆ", "You": "๋‹น์‹ ", - "You've reached the maximum number of files": "์ตœ๋Œ€ ํŒŒ์ผ ์ˆ˜์— ๋„๋‹ฌํ–ˆ์Šต๋‹ˆ๋‹ค." + "You've reached the maximum number of files": "์ตœ๋Œ€ ํŒŒ์ผ ์ˆ˜์— ๋„๋‹ฌํ–ˆ์Šต๋‹ˆ๋‹ค.", + "Search emoji": "์ด๋ชจ์ง€ ๊ฒ€์ƒ‰", + "aria/Clear emoji search": "์ด๋ชจ์ง€ ๊ฒ€์ƒ‰ ์ง€์šฐ๊ธฐ", + "No emoji found": "์ด๋ชจ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", + "aria/Choose default skin tone": "๊ธฐ๋ณธ ํ”ผ๋ถ€์ƒ‰ ์„ ํƒ", + "Frequently used": "์ž์ฃผ ์‚ฌ์šฉํ•จ", + "Smileys & People": "์Šค๋งˆ์ผ๋ฆฌ & ์‚ฌ๋žŒ", + "Animals & Nature": "๋™๋ฌผ & ์ž์—ฐ", + "Food & Drink": "์Œ์‹ & ์Œ๋ฃŒ", + "Activities": "ํ™œ๋™", + "Travel & Places": "์—ฌํ–‰ & ์žฅ์†Œ", + "Objects": "์‚ฌ๋ฌผ", + "Symbols": "๊ธฐํ˜ธ", + "Flags": "๊นƒ๋ฐœ", + "Default": "๊ธฐ๋ณธ", + "Light": "๋ฐ์Œ", + "Medium-Light": "์ค‘๊ฐ„ ๋ฐ์Œ", + "Medium": "์ค‘๊ฐ„", + "Medium-Dark": "์ค‘๊ฐ„ ์–ด๋‘์›€", + "Dark": "์–ด๋‘์›€" } diff --git a/src/i18n/nl.json b/src/i18n/nl.json index e686c72b78..04f9ccc668 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -630,5 +630,24 @@ "Wait until all attachments have uploaded": "Wacht tot alle bijlagen zijn geรผpload", "Waiting for networkโ€ฆ": "Wachten op netwerkโ€ฆ", "You": "Jij", - "You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt" + "You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt", + "Search emoji": "Emoji zoeken", + "aria/Clear emoji search": "Emoji zoekopdracht wissen", + "No emoji found": "Geen emoji gevonden", + "aria/Choose default skin tone": "Standaard huidskleur kiezen", + "Frequently used": "Veelgebruikt", + "Smileys & People": "Smileys en mensen", + "Animals & Nature": "Dieren en natuur", + "Food & Drink": "Eten en drinken", + "Activities": "Activiteiten", + "Travel & Places": "Reizen en plaatsen", + "Objects": "Objecten", + "Symbols": "Symbolen", + "Flags": "Vlaggen", + "Default": "Standaard", + "Light": "Licht", + "Medium-Light": "Middellicht", + "Medium": "Middel", + "Medium-Dark": "Middeldonker", + "Dark": "Donker" } diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 3af6f2c1bf..81ebc10b0e 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -652,5 +652,24 @@ "Wait until all attachments have uploaded": "Espere atรฉ que todos os anexos tenham sido carregados", "Waiting for networkโ€ฆ": "Aguardando redeโ€ฆ", "You": "Vocรช", - "You've reached the maximum number of files": "Vocรช atingiu o nรบmero mรกximo de arquivos" + "You've reached the maximum number of files": "Vocรช atingiu o nรบmero mรกximo de arquivos", + "Search emoji": "Pesquisar emoji", + "aria/Clear emoji search": "Limpar pesquisa de emoji", + "No emoji found": "Nenhum emoji encontrado", + "aria/Choose default skin tone": "Escolher tom de pele padrรฃo", + "Frequently used": "Usados com frequรชncia", + "Smileys & People": "Smileys e pessoas", + "Animals & Nature": "Animais e natureza", + "Food & Drink": "Comida e bebida", + "Activities": "Atividades", + "Travel & Places": "Viagens e lugares", + "Objects": "Objetos", + "Symbols": "Sรญmbolos", + "Flags": "Bandeiras", + "Default": "Padrรฃo", + "Light": "Claro", + "Medium-Light": "Mรฉdio claro", + "Medium": "Mรฉdio", + "Medium-Dark": "Mรฉdio escuro", + "Dark": "Escuro" } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 6403121254..d6e16b21d3 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -680,5 +680,24 @@ "Wait until all attachments have uploaded": "ะŸะพะดะพะถะดะธั‚ะต, ะฟะพะบะฐ ะฒัะต ะฒะปะพะถะตะฝะธั ะทะฐะณั€ัƒะทัั‚ัั", "Waiting for networkโ€ฆ": "ะžะถะธะดะฐะฝะธะต ัะตั‚ะธโ€ฆ", "You": "ะ’ั‹", - "You've reached the maximum number of files": "ะ’ั‹ ะดะพัั‚ะธะณะปะธ ะผะฐะบัะธะผะฐะปัŒะฝะพะณะพ ะบะพะปะธั‡ะตัั‚ะฒะฐ ั„ะฐะนะปะพะฒ" + "You've reached the maximum number of files": "ะ’ั‹ ะดะพัั‚ะธะณะปะธ ะผะฐะบัะธะผะฐะปัŒะฝะพะณะพ ะบะพะปะธั‡ะตัั‚ะฒะฐ ั„ะฐะนะปะพะฒ", + "Search emoji": "ะŸะพะธัะบ ัะผะพะดะทะธ", + "aria/Clear emoji search": "ะžั‡ะธัั‚ะธั‚ัŒ ะฟะพะธัะบ ัะผะพะดะทะธ", + "No emoji found": "ะญะผะพะดะทะธ ะฝะต ะฝะฐะนะดะตะฝั‹", + "aria/Choose default skin tone": "ะ’ั‹ะฑั€ะฐั‚ัŒ ั‚ะพะฝ ะบะพะถะธ ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ", + "Frequently used": "ะงะฐัั‚ะพ ะธัะฟะพะปัŒะทัƒะตะผั‹ะต", + "Smileys & People": "ะกะผะฐะนะปะธะบะธ ะธ ะปัŽะดะธ", + "Animals & Nature": "ะ–ะธะฒะพั‚ะฝั‹ะต ะธ ะฟั€ะธั€ะพะดะฐ", + "Food & Drink": "ะ•ะดะฐ ะธ ะฝะฐะฟะธั‚ะบะธ", + "Activities": "ะะบั‚ะธะฒะฝะพัั‚ะธ", + "Travel & Places": "ะŸัƒั‚ะตัˆะตัั‚ะฒะธั ะธ ะผะตัั‚ะฐ", + "Objects": "ะžะฑัŠะตะบั‚ั‹", + "Symbols": "ะกะธะผะฒะพะปั‹", + "Flags": "ะคะปะฐะณะธ", + "Default": "ะŸะพ ัƒะผะพะปั‡ะฐะฝะธัŽ", + "Light": "ะกะฒะตั‚ะปั‹ะน", + "Medium-Light": "ะฃะผะตั€ะตะฝะฝะพ-ัะฒะตั‚ะปั‹ะน", + "Medium": "ะกั€ะตะดะฝะธะน", + "Medium-Dark": "ะฃะผะตั€ะตะฝะฝะพ-ั‚ั‘ะผะฝั‹ะน", + "Dark": "ะขั‘ะผะฝั‹ะน" } diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 4daa1bd7e4..d43c72ed48 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -628,5 +628,24 @@ "Wait until all attachments have uploaded": "Tรผm ekler yรผklenene kadar bekleyin", "Waiting for networkโ€ฆ": "AฤŸ bekleniyorโ€ฆ", "You": "Sen", - "You've reached the maximum number of files": "Maksimum dosya sayฤฑsฤฑna ulaลŸtฤฑnฤฑz" + "You've reached the maximum number of files": "Maksimum dosya sayฤฑsฤฑna ulaลŸtฤฑnฤฑz", + "Search emoji": "Emoji ara", + "aria/Clear emoji search": "Emoji aramasฤฑnฤฑ temizle", + "No emoji found": "Emoji bulunamadฤฑ", + "aria/Choose default skin tone": "Varsayฤฑlan ten rengini seรง", + "Frequently used": "Sฤฑk kullanฤฑlanlar", + "Smileys & People": "Suratlar ve ฤฐnsanlar", + "Animals & Nature": "Hayvanlar ve DoฤŸa", + "Food & Drink": "Yiyecek ve ฤฐรงecek", + "Activities": "Etkinlikler", + "Travel & Places": "Seyahat ve Yerler", + "Objects": "Nesneler", + "Symbols": "Semboller", + "Flags": "Bayraklar", + "Default": "Varsayฤฑlan", + "Light": "Aรงฤฑk", + "Medium-Light": "Orta aรงฤฑk", + "Medium": "Orta", + "Medium-Dark": "Orta koyu", + "Dark": "Koyu" } From 334f6fb3a220d506900dd01bfacef779d34b39b6 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 15:15:49 +0200 Subject: [PATCH 09/36] feat(emojis): remove emoji-mart peer dependencies Emoji support is now fully built into the stream-chat-react/emojis entry point. Drop @emoji-mart/data, @emoji-mart/react and emoji-mart from peer/optional/dev dependencies (keeping @emoji-mart/data pinned as a devDependency for the data vendoring script) and sync the lockfile. Update AI.md and the tutorial + vite examples (the vite example now demonstrates persisting skin tone and frequently-used to localStorage via the new props) and refresh the emojiSearchIndex / pickerProps docs. Verified via the production build: the index bundle references no emoji picker code or the emoji-data chunk; the dataset is a separate async chunk loaded only by the emojis entry. Migration: emoji-mart, @emoji-mart/react and @emoji-mart/data are no longer peer dependencies and init() is no longer required. Import EmojiPicker from stream-chat-react/emojis and register createTextComposerEmojiMiddleware() (no argument) for autocomplete; passing emoji-mart's SearchIndex as emojiSearchIndex still works. pickerProps beyond theme/style and emoji-mart CSS variables are no longer honored; theme: 'auto' now inherits the ancestor .str-chat__theme-* rather than prefers-color-scheme; the SDK no longer persists skin tone or frequently-used (use the new props). --- AI.md | 41 ++++++----- examples/tutorial/package.json | 2 - examples/tutorial/src/6-emoji-picker/App.tsx | 7 +- examples/tutorial/src/App.tsx | 2 +- examples/vite/package.json | 2 - examples/vite/src/App.tsx | 69 ++++++++++++++++--- package.json | 16 +---- .../MessageComposer/MessageComposer.tsx | 2 +- src/context/ComponentContext.tsx | 2 +- src/plugins/Emojis/EmojiPicker.tsx | 5 +- yarn.lock | 36 +--------- 11 files changed, 91 insertions(+), 93 deletions(-) diff --git a/AI.md b/AI.md index 0fdb9ed29d..8a1999600d 100644 --- a/AI.md +++ b/AI.md @@ -241,26 +241,29 @@ const CustomMessage = () => { **User intent**: "Add emoji picker and autocomplete" +Emoji support is built into the SDK โ€” no `emoji-mart` packages or `init()` call are +required. + **Steps**: -1. Install emoji packages: `npm install emoji-mart @emoji-mart/react @emoji-mart/data` -2. Initialize emoji data: `init({ data })` from `emoji-mart` -3. Import `EmojiPicker` from `stream-chat-react/emojis` -4. Pass `EmojiPicker` and `emojiSearchIndex={SearchIndex}` to `Channel` +1. Import `EmojiPicker` from `stream-chat-react/emojis` and pass it to `Channel`. +2. (Optional) For `:shortcode` autocomplete + emoticon replacement, register the + emoji middleware on the message composer's text composer with + `createTextComposerEmojiMiddleware()` (no argument โ€” it uses the built-in index). ```tsx import { EmojiPicker } from 'stream-chat-react/emojis'; -import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data'; - -init({ data }); - - {/* ... */} -; +{/* ... */}; ``` -**Note**: For React 19, may need package.json overrides for `@emoji-mart/react` +**Notes**: + +- Passing a custom `emojiSearchIndex` (including emoji-mart's `SearchIndex`) is + still supported for advanced use. +- Skin tone and "frequently used" are integrator-managed props on `EmojiPicker` + (`skinTone`/`onSkinToneChange`, `frequentlyUsedEmoji`/`onFrequentlyUsedChange`); + the SDK does not persist them. See `examples/vite/` for a localStorage example. **Reference**: See `examples/tutorial/src/6-emoji-picker/` @@ -365,9 +368,11 @@ body { **Solution**: -- Ensure emoji packages are installed -- Initialize with `init({ data })` before rendering -- For React 19, add package.json overrides if needed +- Ensure `EmojiPicker` from `stream-chat-react/emojis` is passed to `Channel` (or set + via `ComponentContext`) +- Import the emoji picker CSS: `import 'stream-chat-react/css/emoji-picker.css'` +- For `:shortcode` autocomplete, register `createTextComposerEmojiMiddleware()` on + the composer's text composer ## Resources @@ -386,10 +391,8 @@ body { - `react`: ^19.0.0 || ^18.0.0 || ^17.0.0 - `react-dom`: ^19.0.0 || ^18.0.0 || ^17.0.0 - `stream-chat`: ^9.27.2 -- **Optional Dependencies** (for emoji support): - - `emoji-mart`: ^5.4.0 - - `@emoji-mart/react`: ^1.1.0 - - `@emoji-mart/data`: ^1.1.0 +- **Emoji support**: built in via the `stream-chat-react/emojis` entry point โ€” no + `emoji-mart` packages required. ## Best Practices diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index 7a799514d5..6452c8f3f6 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -12,8 +12,6 @@ "hoistingLimits": "workspaces" }, "dependencies": { - "@emoji-mart/data": "^1.2.1", - "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", "stream-chat": "^9.49.0", diff --git a/examples/tutorial/src/6-emoji-picker/App.tsx b/examples/tutorial/src/6-emoji-picker/App.tsx index 5f339501be..b42b927234 100644 --- a/examples/tutorial/src/6-emoji-picker/App.tsx +++ b/examples/tutorial/src/6-emoji-picker/App.tsx @@ -14,9 +14,6 @@ import { } from 'stream-chat-react'; import { EmojiPicker } from 'stream-chat-react/emojis'; -import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data'; - import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; @@ -32,8 +29,6 @@ const filters: ChannelFilters = { members: { $in: [userId] }, }; -init({ data }); - const App = () => { const [isReady, setIsReady] = useState(false); const client = useCreateChatClient({ @@ -72,7 +67,7 @@ const App = () => { - + diff --git a/examples/tutorial/src/App.tsx b/examples/tutorial/src/App.tsx index 35a6b0092f..6348377e5e 100644 --- a/examples/tutorial/src/App.tsx +++ b/examples/tutorial/src/App.tsx @@ -57,7 +57,7 @@ const steps: TutorialStep[] = [ id: 'emoji-picker', title: '6. Emoji Picker', description: - 'Wire a custom EmojiPicker into MessageComposer with emoji-mart search support.', + 'Wire a custom EmojiPicker into MessageComposer with built-in emoji search support.', Component: EmojiPickerStep, }, { diff --git a/examples/vite/package.json b/examples/vite/package.json index 37ea3cf56f..46a9cbee67 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -9,9 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@emoji-mart/data": "^1.2.1", "clsx": "^2.1.1", - "emoji-mart": "^5.6.0", "human-id": "^4.1.3", "modern-normalize": "^3.0.1", "react": "^19.2.6", diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 9ce215e2f7..5080dbc848 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,4 +1,11 @@ -import { type CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react'; +import { + type CSSProperties, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { ChannelFilters, ChannelOptions, @@ -33,8 +40,6 @@ import { WithComponents, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; -import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data/sets/14/native.json'; import { humanId } from 'human-id'; import { appSettingsStore, useAppSettingsSelector } from './AppSettings'; @@ -72,8 +77,6 @@ import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; const PUBLIC_VITE_EXAMPLE_API_KEY = 'xzwhhgtazy6h'; -init({ data }); - const parseUserIdFromToken = (token: string): string | undefined => { try { const [, payload] = token.split('.'); @@ -102,9 +105,21 @@ const sort: ChannelSort = { last_message_at: -1, updated_at: -1 }; // @ts-expect-error ai_generated isn't on LocalMessage's public type yet const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated; +// A few extra reactions, built from emoji-mart-shaped data via `mapEmojiMartData`. +const extendedReactionData = { + emojis: { + clap: { name: 'Clapping Hands', skins: [{ native: '๐Ÿ‘' }] }, + eyes: { name: 'Eyes', skins: [{ native: '๐Ÿ‘€' }] }, + hundred: { name: 'Hundred Points', skins: [{ native: '๐Ÿ’ฏ' }] }, + pray: { name: 'Folded Hands', skins: [{ native: '๐Ÿ™' }] }, + rocket: { name: 'Rocket', skins: [{ native: '๐Ÿš€' }] }, + tada: { name: 'Party Popper', skins: [{ native: '๐ŸŽ‰' }] }, + }, +}; + const newReactionOptions: ReactionOptions = { ...defaultReactionOptions, - extended: mapEmojiMartData(data), + extended: mapEmojiMartData(extendedReactionData), }; const useUser = () => { @@ -173,18 +188,55 @@ const CustomMessageReactions = (props: React.ComponentProps ; +// Demonstrates integrator-owned persistence of the picker's skin tone and +// frequently-used emoji via localStorage. The SDK itself never touches storage โ€” +// it exposes these as controlled props so apps own where the state lives. +const EMOJI_SKIN_TONE_KEY = 'vite-example/emoji-skin-tone'; +const EMOJI_FREQUENTLY_USED_KEY = 'vite-example/emoji-frequently-used'; + +const readStored = (key: string, fallback: T): T => { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +}; + +const writeStored = (key: string, value: unknown) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore storage failures (e.g. private browsing) + } +}; + const EmojiPickerWithCustomOptions = ( props: React.ComponentProps, ) => { const { mode } = useAppSettingsSelector((state) => state.theme); + const [skinTone, setSkinTone] = useState(() => readStored(EMOJI_SKIN_TONE_KEY, 0)); + const [frequentlyUsedEmoji, setFrequentlyUsedEmoji] = useState(() => + readStored(EMOJI_FREQUENTLY_USED_KEY, []), + ); return ( { + setFrequentlyUsedEmoji(ids); + writeStored(EMOJI_FREQUENTLY_USED_KEY, ids); + }} + onSkinToneChange={(tone) => { + setSkinTone(tone); + writeStored(EMOJI_SKIN_TONE_KEY, tone); + }} pickerProps={{ ...props.pickerProps, theme: mode, }} + skinTone={skinTone} /> ); }; @@ -362,9 +414,7 @@ const App = () => { }); composer.textComposer.middlewareExecutor.insert({ - middleware: [ - createTextComposerEmojiMiddleware(SearchIndex) as TextComposerMiddleware, - ], + middleware: [createTextComposerEmojiMiddleware() as TextComposerMiddleware], position: { before: 'stream-io/text-composer/mentions-middleware' }, unique: true, }); @@ -419,7 +469,6 @@ const App = () => { return ( ; /** Custom UI component for rendering button with emoji picker in MessageComposer */ EmojiPicker?: React.ComponentType; - /** Mechanism to be used with autocomplete and text replace features of the `MessageComposer` component, see [emoji-mart `SearchIndex`](https://github.com/missive/emoji-mart#%EF%B8%8F%EF%B8%8F-headless-search) */ + /** Custom emoji search index for `MessageComposer` autocomplete and emoticon replacement. Optional โ€” the SDK ships a built-in `defaultEmojiSearchIndex` (used by `createTextComposerEmojiMiddleware`); emoji-mart's `SearchIndex` also satisfies this interface. */ emojiSearchIndex?: EmojiSearchIndex; /** Custom UI component to be displayed when the `MessageList` is empty, defaults to and accepts same props as: [EmptyStateIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx) */ EmptyStateIndicator?: React.ComponentType; diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 6454582287..a22cd1a60b 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -20,8 +20,9 @@ export type EmojiPickerProps = { wrapperClassName?: string; closeOnEmojiSelect?: boolean; /** - * Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.5.2#options--props) to be - * passed down to the [emoji-mart `Picker`](https://github.com/missive/emoji-mart/tree/v5.5.2#-picker) component + * Properties forwarded to the emoji picker panel. Only `theme` + * ('auto' | 'light' | 'dark') and `style` are honored; other keys are accepted + * for backwards compatibility (they were emoji-mart `Picker` options) but ignored. */ pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & Record>; /** diff --git a/yarn.lock b/yarn.lock index 1848a9f82a..f7740095c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -614,23 +614,13 @@ __metadata: languageName: node linkType: hard -"@emoji-mart/data@npm:^1.2.1": +"@emoji-mart/data@npm:1.2.1": version: 1.2.1 resolution: "@emoji-mart/data@npm:1.2.1" checksum: 10c0/6784b97bf49a0d3ff110d8447bbd3b0449fcbc497294be3d1c3a6cb1609308776895c7520200be604cbecaa5e172c76927e47f34419c72ba8a76fd4e5a53674b languageName: node linkType: hard -"@emoji-mart/react@npm:^1.1.1": - version: 1.1.1 - resolution: "@emoji-mart/react@npm:1.1.1" - peerDependencies: - emoji-mart: ^5.2 - react: ^16.8 || ^17 || ^18 - checksum: 10c0/88a9c8c24bbc5695f0ed2458734c9982c965a16db1999bc731c7cce77f9bf228f1871e899744f9a3f9fdd36a11db7ad6c0e049d710cb91c66c69a2cd4d2ee40a - languageName: node - linkType: hard - "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -2072,11 +2062,9 @@ __metadata: version: 0.0.0-use.local resolution: "@stream-io/stream-chat-react-tutorial@workspace:examples/tutorial" dependencies: - "@emoji-mart/data": "npm:^1.2.1" "@types/react": "npm:^19.2.15" "@types/react-dom": "npm:^19.2.3" "@vitejs/plugin-react": "npm:^6.0.2" - emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" stream-chat: "npm:^9.49.0" @@ -2091,7 +2079,6 @@ __metadata: resolution: "@stream-io/stream-chat-react-vite@workspace:examples/vite" dependencies: "@babel/core": "npm:^7.29.0" - "@emoji-mart/data": "npm:^1.2.1" "@playwright/test": "npm:^1.60.0" "@types/react": "npm:^19.2.15" "@types/react-dom": "npm:^19.2.3" @@ -2099,7 +2086,6 @@ __metadata: "@vitejs/plugin-react-swc": "npm:^4.3.1" babel-plugin-react-compiler: "npm:^1.0.0" clsx: "npm:^2.1.1" - emoji-mart: "npm:^5.6.0" human-id: "npm:^4.1.3" modern-normalize: "npm:^3.0.1" react: "npm:^19.2.6" @@ -4254,13 +4240,6 @@ __metadata: languageName: node linkType: hard -"emoji-mart@npm:^5.6.0": - version: 5.6.0 - resolution: "emoji-mart@npm:5.6.0" - checksum: 10c0/23e68ab10984f101b696d8f8e103e553ffa8e4d644e9a315190a9657903f71b833db09aac51b05de20f33bb9eef5bc1425eecdb2437042b25aff2dad0231f029 - languageName: node - linkType: hard - "emoji-regex@npm:^10.3.0": version: 10.6.0 resolution: "emoji-regex@npm:10.6.0" @@ -10041,8 +10020,7 @@ __metadata: "@breezystack/lamejs": "npm:^1.2.7" "@commitlint/cli": "npm:^21.0.1" "@commitlint/config-conventional": "npm:^21.0.1" - "@emoji-mart/data": "npm:^1.2.1" - "@emoji-mart/react": "npm:^1.1.1" + "@emoji-mart/data": "npm:1.2.1" "@eslint/js": "npm:^9.39.4" "@floating-ui/react": "npm:^0.27.19" "@react-aria/focus": "npm:^3.22.0" @@ -10070,7 +10048,6 @@ __metadata: concurrently: "npm:^9.2.1" conventional-changelog-conventionalcommits: "npm:^9.3.1" dayjs: "npm:^1.11.20" - emoji-mart: "npm:^5.6.0" emoji-regex: "npm:^9.2.2" eslint: "npm:^9.39.4" eslint-plugin-import: "npm:^2.32.0" @@ -10115,9 +10092,6 @@ __metadata: vitest-axe: "npm:^0.1.0" peerDependencies: "@breezystack/lamejs": ^1.2.7 - "@emoji-mart/data": ^1.1.0 - "@emoji-mart/react": ^1.1.0 - emoji-mart: ^5.4.0 modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 @@ -10136,12 +10110,6 @@ __metadata: peerDependenciesMeta: "@breezystack/lamejs": optional: true - "@emoji-mart/data": - optional: true - "@emoji-mart/react": - optional: true - emoji-mart: - optional: true modern-normalize: optional: true languageName: unknown From e61c8c5c10544b80839b77e8bce9a87c5031f9df Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 17:37:50 +0200 Subject: [PATCH 10/36] fix(emojis): persist skin tone and frequently-used across picker open/close The picker panel is mounted only while open, but it previously owned the uncontrolled skin-tone and frequently-used state, so closing the picker (and closeOnEmojiSelect in particular) discarded both and the frequently-used section never accumulated across openings. Hoist that state into the always-mounted EmojiPicker shell via useSkinTone/useFrequentlyUsedEmoji and pass the panel controlled skinToneIndex / frequentlyUsedIds; the shell records usage on select. Adds a regression test covering select -> close -> reopen. Addresses adversarial-review finding: frequently-used and skin-tone state discarded whenever the picker closes. --- src/plugins/Emojis/EmojiPicker.tsx | 22 +++- .../Emojis/__tests__/EmojiPicker.test.tsx | 105 ++++++++++++++++++ .../Emojis/components/EmojiPickerPanel.tsx | 53 ++++----- 3 files changed, 146 insertions(+), 34 deletions(-) create mode 100644 src/plugins/Emojis/__tests__/EmojiPicker.test.tsx diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index a22cd1a60b..bfce0a8bb0 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -10,6 +10,8 @@ import { import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive'; import { EmojiPickerPanel } from './components'; +import { useFrequentlyUsedEmoji } from './hooks/useFrequentlyUsedEmoji'; +import { useSkinTone } from './hooks/useSkinTone'; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; @@ -60,6 +62,17 @@ export const EmojiPicker = (props: EmojiPickerProps) => { const { textareaRef } = useMessageComposerContext('EmojiPicker'); const { textComposer } = useMessageComposerController(); const isCooldownActive = useIsCooldownActive(); + // Skin tone and frequently-used live here (not in the panel) so they survive the + // picker opening/closing โ€” the panel below is mounted only while open. + const [skinTone, setSkinTone] = useSkinTone({ + defaultSkinTone: props.defaultSkinTone, + onSkinToneChange: props.onSkinToneChange, + skinTone: props.skinTone, + }); + const { frequentlyUsedIds, recordUse } = useFrequentlyUsedEmoji({ + frequentlyUsedEmoji: props.frequentlyUsedEmoji, + onFrequentlyUsedChange: props.onFrequentlyUsedChange, + }); const [displayPicker, setDisplayPicker] = useState(false); const [referenceElement, setReferenceElement] = useState( null, @@ -113,13 +126,13 @@ export const EmojiPicker = (props: EmojiPickerProps) => { style={{ left: x ?? 0, position: strategy, top: y ?? 0 }} > { setDisplayPicker(false); referenceElement?.focus(); }} onEmojiSelect={(emoji) => { + recordUse(emoji.id); const textarea = textareaRef.current; if (!textarea) return; textComposer.insertText({ text: emoji.native }); @@ -128,9 +141,8 @@ export const EmojiPicker = (props: EmojiPickerProps) => { setDisplayPicker(false); } }} - onFrequentlyUsedChange={props.onFrequentlyUsedChange} - onSkinToneChange={props.onSkinToneChange} - skinTone={props.skinTone} + onSkinToneChange={setSkinTone} + skinToneIndex={skinTone} style={pickerStyle} theme={props.pickerProps?.theme} /> diff --git a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx new file mode 100644 index 0000000000..529e7e83b8 --- /dev/null +++ b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx @@ -0,0 +1,105 @@ +import type { MouseEventHandler, ReactNode } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// The panel is mounted only while the picker is open, so mock it to a controllable +// stub. This lets us assert that the owner (EmojiPicker) preserves skin tone and +// frequently-used across open/close โ€” without depending on the real panel's +// virtualized grid + async data load, which don't render reliably in jsdom. +vi.mock('../components', () => ({ + EmojiPickerPanel: ({ + frequentlyUsedIds = [], + onEmojiSelect, + onSkinToneChange, + skinToneIndex = 0, + }: { + frequentlyUsedIds?: string[]; + onEmojiSelect: (emoji: { id: string; name: string; native: string }) => void; + onSkinToneChange?: (skinTone: number) => void; + skinToneIndex?: number; + }) => ( +
+ {skinToneIndex} + {frequentlyUsedIds.join(',')} + + +
+ ), +})); + +vi.mock('../../../context', () => ({ + useMessageComposerContext: () => ({ + textareaRef: { current: document.createElement('textarea') }, + }), + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +vi.mock('../../../components', async () => { + const { forwardRef } = await import('react'); + return { + Button: forwardRef>( + function Button(props, ref) { + // Forward only the DOM-valid props the test needs; styling props are dropped. + return ( + + ); + }, + ), + IconEmoji: () => emoji, + useMessageComposerController: () => ({ textComposer: { insertText: vi.fn() } }), + }; +}); + +vi.mock('../../../components/Dialog/hooks/usePopoverPosition', () => ({ + usePopoverPosition: () => ({ + refs: { setFloating: vi.fn(), setReference: vi.fn() }, + strategy: 'absolute', + x: 0, + y: 0, + }), +})); + +vi.mock('../../../components/MessageComposer/hooks/useIsCooldownActive', () => ({ + useIsCooldownActive: () => false, +})); + +// Imported after the mocks so the mocked dependencies are in place. +import { EmojiPicker } from '../EmojiPicker'; + +const openPicker = () => fireEvent.click(screen.getByLabelText('aria/Emoji picker')); + +describe('EmojiPicker session state', () => { + it('keeps skin tone and frequently-used across close and reopen (incl. closeOnEmojiSelect)', () => { + render(); + + openPicker(); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('0'); + expect(screen.getByTestId('frequently-used')).toHaveTextContent(''); + + // Change skin tone, then select an emoji โ€” selecting closes the picker. + fireEvent.click(screen.getByText('set-skin')); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('4'); + fireEvent.click(screen.getByText('select-rocket')); + + // Panel (and any state it might have held) is unmounted. + expect(screen.queryByTestId('panel')).not.toBeInTheDocument(); + + // Reopening shows the retained skin tone and the just-used emoji. + openPicker(); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('4'); + expect(screen.getByTestId('frequently-used')).toHaveTextContent('rocket'); + }); +}); diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index ce09e3bbf3..e16b084c56 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -13,9 +13,7 @@ import { EmojiPickerProvider, } from '../context/EmojiPickerContext'; import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; -import { useFrequentlyUsedEmoji } from '../hooks/useFrequentlyUsedEmoji'; import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav'; -import { useSkinTone } from '../hooks/useSkinTone'; import { buildEmojiSearchData, runSearch } from '../search'; import type { EmojiDataEmoji } from '../data'; import { useTranslationContext } from '../../../context'; @@ -29,22 +27,24 @@ export type EmojiSelection = { export type EmojiPickerPanelProps = { onEmojiSelect: (emoji: EmojiSelection) => void; className?: string; - /** Uncontrolled initial skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ - defaultSkinTone?: number; + /** + * Ordered list of recently used emoji ids (most recent first), rendered as the + * "frequently used" section. This is a controlled value โ€” the owner (EmojiPicker) + * holds it above the panel's mount so it survives the picker opening/closing. + */ + frequentlyUsedIds?: string[]; /** Called when the panel requests to close (e.g. the Escape key). */ onClose?: () => void; - /** Controlled ordered list of recently used emoji ids (most recent first). */ - frequentlyUsedEmoji?: string[]; - /** Called with the updated recently-used list when an emoji is selected. */ - onFrequentlyUsedChange?: (emojiIds: string[]) => void; - /** Called with the new skin tone index when it changes. */ + /** Called with the new skin tone index when the user changes it. */ onSkinToneChange?: (skinTone: number) => void; - /** Controlled skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ - skinTone?: number; + /** Active skin tone index (0 = default, 1โ€“5 = light โ†’ dark). Controlled by the owner. */ + skinToneIndex?: number; style?: CSSProperties; theme?: 'auto' | 'light' | 'dark'; }; +const noop = () => undefined; + const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { if (theme === 'light') return 'str-chat__theme-light'; if (theme === 'dark') return 'str-chat__theme-dark'; @@ -56,32 +56,25 @@ const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { * The native React emoji picker panel that replaces emoji-mart's `` * web component. Loads the vendored dataset, renders search, category navigation, * the emoji grid, a preview and a skin-tone selector, and emits the resolved native - * emoji on selection. Skin tone and frequently-used are integrator-managed props - * (no browser storage in the SDK). + * emoji on selection. + * + * Skin tone and frequently-used are fully controlled (`skinToneIndex`, + * `frequentlyUsedIds`, `onSkinToneChange`): the panel is mounted only while the + * picker is open, so this session state is owned by the always-mounted `EmojiPicker` + * shell rather than held here. */ export const EmojiPickerPanel = ({ className, - defaultSkinTone, - frequentlyUsedEmoji, + frequentlyUsedIds = [], onClose, onEmojiSelect, - onFrequentlyUsedChange, onSkinToneChange, - skinTone, + skinToneIndex = 0, style, theme, }: EmojiPickerPanelProps) => { const { t } = useTranslationContext('EmojiPickerPanel'); const { data } = useEmojiPickerState(); - const [skinToneIndex, setSkinTone] = useSkinTone({ - defaultSkinTone, - onSkinToneChange, - skinTone, - }); - const { frequentlyUsedIds, recordUse } = useFrequentlyUsedEmoji({ - frequentlyUsedEmoji, - onFrequentlyUsedChange, - }); const [previewedEmoji, setPreviewedEmoji] = useState(null); const [activeCategoryId, setActiveCategoryId] = useState(undefined); const [query, setQuery] = useState(''); @@ -123,10 +116,9 @@ export const EmojiPickerPanel = ({ (emoji: EmojiDataEmoji) => { const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? ''; if (!native) return; - recordUse(emoji.id); onEmojiSelect({ id: emoji.id, name: emoji.name, native }); }, - [onEmojiSelect, recordUse, skinToneIndex], + [onEmojiSelect, skinToneIndex], ); const contextValue = useMemo( @@ -196,7 +188,10 @@ export const EmojiPickerPanel = ({
- +
) : ( From ae560f80ad9ee0042516165267e400b368a440b5 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 17:56:15 +0200 Subject: [PATCH 11/36] fix(emojis): stop silently ignoring unsupported EmojiPicker pickerProps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pickerProps used a Record catch-all, so emoji-mart Picker options (data, set, custom, categories, perLine, emojiVersion, locale, ...) type-checked but were silently ignored โ€” customers could lose branded emoji or platform filtering with no signal. Tighten pickerProps to the supported shape (theme + style) so unsupported options are now a compile error, and warn at runtime (matching the SDK's other misuse warnings) when unknown keys reach the picker via `as` casts. Document the supported surface + migration in AI.md and add a test asserting the type rejects emoji-mart options and the runtime warning fires. Addresses adversarial-review finding: legacy pickerProps customizations silently stop working. Migration: EmojiPicker pickerProps only accepts theme and style. emoji-mart Picker options are no longer accepted by the type and are ignored at runtime with a console warning. --- AI.md | 5 +++ src/plugins/Emojis/EmojiPicker.tsx | 42 ++++++++++++++++--- .../Emojis/__tests__/EmojiPicker.test.tsx | 21 ++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/AI.md b/AI.md index 8a1999600d..6a8fc70a1f 100644 --- a/AI.md +++ b/AI.md @@ -264,6 +264,11 @@ import { EmojiPicker } from 'stream-chat-react/emojis'; - Skin tone and "frequently used" are integrator-managed props on `EmojiPicker` (`skinTone`/`onSkinToneChange`, `frequentlyUsedEmoji`/`onFrequentlyUsedChange`); the SDK does not persist them. See `examples/vite/` for a localStorage example. +- `pickerProps` accepts only `theme` and `style`. emoji-mart `Picker` options + (`data`, `set`, `custom`, `categories`, `perLine`, `emojiVersion`, `locale`, โ€ฆ) are + no longer supported: the type rejects them and any passed via `as` casts are + ignored with a console warning. For custom reaction emoji use `mapEmojiMartData`; + for appearance use the `--str-chat__emoji-picker-*` CSS variables. **Reference**: See `examples/tutorial/src/6-emoji-picker/` diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index bfce0a8bb0..f06732e546 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -15,6 +15,19 @@ import { useSkinTone } from './hooks/useSkinTone'; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; +/** The only `pickerProps` keys the built-in picker reads. */ +const SUPPORTED_PICKER_PROP_KEYS = ['style', 'theme']; + +export type EmojiPickerPassthroughProps = { + /** Inline styles applied to the picker panel root. */ + style?: React.CSSProperties; + /** + * Color theme. 'auto' (default) inherits the ancestor SDK theme + * (`.str-chat__theme-*`); 'light' / 'dark' force the panel to that theme. + */ + theme?: 'auto' | 'light' | 'dark'; +}; + export type EmojiPickerProps = { ButtonIconComponent?: React.ComponentType; buttonClassName?: string; @@ -22,11 +35,15 @@ export type EmojiPickerProps = { wrapperClassName?: string; closeOnEmojiSelect?: boolean; /** - * Properties forwarded to the emoji picker panel. Only `theme` - * ('auto' | 'light' | 'dark') and `style` are honored; other keys are accepted - * for backwards compatibility (they were emoji-mart `Picker` options) but ignored. + * Presentation options for the picker panel โ€” `theme` and `style` only. + * + * This is NOT emoji-mart's `Picker` prop bag: emoji-mart options (`data`, `set`, + * `custom`, `categories`, `perLine`, `emojiVersion`, `locale`, `previewPosition`, + * โ€ฆ) are not supported by the built-in picker. The type rejects them, and any that + * reach runtime (e.g. via `as` casts) are ignored with a console warning. See the + * emoji migration notes in `AI.md`. */ - pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & Record>; + pickerProps?: EmojiPickerPassthroughProps; /** * Floating UI placement (default: 'top-end') for the picker popover */ @@ -93,7 +110,22 @@ export const EmojiPicker = (props: EmojiPickerProps) => { const { pickerContainerClassName, wrapperClassName } = classNames; const { ButtonIconComponent = IconEmoji } = props; - const pickerStyle = props.pickerProps?.style as React.CSSProperties | undefined; + const pickerStyle = props.pickerProps?.style; + + const pickerPropsKeys = Object.keys(props.pickerProps ?? {}); + useEffect(() => { + const ignored = pickerPropsKeys.filter( + (key) => !SUPPORTED_PICKER_PROP_KEYS.includes(key), + ); + if (ignored.length) { + console.warn( + `[stream-chat-react] EmojiPicker ignored unsupported pickerProps: ${ignored.join(', ')}. ` + + "Only 'theme' and 'style' are supported; emoji-mart Picker options are no longer available.", + ); + } + // Re-check only when the set of provided keys changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pickerPropsKeys.join(',')]); useEffect(() => { if (!popperElement || !referenceElement) return; diff --git a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx index 529e7e83b8..ee5e024e23 100644 --- a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx +++ b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx @@ -103,3 +103,24 @@ describe('EmojiPicker session state', () => { expect(screen.getByTestId('frequently-used')).toHaveTextContent('rocket'); }); }); + +describe('EmojiPicker pickerProps', () => { + it('warns about unsupported (emoji-mart) pickerProps, but not about theme/style', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { unmount } = render( + , + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('perLine')); + unmount(); + + warn.mockClear(); + render(); + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); +}); From d856bd8e099706ddfb886fde531652963bcf9de9 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 10:15:51 +0200 Subject: [PATCH 12/36] docs(emojis): import emoji-picker.css where the picker is used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emoji picker ships its own stylesheet (dist/css/emoji-picker.css), kept out of index.css on purpose so apps that don't use the picker pay no emoji CSS cost โ€” the same opt-in model as channel-detail.css. But the tutorial's emoji step and AI.md's Scenario 6 happy path never imported it, so anyone following them saw an unstyled picker panel and had to discover the fix in Troubleshooting. - examples/tutorial/src/6-emoji-picker: import emoji-picker.css in layout.css (the vite example already imports it via index.scss). - AI.md Scenario 6: add the stylesheet import as an explicit step and to the code snippet, with a note on why it's separate from index.css. The stylesheet stays a separate opt-in import rather than being folded into index.css, preserving the SDK's bundle/CSS optionality. Addresses adversarial-review finding: emoji picker unusable without the separate emoji-picker.css. --- AI.md | 6 +++++- examples/tutorial/src/6-emoji-picker/layout.css | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/AI.md b/AI.md index 6a8fc70a1f..12ddae5deb 100644 --- a/AI.md +++ b/AI.md @@ -247,12 +247,16 @@ required. **Steps**: 1. Import `EmojiPicker` from `stream-chat-react/emojis` and pass it to `Channel`. -2. (Optional) For `:shortcode` autocomplete + emoticon replacement, register the +2. Import the emoji picker's stylesheet: `import 'stream-chat-react/css/emoji-picker.css'`. + It is intentionally **not** part of `index.css` (so apps that don't use the picker + ship no emoji CSS); without it the picker panel renders unstyled. +3. (Optional) For `:shortcode` autocomplete + emoticon replacement, register the emoji middleware on the message composer's text composer with `createTextComposerEmojiMiddleware()` (no argument โ€” it uses the built-in index). ```tsx import { EmojiPicker } from 'stream-chat-react/emojis'; +import 'stream-chat-react/css/emoji-picker.css'; {/* ... */}; ``` diff --git a/examples/tutorial/src/6-emoji-picker/layout.css b/examples/tutorial/src/6-emoji-picker/layout.css index 2fd790e68c..fb1ab97942 100644 --- a/examples/tutorial/src/6-emoji-picker/layout.css +++ b/examples/tutorial/src/6-emoji-picker/layout.css @@ -1,5 +1,9 @@ @layer stream, stream-overrides; @import 'stream-chat-react/dist/css/index.css' layer(stream); +/* The emoji picker ships its own opt-in stylesheet (kept out of index.css so apps + that don't use it pay no CSS cost). This step renders , so it must + be imported or the picker panel renders unstyled. */ +@import 'stream-chat-react/dist/css/emoji-picker.css' layer(stream); @layer stream-overrides { .custom-theme { From 05dba1a65f82dceebce2eb3256013c403904c25d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 10:39:26 +0200 Subject: [PATCH 13/36] fix(emojis): make EmojiPicker theme='light' override a dark ancestor The SDK's global theme rules define light as the `.str-chat` default and apply dark via `.str-chat__theme-dark`; there is no variable set bound to a `.str-chat__theme-light` class. So when the picker was forced to `theme='light'` inside a `.str-chat__theme-dark` subtree, its panel still inherited the ancestor's dark tokens and the forced light theme was a no-op. (`theme='dark'` already worked because the panel's `.str-chat__theme-dark` class matches the global dark rule.) Re-assert the full light variable set on the forced-light panel (`.str-chat__emoji-picker.str-chat__theme-light`), mirroring how variable-tokens.scss already emits `light.variables` for the `.str-chat__theme-dark .str-chat__theme-inverse` ("light inside dark") case. This adds ~4KB gzipped to the opt-in emoji-picker.css, which only loads with the picker. `theme='auto'` still inherits the ancestor theme. Export `themeClassName` and add a unit test locking the theme-to-class contract the CSS override depends on. Addresses adversarial-review finding: theme='light' does not override a dark chat ancestor. --- .../Emojis/components/EmojiPickerPanel.tsx | 9 ++++++- .../__tests__/EmojiPickerPanel.test.ts | 17 +++++++++++++ src/plugins/Emojis/styling/EmojiPicker.scss | 24 +++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.ts diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index e16b084c56..bf28797e4c 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -45,7 +45,14 @@ export type EmojiPickerPanelProps = { const noop = () => undefined; -const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { +/** + * Maps the `theme` prop to the class applied on the panel root. `light`/`dark` force + * an absolute theme (the forced-light case is backed by a full light-variable override + * in EmojiPicker.scss, since the SDK has no `.str-chat__theme-light` variable set); + * `auto`/undefined applies no class and inherits the ancestor `.str-chat__theme-*`. + * Exported for testing the class contract the theme CSS relies on. + */ +export const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { if (theme === 'light') return 'str-chat__theme-light'; if (theme === 'dark') return 'str-chat__theme-dark'; // 'auto' (default): inherit the ancestor `.str-chat__theme-*`. diff --git a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.ts b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.ts new file mode 100644 index 0000000000..4455765bae --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.ts @@ -0,0 +1,17 @@ +import { themeClassName } from '../EmojiPickerPanel'; + +// The picker's theme CSS keys off the class this maps to: a forced `light`/`dark` +// must emit an SDK theme class on the panel root so the forced-light variable override +// in EmojiPicker.scss can win over an ancestor `.str-chat__theme-dark`. If these strings +// or the mapping drift, forced theming silently stops overriding the ancestor theme. +describe('themeClassName', () => { + it('maps a forced theme to the matching SDK theme class', () => { + expect(themeClassName('light')).toBe('str-chat__theme-light'); + expect(themeClassName('dark')).toBe('str-chat__theme-dark'); + }); + + it('emits no class for auto/undefined so the panel inherits the ancestor theme', () => { + expect(themeClassName('auto')).toBeUndefined(); + expect(themeClassName(undefined)).toBeUndefined(); + }); +}); diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index f4028865b3..12eba98f3a 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -1,4 +1,5 @@ @use '../../../styling/utils'; +@use '../../../styling/light' as light; $emoji-picker-border-radius: 12px; @@ -10,8 +11,10 @@ $emoji-picker-border-radius: 12px; } .str-chat__emoji-picker { - // Component tokens โ€” resolved from semantic tokens so light/dark theming comes - // for free via the inherited `.str-chat__theme-*` ancestor. + // Component tokens โ€” resolved from semantic tokens. With `theme='auto'` these + // inherit the ancestor `.str-chat__theme-*`; with a forced `theme` the semantic + // tokens are re-asserted on the panel below so the override wins regardless of + // the ancestor theme. --str-chat__emoji-picker-background-color: var( --str-chat__background-core-surface-card ); @@ -240,3 +243,20 @@ $emoji-picker-border-radius: 12px; } } } + +// Forced light theme (`pickerProps.theme='light'`). +// +// The SDK's global theme rules (variable-tokens.scss) treat light as the `.str-chat` +// default and apply dark via `.str-chat__theme-dark`; there is no rule that asserts +// light values for a `.str-chat__theme-light` class. So a picker forced to light while +// nested in a `.str-chat__theme-dark` ancestor would inherit the ancestor's dark tokens. +// Re-assert the full light set on the forced-light panel so it wins over any inherited +// theme โ€” mirroring how variable-tokens.scss emits `light.variables` for the +// `.str-chat__theme-dark .str-chat__theme-inverse` ("light inside dark") case. +// +// `theme='dark'` needs no equivalent block: the panel carries `.str-chat__theme-dark`, +// which the global rule (always loaded via index.css) already matches. `theme='auto'` +// intentionally inherits the ancestor theme. +.str-chat__emoji-picker.str-chat__theme-light { + @include light.variables; +} From ce7ea1a90dd1ce007784f7d8db8928d7ae0fe6f1 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 11:48:58 +0200 Subject: [PATCH 14/36] fix(emojis): recover from failed dataset loads and drop invalid grid ARIA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two medium findings from the adversarial review. 1. Recoverable dataset loading loadEmojiData memoized its promise unconditionally, so a single chunk-load failure (offline, or a stale chunk after a deploy) was cached forever: the picker stayed permanently aria-busy across close/reopen and the shared search index used by the composer middleware was poisoned too, with no way back short of a page reload. - Add memoizeAsyncWithReset: memoize the in-flight/resolved promise but drop it on rejection, so the next call retries. Use it for both loadEmojiData and the search index's getIndex โ€” the middleware retries on the next keystroke. - useEmojiPickerState now exposes { data, error, retry }; the panel renders an announced error (role="alert") with a Retry button instead of a stuck spinner. - New i18n keys "Failed to load emojis" and "Retry" across all 12 locales. 2. Valid emoji-grid accessibility The virtualized category view rendered role="row"/role="gridcell" with no owning role="grid" (Virtuoso's root has no grid role), so assistive tech saw gridcells with no grid โ€” invalid ARIA. A valid virtualized grid/row/gridcell tree with correct rowindex/rowcount metadata isn't practical here, so use plain native +
) : (
)} diff --git a/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx new file mode 100644 index 0000000000..7cb040a380 --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { EmojiGrid, type EmojiPickerCategory } from '../EmojiGrid'; +import { EmojiPickerProvider } from '../../context/EmojiPickerContext'; + +// Mirror the repo's react-virtuoso mock: render every item synchronously so the +// non-search view's accessibility tree is assertable in jsdom. +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ + data = [], + itemContent, + }: { + data?: EmojiPickerCategory[]; + itemContent?: (index: number, item: EmojiPickerCategory) => React.ReactNode; + }) => ( +
+ {data.map((item, index) => ( + {itemContent?.(index, item)} + ))} +
+ ), +})); + +const categories: EmojiPickerCategory[] = [ + { + emojis: [ + { + id: 'grinning', + keywords: ['face', 'smile'], + name: 'Grinning', + skins: [{ native: '๐Ÿ˜€', unified: '1f600' }], + version: 1, + }, + ], + id: 'people', + label: 'Smileys & People', + }, +]; + +const renderGrid = () => + render( + + + , + ); + +describe('EmojiGrid accessibility (non-search view)', () => { + it('renders emojis as accessible buttons with no orphaned grid/row/gridcell roles', () => { + renderGrid(); + + // The review flagged row/gridcell elements with no owning grid in the virtualized + // view. Native button semantics avoid the invalid ARIA entirely. + expect(screen.queryAllByRole('grid')).toHaveLength(0); + expect(screen.queryAllByRole('row')).toHaveLength(0); + expect(screen.queryAllByRole('gridcell')).toHaveLength(0); + + expect(screen.getByRole('button', { name: 'Grinning' })).toBeInTheDocument(); + }); + + it('keeps emojis grouped under a labeled category region', () => { + renderGrid(); + + expect(screen.getByRole('region', { name: 'Smileys & People' })).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.ts b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.ts deleted file mode 100644 index 4455765bae..0000000000 --- a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { themeClassName } from '../EmojiPickerPanel'; - -// The picker's theme CSS keys off the class this maps to: a forced `light`/`dark` -// must emit an SDK theme class on the panel root so the forced-light variable override -// in EmojiPicker.scss can win over an ancestor `.str-chat__theme-dark`. If these strings -// or the mapping drift, forced theming silently stops overriding the ancestor theme. -describe('themeClassName', () => { - it('maps a forced theme to the matching SDK theme class', () => { - expect(themeClassName('light')).toBe('str-chat__theme-light'); - expect(themeClassName('dark')).toBe('str-chat__theme-dark'); - }); - - it('emits no class for auto/undefined so the panel inherits the ancestor theme', () => { - expect(themeClassName('auto')).toBeUndefined(); - expect(themeClassName(undefined)).toBeUndefined(); - }); -}); diff --git a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx new file mode 100644 index 0000000000..ac5dc88a3e --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +const { hookState } = vi.hoisted(() => ({ + hookState: { + data: null as unknown, + error: false, + retry: vi.fn(), + }, +})); + +vi.mock('../../hooks/useEmojiPickerState', () => ({ + useEmojiPickerState: () => hookState, +})); + +vi.mock('../../../../context', () => ({ + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +import { EmojiPickerPanel, themeClassName } from '../EmojiPickerPanel'; + +// The picker's theme CSS keys off the class this maps to: a forced `light`/`dark` +// must emit an SDK theme class on the panel root so the forced-light variable override +// in EmojiPicker.scss can win over an ancestor `.str-chat__theme-dark`. If these strings +// or the mapping drift, forced theming silently stops overriding the ancestor theme. +describe('themeClassName', () => { + it('maps a forced theme to the matching SDK theme class', () => { + expect(themeClassName('light')).toBe('str-chat__theme-light'); + expect(themeClassName('dark')).toBe('str-chat__theme-dark'); + }); + + it('emits no class for auto/undefined so the panel inherits the ancestor theme', () => { + expect(themeClassName('auto')).toBeUndefined(); + expect(themeClassName(undefined)).toBeUndefined(); + }); +}); + +describe('EmojiPickerPanel dataset loading', () => { + beforeEach(() => { + hookState.data = null; + hookState.error = false; + hookState.retry.mockClear(); + }); + + it('renders a recoverable error (not a permanent spinner) when the dataset fails to load', () => { + hookState.error = true; + render( {}} />); + + // No stuck aria-busy loading regionโ€ฆ + expect(document.querySelector('[aria-busy="true"]')).toBeNull(); + // โ€ฆan announced error with a working retry instead. + expect(screen.getByRole('alert')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(hookState.retry).toHaveBeenCalledTimes(1); + }); + + it('shows the loading region while the dataset is still loading', () => { + render( {}} />); + + expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/src/plugins/Emojis/data/index.ts b/src/plugins/Emojis/data/index.ts index f42039e357..d31c9443f8 100644 --- a/src/plugins/Emojis/data/index.ts +++ b/src/plugins/Emojis/data/index.ts @@ -1,3 +1,4 @@ +import { memoizeAsyncWithReset } from '../memoizeAsyncWithReset'; import type { EmojiData } from './types'; export type { @@ -7,21 +8,17 @@ export type { EmojiDataSkin, } from './types'; -let dataPromise: Promise | null = null; - /** * Lazily loads the vendored emoji dataset. The JSON is imported dynamically so * bundlers emit it as a separate async chunk โ€” it is fetched only when the emoji * picker or search actually runs, and never enters the main `stream-chat-react` - * bundle. The result is memoized, so repeated calls share a single load. + * bundle. The result is memoized, so repeated calls share a single load; a failed + * load (offline, stale chunk after a deploy) is not cached, so a later call retries. */ -export const loadEmojiData = (): Promise => { - if (!dataPromise) { - dataPromise = import('./emoji-data.json').then( - (mod) => - ((mod as unknown as { default?: EmojiData }).default ?? - mod) as unknown as EmojiData, - ); - } - return dataPromise; -}; +export const loadEmojiData = memoizeAsyncWithReset(() => + import('./emoji-data.json').then( + (mod) => + ((mod as unknown as { default?: EmojiData }).default ?? + mod) as unknown as EmojiData, + ), +); diff --git a/src/plugins/Emojis/hooks/__tests__/useEmojiPickerState.test.ts b/src/plugins/Emojis/hooks/__tests__/useEmojiPickerState.test.ts new file mode 100644 index 0000000000..1ef4c46898 --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useEmojiPickerState.test.ts @@ -0,0 +1,37 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +const { loadState } = vi.hoisted(() => ({ loadState: { attempts: 0 } })); + +// Reject the first dataset load, then succeed โ€” the offline / stale-chunk scenario the +// picker must recover from without a full page reload. +vi.mock('../../data', () => ({ + loadEmojiData: vi.fn(() => { + loadState.attempts += 1; + return loadState.attempts === 1 + ? Promise.reject(new Error('chunk load failed')) + : Promise.resolve({ aliases: {}, categories: [], emojis: {} }); + }), +})); + +import { useEmojiPickerState } from '../useEmojiPickerState'; + +describe('useEmojiPickerState', () => { + beforeEach(() => { + loadState.attempts = 0; + }); + + it('surfaces an error when the dataset fails to load, then recovers on retry', async () => { + const { result } = renderHook(() => useEmojiPickerState()); + + await waitFor(() => expect(result.current.error).toBe(true)); + expect(result.current.data).toBeNull(); + + act(() => { + result.current.retry(); + }); + + await waitFor(() => expect(result.current.data).not.toBeNull()); + expect(result.current.error).toBe(false); + expect(loadState.attempts).toBe(2); + }); +}); diff --git a/src/plugins/Emojis/hooks/useEmojiPickerState.ts b/src/plugins/Emojis/hooks/useEmojiPickerState.ts index ad602553b7..4ecfc25727 100644 --- a/src/plugins/Emojis/hooks/useEmojiPickerState.ts +++ b/src/plugins/Emojis/hooks/useEmojiPickerState.ts @@ -1,26 +1,36 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { type EmojiData, loadEmojiData } from '../data'; /** * Loads the vendored emoji dataset lazily (via the code-split dynamic import) and - * exposes it once resolved. Returns `null` while loading. + * exposes it once resolved. + * + * `data` is `null` while loading. If the load fails (offline, or a stale chunk after a + * deploy) `error` becomes `true`; `retry()` re-attempts it. Because `loadEmojiData` + * drops its memo on failure, the retry actually re-imports rather than replaying the + * rejected promise โ€” so the picker can recover without a full page reload. */ export const useEmojiPickerState = () => { const [data, setData] = useState(null); + const [error, setError] = useState(false); + const [attempt, setAttempt] = useState(0); useEffect(() => { let active = true; + setError(false); loadEmojiData() .then((loaded) => { if (active) setData(loaded); }) .catch(() => { - // Swallow โ€” the picker stays in its loading state if data fails to load. + if (active) setError(true); }); return () => { active = false; }; - }, []); + }, [attempt]); - return { data }; + const retry = useCallback(() => setAttempt((current) => current + 1), []); + + return { data, error, retry }; }; diff --git a/src/plugins/Emojis/memoizeAsyncWithReset.ts b/src/plugins/Emojis/memoizeAsyncWithReset.ts new file mode 100644 index 0000000000..72721b44f0 --- /dev/null +++ b/src/plugins/Emojis/memoizeAsyncWithReset.ts @@ -0,0 +1,19 @@ +/** + * Memoizes an async factory, sharing one in-flight/resolved promise across callers โ€” + * but drops the memo if the promise rejects, so a later call retries instead of + * replaying the failure forever. Used for the lazily code-split emoji dataset (and the + * search index derived from it): a transient chunk-load failure (offline, stale deploy) + * must not permanently disable the picker or poison the shared search index. + */ +export const memoizeAsyncWithReset = (factory: () => Promise) => { + let promise: Promise | null = null; + return (): Promise => { + if (!promise) { + promise = factory().catch((error) => { + promise = null; + throw error; + }); + } + return promise; + }; +}; diff --git a/src/plugins/Emojis/search/EmojiSearchIndex.ts b/src/plugins/Emojis/search/EmojiSearchIndex.ts index b41f1ba399..ee9382a56d 100644 --- a/src/plugins/Emojis/search/EmojiSearchIndex.ts +++ b/src/plugins/Emojis/search/EmojiSearchIndex.ts @@ -3,17 +3,15 @@ import type { EmojiSearchIndexResult, } from '../../../components/MessageComposer'; import { loadEmojiData } from '../data'; +import { memoizeAsyncWithReset } from '../memoizeAsyncWithReset'; import { buildEmojiSearchData, type SearchableEmoji } from './buildEmojiSearchData'; import { runSearch } from './search'; -let indexPromise: Promise | null = null; - -const getIndex = (): Promise => { - if (!indexPromise) { - indexPromise = loadEmojiData().then(buildEmojiSearchData); - } - return indexPromise; -}; +// Memoized, but dropped on failure so a transient dataset-load error does not +// permanently poison the shared search index used by the composer middleware. +const getIndex = memoizeAsyncWithReset(() => + loadEmojiData().then(buildEmojiSearchData), +); const toResult = (emoji: SearchableEmoji): EmojiSearchIndexResult => ({ emoticons: emoji.emoticons, diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index 12eba98f3a..b99f1bd33f 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -241,6 +241,37 @@ $emoji-picker-border-radius: 12px; flex: 1 1 auto; min-block-size: 12rem; } + + &__error { + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-sm); + min-block-size: 12rem; + padding: var(--str-chat__spacing-md); + color: var(--str-chat__emoji-picker-secondary-text-color); + text-align: center; + } + + &__error-retry { + @include utils.button-reset; + cursor: pointer; + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-md); + border: 1px solid var(--str-chat__emoji-picker-border-color); + border-radius: var(--str-chat__radius-8); + color: var(--str-chat__emoji-picker-text-color); + font: inherit; + + &:hover { + background-color: var(--str-chat__emoji-picker-hover-background-color); + } + + &:focus-visible { + @include utils.focusable; + } + } } } From ce86bdcf43c3b8023571cf990c72c4d687c36503 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 13:51:19 +0200 Subject: [PATCH 15/36] fix(emojis): match the built-in picker's styling to emoji-mart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyle the EmojiPicker panel so the drop-in replacement looks like the emoji-mart picker it replaced: - Reorder to category nav on top, search below (emoji-mart layout). - Search: one filled grey field with the icon inside, smaller input text and a compact fixed height (was a bordered box with the icon outside). - Active category marked with an accent underline instead of a filled background. - Smaller, medium-weight, secondary-colour section headers. - Softer search/footer separators (a subtle hairline, not the strong border). - Larger panel (360x440) with a roomier ~9-per-row grid and real gaps. - Footer shows a "Pick an emojiโ€ฆ" placeholder (with a pointer glyph) when nothing is hovered. Adds the "Pick an emojiโ€ฆ" i18n key to all 12 locales. --- src/i18n/de.json | 3 +- src/i18n/en.json | 3 +- src/i18n/es.json | 3 +- src/i18n/fr.json | 3 +- src/i18n/hi.json | 3 +- src/i18n/it.json | 3 +- src/i18n/ja.json | 3 +- src/i18n/ko.json | 3 +- src/i18n/nl.json | 3 +- src/i18n/pt.json | 3 +- src/i18n/ru.json | 3 +- src/i18n/tr.json | 3 +- .../Emojis/components/EmojiPickerPanel.tsx | 2 +- src/plugins/Emojis/components/PreviewPane.tsx | 25 +++--- src/plugins/Emojis/styling/EmojiPicker.scss | 90 +++++++++++++------ 15 files changed, 101 insertions(+), 52 deletions(-) diff --git a/src/i18n/de.json b/src/i18n/de.json index a3c71cea15..f022415ba8 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -649,5 +649,6 @@ "Medium-Dark": "Mitteldunkel", "Dark": "Dunkel", "Failed to load emojis": "Emojis konnten nicht geladen werden", - "Retry": "Erneut versuchen" + "Retry": "Erneut versuchen", + "Pick an emojiโ€ฆ": "Emoji auswรคhlenโ€ฆ" } diff --git a/src/i18n/en.json b/src/i18n/en.json index 63c0671526..86bffd798d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -649,5 +649,6 @@ "Medium-Dark": "Medium-Dark", "Dark": "Dark", "Failed to load emojis": "Failed to load emojis", - "Retry": "Retry" + "Retry": "Retry", + "Pick an emojiโ€ฆ": "Pick an emojiโ€ฆ" } diff --git a/src/i18n/es.json b/src/i18n/es.json index f38388d957..3be116acb7 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -673,5 +673,6 @@ "Medium-Dark": "Medio oscuro", "Dark": "Oscuro", "Failed to load emojis": "No se pudieron cargar los emojis", - "Retry": "Reintentar" + "Retry": "Reintentar", + "Pick an emojiโ€ฆ": "Elige un emojiโ€ฆ" } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 71f0a2116b..ea12e8d6b0 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -673,5 +673,6 @@ "Medium-Dark": "Moyennement foncรฉ", "Dark": "Foncรฉ", "Failed to load emojis": "ร‰chec du chargement des emojis", - "Retry": "Rรฉessayer" + "Retry": "Rรฉessayer", + "Pick an emojiโ€ฆ": "Choisissez un emojiโ€ฆ" } diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 7675044cc9..33d412c9c3 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -650,5 +650,6 @@ "Medium-Dark": "เคฎเคงเฅเคฏเคฎ-เค—เคนเคฐเคพ", "Dark": "เค—เคนเคฐเคพ", "Failed to load emojis": "เค‡เคฎเฅ‹เคœเฅ€ เคฒเฅ‹เคก เคจเคนเฅ€เค‚ เคนเฅ‹ เคธเค•เฅ‡", - "Retry": "เคชเฅเคจเคƒ เคชเฅเคฐเคฏเคพเคธ เค•เคฐเฅ‡เค‚" + "Retry": "เคชเฅเคจเคƒ เคชเฅเคฐเคฏเคพเคธ เค•เคฐเฅ‡เค‚", + "Pick an emojiโ€ฆ": "เค‡เคฎเฅ‹เคœเฅ€ เคšเฅเคจเฅ‡เค‚โ€ฆ" } diff --git a/src/i18n/it.json b/src/i18n/it.json index 8ffb482358..ab810700ce 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -673,5 +673,6 @@ "Medium-Dark": "Medio scuro", "Dark": "Scuro", "Failed to load emojis": "Impossibile caricare gli emoji", - "Retry": "Riprova" + "Retry": "Riprova", + "Pick an emojiโ€ฆ": "Scegli un emojiโ€ฆ" } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 07c8f4a808..582fd766a0 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -636,5 +636,6 @@ "Medium-Dark": "ใ‚„ใ‚„ๆš—ใ„", "Dark": "ๆš—ใ„", "Failed to load emojis": "็ตตๆ–‡ๅญ—ใ‚’่ชญใฟ่พผใ‚ใพใ›ใ‚“ใงใ—ใŸ", - "Retry": "ๅ†่ฉฆ่กŒ" + "Retry": "ๅ†่ฉฆ่กŒ", + "Pick an emojiโ€ฆ": "็ตตๆ–‡ๅญ—ใ‚’้ธๆŠžโ€ฆ" } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 05bdc01906..af1f93028e 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -636,5 +636,6 @@ "Medium-Dark": "์ค‘๊ฐ„ ์–ด๋‘์›€", "Dark": "์–ด๋‘์›€", "Failed to load emojis": "์ด๋ชจ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค", - "Retry": "๋‹ค์‹œ ์‹œ๋„" + "Retry": "๋‹ค์‹œ ์‹œ๋„", + "Pick an emojiโ€ฆ": "์ด๋ชจ์ง€ ์„ ํƒโ€ฆ" } diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 9f7c99b18a..e96989dd51 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -651,5 +651,6 @@ "Medium-Dark": "Middeldonker", "Dark": "Donker", "Failed to load emojis": "Emoji's konden niet worden geladen", - "Retry": "Opnieuw proberen" + "Retry": "Opnieuw proberen", + "Pick an emojiโ€ฆ": "Kies een emojiโ€ฆ" } diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 1b0052b1e7..f9f0872c46 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -673,5 +673,6 @@ "Medium-Dark": "Mรฉdio escuro", "Dark": "Escuro", "Failed to load emojis": "Falha ao carregar os emojis", - "Retry": "Tentar novamente" + "Retry": "Tentar novamente", + "Pick an emojiโ€ฆ": "Escolha um emojiโ€ฆ" } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 1a8cf79ecf..871524ef96 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -701,5 +701,6 @@ "Medium-Dark": "ะฃะผะตั€ะตะฝะฝะพ-ั‚ั‘ะผะฝั‹ะน", "Dark": "ะขั‘ะผะฝั‹ะน", "Failed to load emojis": "ะะต ัƒะดะฐะปะพััŒ ะทะฐะณั€ัƒะทะธั‚ัŒ ัะผะพะดะทะธ", - "Retry": "ะŸะพะฒั‚ะพั€ะธั‚ัŒ" + "Retry": "ะŸะพะฒั‚ะพั€ะธั‚ัŒ", + "Pick an emojiโ€ฆ": "ะ’ั‹ะฑะตั€ะธั‚ะต ัะผะพะดะทะธโ€ฆ" } diff --git a/src/i18n/tr.json b/src/i18n/tr.json index ea24bafdf9..bf21f39a79 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -649,5 +649,6 @@ "Medium-Dark": "Orta koyu", "Dark": "Koyu", "Failed to load emojis": "Emojiler yรผklenemedi", - "Retry": "Yeniden dene" + "Retry": "Yeniden dene", + "Pick an emojiโ€ฆ": "Emoji seรงโ€ฆ" } diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index 3ade912044..2e2e76c82c 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -160,12 +160,12 @@ export const EmojiPickerPanel = ({ > {data ? ( <> - +
{ + const { t } = useTranslationContext('EmojiPickerPreview'); const { skinToneIndex } = useEmojiPickerContext('PreviewPane'); return (
- {emoji ? ( - <> - - {emoji.name} - - ) : null} + + + {emoji ? emoji.name : t('Pick an emojiโ€ฆ')} +
); }; diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index b99f1bd33f..042c4b5037 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -27,8 +27,16 @@ $emoji-picker-border-radius: 12px; --str-chat__background-utility-selected ); --str-chat__emoji-picker-border-color: var(--str-chat__border-core-on-surface); - --str-chat__emoji-picker-width: 20rem; - --str-chat__emoji-picker-height: 22.5rem; + // Gentle hairline for the search/footer dividers (the default on-surface border is + // intentionally strong and reads as too heavy here). + --str-chat__emoji-picker-separator-color: var(--str-chat__border-core-subtle); + // A soft grey fill for the search field (the app background token is white). + --str-chat__emoji-picker-search-background-color: var( + --str-chat__background-utility-hover + ); + --str-chat__emoji-picker-active-indicator-color: var(--str-chat__accent-primary); + --str-chat__emoji-picker-width: 22.5rem; + --str-chat__emoji-picker-height: 27.5rem; --str-chat__emoji-picker-emoji-size: 1.5rem; display: flex; @@ -40,14 +48,26 @@ $emoji-picker-border-radius: 12px; color: var(--str-chat__emoji-picker-text-color); font: var(--str-chat__font-body-default); + // The whole row is styled as one filled search field, with the icon inside it. &__search { display: flex; align-items: center; gap: var(--str-chat__spacing-xs); - padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-sm) 0; + margin: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm) + var(--str-chat__spacing-sm); + padding-inline: var(--str-chat__spacing-xs); + min-block-size: 2rem; + border-radius: var(--str-chat__radius-8); + background-color: var(--str-chat__emoji-picker-search-background-color); + + &:focus-within { + @include utils.focusable; + } .str-chat__icon { flex: none; + inline-size: 1rem; + block-size: 1rem; color: var(--str-chat__emoji-picker-secondary-text-color); } } @@ -55,15 +75,21 @@ $emoji-picker-border-radius: 12px; &__search-input { flex: 1 1 auto; min-inline-size: 0; - padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm); - border: 1px solid var(--str-chat__emoji-picker-border-color); - border-radius: var(--str-chat__radius-8); - background-color: var(--str-chat__background-core-surface-default); + border: none; + background: transparent; color: inherit; font: inherit; + font-size: 0.875rem; + line-height: 1.5; + // The filled field shows focus via `:focus-within`, so the input itself doesn't. + &:focus, &:focus-visible { - @include utils.focusable; + outline: none; + } + + &::placeholder { + color: var(--str-chat__emoji-picker-secondary-text-color); } } @@ -83,28 +109,28 @@ $emoji-picker-border-radius: 12px; &__category-nav { display: flex; - align-items: center; + align-items: stretch; gap: var(--str-chat__spacing-xxs); - padding: var(--str-chat__spacing-xs); - border-block-end: 1px solid var(--str-chat__emoji-picker-border-color); + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm) 0; } &__category-nav-button { @include utils.button-reset; cursor: pointer; + position: relative; display: flex; flex: 1 1 auto; align-items: center; justify-content: center; - padding: var(--str-chat__spacing-xxs); - border-radius: var(--str-chat__radius-4); + padding-block: var(--str-chat__spacing-xs); font-size: 1.125rem; line-height: 1; - opacity: 0.6; + // Muted tabs; the active one is emphasized with an underline bar (like + // emoji-mart) rather than a filled background. + opacity: 0.55; &:hover { - background-color: var(--str-chat__emoji-picker-hover-background-color); - opacity: 1; + opacity: 0.85; } &:focus-visible { @@ -112,14 +138,24 @@ $emoji-picker-border-radius: 12px; } &--active { - background-color: var(--str-chat__emoji-picker-selected-background-color); opacity: 1; + + &::after { + content: ''; + position: absolute; + inset-inline: 15%; + inset-block-end: 0; + block-size: 2px; + border-radius: 2px 2px 0 0; + background-color: var(--str-chat__emoji-picker-active-indicator-color); + } } } &__body { flex: 1 1 auto; min-block-size: 0; + border-block-start: 1px solid var(--str-chat__emoji-picker-separator-color); } &__grid-container { @@ -135,21 +171,20 @@ $emoji-picker-border-radius: 12px; position: sticky; inset-block-start: 0; z-index: 1; - padding-block: var(--str-chat__spacing-xs); + padding-block: var(--str-chat__spacing-xs) var(--str-chat__spacing-xxs); background-color: var(--str-chat__emoji-picker-background-color); color: var(--str-chat__emoji-picker-secondary-text-color); - font: var(--str-chat__font-body-emphasis); - text-transform: capitalize; + font-size: 0.8125rem; + font-weight: 500; } &-emojis { display: grid; - grid-template-columns: repeat( - auto-fill, - minmax(var(--str-chat__emoji-picker-emoji-size), 1fr) - ); - gap: var(--str-chat__spacing-xxs); - padding-block-end: var(--str-chat__spacing-sm); + // Cells are larger than the glyph and separated by a real gap so the grid + // breathes (roughly 9 per row at the default width) rather than packing tight. + grid-template-columns: repeat(auto-fill, minmax(1.75rem, 1fr)); + gap: var(--str-chat__spacing-xs); + padding-block-end: var(--str-chat__spacing-xs); } } @@ -180,7 +215,7 @@ $emoji-picker-border-radius: 12px; gap: var(--str-chat__spacing-sm); min-block-size: 2.75rem; padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm); - border-block-start: 1px solid var(--str-chat__emoji-picker-border-color); + border-block-start: 1px solid var(--str-chat__emoji-picker-separator-color); } &__preview { @@ -200,7 +235,6 @@ $emoji-picker-border-radius: 12px; color: var(--str-chat__emoji-picker-secondary-text-color); white-space: nowrap; text-overflow: ellipsis; - text-transform: capitalize; } } From a20dd46770a76e5ee9110ec7296d191dd2e6c5a1 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 14:19:09 +0200 Subject: [PATCH 16/36] fix(emojis): remove doubled search-field padding so the icon aligns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search field set `padding-inline` on top of the flex `gap` that already follows the (empty) visually-hidden label โ€” the first flex child โ€” so the search icon was inset twice (~16px) and looked misaligned with the content column below. Drop the inline padding; the icon's inset now comes solely from the gap (~8px). Follow-up to the emoji styling pass. --- src/plugins/Emojis/styling/EmojiPicker.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index 042c4b5037..69fe0269cc 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -55,7 +55,9 @@ $emoji-picker-border-radius: 12px; gap: var(--str-chat__spacing-xs); margin: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm) var(--str-chat__spacing-sm); - padding-inline: var(--str-chat__spacing-xs); + // No inline padding: the icon's inset already comes from the flex `gap` after + // the visually-hidden label (the first flex child). Adding padding here would + // stack on top of that gap and push the icon deeper into the field. min-block-size: 2rem; border-radius: var(--str-chat__radius-8); background-color: var(--str-chat__emoji-picker-search-background-color); From 737a03a3b578b66c97278cc002295b760c5c8d0e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 14:54:56 +0200 Subject: [PATCH 17/36] feat(emojis): show emoji shortcode in preview and keep frequently-used to one row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish two details of the built-in emoji picker: - The footer preview now shows the emoji's `:shortcode:` beneath its name (the same token that drives `:` autocomplete), so the preview doubles as a hint. - The "frequently used" section is capped to a single row: its ids are sliced to one row's worth (resolveFrequentlyUsedEmoji) and the frequent grid is pinned to a fixed column count, so it no longer wraps to extra rows as more emoji are used. Also adds EmojiButton skin-tone tests documenting that skin tone applies only to emoji that have skin variants (hands/people) โ€” not the faces shown by default โ€” which is why changing the tone appears to have no effect there. --- .../Emojis/components/EmojiPickerPanel.tsx | 5 +- src/plugins/Emojis/components/PreviewPane.tsx | 14 ++-- .../components/__tests__/EmojiButton.test.tsx | 71 +++++++++++++++++++ .../components/__tests__/PreviewPane.test.tsx | 43 +++++++++++ .../__tests__/frequentlyUsed.test.ts | 39 ++++++++++ .../Emojis/components/frequentlyUsed.ts | 27 +++++++ src/plugins/Emojis/styling/EmojiPicker.scss | 26 +++++++ 7 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx create mode 100644 src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx create mode 100644 src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts create mode 100644 src/plugins/Emojis/components/frequentlyUsed.ts diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index 2e2e76c82c..e9f8f288dd 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -4,6 +4,7 @@ import { CategoryNav } from './CategoryNav'; import { EmojiButton } from './EmojiButton'; import { EmojiGrid, type EmojiGridHandle, type EmojiPickerCategory } from './EmojiGrid'; import { EMOJI_CATEGORY_META } from './categories'; +import { resolveFrequentlyUsedEmoji } from './frequentlyUsed'; import { EmptyResults } from './EmptyResults'; import { PreviewPane } from './PreviewPane'; import { SearchInput } from './SearchInput'; @@ -101,7 +102,9 @@ export const EmojiPickerPanel = ({ const categories = useMemo(() => { if (!data || !frequentlyUsedIds.length) return baseCategories; const frequent: EmojiPickerCategory = { - emojis: frequentlyUsedIds.map((id) => data.emojis[id]).filter(Boolean), + // Capped to a single row (see resolveFrequentlyUsedEmoji + the frequent-section + // CSS) so the section never grows to multiple rows as more emoji are used. + emojis: resolveFrequentlyUsedEmoji(data, frequentlyUsedIds), id: 'frequent', label: t(EMOJI_CATEGORY_META.frequent.labelKey), }; diff --git a/src/plugins/Emojis/components/PreviewPane.tsx b/src/plugins/Emojis/components/PreviewPane.tsx index 4585742355..4bfce16b80 100644 --- a/src/plugins/Emojis/components/PreviewPane.tsx +++ b/src/plugins/Emojis/components/PreviewPane.tsx @@ -7,8 +7,9 @@ export type PreviewPaneProps = { }; /** - * Footer preview of the hovered/focused emoji: a large glyph plus its name. Falls back - * to a "Pick an emojiโ€ฆ" placeholder (like emoji-mart) so the footer is never empty. + * Footer preview of the hovered/focused emoji: a large glyph, its name, and its + * `:shortcode:` (so users learn the token that drives `:` autocomplete). Falls back to + * a "Pick an emojiโ€ฆ" placeholder (like emoji-mart) so the footer is never empty. * Receives the previewed emoji as a prop (kept out of context) so hovering does not * re-render the emoji grid. */ @@ -23,8 +24,13 @@ export const PreviewPane = ({ emoji }: PreviewPaneProps) => { ? (emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? '') : 'โ˜๏ธ'} - - {emoji ? emoji.name : t('Pick an emojiโ€ฆ')} + + + {emoji ? emoji.name : t('Pick an emojiโ€ฆ')} + + {emoji ? ( + {`:${emoji.id}:`} + ) : null}
); diff --git a/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx new file mode 100644 index 0000000000..f7284bd9d4 --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react'; +import { EmojiButton } from '../EmojiButton'; +import { EmojiPickerProvider } from '../../context/EmojiPickerContext'; +import type { EmojiDataEmoji } from '../../data'; + +// A skin-tone-capable emoji (6 skins) and a plain one (1 skin) โ€” mirrors the dataset, +// where only ~305 of ~1870 emoji have skin variants. +const wave: EmojiDataEmoji = { + id: 'wave', + keywords: ['hand'], + name: 'Waving Hand', + skins: [ + { native: '๐Ÿ‘‹', unified: '1f44b' }, + { native: '๐Ÿ‘‹๐Ÿป', unified: '1f44b-1f3fb' }, + { native: '๐Ÿ‘‹๐Ÿผ', unified: '1f44b-1f3fc' }, + { native: '๐Ÿ‘‹๐Ÿฝ', unified: '1f44b-1f3fd' }, + { native: '๐Ÿ‘‹๐Ÿพ', unified: '1f44b-1f3fe' }, + { native: '๐Ÿ‘‹๐Ÿฟ', unified: '1f44b-1f3ff' }, + ], + version: 1, +}; + +const grinning: EmojiDataEmoji = { + id: 'grinning', + keywords: ['face'], + name: 'Grinning', + skins: [{ native: '๐Ÿ˜€', unified: '1f600' }], + version: 1, +}; + +const renderButton = (emoji: EmojiDataEmoji, skinToneIndex: number) => + render( + + + , + ); + +describe('EmojiButton skin tone', () => { + it('renders the default glyph at skin tone 0', () => { + renderButton(wave, 0); + expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('๐Ÿ‘‹'); + }); + + it('renders the toned glyph for a skin-tone-capable emoji', () => { + renderButton(wave, 5); + expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('๐Ÿ‘‹๐Ÿฟ'); + }); + + it('updates the glyph when the active skin tone changes (memo does not block context)', () => { + const { rerender } = renderButton(wave, 0); + expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('๐Ÿ‘‹'); + + rerender( + + + , + ); + expect(screen.getByRole('button', { name: 'Waving Hand' })).toHaveTextContent('๐Ÿ‘‹๐Ÿฝ'); + }); + + it('leaves an emoji with no skin variants unchanged at any tone (why smileys never change)', () => { + renderButton(grinning, 5); + // Falls back to skins[0] โ€” this is why changing skin tone has no visible effect on + // the faces shown by default; only hand/person emoji carry skin variants. + expect(screen.getByRole('button', { name: 'Grinning' })).toHaveTextContent('๐Ÿ˜€'); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx b/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx new file mode 100644 index 0000000000..c5241a8b52 --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; + +vi.mock('../../../../context', () => ({ + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +import { PreviewPane } from '../PreviewPane'; +import { EmojiPickerProvider } from '../../context/EmojiPickerContext'; +import type { EmojiDataEmoji } from '../../data'; + +const smile: EmojiDataEmoji = { + id: 'smile', + keywords: ['happy'], + name: 'Smiling Face', + skins: [{ native: '๐Ÿ˜„', unified: '1f604' }], + version: 1, +}; + +const renderPreview = (emoji: EmojiDataEmoji | null, skinToneIndex = 0) => + render( + + + , + ); + +describe('PreviewPane', () => { + it('shows the emoji name and its shortcode when an emoji is previewed', () => { + renderPreview(smile); + + expect(screen.getByText('Smiling Face')).toBeInTheDocument(); + // The shortcode (`:id:`) must be visible so users learn the autocomplete token. + expect(screen.getByText(':smile:')).toBeInTheDocument(); + }); + + it('shows the placeholder and no shortcode when nothing is previewed', () => { + renderPreview(null); + + expect(screen.getByText('Pick an emojiโ€ฆ')).toBeInTheDocument(); + expect(screen.queryByText(/^:.+:$/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts b/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts new file mode 100644 index 0000000000..f77a8c6cd2 --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts @@ -0,0 +1,39 @@ +import { FREQUENTLY_USED_LIMIT, resolveFrequentlyUsedEmoji } from '../frequentlyUsed'; +import type { EmojiData, EmojiDataEmoji } from '../../data'; + +const makeEmoji = (id: string): EmojiDataEmoji => ({ + id, + keywords: [], + name: id, + skins: [{ native: id, unified: id }], + version: 1, +}); + +const data = { + aliases: {}, + categories: [], + emojis: Object.fromEntries( + Array.from({ length: 30 }, (_, i) => `e${i}`).map((id) => [id, makeEmoji(id)]), + ), +} as unknown as EmojiData; + +describe('resolveFrequentlyUsedEmoji', () => { + it('caps the frequently-used list to a single row so it never wraps', () => { + const ids = Array.from({ length: 30 }, (_, i) => `e${i}`); + const result = resolveFrequentlyUsedEmoji(data, ids); + + expect(result).toHaveLength(FREQUENTLY_USED_LIMIT); + }); + + it('preserves most-recent-first order', () => { + const result = resolveFrequentlyUsedEmoji(data, ['e2', 'e0', 'e1']); + + expect(result.map((emoji) => emoji.id)).toEqual(['e2', 'e0', 'e1']); + }); + + it('drops ids missing from the dataset', () => { + const result = resolveFrequentlyUsedEmoji(data, ['e0', 'does-not-exist', 'e1']); + + expect(result.map((emoji) => emoji.id)).toEqual(['e0', 'e1']); + }); +}); diff --git a/src/plugins/Emojis/components/frequentlyUsed.ts b/src/plugins/Emojis/components/frequentlyUsed.ts new file mode 100644 index 0000000000..ab37a9456f --- /dev/null +++ b/src/plugins/Emojis/components/frequentlyUsed.ts @@ -0,0 +1,27 @@ +import type { EmojiData, EmojiDataEmoji } from '../data'; + +/** + * How many recently-used emoji the "frequently used" section shows. Matches the + * default grid's column count, so the section stays a single row. + * + * Kept in sync with the fixed column count in EmojiPicker.scss + * (`[data-category-id='frequent'] .str-chat__emoji-picker__category-emojis`): the + * slice caps the item count and the CSS pins the columns, together guaranteeing one + * row regardless of how many emoji have been used. + */ +export const FREQUENTLY_USED_LIMIT = 9; + +/** + * Resolves an ordered (most-recent-first) list of emoji ids to dataset entries for the + * "frequently used" section: drops ids missing from the dataset and caps the result to + * a single row (`FREQUENTLY_USED_LIMIT`). + */ +export const resolveFrequentlyUsedEmoji = ( + data: EmojiData, + frequentlyUsedIds: string[], + limit = FREQUENTLY_USED_LIMIT, +): EmojiDataEmoji[] => + frequentlyUsedIds + .map((id) => data.emojis[id]) + .filter(Boolean) + .slice(0, limit); diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index 69fe0269cc..f8110b3467 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -188,6 +188,15 @@ $emoji-picker-border-radius: 12px; gap: var(--str-chat__spacing-xs); padding-block-end: var(--str-chat__spacing-xs); } + + // Pin the "frequently used" section to a single row. The panel caps its ids to + // FREQUENTLY_USED_LIMIT (see frequentlyUsed.ts) and these fixed columns match the + // default grid width, so it fills one aligned row and never wraps to a second as + // more emoji are used. (Uses the literal child class, not `&-emojis`, so the + // combinator doesn't re-expand the whole parent chain.) + &[data-category-id='frequent'] > .str-chat__emoji-picker__category-emojis { + grid-template-columns: repeat(9, minmax(0, 1fr)); + } } &__emoji { @@ -228,15 +237,32 @@ $emoji-picker-border-radius: 12px; min-inline-size: 0; &-emoji { + flex: none; font-size: 1.5rem; line-height: 1; } + // Name (prominent) stacked over the `:shortcode:` (muted), both truncated. + &-text { + display: flex; + flex-direction: column; + min-inline-size: 0; + line-height: 1.2; + } + &-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 0.875rem; + } + + &-shortcode { overflow: hidden; color: var(--str-chat__emoji-picker-secondary-text-color); white-space: nowrap; text-overflow: ellipsis; + font-size: 0.8125rem; } } From 044e97a892ea4c37bd41b6c6610b32186ad021bf Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 15:14:36 +0200 Subject: [PATCH 18/36] feat(emojis): restore reacting with any emoji via loadDefaultExtendedReactionOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reaction selector's "+" (react with any emoji) was driven by `reactionOptions.extended`, which the examples used to populate from the full `@emoji-mart/data` via `mapEmojiMartData`. Removing emoji-mart left `extended` empty, so only the handful of quick reactions showed โ€” and, because `useProcessReactions` renders a reaction only when its type is a known option, arbitrary-emoji reactions could no longer be displayed either. Add `loadDefaultExtendedReactionOptions()` to the `stream-chat-react/emojis` entry: it builds the full extended map (every vendored emoji, keyed by unicode) from the lazily code-split dataset, so importing it costs nothing until called and the dataset never enters an app's initial bundle. Core stays emoji-free โ€” the loader lives in the opt-in emojis entry and only depends on the pure `mapEmojiMartData` from core. Wire it into the vite example (load + merge into reactionOptions.extended) and document the pattern in AI.md. --- AI.md | 25 ++++++++++- examples/vite/src/App.tsx | 41 ++++++++++--------- .../Emojis/__tests__/reactions.test.tsx | 32 +++++++++++++++ src/plugins/Emojis/index.ts | 1 + src/plugins/Emojis/reactions.ts | 24 +++++++++++ 5 files changed, 101 insertions(+), 22 deletions(-) create mode 100644 src/plugins/Emojis/__tests__/reactions.test.tsx create mode 100644 src/plugins/Emojis/reactions.ts diff --git a/AI.md b/AI.md index 12ddae5deb..e8cd9f18a0 100644 --- a/AI.md +++ b/AI.md @@ -271,8 +271,29 @@ import 'stream-chat-react/css/emoji-picker.css'; - `pickerProps` accepts only `theme` and `style`. emoji-mart `Picker` options (`data`, `set`, `custom`, `categories`, `perLine`, `emojiVersion`, `locale`, โ€ฆ) are no longer supported: the type rejects them and any passed via `as` casts are - ignored with a console warning. For custom reaction emoji use `mapEmojiMartData`; - for appearance use the `--str-chat__emoji-picker-*` CSS variables. + ignored with a console warning. For appearance use the `--str-chat__emoji-picker-*` + CSS variables. +- To let users **react with any emoji** (the reaction selector's `+` button), fill + `reactionOptions.extended` with the full emoji set. It also gates display โ€” a reaction + whose type isn't in `quick`/`extended` is not rendered. Load it lazily from the emojis + entry (the dataset is code-split, so this adds nothing to your initial bundle): + + ```tsx + import { defaultReactionOptions, type ReactionOptions } from 'stream-chat-react'; + import { loadDefaultExtendedReactionOptions } from 'stream-chat-react/emojis'; + + const [reactionOptions, setReactionOptions] = + useState(defaultReactionOptions); + useEffect(() => { + loadDefaultExtendedReactionOptions().then((extended) => + setReactionOptions({ ...defaultReactionOptions, extended }), + ); + }, []); + + {/* ... */}; + ``` + + For a curated subset instead, build the `extended` map yourself with `mapEmojiMartData`. **Reference**: See `examples/tutorial/src/6-emoji-picker/` diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 5080dbc848..2cd94c467e 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -29,7 +29,6 @@ import { ChatView, defaultReactionOptions, DialogManagerProvider, - mapEmojiMartData, MessageReactions, NotificationList, type NotificationListProps, @@ -39,7 +38,11 @@ import { useCreateChatClient, WithComponents, } from 'stream-chat-react'; -import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; +import { + createTextComposerEmojiMiddleware, + EmojiPicker, + loadDefaultExtendedReactionOptions, +} from 'stream-chat-react/emojis'; import { humanId } from 'human-id'; import { appSettingsStore, useAppSettingsSelector } from './AppSettings'; @@ -105,23 +108,6 @@ const sort: ChannelSort = { last_message_at: -1, updated_at: -1 }; // @ts-expect-error ai_generated isn't on LocalMessage's public type yet const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated; -// A few extra reactions, built from emoji-mart-shaped data via `mapEmojiMartData`. -const extendedReactionData = { - emojis: { - clap: { name: 'Clapping Hands', skins: [{ native: '๐Ÿ‘' }] }, - eyes: { name: 'Eyes', skins: [{ native: '๐Ÿ‘€' }] }, - hundred: { name: 'Hundred Points', skins: [{ native: '๐Ÿ’ฏ' }] }, - pray: { name: 'Folded Hands', skins: [{ native: '๐Ÿ™' }] }, - rocket: { name: 'Rocket', skins: [{ native: '๐Ÿš€' }] }, - tada: { name: 'Party Popper', skins: [{ native: '๐ŸŽ‰' }] }, - }, -}; - -const newReactionOptions: ReactionOptions = { - ...defaultReactionOptions, - extended: mapEmojiMartData(extendedReactionData), -}; - const useUser = () => { const searchParams = useMemo(() => new URLSearchParams(window.location.search), []); @@ -271,6 +257,21 @@ const CustomAttachmentWithActions = (props: AttachmentProps) => ( const App = () => { const { tokenProvider, userId, userImage, userName } = useUser(); + // Restore "react with any emoji": lazily load the full emoji set and expose it as the + // extended reaction options (the reaction selector's "+" list). The dataset loads on + // demand via the same code-split chunk the picker uses; until it resolves only the + // quick reactions show. + const [reactionOptions, setReactionOptions] = + useState(defaultReactionOptions); + useEffect(() => { + let active = true; + loadDefaultExtendedReactionOptions().then((extended) => { + if (active) setReactionOptions({ ...defaultReactionOptions, extended }); + }); + return () => { + active = false; + }; + }, []); const chatView = useAppSettingsSelector((state) => state.chatView); const { mode: themeMode } = useAppSettingsSelector((state) => state.theme); const initialSearchParams = useMemo( @@ -472,7 +473,7 @@ const App = () => { EmojiPicker: EmojiPickerWithCustomOptions, NotificationList: ConfigurableNotificationList, MessageReactions: CustomMessageReactions, - reactionOptions: newReactionOptions, + reactionOptions, Search: CustomChannelSearch, HeaderEndContent: SidebarToggle, HeaderStartContent: SidebarToggle, diff --git a/src/plugins/Emojis/__tests__/reactions.test.tsx b/src/plugins/Emojis/__tests__/reactions.test.tsx new file mode 100644 index 0000000000..42a21d584f --- /dev/null +++ b/src/plugins/Emojis/__tests__/reactions.test.tsx @@ -0,0 +1,32 @@ +import { render } from '@testing-library/react'; +import { loadDefaultExtendedReactionOptions } from '../reactions'; +import { emojiToUnicode } from '../../../components/Reactions/reactionOptions'; + +describe('loadDefaultExtendedReactionOptions', () => { + it('builds an extended reaction map covering the full emoji dataset', async () => { + const extended = await loadDefaultExtendedReactionOptions(); + + // The full vendored set (~1,870 emoji) โ€” far beyond the handful of quick reactions, + // so the picker's "+" list lets you react with essentially any emoji again. + expect(Object.keys(extended).length).toBeGreaterThan(1000); + }); + + it('keys entries by unicode and renders the native glyph', async () => { + const extended = await loadDefaultExtendedReactionOptions(); + const grinningUnicode = emojiToUnicode('๐Ÿ˜€'); // U+1F600 + + const entry = extended[grinningUnicode]; + expect(entry).toBeDefined(); + expect(entry.unicode).toBe(grinningUnicode); + + const { container } = render(); + expect(container).toHaveTextContent('๐Ÿ˜€'); + }); + + it('memoizes: repeated calls resolve the same map (no rebuild per reaction render)', async () => { + const first = await loadDefaultExtendedReactionOptions(); + const second = await loadDefaultExtendedReactionOptions(); + + expect(first).toBe(second); + }); +}); diff --git a/src/plugins/Emojis/index.ts b/src/plugins/Emojis/index.ts index 92d4ca9abf..0536a8d90e 100644 --- a/src/plugins/Emojis/index.ts +++ b/src/plugins/Emojis/index.ts @@ -3,4 +3,5 @@ export * from './components'; export * from './data'; export * from './EmojiPicker'; export * from './middleware'; +export * from './reactions'; export * from './search'; diff --git a/src/plugins/Emojis/reactions.ts b/src/plugins/Emojis/reactions.ts new file mode 100644 index 0000000000..dcf659c1a7 --- /dev/null +++ b/src/plugins/Emojis/reactions.ts @@ -0,0 +1,24 @@ +import { mapEmojiMartData } from '../../components/Reactions/reactionOptions'; +import { loadEmojiData } from './data'; +import { memoizeAsyncWithReset } from './memoizeAsyncWithReset'; + +/** + * Loads the full set of "extended" reaction options โ€” every vendored emoji, keyed by + * unicode โ€” for use as `reactionOptions.extended`. This restores reacting with (almost) + * any emoji via the reaction selector's "+" button, which previously came from feeding + * the whole `@emoji-mart/data` object into `mapEmojiMartData`. + * + * Both halves of that feature read `reactionOptions.extended`: the selector only shows + * "+" when it is non-empty, and `useProcessReactions` renders a reaction only when its + * type is a known option โ€” so without this map an arbitrary-emoji reaction can be sent + * but not displayed. + * + * The dataset is fetched through the same lazily code-split dynamic import the picker + * uses (and the result is memoized), so importing this loader costs nothing until it is + * actually called and the ~340 KB JSON never lands in an app's initial bundle. + * Integrators call it once โ€” e.g. in an effect โ€” and merge the result into their + * reaction options; see the emoji migration notes in `AI.md`. + */ +export const loadDefaultExtendedReactionOptions = memoizeAsyncWithReset(async () => + mapEmojiMartData(await loadEmojiData()), +); From 79b2a23e5c7e530b7576c04e9b22ce8ac09803bd Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 16:33:51 +0200 Subject: [PATCH 19/36] fix(emojis): let keyboard navigation cross virtualized category boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emoji grid virtualizes at the category level, but useGridKeyboardNav only considered the cells currently mounted. At the edge of the mounted window ArrowRight clamped to the current cell and ArrowDown found nothing, with no way to scroll an unmounted category in โ€” so keyboard users could not reach the full emoji set. Give the hook the ordered categories and a scrollToCategory callback. Within the mounted window it behaves as before; when a move leaves that window it scrolls the neighbouring category into view, waits (MutationObserver, with a timeout fallback) for its cells to mount, then focuses the target โ€” first/last cell for Left/Right, same-column first/last row for Up/Down. Home/End now reach the absolute first/last emoji. Crossing is suppressed in the search view, whose flat results have no category and are all mounted. --- .../Emojis/components/EmojiPickerPanel.tsx | 12 +- .../__tests__/useGridKeyboardNav.test.tsx | 124 +++++++++ .../Emojis/hooks/useGridKeyboardNav.ts | 243 +++++++++++++++--- 3 files changed, 344 insertions(+), 35 deletions(-) create mode 100644 src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index e9f8f288dd..37817b021a 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -88,7 +88,6 @@ export const EmojiPickerPanel = ({ const [query, setQuery] = useState(''); const emojiGridRef = useRef(null); const bodyRef = useRef(null); - const { focusFirst, onKeyDown: onGridKeyDown } = useGridKeyboardNav(bodyRef); const baseCategories = useMemo(() => { if (!data) return []; @@ -111,6 +110,17 @@ export const EmojiPickerPanel = ({ return frequent.emojis.length ? [frequent, ...baseCategories] : baseCategories; }, [baseCategories, data, frequentlyUsedIds, t]); + const scrollToCategory = useCallback((categoryId: string) => { + emojiGridRef.current?.scrollToCategory(categoryId); + }, []); + + // Keyboard nav can target a category that virtualization has unmounted; give it the + // category order + a way to scroll one into view so focus can traverse the whole set. + const { focusFirst, onKeyDown: onGridKeyDown } = useGridKeyboardNav(bodyRef, { + categories, + scrollToCategory, + }); + const searchIndex = useMemo(() => (data ? buildEmojiSearchData(data) : []), [data]); // `null` when not searching; otherwise the (possibly empty) list of matches. diff --git a/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx b/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx new file mode 100644 index 0000000000..979c1f604b --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx @@ -0,0 +1,124 @@ +import { useRef, useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useGridKeyboardNav } from '../useGridKeyboardNav'; + +// jsdom doesn't implement scrollIntoView; the hook calls it after focusing a cell. +beforeAll(() => { + Element.prototype.scrollIntoView = () => undefined; +}); + +type Category = { emojis: string[]; id: string }; + +/** + * Mimics the virtualized grid: only `initialMounted` categories are in the DOM, and + * asking to scroll to a category "mounts" it (as Virtuoso would). This lets us prove + * navigation can cross into a category that virtualization has not yet rendered. + */ +const Harness = ({ + categories, + initialMounted, +}: { + categories: Category[]; + initialMounted: string[]; +}) => { + const ref = useRef(null); + const [mounted, setMounted] = useState(initialMounted); + const scrollToCategory = (id: string) => + setMounted((current) => (current.includes(id) ? current : [...current, id])); + const { onKeyDown } = useGridKeyboardNav(ref, { categories, scrollToCategory }); + + return ( +
+ {categories + .filter((category) => mounted.includes(category.id)) + .map((category) => ( +
+ {category.emojis.map((id) => ( + + ))} +
+ ))} +
+ ); +}; + +const categories: Category[] = [ + { emojis: ['a1', 'a2', 'a3'], id: 'a' }, + { emojis: ['b1', 'b2', 'b3'], id: 'b' }, +]; + +describe('useGridKeyboardNav across virtualization boundaries', () => { + it('ArrowRight past the last mounted cell mounts the next category and focuses its first cell', async () => { + render(); + // The next category isn't mounted yet (virtualization). + expect(screen.queryByText('b1')).not.toBeInTheDocument(); + + screen.getByText('a3').focus(); + fireEvent.keyDown(screen.getByText('a3'), { key: 'ArrowRight' }); + + await waitFor(() => expect(screen.getByText('b1')).toHaveFocus()); + }); + + it('ArrowLeft before the first mounted cell mounts the previous category and focuses its last cell', async () => { + render(); + expect(screen.queryByText('a3')).not.toBeInTheDocument(); + + screen.getByText('b1').focus(); + fireEvent.keyDown(screen.getByText('b1'), { key: 'ArrowLeft' }); + + await waitFor(() => expect(screen.getByText('a3')).toHaveFocus()); + }); + + it('ArrowRight still moves within the mounted set without scrolling', () => { + render(); + + screen.getByText('a1').focus(); + fireEvent.keyDown(screen.getByText('a1'), { key: 'ArrowRight' }); + + expect(screen.getByText('a2')).toHaveFocus(); + }); + + it('does not try to cross categories in the search view (flat results, no category sections)', () => { + const scrollToCategory = vi.fn(); + const SearchHarness = () => { + const ref = useRef(null); + const { onKeyDown } = useGridKeyboardNav(ref, { categories, scrollToCategory }); + // Search results render as a flat grid with no [data-category-id] ancestor. + return ( +
+ {['r1', 'r2'].map((id) => ( + + ))} +
+ ); + }; + render(); + + // ArrowRight at the last result clamps (no category to scroll to)โ€ฆ + screen.getByText('r2').focus(); + fireEvent.keyDown(screen.getByText('r2'), { key: 'ArrowRight' }); + expect(screen.getByText('r2')).toHaveFocus(); + + // โ€ฆand Home goes to the first result rather than scrolling to a category. + fireEvent.keyDown(screen.getByText('r2'), { key: 'Home' }); + expect(screen.getByText('r1')).toHaveFocus(); + + expect(scrollToCategory).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/Emojis/hooks/useGridKeyboardNav.ts b/src/plugins/Emojis/hooks/useGridKeyboardNav.ts index f0da1dfa01..c3ce712d0d 100644 --- a/src/plugins/Emojis/hooks/useGridKeyboardNav.ts +++ b/src/plugins/Emojis/hooks/useGridKeyboardNav.ts @@ -1,24 +1,111 @@ -import { type KeyboardEvent, useCallback, useEffect } from 'react'; +import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react'; type GridRef = { readonly current: HTMLElement | null }; +export type GridKeyboardNavOptions = { + /** + * Ordered categories (only `id` is read). Lets navigation cross into a category that + * virtualization has not mounted โ€” without it, navigation is limited to the cells + * currently in the DOM. + */ + categories?: readonly { id: string }[]; + /** + * Scrolls a category into view so virtualization mounts it (e.g. Virtuoso's + * `scrollToIndex`). Navigation focuses the target cell once that category appears. + */ + scrollToCategory?: (categoryId: string) => void; +}; + const EMOJI_SELECTOR = '.str-chat__emoji-picker__emoji'; +const CATEGORY_SELECTOR = '[data-category-id]'; const NAV_KEYS = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End']; +// Stop waiting for an offscreen category to mount after this long (avoids a dangling +// observer if a scroll never mounts the target). +const MOUNT_TIMEOUT_MS = 1000; + +const cellsIn = (root: Element | null) => + root ? Array.from(root.querySelectorAll(EMOJI_SELECTOR)) : []; + +// Half a cell tall โ€” the tolerance for treating cells as sharing a row by their top edge. +const rowEpsilon = (cell: HTMLButtonElement) => + Math.max(2, cell.getBoundingClientRect().height / 2); + +// The active cell's index within its own visual row (its "column"). +const columnOf = (cell: HTMLButtonElement) => { + const section = cell.closest(CATEGORY_SELECTOR); + if (!section) return 0; + const top = cell.getBoundingClientRect().top; + const eps = rowEpsilon(cell); + const row = cellsIn(section) + .filter((c) => Math.abs(c.getBoundingClientRect().top - top) < eps) + .sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left); + const column = row.indexOf(cell); + return column < 0 ? 0 : column; +}; + +// The cell at `column` of a category's first or last row โ€” used to land in the same +// column when crossing a category boundary via Arrow Up/Down. +const edgeRowCell = ( + cells: HTMLButtonElement[], + edge: 'first' | 'last', + column: number, +) => { + if (!cells.length) return undefined; + const tops = cells.map((c) => c.getBoundingClientRect().top); + const edgeTop = edge === 'first' ? Math.min(...tops) : Math.max(...tops); + const eps = rowEpsilon(cells[0]); + const row = cells + .filter((c) => Math.abs(c.getBoundingClientRect().top - edgeTop) < eps) + .sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left); + const target = row.length ? row : cells; + return target[Math.min(column, target.length - 1)]; +}; + +// The geometrically nearest cell on the adjacent row (same column preferred), matched +// by center-x. Robust across category headers and variable column counts. `undefined` +// when there is no such cell among `cells` (i.e. we're at the mounted top/bottom edge). +const adjacentRowCell = ( + cells: HTMLButtonElement[], + active: HTMLButtonElement, + goingDown: boolean, +) => { + const current = active.getBoundingClientRect(); + const centerX = current.left + current.width / 2; + const centerY = current.top + current.height / 2; + + let best: HTMLButtonElement | undefined; + let bestScore = Infinity; + for (const cell of cells) { + if (cell === active) continue; + const rect = cell.getBoundingClientRect(); + const dy = rect.top + rect.height / 2 - centerY; + // Skip cells on the same row or in the wrong direction. + if (goingDown ? dy <= current.height / 2 : dy >= -current.height / 2) continue; + const dx = Math.abs(rect.left + rect.width / 2 - centerX); + const score = dx + Math.abs(dy) * 2; // prefer same column, then nearest row + if (score < bestScore) { + bestScore = score; + best = cell; + } + } + return best; +}; /** * Roving-tabindex keyboard navigation for the emoji grid. Left/Right move in reading - * order; Up/Down pick the geometrically nearest cell on the adjacent row (robust - * across category headers and virtualization, which a fixed column count is not). - * Operates on the currently rendered cells; focusing scrolls the next ones in. + * order; Up/Down pick the geometrically nearest cell on the adjacent row (robust across + * category headers and virtualization, which a fixed column count is not). + * + * Because the grid is virtualized, a move can land in a category that is not currently + * mounted. When that happens navigation asks `scrollToCategory` to mount it, then + * focuses the target cell as soon as it appears in the DOM โ€” so keyboard users can reach + * the whole emoji set, not just the mounted window. */ -export const useGridKeyboardNav = (gridRef: GridRef) => { - const getButtons = useCallback( - () => - Array.from( - gridRef.current?.querySelectorAll(EMOJI_SELECTOR) ?? [], - ), - [gridRef], - ); +export const useGridKeyboardNav = ( + gridRef: GridRef, + { categories = [], scrollToCategory }: GridKeyboardNavOptions = {}, +) => { + const getButtons = useCallback(() => cellsIn(gridRef.current), [gridRef]); const setRoving = useCallback( (target: HTMLButtonElement) => { @@ -51,6 +138,62 @@ export const useGridKeyboardNav = (gridRef: GridRef) => { focusButton(getButtons()[0]); }, [focusButton, getButtons]); + // Teardown for a pending "focus once the category mounts" watch. Replaced whenever a + // new cross starts; invoked on a superseding keypress and on unmount. + const cancelPending = useRef<() => void>(() => undefined); + useEffect(() => () => cancelPending.current(), []); + + // Ask virtualization to mount `categoryId`, then focus the cell `pick` chooses from + // that category once its cells are in the DOM. Returns `false` (unhandled) only when + // there is no such category or no way to scroll to it. + const crossToCategory = useCallback( + ( + categoryId: string | undefined, + pick: (cells: HTMLButtonElement[]) => HTMLButtonElement | undefined, + ) => { + if (!categoryId || !scrollToCategory) return false; + + const resolve = () => { + const section = gridRef.current?.querySelector( + `${CATEGORY_SELECTOR}[data-category-id="${categoryId}"]`, + ); + const cells = cellsIn(section ?? null); + return cells.length ? pick(cells) : undefined; + }; + + scrollToCategory(categoryId); + cancelPending.current(); + + // Already mounted (adjacent category within the overscan window) โ€” focus now. + const immediate = resolve(); + if (immediate) { + focusButton(immediate); + return true; + } + + const grid = gridRef.current; + if (!grid) return true; + + // Otherwise wait for the scroll to mount it, then focus. + const observer = new MutationObserver(() => { + const target = resolve(); + if (target) { + cancelPending.current(); + focusButton(target); + } + }); + observer.observe(grid, { childList: true, subtree: true }); + const timeout = setTimeout(() => cancelPending.current(), MOUNT_TIMEOUT_MS); + cancelPending.current = () => { + observer.disconnect(); + clearTimeout(timeout); + cancelPending.current = () => undefined; + }; + return true; + }, + [focusButton, gridRef, scrollToCategory], + ); + const onKeyDown = useCallback( (event: KeyboardEvent) => { if (!NAV_KEYS.includes(event.key)) return; @@ -58,48 +201,80 @@ export const useGridKeyboardNav = (gridRef: GridRef) => { const index = buttons.findIndex((button) => button === document.activeElement); if (index === -1) return; event.preventDefault(); + // A fresh keypress supersedes any in-flight wait for an offscreen category. + cancelPending.current(); + + const active = buttons[index]; + // The category the focused cell belongs to; -1 in the (non-virtualized) search + // view, where every result is already mounted so crossing is neither possible nor + // needed. `inCategoryView` gates every scroll-to-mount branch on that. + const activeCategoryId = active + .closest(CATEGORY_SELECTOR) + ?.getAttribute('data-category-id'); + const category = categories.findIndex((entry) => entry.id === activeCategoryId); + const inCategoryView = category >= 0; if (event.key === 'Home') { + if (inCategoryView && crossToCategory(categories[0]?.id, (cells) => cells[0])) { + return; + } focusButton(buttons[0]); return; } if (event.key === 'End') { + const lastId = categories[categories.length - 1]?.id; + if ( + inCategoryView && + crossToCategory(lastId, (cells) => cells[cells.length - 1]) + ) { + return; + } focusButton(buttons[buttons.length - 1]); return; } if (event.key === 'ArrowRight') { - focusButton(buttons[Math.min(index + 1, buttons.length - 1)]); + if (index < buttons.length - 1) { + focusButton(buttons[index + 1]); + return; + } + const next = inCategoryView ? categories[category + 1]?.id : undefined; + if (crossToCategory(next, (cells) => cells[0])) return; + focusButton(active); // truly the last cell โ€” stay put return; } if (event.key === 'ArrowLeft') { - focusButton(buttons[Math.max(index - 1, 0)]); + if (index > 0) { + focusButton(buttons[index - 1]); + return; + } + const previous = category > 0 ? categories[category - 1]?.id : undefined; + if (crossToCategory(previous, (cells) => cells[cells.length - 1])) return; + focusButton(active); return; } - // ArrowUp / ArrowDown โ€” nearest cell on the adjacent row, matched by center-x. - const current = buttons[index].getBoundingClientRect(); - const centerX = current.left + current.width / 2; - const centerY = current.top + current.height / 2; + // ArrowUp / ArrowDown. const goingDown = event.key === 'ArrowDown'; - - let best: HTMLButtonElement | undefined; - let bestScore = Infinity; - for (const button of buttons) { - if (button === buttons[index]) continue; - const rect = button.getBoundingClientRect(); - const dy = rect.top + rect.height / 2 - centerY; - // Skip cells on the same row or the wrong direction. - if (goingDown ? dy <= current.height / 2 : dy >= -current.height / 2) continue; - const dx = Math.abs(rect.left + rect.width / 2 - centerX); - const score = dx + Math.abs(dy) * 2; // prefer same column, then nearest row - if (score < bestScore) { - bestScore = score; - best = button; + const adjacent = adjacentRowCell(buttons, active, goingDown); + if (adjacent) { + focusButton(adjacent); + return; + } + // No adjacent row in the mounted set โ€” cross into the neighbouring category, + // landing in the same column of its first (down) or last (up) row. + const column = columnOf(active); + if (goingDown) { + const next = inCategoryView ? categories[category + 1]?.id : undefined; + if (crossToCategory(next, (cells) => edgeRowCell(cells, 'first', column))) return; + } else if (category > 0) { + const previous = categories[category - 1]?.id; + if (crossToCategory(previous, (cells) => edgeRowCell(cells, 'last', column))) { + return; } } - focusButton(best ?? buttons[index]); + focusButton(active); }, - [focusButton, getButtons], + [categories, crossToCategory, focusButton, getButtons], ); return { focusFirst, onKeyDown }; From c375e7dc315ba75e93bb50fad97c79737600516f Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 17:49:08 +0200 Subject: [PATCH 20/36] fix(emojis): correct three picker option/state bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Text-composer middleware: spread options into a fresh object instead of mutating the shared DEFAULT_OPTIONS, so options no longer leak between createTextComposerEmojiMiddleware() calls โ€” the no-arg default path kept the wrong trigger/minChars after any earlier customized call. - EmojiPicker: record a "frequently used" emoji only after confirming there is a textarea to insert into, so a no-op selection isn't tracked as used. - useFrequentlyUsedEmoji: build each update from a latest-value ref so two selections in one tick both survive instead of the second dropping the first. --- src/plugins/Emojis/EmojiPicker.tsx | 3 ++- .../Emojis/__tests__/EmojiPicker.test.tsx | 25 ++++++++++++++++--- .../__tests__/useFrequentlyUsedEmoji.test.ts | 23 +++++++++++++++++ .../Emojis/hooks/useFrequentlyUsedEmoji.ts | 13 +++++++--- .../textComposerEmojiMiddleware.test.ts | 17 +++++++++++++ .../middleware/textComposerEmojiMiddleware.ts | 5 ++-- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index f06732e546..1d621a64c6 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -164,9 +164,10 @@ export const EmojiPicker = (props: EmojiPickerProps) => { referenceElement?.focus(); }} onEmojiSelect={(emoji) => { - recordUse(emoji.id); const textarea = textareaRef.current; if (!textarea) return; + // Record only once we know the emoji is actually being inserted. + recordUse(emoji.id); textComposer.insertText({ text: emoji.native }); textarea.focus(); if (props.closeOnEmojiSelect) { diff --git a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx index ee5e024e23..88293fbb77 100644 --- a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx +++ b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx @@ -33,10 +33,13 @@ vi.mock('../components', () => ({ ), })); +// Mutable so a test can simulate "no textarea to insert into" (textareaRef.current null). +const { textareaRef } = vi.hoisted(() => ({ + textareaRef: { current: null as HTMLTextAreaElement | null }, +})); + vi.mock('../../../context', () => ({ - useMessageComposerContext: () => ({ - textareaRef: { current: document.createElement('textarea') }, - }), + useMessageComposerContext: () => ({ textareaRef }), useTranslationContext: () => ({ t: (key: string) => key }), })); @@ -81,6 +84,10 @@ import { EmojiPicker } from '../EmojiPicker'; const openPicker = () => fireEvent.click(screen.getByLabelText('aria/Emoji picker')); +beforeEach(() => { + textareaRef.current = document.createElement('textarea'); +}); + describe('EmojiPicker session state', () => { it('keeps skin tone and frequently-used across close and reopen (incl. closeOnEmojiSelect)', () => { render(); @@ -102,6 +109,18 @@ describe('EmojiPicker session state', () => { expect(screen.getByTestId('skin-tone')).toHaveTextContent('4'); expect(screen.getByTestId('frequently-used')).toHaveTextContent('rocket'); }); + + it('does not record a frequently-used emoji when there is no textarea to insert into', () => { + textareaRef.current = null; + render(); + + openPicker(); + expect(screen.getByTestId('frequently-used').textContent).toBe(''); + + // Selecting can't insert (no textarea), so it must not be recorded as "used". + fireEvent.click(screen.getByText('select-rocket')); + expect(screen.getByTestId('frequently-used').textContent).toBe(''); + }); }); describe('EmojiPicker pickerProps', () => { diff --git a/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts b/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts index fd03a7a2e3..2ab949e0a2 100644 --- a/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts +++ b/src/plugins/Emojis/hooks/__tests__/useFrequentlyUsedEmoji.test.ts @@ -23,4 +23,27 @@ describe('useFrequentlyUsedEmoji', () => { rerender({ frequentlyUsedEmoji: ['y', 'x'] }); expect(result.current.frequentlyUsedIds).toEqual(['y', 'x']); }); + + it('keeps both when two emoji are recorded before a re-render (uncontrolled)', () => { + const { result } = renderHook(() => useFrequentlyUsedEmoji({})); + // Two selects in the same tick close over the same list โ€” the second must still + // build on the first, not overwrite it. + act(() => { + result.current.recordUse('a'); + result.current.recordUse('b'); + }); + expect(result.current.frequentlyUsedIds).toEqual(['b', 'a']); + }); + + it('accumulates both when two emoji are recorded before a re-render (controlled)', () => { + const onFrequentlyUsedChange = vi.fn(); + const { result } = renderHook(() => + useFrequentlyUsedEmoji({ frequentlyUsedEmoji: [], onFrequentlyUsedChange }), + ); + act(() => { + result.current.recordUse('a'); + result.current.recordUse('b'); + }); + expect(onFrequentlyUsedChange).toHaveBeenLastCalledWith(['b', 'a']); + }); }); diff --git a/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts b/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts index 0cd961042f..7efb681b2a 100644 --- a/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts +++ b/src/plugins/Emojis/hooks/useFrequentlyUsedEmoji.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; export type UseFrequentlyUsedEmojiParams = { /** Controlled ordered list of recently used emoji ids (most recent first). */ @@ -23,16 +23,23 @@ export const useFrequentlyUsedEmoji = ({ const isControlled = Array.isArray(frequentlyUsedEmoji); const frequentlyUsedIds = isControlled ? frequentlyUsedEmoji : internal; + // Mirror the current list into a ref so several recordUse calls fired before a + // re-render each build on the previous result rather than a stale closure/prop + // (otherwise the second synchronous select would drop the first). + const latestRef = useRef(frequentlyUsedIds); + latestRef.current = frequentlyUsedIds; + const recordUse = useCallback( (emojiId: string) => { const next = [ emojiId, - ...frequentlyUsedIds.filter((existing) => existing !== emojiId), + ...latestRef.current.filter((existing) => existing !== emojiId), ].slice(0, MAX_FREQUENTLY_USED); + latestRef.current = next; if (!isControlled) setInternal(next); onFrequentlyUsedChange?.(next); }, - [frequentlyUsedIds, isControlled, onFrequentlyUsedChange], + [isControlled, onFrequentlyUsedChange], ); return { frequentlyUsedIds, recordUse }; diff --git a/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts b/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts index 0eef1cf59e..db90278195 100644 --- a/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts +++ b/src/plugins/Emojis/middleware/__tests__/textComposerEmojiMiddleware.test.ts @@ -82,4 +82,21 @@ describe('createTextComposerEmojiMiddleware', () => { expect(search).toHaveBeenCalled(); expect(items[0]?.id).toBe('custom'); }); + + it('does not leak options between calls (shared defaults are not mutated)', async () => { + // A call that overrides options must not change the defaults a later no-arg call + // sees. Previously `mergeWith(DEFAULT_OPTIONS, options)` mutated the shared constant. + createTextComposerEmojiMiddleware(undefined, { minChars: 3, trigger: ';' }); + + const withDefaults = createTextComposerEmojiMiddleware(); + const { output } = await runOnChange(withDefaults, { + selection: { end: 4, start: 4 }, + suggestions: undefined, + text: ':smi', + }); + + // Still the default ':' trigger โ€” not the ';' leaked from the earlier call. + expect(output?.suggestions?.trigger).toBe(':'); + expect(output?.suggestions?.query).toBe('smi'); + }); }); diff --git a/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts b/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts index 0d66ea917c..36367d1c08 100644 --- a/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts +++ b/src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts @@ -1,4 +1,3 @@ -import mergeWith from 'lodash.mergewith'; import type { Middleware, SearchSourceOptions, @@ -100,7 +99,9 @@ export const createTextComposerEmojiMiddleware = ( emojiSearchIndex: EmojiSearchIndex = defaultEmojiSearchIndex, options?: Partial, ): EmojiMiddleware => { - const finalOptions = mergeWith(DEFAULT_OPTIONS, options ?? {}); + // Spread into a fresh object โ€” never mutate the shared module-level DEFAULT_OPTIONS + // (options are flat, so a shallow merge is exact). + const finalOptions = { ...DEFAULT_OPTIONS, ...options }; const emojiSearchSource = new EmojiSearchSource(emojiSearchIndex); emojiSearchSource.activate(); From 97b285aee2ea760b00ad291d8891868c96a805f0 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 3 Jul 2026 17:49:08 +0200 Subject: [PATCH 21/36] perf(emojis): drive picker keyboard navigation from the category model Replace the DOM-geometry grid navigation (getBoundingClientRect + row-epsilon tolerance + center-x scoring) with a pure navigateGrid() over the known category model; only the current column count is read from layout (via offsetTop, once per keypress). This makes Up/Down navigation unit-testable (previously impossible in jsdom, so it was uncovered) and removes the duplicated row-extraction helpers. Also track the roving cell in a ref so moving focus flips two tabIndex values instead of sweeping every mounted cell, and reconcile the roving cell in the render effect only when it has actually detached. --- .../hooks/__tests__/gridNavigation.test.ts | 113 +++++++ .../__tests__/useGridKeyboardNav.test.tsx | 8 +- src/plugins/Emojis/hooks/gridNavigation.ts | 81 +++++ .../Emojis/hooks/useGridKeyboardNav.ts | 294 ++++++------------ 4 files changed, 299 insertions(+), 197 deletions(-) create mode 100644 src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts create mode 100644 src/plugins/Emojis/hooks/gridNavigation.ts diff --git a/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts b/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts new file mode 100644 index 0000000000..bdbf1e01ed --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts @@ -0,0 +1,113 @@ +import { navigateGrid } from '../gridNavigation'; + +// A "grid" is a vertical stack of sections (categories); each section is laid out as a +// `columns`-wide grid filled in reading order. navigateGrid is pure: given the section +// lengths, the active (section, index), a key, and the column count, it returns the +// target position (or null to stay put at a grid edge). + +describe('navigateGrid โ€” single section (search view)', () => { + const single = [7]; // rows (cols=3): [0,1,2] [3,4,5] [6] + + it('moves right/left within a row', () => { + expect(navigateGrid(single, { index: 0, section: 0 }, 'ArrowRight', 3)).toEqual({ + index: 1, + section: 0, + }); + expect(navigateGrid(single, { index: 1, section: 0 }, 'ArrowLeft', 3)).toEqual({ + index: 0, + section: 0, + }); + }); + + it('clamps (null) at the last cell going right and the first going left', () => { + expect(navigateGrid(single, { index: 6, section: 0 }, 'ArrowRight', 3)).toBeNull(); + expect(navigateGrid(single, { index: 0, section: 0 }, 'ArrowLeft', 3)).toBeNull(); + }); + + it('moves down/up by one row, same column', () => { + expect(navigateGrid(single, { index: 1, section: 0 }, 'ArrowDown', 3)).toEqual({ + index: 4, + section: 0, + }); + expect(navigateGrid(single, { index: 4, section: 0 }, 'ArrowUp', 3)).toEqual({ + index: 1, + section: 0, + }); + }); + + it('down into a partial last row clamps to that rowโ€™s last cell', () => { + // index 5 (row 1, col 2) down: row 2 has only index 6 โ†’ clamp to 6. + expect(navigateGrid(single, { index: 5, section: 0 }, 'ArrowDown', 3)).toEqual({ + index: 6, + section: 0, + }); + }); + + it('clamps (null) going down from the last row and up from the first', () => { + expect(navigateGrid(single, { index: 6, section: 0 }, 'ArrowDown', 3)).toBeNull(); + expect(navigateGrid(single, { index: 2, section: 0 }, 'ArrowUp', 3)).toBeNull(); + }); + + it('Home/End jump to the first/last cell of the whole grid', () => { + expect(navigateGrid(single, { index: 5, section: 0 }, 'Home', 3)).toEqual({ + index: 0, + section: 0, + }); + expect(navigateGrid(single, { index: 0, section: 0 }, 'End', 3)).toEqual({ + index: 6, + section: 0, + }); + }); +}); + +describe('navigateGrid โ€” multiple sections (category view)', () => { + const multi = [5, 6]; // section 0 rows (cols=3): [0,1,2] [3,4]; section 1: [0,1,2] [3,4,5] + + it('crosses to the next section going right off a sectionโ€™s last cell', () => { + expect(navigateGrid(multi, { index: 4, section: 0 }, 'ArrowRight', 3)).toEqual({ + index: 0, + section: 1, + }); + }); + + it('crosses to the previous section going left off a sectionโ€™s first cell', () => { + expect(navigateGrid(multi, { index: 0, section: 1 }, 'ArrowLeft', 3)).toEqual({ + index: 4, + section: 0, + }); + }); + + it('crosses down into the next section preserving the column', () => { + expect(navigateGrid(multi, { index: 3, section: 0 }, 'ArrowDown', 3)).toEqual({ + index: 0, + section: 1, + }); + expect(navigateGrid(multi, { index: 4, section: 0 }, 'ArrowDown', 3)).toEqual({ + index: 1, + section: 1, + }); + }); + + it('crosses up into the previous sectionโ€™s last row, clamping the column', () => { + expect(navigateGrid(multi, { index: 1, section: 1 }, 'ArrowUp', 3)).toEqual({ + index: 4, + section: 0, + }); + // Column 2 has no cell in section 0's last row ([3,4]) โ†’ clamp to its last cell. + expect(navigateGrid(multi, { index: 2, section: 1 }, 'ArrowUp', 3)).toEqual({ + index: 4, + section: 0, + }); + }); + + it('clamps (null) going right off the very last cell of the last section', () => { + expect(navigateGrid(multi, { index: 5, section: 1 }, 'ArrowRight', 3)).toBeNull(); + }); + + it('End jumps to the last cell of the last section', () => { + expect(navigateGrid(multi, { index: 0, section: 0 }, 'End', 3)).toEqual({ + index: 5, + section: 1, + }); + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx b/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx index 979c1f604b..5ae64e7dcf 100644 --- a/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx +++ b/src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx @@ -7,7 +7,7 @@ beforeAll(() => { Element.prototype.scrollIntoView = () => undefined; }); -type Category = { emojis: string[]; id: string }; +type Category = { emojis: { id: string }[]; id: string }; /** * Mimics the virtualized grid: only `initialMounted` categories are in the DOM, and @@ -33,7 +33,7 @@ const Harness = ({ .filter((category) => mounted.includes(category.id)) .map((category) => (
- {category.emojis.map((id) => ( + {category.emojis.map(({ id }) => ( + ))} +
+
+ ); +} + +const numberOptions = (values: number[]) => + values.map((value) => ({ label: String(value), value })); + +/** + * Always-open picker wired to the current settings, so tweaks show instantly without + * opening the composer. Skin tone and frequently-used are local to the preview โ€” + * selecting an emoji here feeds the "frequently used" row so `maxFrequentRows` can be + * exercised too. + */ +const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) => { + const [skinTone, setSkinTone] = useState(0); + const [frequentlyUsedIds, setFrequentlyUsedIds] = useState([]); + + return ( + + setFrequentlyUsedIds((ids) => [emoji.id, ...ids.filter((id) => id !== emoji.id)]) + } + onSkinToneChange={setSkinTone} + options={{ ...options, exceptEmojis: [] }} + skinToneIndex={skinTone} + /> + ); +}; + +/** + * Playground for the built-in EmojiPicker's `pickerProps`. Each control writes to the + * app settings store; the live preview (and the composer's picker via + * `EmojiPickerWithCustomOptions`) reflect the change instantly. + */ +export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => { + const { emojiPicker } = useAppSettingsState(); + const atDefaults = ( + Object.keys(DEFAULT_EMOJI_PICKER_SETTINGS) as (keyof EmojiPickerSettingsState)[] + ).every((key) => emojiPicker[key] === DEFAULT_EMOJI_PICKER_SETTINGS[key]); + + return ( +
+ + + +
+
+
+ +
+ + label='Navigation position' + onSelect={(navPosition) => update({ navPosition })} + options={[ + { label: 'Top', value: 'top' }, + { label: 'Bottom', value: 'bottom' }, + { label: 'None', value: 'none' }, + ]} + value={emojiPicker.navPosition} + /> + + label='Preview position' + onSelect={(previewPosition) => update({ previewPosition })} + options={[ + { label: 'Top', value: 'top' }, + { label: 'Bottom', value: 'bottom' }, + { label: 'None', value: 'none' }, + ]} + value={emojiPicker.previewPosition} + /> + + label='Search position' + onSelect={(searchPosition) => update({ searchPosition })} + options={[ + { label: 'Sticky', value: 'sticky' }, + { label: 'Static', value: 'static' }, + { label: 'None', value: 'none' }, + ]} + value={emojiPicker.searchPosition} + /> + + label='Skin-tone position' + onSelect={(skinTonePosition) => update({ skinTonePosition })} + options={[ + { label: 'Preview', value: 'preview' }, + { label: 'Search', value: 'search' }, + { label: 'None', value: 'none' }, + ]} + value={emojiPicker.skinTonePosition} + /> + + label='Emoji per line' + onSelect={(perLine) => update({ perLine })} + options={numberOptions([7, 8, 9, 10])} + value={emojiPicker.perLine} + /> + + label='Frequently-used rows' + onSelect={(maxFrequentRows) => update({ maxFrequentRows })} + options={numberOptions([1, 2, 3, 4])} + value={emojiPicker.maxFrequentRows} + /> + + label='Auto-focus search' + onSelect={(autoFocus) => update({ autoFocus })} + options={[ + { label: 'On', value: true }, + { label: 'Off', value: false }, + ]} + value={emojiPicker.autoFocus} + /> + + label='Country flags' + onSelect={(noCountryFlags) => update({ noCountryFlags })} + options={[ + { label: 'Show', value: false }, + { label: 'Hide', value: true }, + ]} + value={emojiPicker.noCountryFlags} + /> +
+
+
Live preview
+ +
+
+
+
+ ); +}; diff --git a/examples/vite/src/AppSettings/tabs/EmojiPicker/index.ts b/examples/vite/src/AppSettings/tabs/EmojiPicker/index.ts new file mode 100644 index 0000000000..dc737187a9 --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/EmojiPicker/index.ts @@ -0,0 +1 @@ +export * from './EmojiPickerTab'; diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 95c3bbf7c4..d396f04cbd 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useMessageComposerContext, useTranslationContext } from '../../context'; import { @@ -12,21 +12,21 @@ import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useI import { EmojiPickerPanel } from './components'; import { useFrequentlyUsedEmoji } from './hooks/useFrequentlyUsedEmoji'; import { useSkinTone } from './hooks/useSkinTone'; +import { + type EmojiPickerPassthroughProps, + resolveEmojiPickerOptions, + warnUnsupportedPickerProps, +} from './options'; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; -/** The only `pickerProps` keys the built-in picker reads. */ -const SUPPORTED_PICKER_PROP_KEYS = ['style', 'theme']; - -export type EmojiPickerPassthroughProps = { - /** Inline styles applied to the picker panel root. */ - style?: React.CSSProperties; - /** - * Color theme. 'auto' (default) inherits the ancestor SDK theme - * (`.str-chat__theme-*`); 'light' / 'dark' force the panel to that theme. - */ - theme?: 'auto' | 'light' | 'dark'; -}; +export type { + EmojiPickerNavPosition, + EmojiPickerPassthroughProps, + EmojiPickerPreviewPosition, + EmojiPickerSearchPosition, + EmojiPickerSkinTonePosition, +} from './options'; export type EmojiPickerProps = { ButtonIconComponent?: React.ComponentType; @@ -35,12 +35,16 @@ export type EmojiPickerProps = { wrapperClassName?: string; closeOnEmojiSelect?: boolean; /** - * Presentation options for the picker panel โ€” `theme` and `style` only. + * Presentation + curated layout/behavior options for the picker panel, using + * emoji-mart-compatible names (`theme`, `style`, `perLine`, `navPosition`, + * `previewPosition`, `searchPosition`, `skinTonePosition`, `categories`, + * `exceptEmojis`, `emojiVersion`, `maxFrequentRows`, `noCountryFlags`, + * `previewEmoji`, `noResultsEmoji`, `autoFocus`, `onClickOutside`). * - * This is NOT emoji-mart's `Picker` prop bag: emoji-mart options (`data`, `set`, - * `custom`, `categories`, `perLine`, `emojiVersion`, `locale`, `previewPosition`, - * โ€ฆ) are not supported by the built-in picker. The type rejects them, and any that - * reach runtime (e.g. via `as` casts) are ignored with a console warning. See the + * Not every emoji-mart `Picker` option is supported: image sets (`set`, + * `getSpritesheetURL`), `custom` emoji, `data`, `i18n`/`locale`, `dynamicWidth`, + * `icons`, and `categoryIcons` are rejected by the type and ignored (with a console + * warning) at runtime; sizing knobs (`emojiSize`, โ€ฆ) are CSS tokens instead. See the * emoji migration notes in `AI.md`. */ pickerProps?: EmojiPickerPassthroughProps; @@ -111,18 +115,15 @@ export const EmojiPicker = (props: EmojiPickerProps) => { const { ButtonIconComponent = IconEmoji } = props; const pickerStyle = props.pickerProps?.style; + const options = resolveEmojiPickerOptions(props.pickerProps); + // Latest-ref so the click-outside listener isn't re-attached when the callback identity + // changes between renders. + const onClickOutsideRef = useRef(props.pickerProps?.onClickOutside); + onClickOutsideRef.current = props.pickerProps?.onClickOutside; const pickerPropsKeys = Object.keys(props.pickerProps ?? {}); useEffect(() => { - const ignored = pickerPropsKeys.filter( - (key) => !SUPPORTED_PICKER_PROP_KEYS.includes(key), - ); - if (ignored.length) { - console.warn( - `[stream-chat-react] EmojiPicker ignored unsupported pickerProps: ${ignored.join(', ')}. ` + - "Only 'theme' and 'style' are supported; emoji-mart Picker options are no longer available.", - ); - } + warnUnsupportedPickerProps(props.pickerProps); // Re-check only when the set of provided keys changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pickerPropsKeys.join(',')]); @@ -142,6 +143,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => { return; } + onClickOutsideRef.current?.(); setDisplayPicker(false); }; @@ -175,6 +177,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => { } }} onSkinToneChange={setSkinTone} + options={options} skinToneIndex={skinTone} style={pickerStyle} theme={props.pickerProps?.theme} diff --git a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx index 4301dc2623..f32a3b9a10 100644 --- a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx +++ b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx @@ -138,11 +138,11 @@ describe('EmojiPicker pickerProps', () => { const { unmount } = render( , ); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('perLine')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('set')); unmount(); warn.mockClear(); @@ -151,4 +151,21 @@ describe('EmojiPicker pickerProps', () => { warn.mockRestore(); }); + + it('does not warn when only supported (curated) picker options are passed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + , + ); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('calls onClickOutside when a pointer press lands outside the open picker', () => { + const onClickOutside = vi.fn(); + render(); + openPicker(); + fireEvent.pointerDown(document.body); + expect(onClickOutside).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/plugins/Emojis/__tests__/options.test.ts b/src/plugins/Emojis/__tests__/options.test.ts new file mode 100644 index 0000000000..0cc3e66aeb --- /dev/null +++ b/src/plugins/Emojis/__tests__/options.test.ts @@ -0,0 +1,47 @@ +import { + DEFAULT_EMOJI_PICKER_OPTIONS, + resolveEmojiPickerOptions, + warnUnsupportedPickerProps, +} from '../options'; + +describe('resolveEmojiPickerOptions', () => { + it('returns the documented defaults when nothing is passed', () => { + expect(resolveEmojiPickerOptions()).toEqual(DEFAULT_EMOJI_PICKER_OPTIONS); + expect(DEFAULT_EMOJI_PICKER_OPTIONS.autoFocus).toBe(true); + expect(DEFAULT_EMOJI_PICKER_OPTIONS.perLine).toBe(9); + expect(DEFAULT_EMOJI_PICKER_OPTIONS.maxFrequentRows).toBe(1); + expect(DEFAULT_EMOJI_PICKER_OPTIONS.navPosition).toBe('top'); + }); + + it('overrides only the provided keys and clamps perLine/maxFrequentRows', () => { + const resolved = resolveEmojiPickerOptions({ + maxFrequentRows: -3, + navPosition: 'bottom', + perLine: 0, + }); + expect(resolved.navPosition).toBe('bottom'); + expect(resolved.perLine).toBe(1); // clamped to >= 1 + expect(resolved.maxFrequentRows).toBe(0); // clamped to >= 0 + expect(resolved.previewPosition).toBe('bottom'); // untouched default + }); +}); + +describe('warnUnsupportedPickerProps', () => { + it('warns about emoji-mart-only keys but not supported ones', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + warnUnsupportedPickerProps({ perLine: 9, set: 'apple', theme: 'dark' }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('set')); + expect(warn.mock.calls[0][0]).not.toContain('perLine'); + warn.mockRestore(); + }); + + it('points styling knobs at the CSS token instead of ignoring them', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + warnUnsupportedPickerProps({ emojiSize: 20 }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('--str-chat__emoji-picker-emoji-size'), + ); + warn.mockRestore(); + }); +}); diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index 35dc321eaa..39b9dfe45f 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -16,8 +16,14 @@ import { import { useDebouncedValue } from '../hooks/useDebouncedValue'; import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav'; +import { resolvePickerLayout } from '../hooks/pickerLayout'; import { buildEmojiSearchData, runSearch } from '../search'; import type { EmojiDataEmoji } from '../data'; +import { filterEmojiData } from '../data/filterEmojiData'; +import { + DEFAULT_EMOJI_PICKER_OPTIONS, + type ResolvedEmojiPickerOptions, +} from '../options'; import { useTranslationContext } from '../../../context'; export type EmojiSelection = { @@ -39,6 +45,8 @@ export type EmojiPickerPanelProps = { onClose?: () => void; /** Called with the new skin tone index when the user changes it. */ onSkinToneChange?: (skinTone: number) => void; + /** Resolved (fully-defaulted) picker options โ€” layout, filtering, and grid config. */ + options?: ResolvedEmojiPickerOptions; /** Active skin tone index (0 = default, 1โ€“5 = light โ†’ dark). Controlled by the owner. */ skinToneIndex?: number; style?: CSSProperties; @@ -78,12 +86,26 @@ export const EmojiPickerPanel = ({ onClose, onEmojiSelect, onSkinToneChange, + options = DEFAULT_EMOJI_PICKER_OPTIONS, skinToneIndex = 0, style, theme, }: EmojiPickerPanelProps) => { const { t } = useTranslationContext('EmojiPickerPanel'); const { data, error, retry } = useEmojiPickerState(); + // One filtered view of the dataset feeds both the grid and the search index so + // exclusions apply consistently. Returns the same reference when no filter is set. + const filteredData = useMemo( + () => + data + ? filterEmojiData(data, { + emojiVersion: options.emojiVersion, + exceptEmojis: options.exceptEmojis, + noCountryFlags: options.noCountryFlags, + }) + : data, + [data, options.emojiVersion, options.exceptEmojis, options.noCountryFlags], + ); const [previewedEmoji, setPreviewedEmoji] = useState(null); const [activeCategoryId, setActiveCategoryId] = useState(undefined); const [query, setQuery] = useState(''); @@ -94,25 +116,49 @@ export const EmojiPickerPanel = ({ const bodyRef = useRef(null); const baseCategories = useMemo(() => { - if (!data) return []; - return data.categories.map((category) => ({ - emojis: category.emojis.map((id) => data.emojis[id]).filter(Boolean), + if (!filteredData) return []; + const built = filteredData.categories.map((category) => ({ + emojis: category.emojis.map((id) => filteredData.emojis[id]).filter(Boolean), id: category.id, label: t(EMOJI_CATEGORY_META[category.id]?.labelKey ?? category.id), })); - }, [data, t]); + // `categories` option: keep only the requested ids, in the requested order. + if (!options.categories) return built; + const byId = new Map(built.map((category) => [category.id, category])); + return options.categories + .map((id) => { + if (!byId.has(id)) { + console.warn( + `[stream-chat-react] EmojiPicker: unknown category id "${id}" ignored.`, + ); + } + return byId.get(id); + }) + .filter((category): category is EmojiPickerCategory => Boolean(category)); + }, [filteredData, options.categories, t]); const categories = useMemo(() => { - if (!data || !frequentlyUsedIds.length) return baseCategories; + if (!filteredData || !frequentlyUsedIds.length) return baseCategories; const frequent: EmojiPickerCategory = { - // Capped to a single row (see resolveFrequentlyUsedEmoji + the frequent-section - // CSS) so the section never grows to multiple rows as more emoji are used. - emojis: resolveFrequentlyUsedEmoji(data, frequentlyUsedIds), + // Capped to `perLine ร— maxFrequentRows` items (default one row) so the section + // grows to at most the configured number of rows as more emoji are used. + emojis: resolveFrequentlyUsedEmoji( + filteredData, + frequentlyUsedIds, + options.perLine * options.maxFrequentRows, + ), id: 'frequent', label: t(EMOJI_CATEGORY_META.frequent.labelKey), }; return frequent.emojis.length ? [frequent, ...baseCategories] : baseCategories; - }, [baseCategories, data, frequentlyUsedIds, t]); + }, [ + baseCategories, + filteredData, + frequentlyUsedIds, + options.maxFrequentRows, + options.perLine, + t, + ]); const scrollToCategory = useCallback((categoryId: string) => { emojiGridRef.current?.scrollToCategory(categoryId); @@ -125,18 +171,21 @@ export const EmojiPickerPanel = ({ scrollToCategory, }); - const searchIndex = useMemo(() => (data ? buildEmojiSearchData(data) : []), [data]); + const searchIndex = useMemo( + () => (filteredData ? buildEmojiSearchData(filteredData) : []), + [filteredData], + ); // `null` when not searching; otherwise the (possibly empty) list of matches. Clearing // the field (empty live `query`) exits search immediately; a non-empty query is taken // from the debounced value. const searchedEmojis = useMemo(() => { const trimmed = query.trim() && debouncedQuery.trim(); - if (!trimmed || !data) return null; + if (!trimmed || !filteredData) return null; return (runSearch(searchIndex, trimmed) ?? []) - .map((result) => data.emojis[result.id]) + .map((result) => filteredData.emojis[result.id]) .filter(Boolean); - }, [data, debouncedQuery, query, searchIndex]); + }, [debouncedQuery, filteredData, query, searchIndex]); const onSelectEmoji = useCallback( (emoji: EmojiDataEmoji) => { @@ -163,6 +212,60 @@ export const EmojiPickerPanel = ({ const isSearching = searchedEmojis !== null; + // Region placement + skin-tone fallbacks are resolved by a pure helper. + const pickerLayout = resolvePickerLayout(options); + const idlePreviewEmoji = options.previewEmoji + ? (filteredData?.emojis[options.previewEmoji] ?? null) + : null; + const noResultsGlyph = options.noResultsEmoji + ? (filteredData?.emojis[options.noResultsEmoji]?.skins[0]?.native ?? undefined) + : undefined; + + const nav = ( + + ); + const skinToneSelector = ( + + ); + const searchInput = pickerLayout.search ? ( + + ) : null; + // The skin-tone selector shares the search row only when it belongs there; otherwise + // the bare input renders (unchanged default), avoiding an extra wrapper. + const searchRow = + searchInput && pickerLayout.skinTone === 'search' ? ( +
+ {searchInput} + {skinToneSelector} +
+ ) : ( + searchInput + ); + const previewRow = (position: 'top' | 'bottom') => + pickerLayout.preview === position ? ( +
+ + {pickerLayout.skinTone === 'preview' ? skinToneSelector : null} +
+ ) : null; + const footerSkinRow = + pickerLayout.skinTone === 'footer' ? ( +
{skinToneSelector}
+ ) : null; + return (
{data ? ( <> - - + {pickerLayout.nav === 'top' ? nav : null} + {previewRow('top')} + {searchRow}
) : ( - + ) ) : ( )}
-
- - -
+ {previewRow('bottom')} + {footerSkinRow} + {pickerLayout.nav === 'bottom' ? nav : null} ) : error ? (
diff --git a/src/plugins/Emojis/components/EmptyResults.tsx b/src/plugins/Emojis/components/EmptyResults.tsx index c54d5f5dab..707d34755a 100644 --- a/src/plugins/Emojis/components/EmptyResults.tsx +++ b/src/plugins/Emojis/components/EmptyResults.tsx @@ -1,13 +1,23 @@ import { useTranslationContext } from '../../../context'; +export type EmptyResultsProps = { + /** Native glyph shown above the "no results" text (from the `noResultsEmoji` option). */ + emoji?: string; +}; + /** * Shown in place of the emoji grid when a search yields no matches. */ -export const EmptyResults = () => { +export const EmptyResults = ({ emoji }: EmptyResultsProps) => { const { t } = useTranslationContext('EmojiPicker'); return (
+ {emoji ? ( + + ) : null} {t('No emoji found')}
); diff --git a/src/plugins/Emojis/components/PreviewPane.tsx b/src/plugins/Emojis/components/PreviewPane.tsx index 4bfce16b80..6186d9785c 100644 --- a/src/plugins/Emojis/components/PreviewPane.tsx +++ b/src/plugins/Emojis/components/PreviewPane.tsx @@ -4,6 +4,8 @@ import type { EmojiDataEmoji } from '../data'; export type PreviewPaneProps = { emoji: EmojiDataEmoji | null; + /** Emoji shown at rest (nothing hovered/focused) instead of the text placeholder. */ + placeholderEmoji?: EmojiDataEmoji | null; }; /** @@ -13,23 +15,26 @@ export type PreviewPaneProps = { * Receives the previewed emoji as a prop (kept out of context) so hovering does not * re-render the emoji grid. */ -export const PreviewPane = ({ emoji }: PreviewPaneProps) => { +export const PreviewPane = ({ emoji, placeholderEmoji }: PreviewPaneProps) => { const { t } = useTranslationContext('EmojiPickerPreview'); const { skinToneIndex } = useEmojiPickerContext('PreviewPane'); + // Prefer the hovered/focused emoji, then a configured resting emoji, then the text + // placeholder. + const shown = emoji ?? placeholderEmoji ?? null; return (
- {emoji ? emoji.name : t('Pick an emojiโ€ฆ')} + {shown ? shown.name : t('Pick an emojiโ€ฆ')} - {emoji ? ( - {`:${emoji.id}:`} + {shown ? ( + {`:${shown.id}:`} ) : null}
diff --git a/src/plugins/Emojis/components/SearchInput.tsx b/src/plugins/Emojis/components/SearchInput.tsx index d6e766117b..461bbce6ea 100644 --- a/src/plugins/Emojis/components/SearchInput.tsx +++ b/src/plugins/Emojis/components/SearchInput.tsx @@ -6,6 +6,8 @@ import { useTranslationContext } from '../../../context'; export type SearchInputProps = { onChange: (value: string) => void; value: string; + /** Focus the input on mount when the picker opens (default `true`). */ + autoFocus?: boolean; /** Called when ArrowDown is pressed, to move focus into the emoji grid. */ onArrowDown?: () => void; }; @@ -14,14 +16,19 @@ export type SearchInputProps = { * Search box for the emoji picker. Mirrors the SDK's SearchBar structure (icon + * labelled input + clear button) and receives focus when the picker opens. */ -export const SearchInput = ({ onArrowDown, onChange, value }: SearchInputProps) => { +export const SearchInput = ({ + autoFocus = true, + onArrowDown, + onChange, + value, +}: SearchInputProps) => { const { t } = useTranslationContext('EmojiPickerSearchInput'); const inputId = useStableId(); const inputRef = useRef(null); useEffect(() => { - inputRef.current?.focus(); - }, []); + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); return (
diff --git a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx index ac5dc88a3e..15987dca7f 100644 --- a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx +++ b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx @@ -16,7 +16,52 @@ vi.mock('../../../../context', () => ({ useTranslationContext: () => ({ t: (key: string) => key }), })); +// The real grid uses react-virtuoso, which renders no items without layout (jsdom). +// Mock it to expose the categories it receives so we can assert what the panel feeds it. +vi.mock('../EmojiGrid', async () => { + const { forwardRef } = await import('react'); + return { + EmojiGrid: forwardRef(function EmojiGrid({ + categories, + }: { + categories: { emojis: { id: string; name: string }[]; id: string }[]; + }) { + return ( +
+ {categories + .flatMap((category) => category.emojis) + .map((emoji) => ( + {emoji.name} + ))} +
+ ); + }), + }; +}); + import { EmojiPickerPanel, themeClassName } from '../EmojiPickerPanel'; +import { DEFAULT_EMOJI_PICKER_OPTIONS } from '../../options'; + +const DATA = { + aliases: {}, + categories: [{ emojis: ['grinning', 'smile'], id: 'people' }], + emojis: { + grinning: { + id: 'grinning', + keywords: [], + name: 'Grinning', + skins: [{ native: '๐Ÿ˜€', unified: '1f600' }], + version: 1, + }, + smile: { + id: 'smile', + keywords: [], + name: 'Smile', + skins: [{ native: '๐Ÿ˜„', unified: '1f604' }], + version: 1, + }, + }, +}; // The picker's theme CSS keys off the class this maps to: a forced `light`/`dark` // must emit an SDK theme class on the panel root so the forced-light variable override @@ -60,3 +105,114 @@ describe('EmojiPickerPanel dataset loading', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); }); + +describe('EmojiPickerPanel option: exceptEmojis', () => { + beforeEach(() => { + hookState.data = DATA; + hookState.error = false; + }); + + it('removes excluded emoji from the grid, keeping the rest', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, exceptEmojis: ['smile'] }} + />, + ); + expect(screen.getByText('Grinning')).toBeInTheDocument(); + expect(screen.queryByText('Smile')).not.toBeInTheDocument(); + }); +}); + +const TWO_CATS = { + aliases: {}, + categories: [ + { emojis: ['grinning'], id: 'people' }, + { emojis: ['dog'], id: 'nature' }, + ], + emojis: { + dog: { + id: 'dog', + keywords: [], + name: 'Dog', + skins: [{ native: '๐Ÿถ', unified: '1f436' }], + version: 1, + }, + grinning: { + id: 'grinning', + keywords: [], + name: 'Grinning', + skins: [{ native: '๐Ÿ˜€', unified: '1f600' }], + version: 1, + }, + }, +}; + +describe('EmojiPickerPanel option: categories', () => { + beforeEach(() => { + hookState.data = TWO_CATS; + hookState.error = false; + }); + + it('shows only the requested categories, in the requested order', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, categories: ['nature'] }} + />, + ); + expect(screen.getAllByRole('tab')).toHaveLength(1); + expect(screen.getByText('Dog')).toBeInTheDocument(); + expect(screen.queryByText('Grinning')).not.toBeInTheDocument(); + }); +}); + +describe('EmojiPickerPanel layout positions', () => { + beforeEach(() => { + hookState.data = DATA; + hookState.error = false; + }); + + it('omits the search input when searchPosition is none', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, searchPosition: 'none' }} + />, + ); + expect(screen.queryByPlaceholderText('Search emoji')).not.toBeInTheDocument(); + }); + + it('omits the category nav when navPosition is none', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, navPosition: 'none' }} + />, + ); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + }); + + it('omits the skin-tone selector when skinTonePosition is none', () => { + render( + {}} + onSkinToneChange={() => {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, skinTonePosition: 'none' }} + />, + ); + expect( + screen.queryByRole('button', { name: 'aria/Choose default skin tone' }), + ).not.toBeInTheDocument(); + }); + + it('does not focus the search input when autoFocus is false', () => { + render( + {}} + options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, autoFocus: false }} + />, + ); + expect(screen.getByPlaceholderText('Search emoji')).not.toHaveFocus(); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx b/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx index c5241a8b52..dd22890ffc 100644 --- a/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx +++ b/src/plugins/Emojis/components/__tests__/PreviewPane.test.tsx @@ -40,4 +40,18 @@ describe('PreviewPane', () => { expect(screen.getByText('Pick an emojiโ€ฆ')).toBeInTheDocument(); expect(screen.queryByText(/^:.+:$/)).not.toBeInTheDocument(); }); + + it('shows the configured resting emoji (previewEmoji) when nothing is hovered', () => { + render( + + + , + ); + + expect(screen.getByText('Smiling Face')).toBeInTheDocument(); + expect(screen.getByText(':smile:')).toBeInTheDocument(); + expect(screen.queryByText('Pick an emojiโ€ฆ')).not.toBeInTheDocument(); + }); }); diff --git a/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts b/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts index f77a8c6cd2..46e4bbf214 100644 --- a/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts +++ b/src/plugins/Emojis/components/__tests__/frequentlyUsed.test.ts @@ -36,4 +36,10 @@ describe('resolveFrequentlyUsedEmoji', () => { expect(result.map((emoji) => emoji.id)).toEqual(['e0', 'e1']); }); + + it('honors an explicit limit (perLine ร— maxFrequentRows)', () => { + const ids = Array.from({ length: 30 }, (_, i) => `e${i}`); + + expect(resolveFrequentlyUsedEmoji(data, ids, 14)).toHaveLength(14); + }); }); diff --git a/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts b/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts new file mode 100644 index 0000000000..391e686dc0 --- /dev/null +++ b/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts @@ -0,0 +1,89 @@ +import type { EmojiData } from '../types'; +import { filterEmojiData, isCountryFlag } from '../filterEmojiData'; + +const DATA: EmojiData = { + aliases: {}, + categories: [ + { emojis: ['grinning', 'smile'], id: 'people' }, + { emojis: ['us', 'fr', 'checkered_flag'], id: 'flags' }, + ], + emojis: { + checkered_flag: { + id: 'checkered_flag', + keywords: [], + name: 'Checkered Flag', + skins: [{ native: '๐Ÿ', unified: '1f3c1' }], + version: 1, + }, + fr: { + id: 'fr', + keywords: [], + name: 'France', + skins: [{ native: '๐Ÿ‡ซ๐Ÿ‡ท', unified: '1f1eb-1f1f7' }], + version: 1, + }, + grinning: { + id: 'grinning', + keywords: [], + name: 'Grinning', + skins: [{ native: '๐Ÿ˜€', unified: '1f600' }], + version: 1, + }, + smile: { + id: 'smile', + keywords: [], + name: 'Smile', + skins: [{ native: '๐Ÿ˜„', unified: '1f604' }], + version: 13, + }, + us: { + id: 'us', + keywords: [], + name: 'United States', + skins: [{ native: '๐Ÿ‡บ๐Ÿ‡ธ', unified: '1f1fa-1f1f8' }], + version: 1, + }, + }, +}; + +describe('isCountryFlag', () => { + it('is true only for regional-indicator pairs', () => { + expect(isCountryFlag(DATA.emojis.us)).toBe(true); + expect(isCountryFlag(DATA.emojis.fr)).toBe(true); + expect(isCountryFlag(DATA.emojis.checkered_flag)).toBe(false); + expect(isCountryFlag(DATA.emojis.grinning)).toBe(false); + }); +}); + +describe('filterEmojiData', () => { + it('returns the same reference when no filters apply', () => { + expect(filterEmojiData(DATA, {})).toBe(DATA); + }); + + it('drops exceptEmojis and prunes them from categories', () => { + const out = filterEmojiData(DATA, { exceptEmojis: ['smile'] }); + expect(out.emojis.smile).toBeUndefined(); + expect(out.categories.find((c) => c.id === 'people')?.emojis).toEqual(['grinning']); + }); + + it('drops emoji newer than emojiVersion', () => { + const out = filterEmojiData(DATA, { emojiVersion: 1 }); + expect(out.emojis.smile).toBeUndefined(); // version 13 > 1 + expect(out.emojis.grinning).toBeDefined(); + }); + + it('drops country flags but keeps non-country flags when noCountryFlags', () => { + const out = filterEmojiData(DATA, { noCountryFlags: true }); + expect(out.emojis.us).toBeUndefined(); + expect(out.emojis.fr).toBeUndefined(); + expect(out.emojis.checkered_flag).toBeDefined(); + }); + + it('removes a category entirely when all its emoji are filtered out', () => { + const out = filterEmojiData(DATA, { + exceptEmojis: ['checkered_flag'], + noCountryFlags: true, + }); + expect(out.categories.find((c) => c.id === 'flags')).toBeUndefined(); + }); +}); diff --git a/src/plugins/Emojis/data/filterEmojiData.ts b/src/plugins/Emojis/data/filterEmojiData.ts new file mode 100644 index 0000000000..2b8ca6ce01 --- /dev/null +++ b/src/plugins/Emojis/data/filterEmojiData.ts @@ -0,0 +1,56 @@ +import type { EmojiData, EmojiDataEmoji } from './types'; + +const REGIONAL_INDICATOR_START = 0x1f1e6; +const REGIONAL_INDICATOR_END = 0x1f1ff; + +/** + * A country flag's `unified` is a pair of Unicode regional-indicator codepoints + * (e.g. `1f1fa-1f1f8` for ๐Ÿ‡บ๐Ÿ‡ธ). Non-country flags (๐Ÿ, ๐Ÿด, ๐Ÿšฉ) fall outside that range. + */ +export const isCountryFlag = (emoji: EmojiDataEmoji): boolean => { + const unified = emoji.skins[0]?.unified ?? ''; + const points = unified.split('-').map((part) => parseInt(part, 16)); + return ( + points.length > 0 && + points.every((cp) => cp >= REGIONAL_INDICATOR_START && cp <= REGIONAL_INDICATOR_END) + ); +}; + +type FilterEmojiDataOptions = { + emojiVersion?: number; + exceptEmojis?: string[]; + noCountryFlags?: boolean; +}; + +/** + * Returns a dataset with emoji removed per the options, pruning any category left empty. + * Returns the SAME reference when no filter applies, so callers memoize cheaply and the + * picker renders identically to the unfiltered dataset. + */ +export const filterEmojiData = ( + data: EmojiData, + { emojiVersion, exceptEmojis = [], noCountryFlags = false }: FilterEmojiDataOptions, +): EmojiData => { + if (!exceptEmojis.length && emojiVersion == null && !noCountryFlags) return data; + + const excluded = new Set(exceptEmojis); + const keep = (emoji: EmojiDataEmoji) => { + if (excluded.has(emoji.id)) return false; + if (emojiVersion != null && emoji.version > emojiVersion) return false; + if (noCountryFlags && isCountryFlag(emoji)) return false; + return true; + }; + + const emojis: EmojiData['emojis'] = {}; + for (const id of Object.keys(data.emojis)) { + if (keep(data.emojis[id])) emojis[id] = data.emojis[id]; + } + const categories = data.categories + .map((category) => ({ + ...category, + emojis: category.emojis.filter((id) => emojis[id]), + })) + .filter((category) => category.emojis.length > 0); + + return { ...data, categories, emojis }; +}; diff --git a/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts b/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts index bdbf1e01ed..95672be903 100644 --- a/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts +++ b/src/plugins/Emojis/hooks/__tests__/gridNavigation.test.ts @@ -111,3 +111,16 @@ describe('navigateGrid โ€” multiple sections (category view)', () => { }); }); }); + +// Column count is a parameter, so a non-default `perLine` navigates by that many cells +// per row โ€” the hook measures columns from layout at runtime and passes them here, which +// is why the perLine option needs no keyboard-nav change. +describe('navigateGrid โ€” honors the column count (perLine parity)', () => { + it('ArrowDown moves by `columns` cells within a section', () => { + // 14 cells at 7 columns: row 0 = [0..6], row 1 = [7..13]; down from 0 โ†’ 7. + expect(navigateGrid([14], { index: 0, section: 0 }, 'ArrowDown', 7)).toEqual({ + index: 7, + section: 0, + }); + }); +}); diff --git a/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts b/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts new file mode 100644 index 0000000000..1d374f15cd --- /dev/null +++ b/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts @@ -0,0 +1,45 @@ +import { DEFAULT_EMOJI_PICKER_OPTIONS } from '../../options'; +import { resolvePickerLayout } from '../pickerLayout'; + +const layout = (over: Partial = {}) => + resolvePickerLayout({ ...DEFAULT_EMOJI_PICKER_OPTIONS, ...over }); + +describe('resolvePickerLayout', () => { + it('maps the defaults (nav top, preview bottom, search sticky, skin in preview)', () => { + expect(layout()).toEqual({ + nav: 'top', + preview: 'bottom', + search: 'sticky', + skinTone: 'preview', + }); + }); + + it('maps `none` to null for each region', () => { + expect(layout({ navPosition: 'none' }).nav).toBeNull(); + expect(layout({ previewPosition: 'none' }).preview).toBeNull(); + expect(layout({ searchPosition: 'none' }).search).toBeNull(); + expect(layout({ skinTonePosition: 'none' }).skinTone).toBeNull(); + }); + + it('falls back skin-tone to the preview row when search is hidden', () => { + expect(layout({ searchPosition: 'none', skinTonePosition: 'search' }).skinTone).toBe( + 'preview', + ); + }); + + it('falls back skin-tone to a standalone footer when preview is hidden', () => { + expect( + layout({ previewPosition: 'none', skinTonePosition: 'preview' }).skinTone, + ).toBe('footer'); + }); + + it('falls back to footer when both search and preview are hidden', () => { + expect( + layout({ + previewPosition: 'none', + searchPosition: 'none', + skinTonePosition: 'search', + }).skinTone, + ).toBe('footer'); + }); +}); diff --git a/src/plugins/Emojis/hooks/pickerLayout.ts b/src/plugins/Emojis/hooks/pickerLayout.ts new file mode 100644 index 0000000000..36c05a9c2c --- /dev/null +++ b/src/plugins/Emojis/hooks/pickerLayout.ts @@ -0,0 +1,42 @@ +import type { ResolvedEmojiPickerOptions } from '../options'; + +export type PickerLayout = { + nav: 'top' | 'bottom' | null; + preview: 'top' | 'bottom' | null; + search: 'sticky' | 'static' | null; + skinTone: 'search' | 'preview' | 'footer' | null; +}; + +/** + * Pure resolver deciding where each picker region renders. `'none'` positions become + * `null` (omit the region). The skin-tone selector normally lives with the search row + * (`skinTonePosition: 'search'`) or the preview row (`'preview'`); when its host region + * is hidden it falls back โ€” to the preview row, then to a standalone footer โ€” so the + * selector never silently disappears when the integrator hides search or preview. + */ +export const resolvePickerLayout = ({ + navPosition, + previewPosition, + searchPosition, + skinTonePosition, +}: Pick< + ResolvedEmojiPickerOptions, + 'navPosition' | 'previewPosition' | 'searchPosition' | 'skinTonePosition' +>): PickerLayout => { + const nav = navPosition === 'none' ? null : navPosition; + const preview = previewPosition === 'none' ? null : previewPosition; + const search = searchPosition === 'none' ? null : searchPosition; + + let skinTone: PickerLayout['skinTone']; + if (skinTonePosition === 'none') { + skinTone = null; + } else if (skinTonePosition === 'search') { + if (search) skinTone = 'search'; + else if (preview) skinTone = 'preview'; + else skinTone = 'footer'; + } else { + skinTone = preview ? 'preview' : 'footer'; + } + + return { nav, preview, search, skinTone }; +}; diff --git a/src/plugins/Emojis/options.ts b/src/plugins/Emojis/options.ts new file mode 100644 index 0000000000..a386f8610b --- /dev/null +++ b/src/plugins/Emojis/options.ts @@ -0,0 +1,141 @@ +import type { CSSProperties } from 'react'; + +export type EmojiPickerNavPosition = 'top' | 'bottom' | 'none'; +export type EmojiPickerPreviewPosition = 'top' | 'bottom' | 'none'; +export type EmojiPickerSearchPosition = 'sticky' | 'static' | 'none'; +export type EmojiPickerSkinTonePosition = 'preview' | 'search' | 'none'; + +export type EmojiPickerPassthroughProps = { + /** Focus the search input when the picker opens (default `true`). */ + autoFocus?: boolean; + /** Category ids to show, in order. Defaults to the dataset order. `frequent` always prepends. */ + categories?: string[]; + /** Hide emoji introduced after this Unicode emoji version. */ + emojiVersion?: number; + /** Emoji ids to exclude from the grid and search. */ + exceptEmojis?: string[]; + /** Max rows in the "frequently used" section (default `1`). */ + maxFrequentRows?: number; + /** Category navigation placement (default `'top'`). */ + navPosition?: EmojiPickerNavPosition; + /** Hide country-flag emoji (default `false`). */ + noCountryFlags?: boolean; + /** Emoji id shown in the empty-search state. */ + noResultsEmoji?: string; + /** Called when a pointer press lands outside the picker. */ + onClickOutside?: () => void; + /** Emoji per row (default `9`). */ + perLine?: number; + /** Emoji id shown in the preview when nothing is hovered/focused. */ + previewEmoji?: string; + /** Preview placement (default `'bottom'`). */ + previewPosition?: EmojiPickerPreviewPosition; + /** Search input placement (default `'sticky'`). */ + searchPosition?: EmojiPickerSearchPosition; + /** Skin-tone selector placement (default `'preview'`). */ + skinTonePosition?: EmojiPickerSkinTonePosition; + /** Inline styles applied to the picker panel root. */ + style?: CSSProperties; + /** Color theme. 'auto' (default) inherits the ancestor SDK theme; 'light'/'dark' force it. */ + theme?: 'auto' | 'light' | 'dark'; +}; + +export type ResolvedEmojiPickerOptions = { + autoFocus: boolean; + categories?: string[]; + emojiVersion?: number; + exceptEmojis: string[]; + maxFrequentRows: number; + navPosition: EmojiPickerNavPosition; + noCountryFlags: boolean; + noResultsEmoji?: string; + perLine: number; + previewEmoji?: string; + previewPosition: EmojiPickerPreviewPosition; + searchPosition: EmojiPickerSearchPosition; + skinTonePosition: EmojiPickerSkinTonePosition; +}; + +export const DEFAULT_EMOJI_PICKER_OPTIONS: ResolvedEmojiPickerOptions = { + autoFocus: true, + exceptEmojis: [], + maxFrequentRows: 1, + navPosition: 'top', + noCountryFlags: false, + perLine: 9, + previewPosition: 'bottom', + searchPosition: 'sticky', + skinTonePosition: 'preview', +}; + +export const resolveEmojiPickerOptions = ( + pickerProps?: EmojiPickerPassthroughProps, +): ResolvedEmojiPickerOptions => { + const p = pickerProps ?? {}; + const d = DEFAULT_EMOJI_PICKER_OPTIONS; + return { + autoFocus: p.autoFocus ?? d.autoFocus, + categories: p.categories, + emojiVersion: p.emojiVersion, + exceptEmojis: p.exceptEmojis ?? d.exceptEmojis, + maxFrequentRows: Math.max(0, Math.floor(p.maxFrequentRows ?? d.maxFrequentRows)), + navPosition: p.navPosition ?? d.navPosition, + noCountryFlags: p.noCountryFlags ?? d.noCountryFlags, + noResultsEmoji: p.noResultsEmoji, + perLine: Math.max(1, Math.floor(p.perLine ?? d.perLine)), + previewEmoji: p.previewEmoji, + previewPosition: p.previewPosition ?? d.previewPosition, + searchPosition: p.searchPosition ?? d.searchPosition, + skinTonePosition: p.skinTonePosition ?? d.skinTonePosition, + }; +}; + +const SUPPORTED_PICKER_PROP_KEYS = [ + 'autoFocus', + 'categories', + 'emojiVersion', + 'exceptEmojis', + 'maxFrequentRows', + 'navPosition', + 'noCountryFlags', + 'noResultsEmoji', + 'onClickOutside', + 'perLine', + 'previewEmoji', + 'previewPosition', + 'searchPosition', + 'skinTonePosition', + 'style', + 'theme', +]; + +/** emoji-mart styling knobs โ†’ the CSS token that replaces each. */ +const STYLING_KNOB_TOKENS: Record = { + emojiButtonColors: '--str-chat__emoji-picker-hover-background-color', + emojiButtonRadius: '--str-chat__radius-4 (on .str-chat__emoji-picker__emoji)', + emojiButtonSize: '--str-chat__emoji-picker-emoji-size', + emojiSize: '--str-chat__emoji-picker-emoji-size', +}; + +export const warnUnsupportedPickerProps = ( + pickerProps?: Record, +): void => { + if (!pickerProps) return; + const keys = Object.keys(pickerProps); + const styling = keys.filter((key) => key in STYLING_KNOB_TOKENS); + const ignored = keys.filter( + (key) => !SUPPORTED_PICKER_PROP_KEYS.includes(key) && !(key in STYLING_KNOB_TOKENS), + ); + if (styling.length) { + console.warn( + `[stream-chat-react] EmojiPicker: ${styling.join(', ')} are emoji-mart styling props. ` + + `Use the matching CSS token instead (e.g. ${STYLING_KNOB_TOKENS[styling[0]]}).`, + ); + } + if (ignored.length) { + console.warn( + `[stream-chat-react] EmojiPicker ignored unsupported pickerProps: ${ignored.join(', ')}. ` + + 'These emoji-mart Picker options are not available in the built-in picker.', + ); + } +}; diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index f8110b3467..a5678361a1 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -99,16 +99,38 @@ $emoji-picker-border-radius: 12px; flex: none; } + // Search + skin-tone selector share one row (only when skinTonePosition='search'). + &__search-row { + display: flex; + align-items: center; + + .str-chat__emoji-picker__search { + flex: 1 1 auto; + } + + .str-chat__emoji-picker__skin-tone-toggle, + .str-chat__emoji-picker__skin-tones { + margin-inline-end: var(--str-chat__spacing-sm); + } + } + &__empty { display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: var(--str-chat__spacing-xs); min-block-size: 12rem; padding: var(--str-chat__spacing-md); color: var(--str-chat__emoji-picker-secondary-text-color); text-align: center; } + &__empty-emoji { + font-size: 2rem; + line-height: 1; + } + &__category-nav { display: flex; align-items: stretch; @@ -182,9 +204,12 @@ $emoji-picker-border-radius: 12px; &-emojis { display: grid; - // Cells are larger than the glyph and separated by a real gap so the grid - // breathes (roughly 9 per row at the default width) rather than packing tight. - grid-template-columns: repeat(auto-fill, minmax(1.75rem, 1fr)); + // Fixed column count driven by the `perLine` option (default 9); cells share the + // row evenly with a real gap so the grid breathes rather than packing tight. + grid-template-columns: repeat( + var(--str-chat__emoji-picker-per-line, 9), + minmax(0, 1fr) + ); gap: var(--str-chat__spacing-xs); padding-block-end: var(--str-chat__spacing-xs); } @@ -195,7 +220,10 @@ $emoji-picker-border-radius: 12px; // more emoji are used. (Uses the literal child class, not `&-emojis`, so the // combinator doesn't re-expand the whole parent chain.) &[data-category-id='frequent'] > .str-chat__emoji-picker__category-emojis { - grid-template-columns: repeat(9, minmax(0, 1fr)); + grid-template-columns: repeat( + var(--str-chat__emoji-picker-per-line, 9), + minmax(0, 1fr) + ); } } @@ -229,6 +257,12 @@ $emoji-picker-border-radius: 12px; border-block-start: 1px solid var(--str-chat__emoji-picker-separator-color); } + // Preview positioned at the top: divider below it instead of above. + &__footer--top { + border-block-start: none; + border-block-end: 1px solid var(--str-chat__emoji-picker-separator-color); + } + &__preview { display: flex; flex: 1 1 auto; From bf7575bbd46ec2e77b6c48e9d5735639d04b2cc1 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 7 Jul 2026 14:52:59 +0200 Subject: [PATCH 26/36] feat(emojis): add StreamEmojiPicker and deprecate emoji-mart EmojiPicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `StreamEmojiPicker`, the built-in (emoji-mart-free) picker, as the recommended successor exported from `stream-chat-react/emojis`. The existing `EmojiPicker` keeps rendering the emoji-mart picker unchanged for backwards compatibility โ€” now marked `@deprecated` with a one-time console warning. It and the optional emoji-mart peer dependencies are scheduled for removal in v15. - `EmojiPicker.tsx` remains the emoji-mart engine (history preserved) and warns once, pointing at `StreamEmojiPicker`. - `StreamEmojiPicker.tsx` is the built-in successor (new file): native React panel, in-house search index, vendored dataset, curated `pickerProps`. - Re-add `@emoji-mart/data`, `@emoji-mart/react`, `emoji-mart` as OPTIONAL peer dependencies (removed entirely in v15). - vite example: the settings pane gains a picker-engine toggle (Stream vs emoji-mart) that drives both the composer picker and the live preview; the shared option controls exercise both engines. - Examples and `AI.md` recommend `StreamEmojiPicker` and document the migration. Everything here is additive or a soft-deprecation โ€” no breaking changes; the breaking removal lands in v15. Message reactions are unaffected: the extended set loads from the vendored dataset via `loadDefaultExtendedReactionOptions` (no emoji-mart install required), code-split and fetched on demand. --- AI.md | 27 ++- examples/tutorial/src/6-emoji-picker/App.tsx | 4 +- examples/vite/package.json | 3 + examples/vite/src/App.tsx | 17 +- examples/vite/src/AppSettings/state.ts | 2 + .../tabs/EmojiPicker/EmojiPickerTab.tsx | 36 ++- package.json | 14 ++ src/plugins/Emojis/EmojiPicker.tsx | 119 +++------- src/plugins/Emojis/StreamEmojiPicker.tsx | 205 ++++++++++++++++++ .../Emojis/__tests__/EmojiPicker.test.tsx | 141 +++--------- .../__tests__/StreamEmojiPicker.test.tsx | 173 +++++++++++++++ .../Emojis/__tests__/entry-exports.test.ts | 8 + src/plugins/Emojis/index.ts | 1 + yarn.lock | 33 ++- 14 files changed, 570 insertions(+), 213 deletions(-) create mode 100644 src/plugins/Emojis/StreamEmojiPicker.tsx create mode 100644 src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx create mode 100644 src/plugins/Emojis/__tests__/entry-exports.test.ts diff --git a/AI.md b/AI.md index 330c077976..88c8733973 100644 --- a/AI.md +++ b/AI.md @@ -241,12 +241,18 @@ const CustomMessage = () => { **User intent**: "Add emoji picker and autocomplete" -Emoji support is built into the SDK โ€” no `emoji-mart` packages or `init()` call are -required. +Emoji support is built into the SDK โ€” the **`StreamEmojiPicker`** needs no `emoji-mart` +packages or `init()` call. + +> **Deprecation:** the `EmojiPicker` export from `stream-chat-react/emojis` is the legacy +> **emoji-mart-backed** picker. It still works unchanged for backwards compatibility, but +> is **deprecated and will be removed in v15** and logs a one-time console warning. New +> integrations should use `StreamEmojiPicker`; existing emoji-mart integrations keep +> working until you migrate (see _Migrating from emoji-mart_ below). **Steps**: -1. Import `EmojiPicker` from `stream-chat-react/emojis` and pass it to `Channel`. +1. Import `StreamEmojiPicker` from `stream-chat-react/emojis` and pass it to `Channel`. 2. Import the emoji picker's stylesheet: `import 'stream-chat-react/css/emoji-picker.css'`. It is intentionally **not** part of `index.css` (so apps that don't use the picker ship no emoji CSS); without it the picker panel renders unstyled. @@ -255,20 +261,20 @@ required. `createTextComposerEmojiMiddleware()` (no argument โ€” it uses the built-in index). ```tsx -import { EmojiPicker } from 'stream-chat-react/emojis'; +import { StreamEmojiPicker } from 'stream-chat-react/emojis'; import 'stream-chat-react/css/emoji-picker.css'; -{/* ... */}; +{/* ... */}; ``` **Notes**: - Passing a custom `emojiSearchIndex` (including emoji-mart's `SearchIndex`) is still supported for advanced use. -- Skin tone and "frequently used" are integrator-managed props on `EmojiPicker` +- On `StreamEmojiPicker`, skin tone and "frequently used" are integrator-managed props (`skinTone`/`onSkinToneChange`, `frequentlyUsedEmoji`/`onFrequentlyUsedChange`); the SDK does not persist them. See `examples/vite/` for a localStorage example. -- `pickerProps` accepts a curated set of emoji-mart-compatible `Picker` options (plus +- On `StreamEmojiPicker`, `pickerProps` accepts a curated set of emoji-mart-compatible `Picker` options (plus `theme` and `style`): - **Layout**: `navPosition` / `previewPosition` (`'top' | 'bottom' | 'none'`), `searchPosition` (`'sticky' | 'static' | 'none'`), `skinTonePosition` @@ -312,6 +318,13 @@ import 'stream-chat-react/css/emoji-picker.css'; For a curated subset instead, build the `extended` map yourself with `mapEmojiMartData`. +- **Migrating from emoji-mart** (the deprecated `EmojiPicker`): swap `EmojiPicker` โ†’ + `StreamEmojiPicker`, then remove the `emoji-mart` / `@emoji-mart/react` / + `@emoji-mart/data` installs and any `init({ data })` call. The deprecated `EmojiPicker` + still accepts raw emoji-mart `pickerProps` (e.g. `set`, `emojiSize`); `StreamEmojiPicker` + uses the curated `pickerProps` above. Autocomplete (`createTextComposerEmojiMiddleware`) + and reactions (`mapEmojiMartData`) are engine-agnostic and need no changes. + **Reference**: See `examples/tutorial/src/6-emoji-picker/` ## TypeScript Setup diff --git a/examples/tutorial/src/6-emoji-picker/App.tsx b/examples/tutorial/src/6-emoji-picker/App.tsx index b42b927234..315b636f3d 100644 --- a/examples/tutorial/src/6-emoji-picker/App.tsx +++ b/examples/tutorial/src/6-emoji-picker/App.tsx @@ -12,7 +12,7 @@ import { Window, WithComponents, } from 'stream-chat-react'; -import { EmojiPicker } from 'stream-chat-react/emojis'; +import { StreamEmojiPicker } from 'stream-chat-react/emojis'; import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; @@ -61,7 +61,7 @@ const App = () => { return ( - + diff --git a/examples/vite/package.json b/examples/vite/package.json index 4222e13235..87b938f87f 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -9,7 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "clsx": "^2.1.1", + "emoji-mart": "^5.6.0", "human-id": "^4.1.3", "modern-normalize": "^3.0.1", "react": "^19.2.6", diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index a86923ec57..bb2d7bc72a 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -42,6 +42,7 @@ import { createTextComposerEmojiMiddleware, EmojiPicker, loadDefaultExtendedReactionOptions, + StreamEmojiPicker, } from 'stream-chat-react/emojis'; import { humanId } from 'human-id'; @@ -198,17 +199,25 @@ const writeStored = (key: string, value: unknown) => { }; const EmojiPickerWithCustomOptions = ( - props: React.ComponentProps, + props: React.ComponentProps, ) => { const { mode } = useAppSettingsSelector((state) => state.theme); - const emojiPicker = useAppSettingsSelector((state) => state.emojiPicker); + const { engine, ...pickerOptions } = useAppSettingsSelector( + (state) => state.emojiPicker, + ); const [skinTone, setSkinTone] = useState(() => readStored(EMOJI_SKIN_TONE_KEY, 0)); const [frequentlyUsedEmoji, setFrequentlyUsedEmoji] = useState(() => readStored(EMOJI_FREQUENTLY_USED_KEY, []), ); + // The deprecated emoji-mart picker self-manages skin tone + frequently-used and + // accepts the same emoji-mart-compatible option names, so it only needs pickerProps. + if (engine === 'emoji-mart') { + return ; + } + return ( - { @@ -221,7 +230,7 @@ const EmojiPickerWithCustomOptions = ( }} pickerProps={{ ...props.pickerProps, - ...emojiPicker, + ...pickerOptions, theme: mode, }} skinTone={skinTone} diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 3fa5129473..d46b951832 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -13,6 +13,7 @@ export type ChatViewSettingsState = { export type EmojiPickerSettingsState = { autoFocus: boolean; + engine: 'emoji-mart' | 'stream'; maxFrequentRows: number; navPosition: 'top' | 'bottom' | 'none'; noCountryFlags: boolean; @@ -25,6 +26,7 @@ export type EmojiPickerSettingsState = { // Mirrors the SDK's EmojiPicker defaults; the settings tab resets to this. export const DEFAULT_EMOJI_PICKER_SETTINGS: EmojiPickerSettingsState = { autoFocus: true, + engine: 'stream', maxFrequentRows: 1, navPosition: 'top', noCountryFlags: false, diff --git a/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx b/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx index 937779af5d..763b6b4a01 100644 --- a/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx +++ b/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react'; +import EmojiMartPickerImport from '@emoji-mart/react'; +import { type ComponentType, useState } from 'react'; import { Button } from 'stream-chat-react'; import { EmojiPickerPanel, type EmojiSelection } from 'stream-chat-react/emojis'; import { appSettingsStore, DEFAULT_EMOJI_PICKER_SETTINGS, type EmojiPickerSettingsState, + useAppSettingsSelector, useAppSettingsState, } from '../../state'; import { @@ -12,6 +14,10 @@ import { SettingsTabLayoutHeader, } from '../SettingsTabLayoutComponents.tsx'; +// @emoji-mart/react ships CJS-on-default; unwrap for strict-ESM (Vite 8) interop. +const EmojiMart = ((EmojiMartPickerImport as { default?: unknown }).default ?? + EmojiMartPickerImport) as ComponentType>; + type EmojiPickerTabProps = { close: () => void; }; @@ -66,9 +72,24 @@ const numberOptions = (values: number[]) => * exercised too. */ const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) => { + const { mode } = useAppSettingsSelector((state) => state.theme); + const { engine, ...pickerOptions } = options; const [skinTone, setSkinTone] = useState(0); const [frequentlyUsedIds, setFrequentlyUsedIds] = useState([]); + // The deprecated emoji-mart picker renders inline too and honors the same + // emoji-mart-compatible option names, so the same controls drive both engines. + if (engine === 'emoji-mart') { + return ( + (await import('@emoji-mart/data')).default} + onEmojiSelect={() => undefined} + theme={mode} + /> + ); + } + return ( [emoji.id, ...ids.filter((id) => id !== emoji.id)]) } onSkinToneChange={setSkinTone} - options={{ ...options, exceptEmojis: [] }} + options={{ ...pickerOptions, exceptEmojis: [] }} skinToneIndex={skinTone} /> ); @@ -97,7 +118,7 @@ export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => {
@@ -117,6 +138,15 @@ export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => { Reset to defaults
+ + label='Picker engine' + onSelect={(engine) => update({ engine })} + options={[ + { label: 'Stream (native)', value: 'stream' }, + { label: 'emoji-mart (deprecated)', value: 'emoji-mart' }, + ]} + value={emojiPicker.engine} + /> label='Navigation position' onSelect={(navPosition) => update({ navPosition })} diff --git a/package.json b/package.json index 9cc698f36f..6dad204ab6 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,9 @@ }, "peerDependencies": { "@breezystack/lamejs": "^1.2.7", + "@emoji-mart/data": "^1.1.0", + "@emoji-mart/react": "^1.1.0", + "emoji-mart": "^5.4.0", "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", @@ -114,6 +117,15 @@ "@breezystack/lamejs": { "optional": true }, + "@emoji-mart/data": { + "optional": true + }, + "@emoji-mart/react": { + "optional": true + }, + "emoji-mart": { + "optional": true + }, "modern-normalize": { "optional": true } @@ -129,6 +141,7 @@ "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", "@emoji-mart/data": "1.2.1", + "@emoji-mart/react": "^1.1.1", "@eslint/js": "^9.39.4", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", @@ -151,6 +164,7 @@ "@vitest/eslint-plugin": "^1.6.20", "concurrently": "^9.2.1", "conventional-changelog-conventionalcommits": "^9.3.1", + "emoji-mart": "^5.6.0", "eslint": "^9.39.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index d396f04cbd..b9f6931c89 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import PickerImport from '@emoji-mart/react'; import { useMessageComposerContext, useTranslationContext } from '../../context'; import { @@ -9,24 +10,19 @@ import { } from '../../components'; import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive'; -import { EmojiPickerPanel } from './components'; -import { useFrequentlyUsedEmoji } from './hooks/useFrequentlyUsedEmoji'; -import { useSkinTone } from './hooks/useSkinTone'; -import { - type EmojiPickerPassthroughProps, - resolveEmojiPickerOptions, - warnUnsupportedPickerProps, -} from './options'; + +// @emoji-mart/react ships as CJS with the component on `exports.default`. Under +// spec-strict ESM interop (e.g. Vite 8 / Rolldown, native Node ESM) a default +// import yields the module namespace `{ default }` instead of the component, +// which makes React throw "Element type is invalid ... got: object". Unwrap the +// default defensively so it works regardless of interop. +const Picker = + (PickerImport as unknown as { default?: typeof PickerImport }).default ?? PickerImport; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; -export type { - EmojiPickerNavPosition, - EmojiPickerPassthroughProps, - EmojiPickerPreviewPosition, - EmojiPickerSearchPosition, - EmojiPickerSkinTonePosition, -} from './options'; +// Warn at most once per session that this engine is going away. +let hasWarnedEmojiMartDeprecation = false; export type EmojiPickerProps = { ButtonIconComponent?: React.ComponentType; @@ -35,37 +31,14 @@ export type EmojiPickerProps = { wrapperClassName?: string; closeOnEmojiSelect?: boolean; /** - * Presentation + curated layout/behavior options for the picker panel, using - * emoji-mart-compatible names (`theme`, `style`, `perLine`, `navPosition`, - * `previewPosition`, `searchPosition`, `skinTonePosition`, `categories`, - * `exceptEmojis`, `emojiVersion`, `maxFrequentRows`, `noCountryFlags`, - * `previewEmoji`, `noResultsEmoji`, `autoFocus`, `onClickOutside`). - * - * Not every emoji-mart `Picker` option is supported: image sets (`set`, - * `getSpritesheetURL`), `custom` emoji, `data`, `i18n`/`locale`, `dynamicWidth`, - * `icons`, and `categoryIcons` are rejected by the type and ignored (with a console - * warning) at runtime; sizing knobs (`emojiSize`, โ€ฆ) are CSS tokens instead. See the - * emoji migration notes in `AI.md`. + * Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.6.0#options--props) to be + * passed down to the [emoji-mart `Picker`](https://github.com/missive/emoji-mart/tree/v5.6.0#-picker) component */ - pickerProps?: EmojiPickerPassthroughProps; + pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & Record>; /** * Floating UI placement (default: 'top-end') for the picker popover */ placement?: PopperLikePlacement; - /** Uncontrolled initial skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ - defaultSkinTone?: number; - /** - * Controlled ordered list of recently used emoji ids (most recent first). The SDK - * does not persist this โ€” provide it (and `onFrequentlyUsedChange`) to control the - * "frequently used" section; otherwise it is tracked in memory for the session. - */ - frequentlyUsedEmoji?: string[]; - /** Called with the updated recently-used list when an emoji is selected. */ - onFrequentlyUsedChange?: (emojiIds: string[]) => void; - /** Called with the new skin tone index when it changes. */ - onSkinToneChange?: (skinTone: number) => void; - /** Controlled skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ - skinTone?: number; }; const defaultButtonClassName = 'str-chat__emoji-picker-button'; @@ -78,22 +51,16 @@ const classNames: Pick< wrapperClassName: 'str-chat__message-textarea-emoji-picker', }; +/** + * @deprecated The emoji-mart-based `EmojiPicker` will be removed in v15. Switch to + * `StreamEmojiPicker` from `stream-chat-react/emojis`, which needs no emoji-mart + * packages. See the emoji section of `AI.md` for migration notes. + */ export const EmojiPicker = (props: EmojiPickerProps) => { const { t } = useTranslationContext('EmojiPicker'); const { textareaRef } = useMessageComposerContext('EmojiPicker'); const { textComposer } = useMessageComposerController(); const isCooldownActive = useIsCooldownActive(); - // Skin tone and frequently-used live here (not in the panel) so they survive the - // picker opening/closing โ€” the panel below is mounted only while open. - const [skinTone, setSkinTone] = useSkinTone({ - defaultSkinTone: props.defaultSkinTone, - onSkinToneChange: props.onSkinToneChange, - skinTone: props.skinTone, - }); - const { frequentlyUsedIds, recordUse } = useFrequentlyUsedEmoji({ - frequentlyUsedEmoji: props.frequentlyUsedEmoji, - onFrequentlyUsedChange: props.onFrequentlyUsedChange, - }); const [displayPicker, setDisplayPicker] = useState(false); const [referenceElement, setReferenceElement] = useState( null, @@ -104,6 +71,15 @@ export const EmojiPicker = (props: EmojiPickerProps) => { placement: props.placement ?? 'top-end', }); + useEffect(() => { + if (hasWarnedEmojiMartDeprecation) return; + hasWarnedEmojiMartDeprecation = true; + console.warn( + '[stream-chat-react] The `emoji-mart`-based `EmojiPicker` is deprecated and will be removed in the next major version. ' + + 'Switch to `StreamEmojiPicker` from `stream-chat-react/emojis` as it offers the same functionality without 3rd party dependencies.', + ); + }, []); + useEffect(() => { refs.setReference(referenceElement); }, [referenceElement, refs]); @@ -114,19 +90,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => { const { pickerContainerClassName, wrapperClassName } = classNames; const { ButtonIconComponent = IconEmoji } = props; - const pickerStyle = props.pickerProps?.style; - const options = resolveEmojiPickerOptions(props.pickerProps); - // Latest-ref so the click-outside listener isn't re-attached when the callback identity - // changes between renders. - const onClickOutsideRef = useRef(props.pickerProps?.onClickOutside); - onClickOutsideRef.current = props.pickerProps?.onClickOutside; - - const pickerPropsKeys = Object.keys(props.pickerProps ?? {}); - useEffect(() => { - warnUnsupportedPickerProps(props.pickerProps); - // Re-check only when the set of provided keys changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pickerPropsKeys.join(',')]); + const pickerStyle = props.pickerProps?.style as React.CSSProperties | undefined; useEffect(() => { if (!popperElement || !referenceElement) return; @@ -143,7 +107,6 @@ export const EmojiPicker = (props: EmojiPickerProps) => { return; } - onClickOutsideRef.current?.(); setDisplayPicker(false); }; @@ -159,35 +122,25 @@ export const EmojiPicker = (props: EmojiPickerProps) => { ref={setPopperElement} style={{ left: x ?? 0, position: strategy, top: y ?? 0 }} > - { - setDisplayPicker(false); - referenceElement?.focus(); - }} - onEmojiSelect={(emoji) => { + (await import('@emoji-mart/data')).default} + onEmojiSelect={(e: { native: string }) => { const textarea = textareaRef.current; if (!textarea) return; - // Record only once we know the emoji is actually being inserted. - recordUse(emoji.id); - textComposer.insertText({ text: emoji.native }); + textComposer.insertText({ text: e.native }); textarea.focus(); if (props.closeOnEmojiSelect) { setDisplayPicker(false); } }} - onSkinToneChange={setSkinTone} - options={options} - skinToneIndex={skinTone} - style={pickerStyle} - theme={props.pickerProps?.theme} + {...props.pickerProps} + style={{ ...pickerStyle, '--shadow': 'none' }} />
)} +
+ ); +}; diff --git a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx index f32a3b9a10..1f2cd1f21d 100644 --- a/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx +++ b/src/plugins/Emojis/__tests__/EmojiPicker.test.tsx @@ -1,57 +1,36 @@ -import type { AriaAttributes, MouseEventHandler, ReactNode } from 'react'; +import type { MouseEventHandler, ReactNode } from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; -// The panel is mounted only while the picker is open, so mock it to a controllable -// stub. This lets us assert that the owner (EmojiPicker) preserves skin tone and -// frequently-used across open/close โ€” without depending on the real panel's -// virtualized grid + async data load, which don't render reliably in jsdom. -vi.mock('../components', () => ({ - EmojiPickerPanel: ({ - frequentlyUsedIds = [], - onEmojiSelect, - onSkinToneChange, - skinToneIndex = 0, - }: { - frequentlyUsedIds?: string[]; - onEmojiSelect: (emoji: { id: string; name: string; native: string }) => void; - onSkinToneChange?: (skinTone: number) => void; - skinToneIndex?: number; - }) => ( -
- {skinToneIndex} - {frequentlyUsedIds.join(',')} - -
), })); +vi.mock('@emoji-mart/data', () => ({ default: {} })); -// Mutable so a test can simulate "no textarea to insert into" (textareaRef.current null). const { textareaRef } = vi.hoisted(() => ({ - textareaRef: { current: null as HTMLTextAreaElement | null }, + textareaRef: { + current: document.createElement('textarea') as HTMLTextAreaElement | null, + }, })); +const insertText = vi.hoisted(() => vi.fn()); vi.mock('../../../context', () => ({ useMessageComposerContext: () => ({ textareaRef }), useTranslationContext: () => ({ t: (key: string) => key }), })); - vi.mock('../../../components', async () => { const { forwardRef } = await import('react'); return { Button: forwardRef>( function Button(props, ref) { - // Forward only the DOM-valid props the test needs; styling props are dropped. return ( + +
+ ), +})); + +// Mutable so a test can simulate "no textarea to insert into" (textareaRef.current null). +const { textareaRef } = vi.hoisted(() => ({ + textareaRef: { current: null as HTMLTextAreaElement | null }, +})); + +vi.mock('../../../context', () => ({ + useMessageComposerContext: () => ({ textareaRef }), + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +vi.mock('../../../components', async () => { + const { forwardRef } = await import('react'); + return { + Button: forwardRef>( + function Button(props, ref) { + // Forward only the DOM-valid props the test needs; styling props are dropped. + return ( + + ); + }, + ), + IconEmoji: () => emoji, + useMessageComposerController: () => ({ textComposer: { insertText: vi.fn() } }), + }; +}); + +vi.mock('../../../components/Dialog/hooks/usePopoverPosition', () => ({ + usePopoverPosition: () => ({ + refs: { setFloating: vi.fn(), setReference: vi.fn() }, + strategy: 'absolute', + x: 0, + y: 0, + }), +})); + +vi.mock('../../../components/MessageComposer/hooks/useIsCooldownActive', () => ({ + useIsCooldownActive: () => false, +})); + +// Imported after the mocks so the mocked dependencies are in place. +import { StreamEmojiPicker } from '../StreamEmojiPicker'; + +const openPicker = () => fireEvent.click(screen.getByLabelText('aria/Emoji picker')); + +beforeEach(() => { + textareaRef.current = document.createElement('textarea'); +}); + +describe('StreamEmojiPicker session state', () => { + it('keeps skin tone and frequently-used across close and reopen (incl. closeOnEmojiSelect)', () => { + render(); + + openPicker(); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('0'); + expect(screen.getByTestId('frequently-used')).toHaveTextContent(''); + + // Change skin tone, then select an emoji โ€” selecting closes the picker. + fireEvent.click(screen.getByText('set-skin')); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('4'); + fireEvent.click(screen.getByText('select-rocket')); + + // Panel (and any state it might have held) is unmounted. + expect(screen.queryByTestId('panel')).not.toBeInTheDocument(); + + // Reopening shows the retained skin tone and the just-used emoji. + openPicker(); + expect(screen.getByTestId('skin-tone')).toHaveTextContent('4'); + expect(screen.getByTestId('frequently-used')).toHaveTextContent('rocket'); + }); + + it('does not record a frequently-used emoji when there is no textarea to insert into', () => { + textareaRef.current = null; + render(); + + openPicker(); + expect(screen.getByTestId('frequently-used').textContent).toBe(''); + + // Selecting can't insert (no textarea), so it must not be recorded as "used". + fireEvent.click(screen.getByText('select-rocket')); + expect(screen.getByTestId('frequently-used').textContent).toBe(''); + }); + + it('marks the toggle button as opening a dialog popup', () => { + render(); + expect(screen.getByLabelText('aria/Emoji picker')).toHaveAttribute( + 'aria-haspopup', + 'dialog', + ); + }); +}); + +describe('StreamEmojiPicker pickerProps', () => { + it('warns about unsupported (emoji-mart) pickerProps, but not about theme/style', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { unmount } = render( + , + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('set')); + unmount(); + + warn.mockClear(); + render(); + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); + + it('does not warn when only supported (curated) picker options are passed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + , + ); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('calls onClickOutside when a pointer press lands outside the open picker', () => { + const onClickOutside = vi.fn(); + render(); + openPicker(); + fireEvent.pointerDown(document.body); + expect(onClickOutside).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/Emojis/__tests__/entry-exports.test.ts b/src/plugins/Emojis/__tests__/entry-exports.test.ts new file mode 100644 index 0000000000..509670a8a6 --- /dev/null +++ b/src/plugins/Emojis/__tests__/entry-exports.test.ts @@ -0,0 +1,8 @@ +import * as emojis from '../index'; + +describe('emojis entry exports', () => { + it('exports both the deprecated EmojiPicker and the StreamEmojiPicker successor', () => { + expect(typeof emojis.EmojiPicker).toBe('function'); + expect(typeof emojis.StreamEmojiPicker).toBe('function'); + }); +}); diff --git a/src/plugins/Emojis/index.ts b/src/plugins/Emojis/index.ts index 0536a8d90e..75470a3e9d 100644 --- a/src/plugins/Emojis/index.ts +++ b/src/plugins/Emojis/index.ts @@ -5,3 +5,4 @@ export * from './EmojiPicker'; export * from './middleware'; export * from './reactions'; export * from './search'; +export * from './StreamEmojiPicker'; diff --git a/yarn.lock b/yarn.lock index 6ace44b6f7..c587615352 100644 --- a/yarn.lock +++ b/yarn.lock @@ -614,13 +614,23 @@ __metadata: languageName: node linkType: hard -"@emoji-mart/data@npm:1.2.1": +"@emoji-mart/data@npm:1.2.1, @emoji-mart/data@npm:^1.2.1": version: 1.2.1 resolution: "@emoji-mart/data@npm:1.2.1" checksum: 10c0/6784b97bf49a0d3ff110d8447bbd3b0449fcbc497294be3d1c3a6cb1609308776895c7520200be604cbecaa5e172c76927e47f34419c72ba8a76fd4e5a53674b languageName: node linkType: hard +"@emoji-mart/react@npm:^1.1.1": + version: 1.1.1 + resolution: "@emoji-mart/react@npm:1.1.1" + peerDependencies: + emoji-mart: ^5.2 + react: ^16.8 || ^17 || ^18 + checksum: 10c0/88a9c8c24bbc5695f0ed2458734c9982c965a16db1999bc731c7cce77f9bf228f1871e899744f9a3f9fdd36a11db7ad6c0e049d710cb91c66c69a2cd4d2ee40a + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -2079,6 +2089,8 @@ __metadata: resolution: "@stream-io/stream-chat-react-vite@workspace:examples/vite" dependencies: "@babel/core": "npm:^7.29.0" + "@emoji-mart/data": "npm:^1.2.1" + "@emoji-mart/react": "npm:^1.1.1" "@playwright/test": "npm:^1.60.0" "@types/react": "npm:^19.2.15" "@types/react-dom": "npm:^19.2.3" @@ -2086,6 +2098,7 @@ __metadata: "@vitejs/plugin-react-swc": "npm:^4.3.1" babel-plugin-react-compiler: "npm:^1.0.0" clsx: "npm:^2.1.1" + emoji-mart: "npm:^5.6.0" human-id: "npm:^4.1.3" modern-normalize: "npm:^3.0.1" react: "npm:^19.2.6" @@ -4240,6 +4253,13 @@ __metadata: languageName: node linkType: hard +"emoji-mart@npm:^5.6.0": + version: 5.6.0 + resolution: "emoji-mart@npm:5.6.0" + checksum: 10c0/23e68ab10984f101b696d8f8e103e553ffa8e4d644e9a315190a9657903f71b833db09aac51b05de20f33bb9eef5bc1425eecdb2437042b25aff2dad0231f029 + languageName: node + linkType: hard + "emoji-regex@npm:^10.3.0": version: 10.6.0 resolution: "emoji-regex@npm:10.6.0" @@ -10021,6 +10041,7 @@ __metadata: "@commitlint/cli": "npm:^21.0.1" "@commitlint/config-conventional": "npm:^21.0.1" "@emoji-mart/data": "npm:1.2.1" + "@emoji-mart/react": "npm:^1.1.1" "@eslint/js": "npm:^9.39.4" "@floating-ui/react": "npm:^0.27.19" "@react-aria/focus": "npm:^3.22.0" @@ -10048,6 +10069,7 @@ __metadata: concurrently: "npm:^9.2.1" conventional-changelog-conventionalcommits: "npm:^9.3.1" dayjs: "npm:^1.11.20" + emoji-mart: "npm:^5.6.0" emoji-regex: "npm:^9.2.2" eslint: "npm:^9.39.4" eslint-plugin-import: "npm:^2.32.0" @@ -10092,6 +10114,9 @@ __metadata: vitest-axe: "npm:^0.1.0" peerDependencies: "@breezystack/lamejs": ^1.2.7 + "@emoji-mart/data": ^1.1.0 + "@emoji-mart/react": ^1.1.0 + emoji-mart: ^5.4.0 modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 @@ -10110,6 +10135,12 @@ __metadata: peerDependenciesMeta: "@breezystack/lamejs": optional: true + "@emoji-mart/data": + optional: true + "@emoji-mart/react": + optional: true + emoji-mart: + optional: true modern-normalize: optional: true languageName: unknown From 5ddc4bd3672acecd2f7e1ec20f7ebf1fcf5453e5 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 7 Jul 2026 15:19:39 +0200 Subject: [PATCH 27/36] fix(emojis): keep the vite example emoji preview pinned to the right --- examples/vite/src/AppSettings/AppSettings.scss | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index 245448d449..9975fb96fc 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -1090,15 +1090,22 @@ justify-content: flex-end; } - // Emoji Picker tab: controls on the left, a live preview on the right that stays in - // view (sticky) while the controls scroll. Wraps to a single column when too narrow. + // Emoji Picker tab: controls on the left, a live preview pinned on the right that + // stays in view (sticky) while the controls scroll, so config changes are immediately + // visible. Stacks into a single column only on narrow screens. .app__emoji-settings { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: flex-start; gap: var(--str-chat__spacing-xl); } + @media (max-width: 768px) { + .app__emoji-settings { + flex-wrap: wrap; + } + } + .app__emoji-settings__controls { display: flex; flex: 1 1 300px; From 9055a79ece503138cdb75aa36dac357d3e91c70e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 7 Jul 2026 16:39:30 +0200 Subject: [PATCH 28/36] chore: sync i18n strings --- src/i18n/de.json | 46 +++++++++++++++++++++++----------------------- src/i18n/en.json | 46 +++++++++++++++++++++++----------------------- src/i18n/es.json | 46 +++++++++++++++++++++++----------------------- src/i18n/fr.json | 46 +++++++++++++++++++++++----------------------- src/i18n/hi.json | 46 +++++++++++++++++++++++----------------------- src/i18n/it.json | 46 +++++++++++++++++++++++----------------------- src/i18n/ja.json | 46 +++++++++++++++++++++++----------------------- src/i18n/ko.json | 46 +++++++++++++++++++++++----------------------- src/i18n/nl.json | 46 +++++++++++++++++++++++----------------------- src/i18n/pt.json | 46 +++++++++++++++++++++++----------------------- src/i18n/ru.json | 46 +++++++++++++++++++++++----------------------- src/i18n/tr.json | 46 +++++++++++++++++++++++----------------------- 12 files changed, 276 insertions(+), 276 deletions(-) diff --git a/src/i18n/de.json b/src/i18n/de.json index f022415ba8..cd7269d21c 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -43,6 +43,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} hat abgestimmt: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“Geteilter Standort", "Actions": "Actions", + "Activities": "Aktivitรคten", "Add": "Hinzufรผgen", "Add {{ count }} members_one": "{{ count }} Mitglied hinzufรผgen", "Add {{ count }} members_other": "{{ count }} Mitglieder hinzufรผgen", @@ -65,6 +66,7 @@ "Also sent in channel": "Auch im Kanal gesendet", "An error has occurred during recording": "Ein Fehler ist wรคhrend der Aufnahme aufgetreten", "An error has occurred during the recording processing": "Ein Fehler ist wรคhrend der Aufnahmeverarbeitung aufgetreten", + "Animals & Nature": "Tiere & Natur", "Anonymous": "Anonym", "Anonymous poll": "Anonyme Umfrage", "Archive": "Archivieren", @@ -84,6 +86,8 @@ "aria/Channel list": "Kanalliste", "aria/Channel search results": "Kanalsuchergebnisse", "aria/Chat view tabs": "Chat-Ansicht Tabs", + "aria/Choose default skin tone": "Standard-Hautton wรคhlen", + "aria/Clear emoji search": "Emoji-Suche lรถschen", "aria/Clear search": "Suche leeren", "aria/Close callout dialog": "Hinweisdialog schlieรŸen", "aria/Close thread": "Thread schlieรŸen", @@ -207,6 +211,8 @@ "Create a question, add options, and configure poll settings": "Erstelle eine Frage, fรผge Optionen hinzu und konfiguriere die Umfrageeinstellungen", "Create poll": "Umfrage erstellen", "Current location": "Aktueller Standort", + "Dark": "Dunkel", + "Default": "Standard", "Delete": "Lรถschen", "Delete chat": "Chat lรถschen", "Delete for me": "Fรผr mich lรถschen", @@ -275,6 +281,7 @@ "Failed to jump to the first unread message": "Fehler beim Springen zur ersten ungelesenen Nachricht", "Failed to leave channel": "Kanal konnte nicht verlassen werden", "Failed to load channels": "Kanรคle konnten nicht geladen werden", + "Failed to load emojis": "Emojis konnten nicht geladen werden", "Failed to load more channels": "Weitere Kanรคle konnten nicht geladen werden", "Failed to mark channel as read": "Fehler beim Markieren des Kanals als gelesen", "Failed to play the recording": "Wiedergabe der Aufnahme fehlgeschlagen", @@ -292,6 +299,9 @@ "fileCount_other": "{{ count }} dateien", "Files": "Dateien", "Flag": "Melden", + "Flags": "Flaggen", + "Food & Drink": "Essen & Trinken", + "Frequently used": "Hรคufig verwendet", "Generating...": "Generieren...", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufรคlliges Gif in den Kanal", @@ -365,6 +375,7 @@ "Leave chat": "Kanal verlassen", "Left channel": "Kanal verlassen", "Let others add options": "Andere Optionen hinzufรผgen lassen", + "Light": "Hell", "Limit votes per person": "Stimmen pro Person begrenzen", "Link": "Link", "linkCount_one": "Link", @@ -383,6 +394,9 @@ "Mark as unread": "Als ungelesen markieren", "Maximum number of votes (from 2 to 10)": "Maximale Anzahl der Stimmen (von 2 bis 10)", "Maximum votes per person": "Maximale Stimmen pro Person", + "Medium": "Mittel", + "Medium-Dark": "Mitteldunkel", + "Medium-Light": "Mittelhell", "Member detail": "Mitgliederdetails", "mention/Channel": "Kanal", "mention/Channel Description": "Alle in diesem Kanal benachrichtigen", @@ -413,6 +427,7 @@ "Next image": "Nรคchstes Bild", "No chats here yetโ€ฆ": "Noch keine Chats hier...", "No conversations yet": "Noch keine Unterhaltungen", + "No emoji found": "Keine Emojis gefunden", "No files": "Keine Dateien", "No items exist": "Keine Elemente vorhanden", "No member found": "Kein Mitglied gefunden", @@ -424,6 +439,7 @@ "Nobody will be able to vote in this poll anymore.": "Niemand kann mehr in dieser Umfrage abstimmen.", "Nothing yet...": "Noch nichts...", "Notify all {{ role }} members": "Alle Mitglieder mit Rolle {{ role }} benachrichtigen", + "Objects": "Objekte", "Offline": "Offline", "Ok": "OK", "Online": "Online", @@ -443,6 +459,7 @@ "People matching": "Passende Personen", "Photo": "Foto", "Photos & videos": "Fotos & Videos", + "Pick an emojiโ€ฆ": "Emoji auswรคhlenโ€ฆ", "Pin": "Anheften", "Pin a message to see it here": "Hefte eine Nachricht an, um sie hier zu sehen", "Pinned by {{ name }}": "Angeheftet von {{ name }}", @@ -487,6 +504,7 @@ "replyCount_one": "1 Antwort", "replyCount_other": "{{ count }} Antworten", "Resend": "Erneut senden", + "Retry": "Erneut versuchen", "Retry upload": "Upload erneut versuchen", "Review all options available in this poll": "รœberprรผfe alle verfรผgbaren Optionen in dieser Umfrage", "Review comments submitted with poll answers": "รœberprรผfe Kommentare, die mit Umfrageantworten eingereicht wurden", @@ -497,6 +515,7 @@ "Save for later": "Fรผr spรคter speichern", "Saved for later": "Fรผr spรคter gespeichert", "Search": "Suche", + "Search emoji": "Emoji suchen", "Search GIFs": "GIFs suchen", "search-results-header-filter-source-button-label--channels": "Kanรคle", "search-results-header-filter-source-button-label--messages": "Nachrichten", @@ -532,12 +551,14 @@ "size limit": "GrรถรŸenbeschrรคnkung", "Slow Mode ON": "Langsamer Modus EIN", "Slow mode, wait {{ seconds }}s...": "Langsamer Modus, warte {{ seconds }}s...", + "Smileys & People": "Smileys & Personen", "Some of the files will not be accepted": "Einige der Dateien werden nicht akzeptiert", "Start typing to search": "Tippen Sie, um zu suchen", "Stop sharing": "Teilen beenden", "Submit": "Absenden", "Suggest a new option to add to this poll": "Schlage eine neue Option vor, die zu dieser Umfrage hinzugefรผgt werden soll", "Suggest an option": "Eine Option vorschlagen", + "Symbols": "Symbole", "Tap to remove": "Tippen zum Entfernen", "Tap to remove: {{ reactionName }}": "Tippen zum Entfernen: {{ reactionName }}", "Thinking...": "Denken...", @@ -576,6 +597,7 @@ "Translated": "รœbersetzt", "Translated from {{ language }}": "รœbersetzung aus {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Reisen & Orte", "Type a number from 2 to 10": "Geben Sie eine Zahl von 2 bis 10 ein", "Unarchive": "Archivierung aufheben", "unban-command-args": "[@Benutzername]", @@ -628,27 +650,5 @@ "Wait until all attachments have uploaded": "Bitte warten, bis alle Anhรคnge hochgeladen wurden", "Waiting for networkโ€ฆ": "Warte auf Netzwerkโ€ฆ", "You": "Du", - "You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht", - "Search emoji": "Emoji suchen", - "aria/Clear emoji search": "Emoji-Suche lรถschen", - "No emoji found": "Keine Emojis gefunden", - "aria/Choose default skin tone": "Standard-Hautton wรคhlen", - "Frequently used": "Hรคufig verwendet", - "Smileys & People": "Smileys & Personen", - "Animals & Nature": "Tiere & Natur", - "Food & Drink": "Essen & Trinken", - "Activities": "Aktivitรคten", - "Travel & Places": "Reisen & Orte", - "Objects": "Objekte", - "Symbols": "Symbole", - "Flags": "Flaggen", - "Default": "Standard", - "Light": "Hell", - "Medium-Light": "Mittelhell", - "Medium": "Mittel", - "Medium-Dark": "Mitteldunkel", - "Dark": "Dunkel", - "Failed to load emojis": "Emojis konnten nicht geladen werden", - "Retry": "Erneut versuchen", - "Pick an emojiโ€ฆ": "Emoji auswรคhlenโ€ฆ" + "You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht" } diff --git a/src/i18n/en.json b/src/i18n/en.json index 86bffd798d..9559770742 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -43,6 +43,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“Shared location", "Actions": "Actions", + "Activities": "Activities", "Add": "Add", "Add {{ count }} members_one": "Add {{ count }} member", "Add {{ count }} members_other": "Add {{ count }} members", @@ -65,6 +66,7 @@ "Also sent in channel": "Also sent in channel", "An error has occurred during recording": "An error has occurred during recording", "An error has occurred during the recording processing": "An error has occurred during the recording processing", + "Animals & Nature": "Animals & Nature", "Anonymous": "Anonymous", "Anonymous poll": "Anonymous Poll", "Archive": "Archive", @@ -84,6 +86,8 @@ "aria/Channel list": "Channel list", "aria/Channel search results": "Channel search results", "aria/Chat view tabs": "Chat view tabs", + "aria/Choose default skin tone": "aria/Choose default skin tone", + "aria/Clear emoji search": "aria/Clear emoji search", "aria/Clear search": "Clear search", "aria/Close callout dialog": "Close callout dialog", "aria/Close thread": "Close thread", @@ -207,6 +211,8 @@ "Create a question, add options, and configure poll settings": "Create a question, add options, and configure poll settings", "Create poll": "Create Poll", "Current location": "Current location", + "Dark": "Dark", + "Default": "Default", "Delete": "Delete", "Delete chat": "Delete chat", "Delete for me": "Delete for me", @@ -275,6 +281,7 @@ "Failed to jump to the first unread message": "Failed to jump to the first unread message", "Failed to leave channel": "Failed to leave channel", "Failed to load channels": "Failed to load channels", + "Failed to load emojis": "Failed to load emojis", "Failed to load more channels": "Failed to load more channels", "Failed to mark channel as read": "Failed to mark channel as read", "Failed to play the recording": "Failed to play the recording", @@ -292,6 +299,9 @@ "fileCount_other": "{{ count }} files", "Files": "Files", "Flag": "Flag", + "Flags": "Flags", + "Food & Drink": "Food & Drink", + "Frequently used": "Frequently used", "Generating...": "Generating...", "giphy-command-args": "[text]", "giphy-command-description": "Post a random gif to the channel", @@ -365,6 +375,7 @@ "Leave chat": "Leave chat", "Left channel": "Left channel", "Let others add options": "Let Others Add Options", + "Light": "Light", "Limit votes per person": "Limit Votes per Person", "Link": "Link", "linkCount_one": "Link", @@ -383,6 +394,9 @@ "Mark as unread": "Mark as unread", "Maximum number of votes (from 2 to 10)": "Maximum number of votes (from 2 to 10)", "Maximum votes per person": "Maximum votes per person", + "Medium": "Medium", + "Medium-Dark": "Medium-Dark", + "Medium-Light": "Medium-Light", "Member detail": "Member detail", "mention/Channel": "Channel", "mention/Channel Description": "Notify everyone in this channel", @@ -413,6 +427,7 @@ "Next image": "Next image", "No chats here yetโ€ฆ": "No chats here yetโ€ฆ", "No conversations yet": "No conversations yet", + "No emoji found": "No emoji found", "No files": "No files", "No items exist": "No items exist", "No member found": "No member found", @@ -424,6 +439,7 @@ "Nobody will be able to vote in this poll anymore.": "Nobody will be able to vote in this poll anymore.", "Nothing yet...": "Nothing yet...", "Notify all {{ role }} members": "Notify all {{ role }} members", + "Objects": "Objects", "Offline": "Offline", "Ok": "Ok", "Online": "Online", @@ -443,6 +459,7 @@ "People matching": "People matching", "Photo": "Photo", "Photos & videos": "Photos & videos", + "Pick an emojiโ€ฆ": "Pick an emojiโ€ฆ", "Pin": "Pin", "Pin a message to see it here": "Pin a message to see it here", "Pinned by {{ name }}": "Pinned by {{ name }}", @@ -487,6 +504,7 @@ "replyCount_one": "1 reply", "replyCount_other": "{{ count }} replies", "Resend": "Resend", + "Retry": "Retry", "Retry upload": "Retry upload", "Review all options available in this poll": "Review all options available in this poll", "Review comments submitted with poll answers": "Review comments submitted with poll answers", @@ -497,6 +515,7 @@ "Save for later": "Save for later", "Saved for later": "Saved for later", "Search": "Search", + "Search emoji": "Search emoji", "Search GIFs": "Search GIFs", "search-results-header-filter-source-button-label--channels": "channels", "search-results-header-filter-source-button-label--messages": "messages", @@ -532,12 +551,14 @@ "size limit": "size limit", "Slow Mode ON": "Slow Mode ON", "Slow mode, wait {{ seconds }}s...": "Slow mode, wait {{ seconds }}s...", + "Smileys & People": "Smileys & People", "Some of the files will not be accepted": "Some of the files will not be accepted", "Start typing to search": "Start typing to search", "Stop sharing": "Stop sharing", "Submit": "Submit", "Suggest a new option to add to this poll": "Suggest a new option to add to this poll", "Suggest an option": "Suggest an Option", + "Symbols": "Symbols", "Tap to remove": "Tap to remove", "Tap to remove: {{ reactionName }}": "Tap to remove: {{ reactionName }}", "Thinking...": "Thinking...", @@ -576,6 +597,7 @@ "Translated": "Translated", "Translated from {{ language }}": "Translated from {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Travel & Places", "Type a number from 2 to 10": "Type a number from 2 to 10", "Unarchive": "Unarchive", "unban-command-args": "[@username]", @@ -628,27 +650,5 @@ "Wait until all attachments have uploaded": "Wait until all attachments have uploaded", "Waiting for networkโ€ฆ": "Waiting for networkโ€ฆ", "You": "You", - "You've reached the maximum number of files": "You've reached the maximum number of files", - "Search emoji": "Search emoji", - "aria/Clear emoji search": "aria/Clear emoji search", - "No emoji found": "No emoji found", - "aria/Choose default skin tone": "aria/Choose default skin tone", - "Frequently used": "Frequently used", - "Smileys & People": "Smileys & People", - "Animals & Nature": "Animals & Nature", - "Food & Drink": "Food & Drink", - "Activities": "Activities", - "Travel & Places": "Travel & Places", - "Objects": "Objects", - "Symbols": "Symbols", - "Flags": "Flags", - "Default": "Default", - "Light": "Light", - "Medium-Light": "Medium-Light", - "Medium": "Medium", - "Medium-Dark": "Medium-Dark", - "Dark": "Dark", - "Failed to load emojis": "Failed to load emojis", - "Retry": "Retry", - "Pick an emojiโ€ฆ": "Pick an emojiโ€ฆ" + "You've reached the maximum number of files": "You've reached the maximum number of files" } diff --git a/src/i18n/es.json b/src/i18n/es.json index 3be116acb7..b6627cc931 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -53,6 +53,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} votรณ: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“Ubicaciรณn compartida", "Actions": "Actions", + "Activities": "Actividades", "Add": "Aรฑadir", "Add {{ count }} members_one": "Aรฑadir {{ count }} miembro", "Add {{ count }} members_many": "Aรฑadir {{ count }} miembros", @@ -76,6 +77,7 @@ "Also sent in channel": "Tambiรฉn enviado en el canal", "An error has occurred during recording": "Se ha producido un error durante la grabaciรณn", "An error has occurred during the recording processing": "Se ha producido un error durante el procesamiento de la grabaciรณn", + "Animals & Nature": "Animales y naturaleza", "Anonymous": "Anรณnimo", "Anonymous poll": "Encuesta anรณnima", "Archive": "Archivo", @@ -95,6 +97,8 @@ "aria/Channel list": "Lista de canales", "aria/Channel search results": "Resultados de bรบsqueda de canales", "aria/Chat view tabs": "Pestaรฑas de vista del chat", + "aria/Choose default skin tone": "Elegir tono de piel predeterminado", + "aria/Clear emoji search": "Borrar bรบsqueda de emojis", "aria/Clear search": "Borrar bรบsqueda", "aria/Close callout dialog": "Cerrar diรกlogo de aviso", "aria/Close thread": "Cerrar hilo", @@ -218,6 +222,8 @@ "Create a question, add options, and configure poll settings": "Crea una pregunta, aรฑade opciones y configura los ajustes de la encuesta", "Create poll": "Crear encuesta", "Current location": "Ubicaciรณn actual", + "Dark": "Oscuro", + "Default": "Predeterminado", "Delete": "Borrar", "Delete chat": "Eliminar chat", "Delete for me": "Eliminar para mรญ", @@ -286,6 +292,7 @@ "Failed to jump to the first unread message": "Error al saltar al primer mensaje no leรญdo", "Failed to leave channel": "No se pudo salir del canal", "Failed to load channels": "No se pudieron cargar los canales", + "Failed to load emojis": "No se pudieron cargar los emojis", "Failed to load more channels": "No se pudieron cargar mรกs canales", "Failed to mark channel as read": "Error al marcar el canal como leรญdo", "Failed to play the recording": "No se pudo reproducir la grabaciรณn", @@ -304,6 +311,9 @@ "fileCount_other": "{{ count }} archivos", "Files": "Archivos", "Flag": "Marcar", + "Flags": "Banderas", + "Food & Drink": "Comida y bebida", + "Frequently used": "Usados frecuentemente", "Generating...": "Generando...", "giphy-command-args": "[texto]", "giphy-command-description": "Publicar un gif aleatorio en el canal", @@ -378,6 +388,7 @@ "Leave chat": "Abandonar canal", "Left channel": "Canal abandonado", "Let others add options": "Permitir que otros aรฑadan opciones", + "Light": "Claro", "Limit votes per person": "Limitar votos por persona", "Link": "Enlace", "linkCount_one": "Enlace", @@ -397,6 +408,9 @@ "Mark as unread": "Marcar como no leรญdo", "Maximum number of votes (from 2 to 10)": "Nรบmero mรกximo de votos (de 2 a 10)", "Maximum votes per person": "Mรกximo de votos por persona", + "Medium": "Medio", + "Medium-Dark": "Medio oscuro", + "Medium-Light": "Medio claro", "Member detail": "Detalle del miembro", "mention/Channel": "Canal", "mention/Channel Description": "Notificar a todos en este canal", @@ -427,6 +441,7 @@ "Next image": "Siguiente imagen", "No chats here yetโ€ฆ": "Aรบn no hay mensajes aquรญ...", "No conversations yet": "Aรบn no hay conversaciones", + "No emoji found": "No se encontraron emojis", "No files": "No hay archivos", "No items exist": "No existen elementos", "No member found": "No se encontrรณ ningรบn miembro", @@ -438,6 +453,7 @@ "Nobody will be able to vote in this poll anymore.": "Nadie podrรก votar en esta encuesta.", "Nothing yet...": "Nada aรบn...", "Notify all {{ role }} members": "Notificar a todos los miembros con rol {{ role }}", + "Objects": "Objetos", "Offline": "Desconectado", "Ok": "Aceptar", "Online": "En lรญnea", @@ -457,6 +473,7 @@ "People matching": "Personas que coinciden", "Photo": "Foto", "Photos & videos": "Fotos y videos", + "Pick an emojiโ€ฆ": "Elige un emojiโ€ฆ", "Pin": "Fijar", "Pin a message to see it here": "Fija un mensaje para verlo aquรญ", "Pinned by {{ name }}": "Fijado por {{ name }}", @@ -504,6 +521,7 @@ "replyCount_many": "{{ count }} respuestas", "replyCount_other": "{{ count }} respuestas", "Resend": "Reenviar", + "Retry": "Reintentar", "Retry upload": "Reintentar la carga", "Review all options available in this poll": "Revisa todas las opciones disponibles en esta encuesta", "Review comments submitted with poll answers": "Revisa los comentarios enviados con las respuestas de la encuesta", @@ -514,6 +532,7 @@ "Save for later": "Guardar para mรกs tarde", "Saved for later": "Guardado para mรกs tarde", "Search": "Buscar", + "Search emoji": "Buscar emoji", "Search GIFs": "Buscar GIFs", "search-results-header-filter-source-button-label--channels": "canales", "search-results-header-filter-source-button-label--messages": "mensajes", @@ -551,12 +570,14 @@ "size limit": "lรญmite de tamaรฑo", "Slow Mode ON": "Modo lento activado", "Slow mode, wait {{ seconds }}s...": "Modo lento, espera {{ seconds }} s...", + "Smileys & People": "Emoticonos y personas", "Some of the files will not be accepted": "Algunos archivos no serรกn aceptados", "Start typing to search": "Empieza a escribir para buscar", "Stop sharing": "Dejar de compartir", "Submit": "Enviar", "Suggest a new option to add to this poll": "Sugiere una nueva opciรณn para aรฑadir a esta encuesta", "Suggest an option": "Sugerir una opciรณn", + "Symbols": "Sรญmbolos", "Tap to remove": "Toca para quitar", "Tap to remove: {{ reactionName }}": "Toca para quitar: {{ reactionName }}", "Thinking...": "Pensando...", @@ -597,6 +618,7 @@ "Translated": "Traducido", "Translated from {{ language }}": "Traducido de {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Viajes y lugares", "Type a number from 2 to 10": "Escribe un nรบmero del 2 al 10", "Unarchive": "Desarchivar", "unban-command-args": "[@usuario]", @@ -652,27 +674,5 @@ "Wait until all attachments have uploaded": "Espere hasta que se hayan cargado todos los archivos adjuntos", "Waiting for networkโ€ฆ": "Esperando redโ€ฆ", "You": "Tรบ", - "You've reached the maximum number of files": "Has alcanzado el nรบmero mรกximo de archivos", - "Search emoji": "Buscar emoji", - "aria/Clear emoji search": "Borrar bรบsqueda de emojis", - "No emoji found": "No se encontraron emojis", - "aria/Choose default skin tone": "Elegir tono de piel predeterminado", - "Frequently used": "Usados frecuentemente", - "Smileys & People": "Emoticonos y personas", - "Animals & Nature": "Animales y naturaleza", - "Food & Drink": "Comida y bebida", - "Activities": "Actividades", - "Travel & Places": "Viajes y lugares", - "Objects": "Objetos", - "Symbols": "Sรญmbolos", - "Flags": "Banderas", - "Default": "Predeterminado", - "Light": "Claro", - "Medium-Light": "Medio claro", - "Medium": "Medio", - "Medium-Dark": "Medio oscuro", - "Dark": "Oscuro", - "Failed to load emojis": "No se pudieron cargar los emojis", - "Retry": "Reintentar", - "Pick an emojiโ€ฆ": "Elige un emojiโ€ฆ" + "You've reached the maximum number of files": "Has alcanzado el nรบmero mรกximo de archivos" } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index ea12e8d6b0..9a82c2b8ba 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -53,6 +53,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} a votรฉ : {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“Emplacement partagรฉ", "Actions": "Actions", + "Activities": "Activitรฉs", "Add": "Ajouter", "Add {{ count }} members_one": "Ajouter {{ count }} membre", "Add {{ count }} members_many": "Ajouter {{ count }} membres", @@ -76,6 +77,7 @@ "Also sent in channel": "ร‰galement envoyรฉ dans le canal", "An error has occurred during recording": "Une erreur s'est produite pendant l'enregistrement", "An error has occurred during the recording processing": "Une erreur s'est produite pendant le traitement de l'enregistrement", + "Animals & Nature": "Animaux et nature", "Anonymous": "Anonyme", "Anonymous poll": "Sondage anonyme", "Archive": "Archiver", @@ -95,6 +97,8 @@ "aria/Channel list": "Liste des canaux", "aria/Channel search results": "Rรฉsultats de recherche de canaux", "aria/Chat view tabs": "Onglets de la vue de chat", + "aria/Choose default skin tone": "Choisir le teint par dรฉfaut", + "aria/Clear emoji search": "Effacer la recherche d'emoji", "aria/Clear search": "Effacer la recherche", "aria/Close callout dialog": "Fermer la boรฎte de dialogue d'information", "aria/Close thread": "Fermer le fil", @@ -218,6 +222,8 @@ "Create a question, add options, and configure poll settings": "Crรฉez une question, ajoutez des options et configurez les paramรจtres du sondage", "Create poll": "Crรฉer un sondage", "Current location": "Emplacement actuel", + "Dark": "Foncรฉ", + "Default": "Par dรฉfaut", "Delete": "Supprimer", "Delete chat": "Supprimer le chat", "Delete for me": "Supprimer pour moi", @@ -286,6 +292,7 @@ "Failed to jump to the first unread message": "ร‰chec du saut vers le premier message non lu", "Failed to leave channel": "Impossible de quitter le canal", "Failed to load channels": "Impossible de charger les canaux", + "Failed to load emojis": "ร‰chec du chargement des emojis", "Failed to load more channels": "Impossible de charger davantage de canaux", "Failed to mark channel as read": "ร‰chec du marquage du canal comme lu", "Failed to play the recording": "Impossible de lire l'enregistrement", @@ -304,6 +311,9 @@ "fileCount_other": "{{ count }} fichiers", "Files": "Fichiers", "Flag": "Signaler", + "Flags": "Drapeaux", + "Food & Drink": "Nourriture et boissons", + "Frequently used": "Frรฉquemment utilisรฉs", "Generating...": "Gรฉnรฉration...", "giphy-command-args": "[texte]", "giphy-command-description": "Poster un GIF alรฉatoire dans le canal", @@ -378,6 +388,7 @@ "Leave chat": "Quitter le canal", "Left channel": "Canal quittรฉ", "Let others add options": "Permettre ร  d'autres d'ajouter des options", + "Light": "Clair", "Limit votes per person": "Limiter les votes par personne", "Link": "Lien", "linkCount_one": "Lien", @@ -397,6 +408,9 @@ "Mark as unread": "Marquer comme non lu", "Maximum number of votes (from 2 to 10)": "Nombre maximum de votes (de 2 ร  10)", "Maximum votes per person": "Nombre maximal de votes par personne", + "Medium": "Moyen", + "Medium-Dark": "Moyennement foncรฉ", + "Medium-Light": "Moyennement clair", "Member detail": "Dรฉtails du membre", "mention/Channel": "Canal", "mention/Channel Description": "Notifier tout le monde dans ce canal", @@ -427,6 +441,7 @@ "Next image": "Image suivante", "No chats here yetโ€ฆ": "Pas encore de messages ici...", "No conversations yet": "Aucune conversation pour le moment", + "No emoji found": "Aucun emoji trouvรฉ", "No files": "Aucun fichier", "No items exist": "Aucun รฉlรฉment", "No member found": "Aucun membre trouvรฉ", @@ -438,6 +453,7 @@ "Nobody will be able to vote in this poll anymore.": "Personne ne pourra plus voter dans ce sondage.", "Nothing yet...": "Rien pour l'instant...", "Notify all {{ role }} members": "Notifier tous les membres ayant le rรดle {{ role }}", + "Objects": "Objets", "Offline": "Hors ligne", "Ok": "D'accord", "Online": "En ligne", @@ -457,6 +473,7 @@ "People matching": "Correspondance de personnes", "Photo": "Photo", "Photos & videos": "Photos et vidรฉos", + "Pick an emojiโ€ฆ": "Choisissez un emojiโ€ฆ", "Pin": "ร‰pingler", "Pin a message to see it here": "ร‰pinglez un message pour le voir ici", "Pinned by {{ name }}": "ร‰pinglรฉ par {{ name }}", @@ -504,6 +521,7 @@ "replyCount_many": "{{ count }} rรฉponses", "replyCount_other": "{{ count }} rรฉponses", "Resend": "Renvoyer", + "Retry": "Rรฉessayer", "Retry upload": "Rรฉessayer le tรฉlรฉchargement", "Review all options available in this poll": "Consultez toutes les options disponibles dans ce sondage", "Review comments submitted with poll answers": "Consultez les commentaires envoyรฉs avec les rรฉponses au sondage", @@ -514,6 +532,7 @@ "Save for later": "Enregistrer pour plus tard", "Saved for later": "Enregistrรฉ pour plus tard", "Search": "Rechercher", + "Search emoji": "Rechercher un emoji", "Search GIFs": "Rechercher des GIFs", "search-results-header-filter-source-button-label--channels": "canaux", "search-results-header-filter-source-button-label--messages": "messages", @@ -551,12 +570,14 @@ "size limit": "limite de taille", "Slow Mode ON": "Mode lent activรฉ", "Slow mode, wait {{ seconds }}s...": "Mode lent, attendez {{ seconds }} s...", + "Smileys & People": "ร‰moticรดnes et personnes", "Some of the files will not be accepted": "Certains fichiers ne seront pas acceptรฉs", "Start typing to search": "Commencez ร  taper pour rechercher", "Stop sharing": "Arrรชter de partager", "Submit": "Envoyer", "Suggest a new option to add to this poll": "Suggรฉrez une nouvelle option ร  ajouter ร  ce sondage", "Suggest an option": "Suggรฉrer une option", + "Symbols": "Symboles", "Tap to remove": "Appuyez pour retirer", "Tap to remove: {{ reactionName }}": "Appuyez pour retirer: {{ reactionName }}", "Thinking...": "Rรฉflexion...", @@ -597,6 +618,7 @@ "Translated": "Traduit", "Translated from {{ language }}": "Traduit du {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Voyages et lieux", "Type a number from 2 to 10": "Tapez un nombre de 2 ร  10", "Unarchive": "Dรฉsarchiver", "unban-command-args": "[@nomdutilisateur]", @@ -652,27 +674,5 @@ "Wait until all attachments have uploaded": "Attendez que toutes les piรจces jointes soient tรฉlรฉchargรฉes", "Waiting for networkโ€ฆ": "En attente du rรฉseauโ€ฆ", "You": "Vous", - "You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers", - "Search emoji": "Rechercher un emoji", - "aria/Clear emoji search": "Effacer la recherche d'emoji", - "No emoji found": "Aucun emoji trouvรฉ", - "aria/Choose default skin tone": "Choisir le teint par dรฉfaut", - "Frequently used": "Frรฉquemment utilisรฉs", - "Smileys & People": "ร‰moticรดnes et personnes", - "Animals & Nature": "Animaux et nature", - "Food & Drink": "Nourriture et boissons", - "Activities": "Activitรฉs", - "Travel & Places": "Voyages et lieux", - "Objects": "Objets", - "Symbols": "Symboles", - "Flags": "Drapeaux", - "Default": "Par dรฉfaut", - "Light": "Clair", - "Medium-Light": "Moyennement clair", - "Medium": "Moyen", - "Medium-Dark": "Moyennement foncรฉ", - "Dark": "Foncรฉ", - "Failed to load emojis": "ร‰chec du chargement des emojis", - "Retry": "Rรฉessayer", - "Pick an emojiโ€ฆ": "Choisissez un emojiโ€ฆ" + "You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers" } diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 33d412c9c3..77a857a6b6 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -43,6 +43,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} เคจเฅ‡ เคตเฅ‹เคŸ เคฆเคฟเคฏเคพ: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“เคธเคพเคเคพ เค•เคฟเคฏเคพ เค—เคฏเคพ เคธเฅเคฅเคพเคจ", "Actions": "Actions", + "Activities": "เค—เคคเคฟเคตเคฟเคงเคฟเคฏเคพเค", "Add": "เคœเฅ‹เคกเคผเฅ‡เค‚", "Add {{ count }} members_one": "{{ count }} เคธเคฆเคธเฅเคฏ เคœเฅ‹เคกเคผเฅ‡เค‚", "Add {{ count }} members_other": "{{ count }} เคธเคฆเคธเฅเคฏ เคœเฅ‹เคกเคผเฅ‡เค‚", @@ -65,6 +66,7 @@ "Also sent in channel": "เคšเฅˆเคจเคฒ เคฎเฅ‡เค‚ เคญเฅ€ เคญเฅ‡เคœเคพ เค—เคฏเคพ", "An error has occurred during recording": "เคฐเฅ‡เค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เค•เฅ‡ เคฆเฅŒเคฐเคพเคจ เคเค• เคคเฅเคฐเฅเคŸเคฟ เค† เค—เคˆ เคนเฅˆ", "An error has occurred during the recording processing": "เคฐเฅ‡เค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เคชเฅเคฐเฅ‹เคธเฅ‡เคธเคฟเค‚เค— เค•เฅ‡ เคฆเฅŒเคฐเคพเคจ เคเค• เคคเฅเคฐเฅเคŸเคฟ เค† เค—เคˆ เคนเฅˆ", + "Animals & Nature": "เคœเคพเคจเคตเคฐ เค”เคฐ เคชเฅเคฐเค•เฅƒเคคเคฟ", "Anonymous": "เค—เฅเคฎเคจเคพเคฎ", "Anonymous poll": "เค—เฅเคฎเคจเคพเคฎ เคฎเคคเคฆเคพเคจ", "Archive": "เค†เคฐเฅเค•เคพเค‡เคต", @@ -84,6 +86,8 @@ "aria/Channel list": "เคšเฅˆเคจเคฒ เคธเฅ‚เคšเฅ€", "aria/Channel search results": "เคšเฅˆเคจเคฒ เค–เฅ‹เคœ เคชเคฐเคฟเคฃเคพเคฎ", "aria/Chat view tabs": "เคšเฅˆเคŸ เคตเฅเคฏเฅ‚ เคŸเฅˆเคฌ", + "aria/Choose default skin tone": "เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ เคคเฅเคตเคšเคพ เคŸเฅ‹เคจ เคšเฅเคจเฅ‡เค‚", + "aria/Clear emoji search": "เค‡เคฎเฅ‹เคœเฅ€ เค–เฅ‹เคœ เคธเคพเคซเคผ เค•เคฐเฅ‡เค‚", "aria/Clear search": "เค–เฅ‹เคœ เคธเคพเคซเคผ เค•เคฐเฅ‡เค‚", "aria/Close callout dialog": "เค•เฅ‰เคฒเค†เค‰เคŸ เคธเค‚เคตเคพเคฆ เคฌเค‚เคฆ เค•เคฐเฅ‡เค‚", "aria/Close thread": "เคฅเฅเคฐเฅ‡เคก เคฌเค‚เคฆ เค•เคฐเฅ‡เค‚", @@ -207,6 +211,8 @@ "Create a question, add options, and configure poll settings": "เคเค• เคชเฅเคฐเคถเฅเคจ เคฌเคจเคพเคเค‚, เคตเคฟเค•เคฒเฅเคช เคœเฅ‹เคกเคผเฅ‡เค‚ เค”เคฐ เคชเฅ‹เคฒ เคธเฅ‡เคŸเคฟเค‚เค—เฅเคธ เค•เฅ‰เคจเฅเคซเคผเคฟเค—เคฐ เค•เคฐเฅ‡เค‚", "Create poll": "เคฎเคคเคฆเคพเคจ เคฌเคจเคพเคเค", "Current location": "เคตเคฐเฅเคคเคฎเคพเคจ เคธเฅเคฅเคพเคจ", + "Dark": "เค—เคนเคฐเคพ", + "Default": "เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ", "Delete": "เคกเคฟเคฒเฅ€เคŸ", "Delete chat": "เคšเฅˆเคŸ เคนเคŸเคพเคเค‚", "Delete for me": "เคฎเฅ‡เคฐเฅ‡ เคฒเคฟเค เคกเคฟเคฒเฅ€เคŸ เค•เคฐเฅ‡เค‚", @@ -276,6 +282,7 @@ "Failed to jump to the first unread message": "เคชเคนเคฒเฅ‡ เค…เคชเค เคฟเคค เคธเค‚เคฆเฅ‡เคถ เคชเคฐ เคœเคพเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒ", "Failed to leave channel": "เคšเฅˆเคจเคฒ เค›เฅ‹เคกเคผเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒ", "Failed to load channels": "เคšเฅˆเคจเคฒ เคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒ", + "Failed to load emojis": "เค‡เคฎเฅ‹เคœเฅ€ เคฒเฅ‹เคก เคจเคนเฅ€เค‚ เคนเฅ‹ เคธเค•เฅ‡", "Failed to load more channels": "เค”เคฐ เคšเฅˆเคจเคฒ เคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒ", "Failed to mark channel as read": "เคšเฅˆเคจเคฒ เค•เฅ‹ เคชเคขเคผเคพ เคนเฅเค† เคšเคฟเคนเฅเคจเคฟเคค เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒเฅค", "Failed to play the recording": "เคฐเฅ‡เค•เฅ‰เคฐเฅเคกเคฟเค‚เค— เคชเฅเคฒเฅ‡ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒ", @@ -293,6 +300,9 @@ "fileCount_other": "{{ count }} เคซเคผเคพเค‡เคฒเฅ‡เค‚", "Files": "เคซเคผเคพเค‡เคฒเฅ‡เค‚", "Flag": "เคซเฅเคฒเฅˆเค— เค•เคฐเฅ‡", + "Flags": "เคเค‚เคกเฅ‡", + "Food & Drink": "เค–เคพเคจเคพ เค”เคฐ เคชเฅ‡เคฏ", + "Frequently used": "เค…เค•เฅเคธเคฐ เค‰เคชเคฏเฅ‹เค— เค•เคฟเค เค—เค", "Generating...": "เคฌเคจเคพ เคฐเคนเคพ เคนเฅˆ...", "giphy-command-args": "[เคชเคพเค ]", "giphy-command-description": "เคšเฅˆเคจเคฒ เคชเคฐ เคเค• เค•เฅเคฐเฅ‰เคซเคฟเคฒ เคœเฅ€เค†เค‡เคเคซ เคชเฅ‹เคธเฅเคŸ เค•เคฐเฅ‡เค‚", @@ -366,6 +376,7 @@ "Leave chat": "เคšเฅˆเคจเคฒ เค›เฅ‹เคกเคผเฅ‡เค‚", "Left channel": "เคšเฅˆเคจเคฒ เค›เฅ‹เคกเคผ เคฆเคฟเคฏเคพ เค—เคฏเคพ", "Let others add options": "เคฆเฅ‚เคธเคฐเฅ‹เค‚ เค•เฅ‹ เคตเคฟเค•เคฒเฅเคช เคœเฅ‹เคกเคผเคจเฅ‡ เคฆเฅ‡เค‚", + "Light": "เคนเคฒเฅเค•เคพ", "Limit votes per person": "เคชเฅเคฐเคคเคฟ เคตเฅเคฏเค•เฅเคคเคฟ เคตเฅ‹เคŸ เคธเฅ€เคฎเคฟเคค เค•เคฐเฅ‡เค‚", "Link": "เคฒเคฟเค‚เค•", "linkCount_one": "1 เคฒเคฟเค‚เค•", @@ -384,6 +395,9 @@ "Mark as unread": "เค…เคชเค เคฟเคค เคšเคฟเคนเฅเคจเคฟเคค เค•เคฐเฅ‡เค‚", "Maximum number of votes (from 2 to 10)": "เค…เคงเคฟเค•เคคเคฎ เคตเฅ‹เคŸเฅ‹เค‚ เค•เฅ€ เคธเค‚เค–เฅเคฏเคพ (2 เคธเฅ‡ 10)", "Maximum votes per person": "เคชเฅเคฐเคคเคฟ เคตเฅเคฏเค•เฅเคคเคฟ เค…เคงเคฟเค•เคคเคฎ เคตเฅ‹เคŸ", + "Medium": "เคฎเคงเฅเคฏเคฎ", + "Medium-Dark": "เคฎเคงเฅเคฏเคฎ-เค—เคนเคฐเคพ", + "Medium-Light": "เคฎเคงเฅเคฏเคฎ-เคนเคฒเฅเค•เคพ", "Member detail": "เคธเคฆเคธเฅเคฏ เคตเคฟเคตเคฐเคฃ", "mention/Channel": "เคšเฅˆเคจเคฒ", "mention/Channel Description": "เค‡เคธ เคšเฅˆเคจเคฒ เคฎเฅ‡เค‚ เคธเคญเฅ€ เค•เฅ‹ เคธเฅ‚เคšเคฟเคค เค•เคฐเฅ‡เค‚", @@ -414,6 +428,7 @@ "Next image": "เค…เค—เคฒเฅ€ เค›เคตเคฟ", "No chats here yetโ€ฆ": "เคฏเคนเคพเค‚ เค…เคญเฅ€ เคคเค• เค•เฅ‹เคˆ เคšเฅˆเคŸ เคจเคนเฅ€เค‚...", "No conversations yet": "เค…เคญเฅ€ เคคเค• เค•เฅ‹เคˆ เคฌเคพเคคเคšเฅ€เคค เคจเคนเฅ€เค‚ เคนเฅˆ", + "No emoji found": "เค•เฅ‹เคˆ เค‡เคฎเฅ‹เคœเฅ€ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", "No files": "เค•เฅ‹เคˆ เคซเคผเคพเค‡เคฒ เคจเคนเฅ€เค‚", "No items exist": "เค•เฅ‹เคˆ เค†เค‡เคŸเคฎ เคฎเฅŒเคœเฅ‚เคฆ เคจเคนเฅ€เค‚ เคนเฅˆ", "No member found": "เค•เฅ‹เคˆ เคธเคฆเคธเฅเคฏ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", @@ -425,6 +440,7 @@ "Nobody will be able to vote in this poll anymore.": "เค…เคฌ เค•เฅ‹เคˆ เคญเฅ€ เค‡เคธ เคฎเคคเคฆเคพเคจ เคฎเฅ‡เค‚ เคฎเคคเคฆเคพเคจ เคจเคนเฅ€เค‚ เค•เคฐ เคธเค•เฅ‡เค—เคพเฅค", "Nothing yet...": "เค•เฅ‹เคˆ เคฎเฅˆเคธเฅ‡เคœ เคจเคนเฅ€เค‚ เคนเฅˆ", "Notify all {{ role }} members": "{{ role }} เคญเฅ‚เคฎเคฟเค•เคพ เคตเคพเคฒเฅ‡ เคธเคญเฅ€ เคธเคฆเคธเฅเคฏเฅ‹เค‚ เค•เฅ‹ เคธเฅ‚เคšเคฟเคค เค•เคฐเฅ‡เค‚", + "Objects": "เคตเคธเฅเคคเฅเคเค", "Offline": "เค‘เคซเคฒเคพเค‡เคจ", "Ok": "เค เฅ€เค• เคนเฅˆ", "Online": "เค‘เคจเคฒเคพเค‡เคจ", @@ -444,6 +460,7 @@ "People matching": "เคฎเฅ‡เคฒ เค–เคพเคคเฅ‡ เคฒเฅ‹เค—", "Photo": "เคซเคผเฅ‹เคŸเฅ‹", "Photos & videos": "เคซเคผเฅ‹เคŸเฅ‹ เค”เคฐ เคตเฅ€เคกเคฟเคฏเฅ‹", + "Pick an emojiโ€ฆ": "เค‡เคฎเฅ‹เคœเฅ€ เคšเฅเคจเฅ‡เค‚โ€ฆ", "Pin": "เคชเคฟเคจ", "Pin a message to see it here": "เค‡เคธเฅ‡ เคฏเคนเคพเค เคฆเฅ‡เค–เคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคธเค‚เคฆเฅ‡เคถ เคชเคฟเคจ เค•เคฐเฅ‡เค‚", "Pinned by {{ name }}": "{{ name }} เคฆเฅเคตเคพเคฐเคพ เคชเคฟเคจ เค•เคฟเคฏเคพ เค—เคฏเคพ", @@ -488,6 +505,7 @@ "replyCount_one": "1 เคฐเคฟเคชเฅเคฒเคพเคˆ", "replyCount_other": "{{ count }} เคฐเคฟเคชเฅเคฒเคพเคˆ", "Resend": "เคซเคฟเคฐ เคธเฅ‡ เคญเฅ‡เคœเฅ‡เค‚", + "Retry": "เคชเฅเคจเคƒ เคชเฅเคฐเคฏเคพเคธ เค•เคฐเฅ‡เค‚", "Retry upload": "เค…เคชเคฒเฅ‹เคก เคซเคฟเคฐ เคธเฅ‡ เค•เคฐเฅ‡เค‚", "Review all options available in this poll": "เค‡เคธ เคชเฅ‹เคฒ เคฎเฅ‡เค‚ เค‰เคชเคฒเคฌเฅเคง เคธเคญเฅ€ เคตเคฟเค•เคฒเฅเคชเฅ‹เค‚ เค•เฅ€ เคธเคฎเฅ€เค•เฅเคทเคพ เค•เคฐเฅ‡เค‚", "Review comments submitted with poll answers": "เคชเฅ‹เคฒ เค‰เคคเฅเคคเคฐเฅ‹เค‚ เค•เฅ‡ เคธเคพเคฅ เคญเฅ‡เคœเฅ€ เค—เคˆ เคŸเคฟเคชเฅเคชเคฃเคฟเคฏเฅ‹เค‚ เค•เฅ€ เคธเคฎเฅ€เค•เฅเคทเคพ เค•เคฐเฅ‡เค‚", @@ -498,6 +516,7 @@ "Save for later": "เคฌเคพเคฆ เค•เฅ‡ เคฒเคฟเค เคธเคนเฅ‡เคœเฅ‡เค‚", "Saved for later": "เคฌเคพเคฆ เค•เฅ‡ เคฒเคฟเค เคธเคนเฅ‡เคœเคพ เค—เคฏเคพ", "Search": "เค–เฅ‹เคœ", + "Search emoji": "เค‡เคฎเฅ‹เคœเฅ€ เค–เฅ‹เคœเฅ‡เค‚", "Search GIFs": "GIF เค–เฅ‹เคœเฅ‡เค‚", "search-results-header-filter-source-button-label--channels": "เคšเฅˆเคจเคฒเฅเคธ", "search-results-header-filter-source-button-label--messages": "เคธเค‚เคฆเฅ‡เคถ", @@ -533,12 +552,14 @@ "size limit": "เค†เค•เคพเคฐ เคธเฅ€เคฎเคพ", "Slow Mode ON": "เคธเฅเคฒเฅ‹ เคฎเฅ‹เคก เค‘เคจ", "Slow mode, wait {{ seconds }}s...": "เคธเฅเคฒเฅ‹ เคฎเฅ‹เคก, {{ seconds }} เคธเฅ‡เค•เค‚เคก เคชเฅเคฐเคคเฅ€เค•เฅเคทเคพ เค•เคฐเฅ‡เค‚...", + "Smileys & People": "เคธเฅเคฎเคพเค‡เคฒเฅ€ เค”เคฐ เคฒเฅ‹เค—", "Some of the files will not be accepted": "เค•เฅเค› เคซเคผเคพเค‡เคฒเฅ‡เค‚ เคธเฅเคตเฅ€เค•เคพเคฐ เคจเคนเฅ€เค‚ เค•เฅ€ เคœเคพเคเค‚เค—เฅ€", "Start typing to search": "เค–เฅ‹เคœเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคŸเคพเค‡เคช เค•เคฐเคจเคพ เคถเฅเคฐเฅ‚ เค•เคฐเฅ‡เค‚", "Stop sharing": "เคธเคพเคเคพ เค•เคฐเคจเคพ เคฌเค‚เคฆ เค•เคฐเฅ‡เค‚", "Submit": "เคœเคฎเคพ เค•เคฐเฅ‡เค‚", "Suggest a new option to add to this poll": "เค‡เคธ เคชเฅ‹เคฒ เคฎเฅ‡เค‚ เคœเฅ‹เคกเคผเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคเค• เคจเคฏเคพ เคตเคฟเค•เคฒเฅเคช เคธเฅเคเคพเคเค‚", "Suggest an option": "เคเค• เคตเคฟเค•เคฒเฅเคช เคธเฅเคเคพเคต เคฆเฅ‡เค‚", + "Symbols": "เคชเฅเคฐเคคเฅ€เค•", "Tap to remove": "เคนเคŸเคพเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคŸเฅˆเคช เค•เคฐเฅ‡เค‚", "Tap to remove: {{ reactionName }}": "เคนเคŸเคพเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคŸเฅˆเคช เค•เคฐเฅ‡เค‚: {{ reactionName }}", "Thinking...": "เคธเฅ‹เคš เคฐเคนเคพ เคนเฅˆ...", @@ -577,6 +598,7 @@ "Translated": "เค…เคจเฅเคตเคพเคฆเคฟเคค", "Translated from {{ language }}": "{{ language }} เคธเฅ‡ เค…เคจเฅเคตเคพเคฆเคฟเคค", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "เคฏเคพเคคเฅเคฐเคพ เค”เคฐ เคธเฅเคฅเคพเคจ", "Type a number from 2 to 10": "2 เคธเฅ‡ 10 เคคเค• เค•เคพ เคเค• เคจเค‚เคฌเคฐ เคŸเคพเค‡เคช เค•เคฐเฅ‡เค‚", "Unarchive": "เค…เคจเค†เคฐเฅเค•เคพเค‡เคต", "unban-command-args": "[@เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคจเคพเคฎ]", @@ -629,27 +651,5 @@ "Wait until all attachments have uploaded": "เคธเคญเฅ€ เค…เคŸเฅˆเคšเคฎเฅ‡เค‚เคŸ เค…เคชเคฒเฅ‹เคก เคนเฅ‹เคจเฅ‡ เคคเค• เคชเฅเคฐเคคเฅ€เค•เฅเคทเคพ เค•เคฐเฅ‡เค‚", "Waiting for networkโ€ฆ": "เคจเฅ‡เคŸเคตเคฐเฅเค• เค•เฅ€ เคชเฅเคฐเคคเฅ€เค•เฅเคทเคพโ€ฆ", "You": "เค†เคช", - "You've reached the maximum number of files": "เค†เคช เค…เคงเคฟเค•เคคเคฎ เคซเคผเคพเค‡เคฒเฅ‹เค‚ เคคเค• เคชเคนเฅเคเคš เค—เค เคนเฅˆเค‚", - "Search emoji": "เค‡เคฎเฅ‹เคœเฅ€ เค–เฅ‹เคœเฅ‡เค‚", - "aria/Clear emoji search": "เค‡เคฎเฅ‹เคœเฅ€ เค–เฅ‹เคœ เคธเคพเคซเคผ เค•เคฐเฅ‡เค‚", - "No emoji found": "เค•เฅ‹เคˆ เค‡เคฎเฅ‹เคœเฅ€ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ", - "aria/Choose default skin tone": "เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ เคคเฅเคตเคšเคพ เคŸเฅ‹เคจ เคšเฅเคจเฅ‡เค‚", - "Frequently used": "เค…เค•เฅเคธเคฐ เค‰เคชเคฏเฅ‹เค— เค•เคฟเค เค—เค", - "Smileys & People": "เคธเฅเคฎเคพเค‡เคฒเฅ€ เค”เคฐ เคฒเฅ‹เค—", - "Animals & Nature": "เคœเคพเคจเคตเคฐ เค”เคฐ เคชเฅเคฐเค•เฅƒเคคเคฟ", - "Food & Drink": "เค–เคพเคจเคพ เค”เคฐ เคชเฅ‡เคฏ", - "Activities": "เค—เคคเคฟเคตเคฟเคงเคฟเคฏเคพเค", - "Travel & Places": "เคฏเคพเคคเฅเคฐเคพ เค”เคฐ เคธเฅเคฅเคพเคจ", - "Objects": "เคตเคธเฅเคคเฅเคเค", - "Symbols": "เคชเฅเคฐเคคเฅ€เค•", - "Flags": "เคเค‚เคกเฅ‡", - "Default": "เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ", - "Light": "เคนเคฒเฅเค•เคพ", - "Medium-Light": "เคฎเคงเฅเคฏเคฎ-เคนเคฒเฅเค•เคพ", - "Medium": "เคฎเคงเฅเคฏเคฎ", - "Medium-Dark": "เคฎเคงเฅเคฏเคฎ-เค—เคนเคฐเคพ", - "Dark": "เค—เคนเคฐเคพ", - "Failed to load emojis": "เค‡เคฎเฅ‹เคœเฅ€ เคฒเฅ‹เคก เคจเคนเฅ€เค‚ เคนเฅ‹ เคธเค•เฅ‡", - "Retry": "เคชเฅเคจเคƒ เคชเฅเคฐเคฏเคพเคธ เค•เคฐเฅ‡เค‚", - "Pick an emojiโ€ฆ": "เค‡เคฎเฅ‹เคœเฅ€ เคšเฅเคจเฅ‡เค‚โ€ฆ" + "You've reached the maximum number of files": "เค†เคช เค…เคงเคฟเค•เคคเคฎ เคซเคผเคพเค‡เคฒเฅ‹เค‚ เคคเค• เคชเคนเฅเคเคš เค—เค เคนเฅˆเค‚" } diff --git a/src/i18n/it.json b/src/i18n/it.json index ab810700ce..a6054e4988 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -53,6 +53,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} ha votato: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“Posizione condivisa", "Actions": "Actions", + "Activities": "Attivitร ", "Add": "Aggiungi", "Add {{ count }} members_one": "Aggiungi {{ count }} membro", "Add {{ count }} members_many": "Aggiungi {{ count }} membri", @@ -76,6 +77,7 @@ "Also sent in channel": "Inviato anche nel canale", "An error has occurred during recording": "Si รจ verificato un errore durante la registrazione", "An error has occurred during the recording processing": "Si รจ verificato un errore durante l'elaborazione della registrazione", + "Animals & Nature": "Animali e natura", "Anonymous": "Anonimo", "Anonymous poll": "Sondaggio anonimo", "Archive": "Archivia", @@ -95,6 +97,8 @@ "aria/Channel list": "Elenco dei canali", "aria/Channel search results": "Risultati della ricerca dei canali", "aria/Chat view tabs": "Schede visualizzazione chat", + "aria/Choose default skin tone": "Scegli la tonalitร  della pelle predefinita", + "aria/Clear emoji search": "Cancella ricerca emoji", "aria/Clear search": "Cancella ricerca", "aria/Close callout dialog": "Chiudi finestra informativa", "aria/Close thread": "Chiudi discussione", @@ -218,6 +222,8 @@ "Create a question, add options, and configure poll settings": "Crea una domanda, aggiungi opzioni e configura le impostazioni del sondaggio", "Create poll": "Crea sondaggio", "Current location": "Posizione attuale", + "Dark": "Scuro", + "Default": "Predefinito", "Delete": "Elimina", "Delete chat": "Elimina chat", "Delete for me": "Elimina per me", @@ -286,6 +292,7 @@ "Failed to jump to the first unread message": "Impossibile passare al primo messaggio non letto", "Failed to leave channel": "Impossibile lasciare il canale", "Failed to load channels": "Impossibile caricare i canali", + "Failed to load emojis": "Impossibile caricare gli emoji", "Failed to load more channels": "Impossibile caricare altri canali", "Failed to mark channel as read": "Impossibile contrassegnare il canale come letto", "Failed to play the recording": "Impossibile riprodurre la registrazione", @@ -304,6 +311,9 @@ "fileCount_other": "{{ count }} file", "Files": "File", "Flag": "Segnala", + "Flags": "Bandiere", + "Food & Drink": "Cibo e bevande", + "Frequently used": "Usati di frequente", "Generating...": "Generando...", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", @@ -378,6 +388,7 @@ "Leave chat": "Lascia il canale", "Left channel": "Canale lasciato", "Let others add options": "Lascia che altri aggiungano opzioni", + "Light": "Chiaro", "Limit votes per person": "Limita i voti per persona", "Link": "Collegamento", "linkCount_one": "Link", @@ -397,6 +408,9 @@ "Mark as unread": "Contrassegna come non letto", "Maximum number of votes (from 2 to 10)": "Numero massimo di voti (da 2 a 10)", "Maximum votes per person": "Voti massimi per persona", + "Medium": "Medio", + "Medium-Dark": "Medio scuro", + "Medium-Light": "Medio chiaro", "Member detail": "Dettagli membro", "mention/Channel": "Canale", "mention/Channel Description": "Notifica tutti in questo canale", @@ -427,6 +441,7 @@ "Next image": "Immagine successiva", "No chats here yetโ€ฆ": "Non ci sono ancora messaggi qui...", "No conversations yet": "Ancora nessuna conversazione", + "No emoji found": "Nessun emoji trovato", "No files": "Nessun file", "No items exist": "Nessun elemento presente", "No member found": "Nessun membro trovato", @@ -438,6 +453,7 @@ "Nobody will be able to vote in this poll anymore.": "Nessuno potrร  piรน votare in questo sondaggio.", "Nothing yet...": "Ancora niente...", "Notify all {{ role }} members": "Notifica tutti i membri con ruolo {{ role }}", + "Objects": "Oggetti", "Offline": "Offline", "Ok": "OK", "Online": "Online", @@ -457,6 +473,7 @@ "People matching": "Persone che corrispondono", "Photo": "Foto", "Photos & videos": "Foto e video", + "Pick an emojiโ€ฆ": "Scegli un emojiโ€ฆ", "Pin": "Appunta", "Pin a message to see it here": "Appunta un messaggio per vederlo qui", "Pinned by {{ name }}": "Appuntato da {{ name }}", @@ -504,6 +521,7 @@ "replyCount_many": "{{ count }} risposte", "replyCount_other": "{{ count }} risposte", "Resend": "Invia di nuovo", + "Retry": "Riprova", "Retry upload": "Riprova caricamento", "Review all options available in this poll": "Rivedi tutte le opzioni disponibili in questo sondaggio", "Review comments submitted with poll answers": "Rivedi i commenti inviati con le risposte al sondaggio", @@ -514,6 +532,7 @@ "Save for later": "Salva per dopo", "Saved for later": "Salvato per dopo", "Search": "Cerca", + "Search emoji": "Cerca emoji", "Search GIFs": "Cerca GIF", "search-results-header-filter-source-button-label--channels": "canali", "search-results-header-filter-source-button-label--messages": "messaggi", @@ -551,12 +570,14 @@ "size limit": "limite di dimensione", "Slow Mode ON": "Modalitร  lenta attivata", "Slow mode, wait {{ seconds }}s...": "Modalitร  lenta, attendi {{ seconds }} s...", + "Smileys & People": "Faccine e persone", "Some of the files will not be accepted": "Alcuni dei file non saranno accettati", "Start typing to search": "Inizia a digitare per cercare", "Stop sharing": "Ferma condivisione", "Submit": "Invia", "Suggest a new option to add to this poll": "Suggerisci una nuova opzione da aggiungere a questo sondaggio", "Suggest an option": "Suggerisci un'opzione", + "Symbols": "Simboli", "Tap to remove": "Tocca per rimuovere", "Tap to remove: {{ reactionName }}": "Tocca per rimuovere: {{ reactionName }}", "Thinking...": "Pensando...", @@ -597,6 +618,7 @@ "Translated": "Tradotto", "Translated from {{ language }}": "Tradotto da {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Viaggi e luoghi", "Type a number from 2 to 10": "Digita un numero da 2 a 10", "Unarchive": "Ripristina", "unban-command-args": "[@nomeutente]", @@ -652,27 +674,5 @@ "Wait until all attachments have uploaded": "Attendi il caricamento di tutti gli allegati", "Waiting for networkโ€ฆ": "In attesa della reteโ€ฆ", "You": "Tu", - "You've reached the maximum number of files": "Hai raggiunto il numero massimo di file", - "Search emoji": "Cerca emoji", - "aria/Clear emoji search": "Cancella ricerca emoji", - "No emoji found": "Nessun emoji trovato", - "aria/Choose default skin tone": "Scegli la tonalitร  della pelle predefinita", - "Frequently used": "Usati di frequente", - "Smileys & People": "Faccine e persone", - "Animals & Nature": "Animali e natura", - "Food & Drink": "Cibo e bevande", - "Activities": "Attivitร ", - "Travel & Places": "Viaggi e luoghi", - "Objects": "Oggetti", - "Symbols": "Simboli", - "Flags": "Bandiere", - "Default": "Predefinito", - "Light": "Chiaro", - "Medium-Light": "Medio chiaro", - "Medium": "Medio", - "Medium-Dark": "Medio scuro", - "Dark": "Scuro", - "Failed to load emojis": "Impossibile caricare gli emoji", - "Retry": "Riprova", - "Pick an emojiโ€ฆ": "Scegli un emojiโ€ฆ" + "You've reached the maximum number of files": "Hai raggiunto il numero massimo di file" } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 582fd766a0..51885a30d3 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -40,6 +40,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} ใŒๆŠ•็ฅจ: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“ๅ…ฑๆœ‰ใ•ใ‚ŒใŸไฝ็ฝฎๆƒ…ๅ ฑ", "Actions": "Actions", + "Activities": "ใ‚ขใ‚ฏใƒ†ใ‚ฃใƒ“ใƒ†ใ‚ฃ", "Add": "่ฟฝๅŠ ", "Add {{ count }} members_other": "{{ count }}ไบบใฎใƒกใƒณใƒใƒผใ‚’่ฟฝๅŠ ", "Add a comment": "ใ‚ณใƒกใƒณใƒˆใ‚’่ฟฝๅŠ ", @@ -61,6 +62,7 @@ "Also sent in channel": "ใƒใƒฃใƒณใƒใƒซใซใ‚‚้€ไฟกๆธˆใฟ", "An error has occurred during recording": "้Œฒ้Ÿณไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", "An error has occurred during the recording processing": "้Œฒ้Ÿณๅ‡ฆ็†ไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ", + "Animals & Nature": "ๅ‹•็‰ฉใจ่‡ช็„ถ", "Anonymous": "ๅŒฟๅ", "Anonymous poll": "ๅŒฟๅๆŠ•็ฅจ", "Archive": "ใ‚ขใƒผใ‚ซใ‚คใƒ–", @@ -80,6 +82,8 @@ "aria/Channel list": "ใƒใƒฃใƒณใƒใƒซไธ€่ฆง", "aria/Channel search results": "ใƒใƒฃใƒณใƒใƒซๆคœ็ดข็ตๆžœ", "aria/Chat view tabs": "ใƒใƒฃใƒƒใƒˆใƒ“ใƒฅใƒผใฎใ‚ฟใƒ–", + "aria/Choose default skin tone": "ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใฎ่‚Œใฎ่‰ฒใ‚’้ธๆŠž", + "aria/Clear emoji search": "็ตตๆ–‡ๅญ—ๆคœ็ดขใ‚’ใ‚ฏใƒชใ‚ข", "aria/Clear search": "ๆคœ็ดขใ‚’ใ‚ฏใƒชใ‚ข", "aria/Close callout dialog": "ๅนใๅ‡บใ—ใƒ€ใ‚คใ‚ขใƒญใ‚ฐใ‚’้–‰ใ˜ใ‚‹", "aria/Close thread": "ใ‚นใƒฌใƒƒใƒ‰ใ‚’้–‰ใ˜ใ‚‹", @@ -203,6 +207,8 @@ "Create a question, add options, and configure poll settings": "่ณชๅ•ใ‚’ไฝœๆˆใ—ใ€้ธๆŠž่‚ขใ‚’่ฟฝๅŠ ใ—ใฆๆŠ•็ฅจ่จญๅฎšใ‚’ๆง‹ๆˆ", "Create poll": "ๆŠ•็ฅจใ‚’ไฝœๆˆ", "Current location": "็พๅœจใฎไฝ็ฝฎ", + "Dark": "ๆš—ใ„", + "Default": "ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆ", "Delete": "ๆถˆๅŽป", "Delete chat": "ใƒใƒฃใƒƒใƒˆใ‚’ๅ‰Š้™ค", "Delete for me": "่‡ชๅˆ†็”จใซๅ‰Š้™ค", @@ -271,6 +277,7 @@ "Failed to jump to the first unread message": "ๆœ€ๅˆใฎๆœช่ชญใƒกใƒƒใ‚ปใƒผใ‚ธใซใ‚ธใƒฃใƒณใƒ—ใงใใพใ›ใ‚“ใงใ—ใŸ", "Failed to leave channel": "ใƒใƒฃใƒณใƒใƒซใฎ้€€ๅ‡บใซๅคฑๆ•—ใ—ใพใ—ใŸ", "Failed to load channels": "ใƒใƒฃใƒณใƒใƒซใฎ่ชญใฟ่พผใฟใซๅคฑๆ•—ใ—ใพใ—ใŸ", + "Failed to load emojis": "็ตตๆ–‡ๅญ—ใ‚’่ชญใฟ่พผใ‚ใพใ›ใ‚“ใงใ—ใŸ", "Failed to load more channels": "ใ•ใ‚‰ใซใƒใƒฃใƒณใƒใƒซใ‚’่ชญใฟ่พผใ‚ใพใ›ใ‚“ใงใ—ใŸ", "Failed to mark channel as read": "ใƒใƒฃใƒณใƒใƒซใ‚’ๆ—ข่ชญใซใ™ใ‚‹ใ“ใจใŒใงใใพใ›ใ‚“ใงใ—ใŸ", "Failed to play the recording": "้Œฒ้Ÿณใฎๅ†็”Ÿใซๅคฑๆ•—ใ—ใพใ—ใŸ", @@ -287,6 +294,9 @@ "fileCount_other": "{{ count }}ไปถใฎใƒ•ใ‚กใ‚คใƒซ", "Files": "ใƒ•ใ‚กใ‚คใƒซ", "Flag": "ใƒ•ใƒฉใ‚ฐ", + "Flags": "ๆ——", + "Food & Drink": "้ฃŸใน็‰ฉใจ้ฃฒใฟ็‰ฉ", + "Frequently used": "ใ‚ˆใไฝฟใ†", "Generating...": "็”Ÿๆˆไธญ...", "giphy-command-args": "[ใƒ†ใ‚ญใ‚นใƒˆ]", "giphy-command-description": "ใƒใƒฃใƒณใƒใƒซใซใƒฉใƒณใƒ€ใƒ ใชGIFใ‚’ๆŠ•็จฟใ™ใ‚‹", @@ -359,6 +369,7 @@ "Leave chat": "ใƒใƒฃใƒณใƒใƒซใ‚’้€€ๅ‡บ", "Left channel": "ใƒใƒฃใƒณใƒใƒซใ‚’้€€ๅ‡บใ—ใพใ—ใŸ", "Let others add options": "ไป–ใฎไบบใŒ้ธๆŠž่‚ขใ‚’่ฟฝๅŠ ใงใใ‚‹ใ‚ˆใ†ใซใ™ใ‚‹", + "Light": "ๆ˜Žใ‚‹ใ„", "Limit votes per person": "1ไบบใ‚ใŸใ‚ŠใฎๆŠ•็ฅจๆ•ฐใ‚’ๅˆถ้™ใ™ใ‚‹", "Link": "ใƒชใƒณใ‚ฏ", "linkCount_other": "{{ count }}ไปถใฎใƒชใƒณใ‚ฏ", @@ -376,6 +387,9 @@ "Mark as unread": "ๆœช่ชญใจใ—ใฆใƒžใƒผใ‚ฏ", "Maximum number of votes (from 2 to 10)": "ๆœ€ๅคงๆŠ•็ฅจๆ•ฐ๏ผˆ2ใ‹ใ‚‰10ใพใง๏ผ‰", "Maximum votes per person": "1ไบบใ‚ใŸใ‚Šใฎๆœ€ๅคงๆŠ•็ฅจๆ•ฐ", + "Medium": "ๆ™ฎ้€š", + "Medium-Dark": "ใ‚„ใ‚„ๆš—ใ„", + "Medium-Light": "ใ‚„ใ‚„ๆ˜Žใ‚‹ใ„", "Member detail": "ใƒกใƒณใƒใƒผ่ฉณ็ดฐ", "mention/Channel": "ใƒใƒฃใƒณใƒใƒซ", "mention/Channel Description": "ใ“ใฎใƒใƒฃใƒณใƒใƒซใฎๅ…จๅ“กใซ้€š็Ÿฅ", @@ -406,6 +420,7 @@ "Next image": "ๆฌกใฎ็”ปๅƒ", "No chats here yetโ€ฆ": "ใ“ใ“ใซใฏใพใ ใƒใƒฃใƒƒใƒˆใฏใ‚ใ‚Šใพใ›ใ‚“โ€ฆ", "No conversations yet": "ใพใ ไผš่ฉฑใฏใ‚ใ‚Šใพใ›ใ‚“", + "No emoji found": "็ตตๆ–‡ๅญ—ใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", "No files": "ใƒ•ใ‚กใ‚คใƒซใฏใ‚ใ‚Šใพใ›ใ‚“", "No items exist": "้ …็›ฎใŒใ‚ใ‚Šใพใ›ใ‚“", "No member found": "ใƒกใƒณใƒใƒผใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", @@ -417,6 +432,7 @@ "Nobody will be able to vote in this poll anymore.": "ใ“ใฎๆŠ•็ฅจใงใฏใ€่ชฐใ‚‚ๆŠ•็ฅจใงใใชใใชใ‚Šใพใ™ใ€‚", "Nothing yet...": "ใพใ ไฝ•ใ‚‚ใ‚ใ‚Šใพใ›ใ‚“...", "Notify all {{ role }} members": "{{ role }} ใƒกใƒณใƒใƒผๅ…จๅ“กใซ้€š็Ÿฅ", + "Objects": "็‰ฉ", "Offline": "ใ‚ชใƒ•ใƒฉใ‚คใƒณ", "Ok": "OK", "Online": "ใ‚ชใƒณใƒฉใ‚คใƒณ", @@ -436,6 +452,7 @@ "People matching": "ไธ€่‡ดใ™ใ‚‹ไบบ", "Photo": "ๅ†™็œŸ", "Photos & videos": "ๅ†™็œŸใจๅ‹•็”ป", + "Pick an emojiโ€ฆ": "็ตตๆ–‡ๅญ—ใ‚’้ธๆŠžโ€ฆ", "Pin": "ใƒ”ใƒณ", "Pin a message to see it here": "ใ“ใ“ใซ่กจ็คบใ™ใ‚‹ใซใฏใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’ใƒ”ใƒณ็•™ใ‚ใ—ใฆใใ ใ•ใ„", "Pinned by {{ name }}": "{{ name }}ใŒใƒ”ใƒณใ—ใพใ—ใŸ", @@ -478,6 +495,7 @@ "replyCount_one": "1ไปถใฎ่ฟ”ไฟก", "replyCount_other": "{{ count }} ่ฟ”ไฟก", "Resend": "ๅ†้€ไฟก", + "Retry": "ๅ†่ฉฆ่กŒ", "Retry upload": "ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ‚’ๅ†่ฉฆ่กŒ", "Review all options available in this poll": "ใ“ใฎๆŠ•็ฅจใงๅˆฉ็”จๅฏ่ƒฝใชใ™ในใฆใฎ้ธๆŠž่‚ขใ‚’็ขบ่ช", "Review comments submitted with poll answers": "ๆŠ•็ฅจๅ›ž็ญ”ใจใจใ‚‚ใซ้€ไฟกใ•ใ‚ŒใŸใ‚ณใƒกใƒณใƒˆใ‚’็ขบ่ช", @@ -488,6 +506,7 @@ "Save for later": "ๅพŒใงไฟๅญ˜", "Saved for later": "ๅพŒใงไฟๅญ˜ๆธˆใฟ", "Search": "ๆŽขใ™", + "Search emoji": "็ตตๆ–‡ๅญ—ใ‚’ๆคœ็ดข", "Search GIFs": "GIFใ‚’ๆคœ็ดข", "search-results-header-filter-source-button-label--channels": "ใƒใƒฃใƒณใƒใƒซ", "search-results-header-filter-source-button-label--messages": "ใƒกใƒƒใ‚ปใƒผใ‚ธ", @@ -523,12 +542,14 @@ "size limit": "ใ‚ตใ‚คใ‚บๅˆถ้™", "Slow Mode ON": "ใ‚นใƒญใƒผใƒขใƒผใƒ‰ใ‚ชใƒณ", "Slow mode, wait {{ seconds }}s...": "ใ‚นใƒญใƒผใƒขใƒผใƒ‰ใ€{{ seconds }}็ง’ใŠๅพ…ใกใใ ใ•ใ„...", + "Smileys & People": "ใ‚นใƒžใ‚คใƒชใƒผใจไบบใ€…", "Some of the files will not be accepted": "ไธ€้ƒจใฎใƒ•ใ‚กใ‚คใƒซใฏๅ—ใ‘ไป˜ใ‘ใ‚‰ใ‚Œใพใ›ใ‚“", "Start typing to search": "ๆคœ็ดขใ™ใ‚‹ใซใฏๅ…ฅๅŠ›ใ‚’้–‹ๅง‹ใ—ใฆใใ ใ•ใ„", "Stop sharing": "ๅ…ฑๆœ‰ใ‚’ๅœๆญข", "Submit": "้€ไฟก", "Suggest a new option to add to this poll": "ใ“ใฎๆŠ•็ฅจใซ่ฟฝๅŠ ใ™ใ‚‹ๆ–ฐใ—ใ„้ธๆŠž่‚ขใ‚’ๆๆกˆ", "Suggest an option": "ใ‚ชใƒ—ใ‚ทใƒงใƒณใ‚’ๆๆกˆ", + "Symbols": "่จ˜ๅท", "Tap to remove": "ใ‚ฟใƒƒใƒ—ใ—ใฆๅ‰Š้™ค", "Tap to remove: {{ reactionName }}": "ใ‚ฟใƒƒใƒ—ใ—ใฆๅ‰Š้™ค: {{ reactionName }}", "Thinking...": "่€ƒใˆไธญ...", @@ -565,6 +586,7 @@ "Translated": "็ฟป่จณๆธˆใฟ", "Translated from {{ language }}": "{{ language }}ใ‹ใ‚‰็ฟป่จณ", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "ๆ—…่กŒใจๅ ดๆ‰€", "Type a number from 2 to 10": "2ใ‹ใ‚‰10ใพใงใฎๆ•ฐๅญ—ใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„", "Unarchive": "ใ‚ขใƒผใ‚ซใ‚คใƒ–่งฃ้™ค", "unban-command-args": "[@ใƒฆใƒผใ‚ถๅ]", @@ -615,27 +637,5 @@ "Wait until all attachments have uploaded": "ใ™ในใฆใฎๆทปไป˜ใƒ•ใ‚กใ‚คใƒซใŒใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ•ใ‚Œใ‚‹ใพใงใŠๅพ…ใกใใ ใ•ใ„", "Waiting for networkโ€ฆ": "ใƒใƒƒใƒˆใƒฏใƒผใ‚ฏใ‚’ๅพ…ๆฉŸไธญโ€ฆ", "You": "ใ‚ใชใŸ", - "You've reached the maximum number of files": "ใƒ•ใ‚กใ‚คใƒซใฎๆœ€ๅคงๆ•ฐใซ้”ใ—ใพใ—ใŸ", - "Search emoji": "็ตตๆ–‡ๅญ—ใ‚’ๆคœ็ดข", - "aria/Clear emoji search": "็ตตๆ–‡ๅญ—ๆคœ็ดขใ‚’ใ‚ฏใƒชใ‚ข", - "No emoji found": "็ตตๆ–‡ๅญ—ใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", - "aria/Choose default skin tone": "ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใฎ่‚Œใฎ่‰ฒใ‚’้ธๆŠž", - "Frequently used": "ใ‚ˆใไฝฟใ†", - "Smileys & People": "ใ‚นใƒžใ‚คใƒชใƒผใจไบบใ€…", - "Animals & Nature": "ๅ‹•็‰ฉใจ่‡ช็„ถ", - "Food & Drink": "้ฃŸใน็‰ฉใจ้ฃฒใฟ็‰ฉ", - "Activities": "ใ‚ขใ‚ฏใƒ†ใ‚ฃใƒ“ใƒ†ใ‚ฃ", - "Travel & Places": "ๆ—…่กŒใจๅ ดๆ‰€", - "Objects": "็‰ฉ", - "Symbols": "่จ˜ๅท", - "Flags": "ๆ——", - "Default": "ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆ", - "Light": "ๆ˜Žใ‚‹ใ„", - "Medium-Light": "ใ‚„ใ‚„ๆ˜Žใ‚‹ใ„", - "Medium": "ๆ™ฎ้€š", - "Medium-Dark": "ใ‚„ใ‚„ๆš—ใ„", - "Dark": "ๆš—ใ„", - "Failed to load emojis": "็ตตๆ–‡ๅญ—ใ‚’่ชญใฟ่พผใ‚ใพใ›ใ‚“ใงใ—ใŸ", - "Retry": "ๅ†่ฉฆ่กŒ", - "Pick an emojiโ€ฆ": "็ตตๆ–‡ๅญ—ใ‚’้ธๆŠžโ€ฆ" + "You've reached the maximum number of files": "ใƒ•ใ‚กใ‚คใƒซใฎๆœ€ๅคงๆ•ฐใซ้”ใ—ใพใ—ใŸ" } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index af1f93028e..4096994ffd 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -40,6 +40,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}}์ด(๊ฐ€) ํˆฌํ‘œํ•จ: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“๊ณต์œ ๋œ ์œ„์น˜", "Actions": "Actions", + "Activities": "ํ™œ๋™", "Add": "์ถ”๊ฐ€", "Add {{ count }} members_other": "{{ count }}๋ช… ๋ฉค๋ฒ„ ์ถ”๊ฐ€", "Add a comment": "๋Œ“๊ธ€ ์ถ”๊ฐ€", @@ -61,6 +62,7 @@ "Also sent in channel": "์ฑ„๋„์—๋„ ์ „์†ก๋จ", "An error has occurred during recording": "๋…น์Œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค", "An error has occurred during the recording processing": "๋…น์Œ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค", + "Animals & Nature": "๋™๋ฌผ & ์ž์—ฐ", "Anonymous": "์ต๋ช…", "Anonymous poll": "์ต๋ช… ํˆฌํ‘œ", "Archive": "์•„์นด์ด๋ธŒ", @@ -80,6 +82,8 @@ "aria/Channel list": "์ฑ„๋„ ๋ชฉ๋ก", "aria/Channel search results": "์ฑ„๋„ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ", "aria/Chat view tabs": "์ฑ„ํŒ… ๋ณด๊ธฐ ํƒญ", + "aria/Choose default skin tone": "๊ธฐ๋ณธ ํ”ผ๋ถ€์ƒ‰ ์„ ํƒ", + "aria/Clear emoji search": "์ด๋ชจ์ง€ ๊ฒ€์ƒ‰ ์ง€์šฐ๊ธฐ", "aria/Clear search": "๊ฒ€์ƒ‰ ์ง€์šฐ๊ธฐ", "aria/Close callout dialog": "์ฝœ์•„์›ƒ ๋Œ€ํ™” ์ƒ์ž ๋‹ซ๊ธฐ", "aria/Close thread": "์Šค๋ ˆ๋“œ ๋‹ซ๊ธฐ", @@ -203,6 +207,8 @@ "Create a question, add options, and configure poll settings": "์งˆ๋ฌธ์„ ๋งŒ๋“ค๊ณ  ์˜ต์…˜์„ ์ถ”๊ฐ€ํ•œ ๋’ค ํˆฌํ‘œ ์„ค์ • ๊ตฌ์„ฑ", "Create poll": "ํˆฌํ‘œ ์ƒ์„ฑ", "Current location": "ํ˜„์žฌ ์œ„์น˜", + "Dark": "์–ด๋‘์›€", + "Default": "๊ธฐ๋ณธ", "Delete": "์‚ญ์ œ", "Delete chat": "์ฑ„ํŒ… ์‚ญ์ œ", "Delete for me": "๋‚˜๋งŒ ์‚ญ์ œ", @@ -271,6 +277,7 @@ "Failed to jump to the first unread message": "์ฒซ ๋ฒˆ์งธ ์ฝ์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€๋กœ ์ด๋™ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค", "Failed to leave channel": "์ฑ„๋„ ๋‚˜๊ฐ€๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", "Failed to load channels": "์ฑ„๋„์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค", + "Failed to load emojis": "์ด๋ชจ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค", "Failed to load more channels": "์ฑ„๋„์„ ๋” ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค", "Failed to mark channel as read": "์ฑ„๋„์„ ์ฝ์Œ์œผ๋กœ ํ‘œ์‹œํ•˜๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", "Failed to play the recording": "๋…น์Œ์„ ์žฌ์ƒํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค", @@ -287,6 +294,9 @@ "fileCount_other": "ํŒŒ์ผ {{ count }}๊ฐœ", "Files": "ํŒŒ์ผ", "Flag": "ํ”Œ๋ž˜๊ทธ", + "Flags": "๊นƒ๋ฐœ", + "Food & Drink": "์Œ์‹ & ์Œ๋ฃŒ", + "Frequently used": "์ž์ฃผ ์‚ฌ์šฉํ•จ", "Generating...": "์ƒ์„ฑ ์ค‘...", "giphy-command-args": "[ํ…์ŠคํŠธ]", "giphy-command-description": "์ฑ„๋„์— ๋ฌด์ž‘์œ„ GIF ๊ฒŒ์‹œ", @@ -359,6 +369,7 @@ "Leave chat": "์ฑ„๋„ ๋‚˜๊ฐ€๊ธฐ", "Left channel": "์ฑ„๋„์„ ๋‚˜๊ฐ”์Šต๋‹ˆ๋‹ค", "Let others add options": "๋‹ค๋ฅธ ์‚ฌ๋žŒ์ด ์„ ํƒ์ง€๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ—ˆ์šฉ", + "Light": "๋ฐ์Œ", "Limit votes per person": "1์ธ๋‹น ํˆฌํ‘œ ์ˆ˜ ์ œํ•œ", "Link": "๋งํฌ", "linkCount_other": "๋งํฌ {{ count }}๊ฐœ", @@ -376,6 +387,9 @@ "Mark as unread": "์ฝ์ง€ ์•Š์Œ์œผ๋กœ ํ‘œ์‹œ", "Maximum number of votes (from 2 to 10)": "์ตœ๋Œ€ ํˆฌํ‘œ ์ˆ˜ (2์—์„œ 10๊นŒ์ง€)", "Maximum votes per person": "1์ธ๋‹น ์ตœ๋Œ€ ํˆฌํ‘œ ์ˆ˜", + "Medium": "์ค‘๊ฐ„", + "Medium-Dark": "์ค‘๊ฐ„ ์–ด๋‘์›€", + "Medium-Light": "์ค‘๊ฐ„ ๋ฐ์Œ", "Member detail": "๋ฉค๋ฒ„ ์ƒ์„ธ ์ •๋ณด", "mention/Channel": "์ฑ„๋„", "mention/Channel Description": "์ด ์ฑ„๋„์˜ ๋ชจ๋‘์—๊ฒŒ ์•Œ๋ฆผ", @@ -406,6 +420,7 @@ "Next image": "๋‹ค์Œ ์ด๋ฏธ์ง€", "No chats here yetโ€ฆ": "์•„์ง ์ฑ„ํŒ…์ด ์—†์Šต๋‹ˆ๋‹ค...", "No conversations yet": "์•„์ง ๋Œ€ํ™”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", + "No emoji found": "์ด๋ชจ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", "No files": "ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค", "No items exist": "ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.", "No member found": "๋ฉค๋ฒ„๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", @@ -417,6 +432,7 @@ "Nobody will be able to vote in this poll anymore.": "์ด ํˆฌํ‘œ์— ๋” ์ด์ƒ ์•„๋ฌด๋„ ํˆฌํ‘œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", "Nothing yet...": "์•„์ง ์•„๋ฌด๊ฒƒ๋„...", "Notify all {{ role }} members": "{{ role }} ์—ญํ• ์˜ ๋ชจ๋“  ๋ฉค๋ฒ„์—๊ฒŒ ์•Œ๋ฆผ", + "Objects": "์‚ฌ๋ฌผ", "Offline": "์˜คํ”„๋ผ์ธ", "Ok": "ํ™•์ธ", "Online": "์˜จ๋ผ์ธ", @@ -436,6 +452,7 @@ "People matching": "์ผ์น˜ํ•˜๋Š” ์‚ฌ๋žŒ", "Photo": "์‚ฌ์ง„", "Photos & videos": "์‚ฌ์ง„ ๋ฐ ๋™์˜์ƒ", + "Pick an emojiโ€ฆ": "์ด๋ชจ์ง€ ์„ ํƒโ€ฆ", "Pin": "ํ•€", "Pin a message to see it here": "์—ฌ๊ธฐ์—์„œ ๋ณด๋ ค๋ฉด ๋ฉ”์‹œ์ง€๋ฅผ ๊ณ ์ •ํ•˜์„ธ์š”", "Pinned by {{ name }}": "{{ name }}๋‹˜์ด ํ•€ํ•จ", @@ -478,6 +495,7 @@ "replyCount_one": "๋‹ต์žฅ 1๊ฐœ", "replyCount_other": "{{ count }} ๋‹ต์žฅ", "Resend": "๋‹ค์‹œ ๋ณด๋‚ด๊ธฐ", + "Retry": "๋‹ค์‹œ ์‹œ๋„", "Retry upload": "์—…๋กœ๋“œ ๋‹ค์‹œ ์‹œ๋„", "Review all options available in this poll": "์ด ํˆฌํ‘œ์—์„œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋“  ์˜ต์…˜ ๊ฒ€ํ† ", "Review comments submitted with poll answers": "ํˆฌํ‘œ ๋‹ต๋ณ€๊ณผ ํ•จ๊ป˜ ์ œ์ถœ๋œ ๋Œ“๊ธ€ ๊ฒ€ํ† ", @@ -488,6 +506,7 @@ "Save for later": "๋‚˜์ค‘์— ์ €์žฅ", "Saved for later": "๋‚˜์ค‘์— ์ €์žฅ๋จ", "Search": "์ฐพ๋‹ค", + "Search emoji": "์ด๋ชจ์ง€ ๊ฒ€์ƒ‰", "Search GIFs": "GIF ๊ฒ€์ƒ‰", "search-results-header-filter-source-button-label--channels": "์ฑ„๋„", "search-results-header-filter-source-button-label--messages": "๋ฉ”์‹œ์ง€", @@ -523,12 +542,14 @@ "size limit": "ํฌ๊ธฐ ์ œํ•œ", "Slow Mode ON": "์Šฌ๋กœ์šฐ ๋ชจ๋“œ ์ผœ์ง", "Slow mode, wait {{ seconds }}s...": "์Šฌ๋กœ์šฐ ๋ชจ๋“œ, {{ seconds }}์ดˆ ๊ธฐ๋‹ค๋ ค ์ฃผ์„ธ์š”...", + "Smileys & People": "์Šค๋งˆ์ผ๋ฆฌ & ์‚ฌ๋žŒ", "Some of the files will not be accepted": "์ผ๋ถ€ ํŒŒ์ผ์€ ํ—ˆ์šฉ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค", "Start typing to search": "๊ฒ€์ƒ‰ํ•˜๋ ค๋ฉด ์ž…๋ ฅ์„ ์‹œ์ž‘ํ•˜์„ธ์š”", "Stop sharing": "๊ณต์œ  ์ค‘์ง€", "Submit": "์ œ์ถœ", "Suggest a new option to add to this poll": "์ด ํˆฌํ‘œ์— ์ถ”๊ฐ€ํ•  ์ƒˆ ์˜ต์…˜ ์ œ์•ˆ", "Suggest an option": "์˜ต์…˜ ์ œ์•ˆ", + "Symbols": "๊ธฐํ˜ธ", "Tap to remove": "์ œ๊ฑฐํ•˜๋ ค๋ฉด ํƒญํ•˜์„ธ์š”", "Tap to remove: {{ reactionName }}": "์ œ๊ฑฐํ•˜๋ ค๋ฉด ํƒญํ•˜์„ธ์š”: {{ reactionName }}", "Thinking...": "์ƒ๊ฐ ์ค‘...", @@ -565,6 +586,7 @@ "Translated": "๋ฒˆ์—ญ๋จ", "Translated from {{ language }}": "{{ language }}(์œผ)๋กœ ๋ฒˆ์—ญ๋จ", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "์—ฌํ–‰ & ์žฅ์†Œ", "Type a number from 2 to 10": "2์—์„œ 10 ์‚ฌ์ด์˜ ์ˆซ์ž๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”", "Unarchive": "์•„์นด์ด๋ธŒ ํ•ด์ œ", "unban-command-args": "[@์‚ฌ์šฉ์ž์ด๋ฆ„]", @@ -615,27 +637,5 @@ "Wait until all attachments have uploaded": "๋ชจ๋“  ์ฒจ๋ถ€ ํŒŒ์ผ์ด ์—…๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฝ๋‹ˆ๋‹ค.", "Waiting for networkโ€ฆ": "๋„คํŠธ์›Œํฌ ๋Œ€๊ธฐ ์ค‘โ€ฆ", "You": "๋‹น์‹ ", - "You've reached the maximum number of files": "์ตœ๋Œ€ ํŒŒ์ผ ์ˆ˜์— ๋„๋‹ฌํ–ˆ์Šต๋‹ˆ๋‹ค.", - "Search emoji": "์ด๋ชจ์ง€ ๊ฒ€์ƒ‰", - "aria/Clear emoji search": "์ด๋ชจ์ง€ ๊ฒ€์ƒ‰ ์ง€์šฐ๊ธฐ", - "No emoji found": "์ด๋ชจ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", - "aria/Choose default skin tone": "๊ธฐ๋ณธ ํ”ผ๋ถ€์ƒ‰ ์„ ํƒ", - "Frequently used": "์ž์ฃผ ์‚ฌ์šฉํ•จ", - "Smileys & People": "์Šค๋งˆ์ผ๋ฆฌ & ์‚ฌ๋žŒ", - "Animals & Nature": "๋™๋ฌผ & ์ž์—ฐ", - "Food & Drink": "์Œ์‹ & ์Œ๋ฃŒ", - "Activities": "ํ™œ๋™", - "Travel & Places": "์—ฌํ–‰ & ์žฅ์†Œ", - "Objects": "์‚ฌ๋ฌผ", - "Symbols": "๊ธฐํ˜ธ", - "Flags": "๊นƒ๋ฐœ", - "Default": "๊ธฐ๋ณธ", - "Light": "๋ฐ์Œ", - "Medium-Light": "์ค‘๊ฐ„ ๋ฐ์Œ", - "Medium": "์ค‘๊ฐ„", - "Medium-Dark": "์ค‘๊ฐ„ ์–ด๋‘์›€", - "Dark": "์–ด๋‘์›€", - "Failed to load emojis": "์ด๋ชจ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค", - "Retry": "๋‹ค์‹œ ์‹œ๋„", - "Pick an emojiโ€ฆ": "์ด๋ชจ์ง€ ์„ ํƒโ€ฆ" + "You've reached the maximum number of files": "์ตœ๋Œ€ ํŒŒ์ผ ์ˆ˜์— ๋„๋‹ฌํ–ˆ์Šต๋‹ˆ๋‹ค." } diff --git a/src/i18n/nl.json b/src/i18n/nl.json index e96989dd51..efc492cc2c 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -43,6 +43,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} heeft gestemd: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“Gedeelde locatie", "Actions": "Actions", + "Activities": "Activiteiten", "Add": "Toevoegen", "Add {{ count }} members_one": "{{ count }} lid toevoegen", "Add {{ count }} members_other": "{{ count }} leden toevoegen", @@ -65,6 +66,7 @@ "Also sent in channel": "Ook in kanaal verzonden", "An error has occurred during recording": "Er is een fout opgetreden tijdens het opnemen", "An error has occurred during the recording processing": "Er is een fout opgetreden tijdens de verwerking van de opname", + "Animals & Nature": "Dieren en natuur", "Anonymous": "Anoniem", "Anonymous poll": "Anonieme peiling", "Archive": "Archief", @@ -84,6 +86,8 @@ "aria/Channel list": "Kanaallijst", "aria/Channel search results": "Zoekresultaten voor kanalen", "aria/Chat view tabs": "Tabbladen chatweergave", + "aria/Choose default skin tone": "Standaard huidskleur kiezen", + "aria/Clear emoji search": "Emoji zoekopdracht wissen", "aria/Clear search": "Zoekopdracht wissen", "aria/Close callout dialog": "Calloutdialoog sluiten", "aria/Close thread": "Draad sluiten", @@ -207,6 +211,8 @@ "Create a question, add options, and configure poll settings": "Maak een vraag, voeg opties toe en stel de pollinstellingen in", "Create poll": "Maak peiling", "Current location": "Huidige locatie", + "Dark": "Donker", + "Default": "Standaard", "Delete": "Verwijder", "Delete chat": "Chat verwijderen", "Delete for me": "Voor mij verwijderen", @@ -275,6 +281,7 @@ "Failed to jump to the first unread message": "Niet gelukt om naar het eerste ongelezen bericht te springen", "Failed to leave channel": "Kanaal verlaten mislukt", "Failed to load channels": "Kanalen konden niet worden geladen", + "Failed to load emojis": "Emoji's konden niet worden geladen", "Failed to load more channels": "Meer kanalen konden niet worden geladen", "Failed to mark channel as read": "Kanaal kon niet als gelezen worden gemarkeerd", "Failed to play the recording": "Kan de opname niet afspelen", @@ -292,6 +299,9 @@ "fileCount_other": "{{ count }} bestanden", "Files": "Bestanden", "Flag": "Markeer", + "Flags": "Vlaggen", + "Food & Drink": "Eten en drinken", + "Frequently used": "Veelgebruikt", "Generating...": "Genereren...", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", @@ -365,6 +375,7 @@ "Leave chat": "Kanaal verlaten", "Left channel": "Kanaal verlaten", "Let others add options": "Laat anderen opties toevoegen", + "Light": "Licht", "Limit votes per person": "Stemmen per persoon beperken", "Link": "Link", "linkCount_one": "Link", @@ -383,6 +394,9 @@ "Mark as unread": "Markeren als ongelezen", "Maximum number of votes (from 2 to 10)": "Maximaal aantal stemmen (van 2 tot 10)", "Maximum votes per person": "Maximum aantal stemmen per persoon", + "Medium": "Middel", + "Medium-Dark": "Middeldonker", + "Medium-Light": "Middellicht", "Member detail": "Lidgegevens", "mention/Channel": "Kanaal", "mention/Channel Description": "Iedereen in dit kanaal informeren", @@ -413,6 +427,7 @@ "Next image": "Volgende afbeelding", "No chats here yetโ€ฆ": "Nog geen chats hier...", "No conversations yet": "Nog geen gesprekken", + "No emoji found": "Geen emoji gevonden", "No files": "Geen bestanden", "No items exist": "Er zijn geen items", "No member found": "Geen lid gevonden", @@ -424,6 +439,7 @@ "Nobody will be able to vote in this poll anymore.": "Niemand kan meer stemmen in deze peiling.", "Nothing yet...": "Nog niets ...", "Notify all {{ role }} members": "Alle leden met rol {{ role }} informeren", + "Objects": "Objecten", "Offline": "Offline", "Ok": "Okรฉ", "Online": "Online", @@ -443,6 +459,7 @@ "People matching": "Mensen die matchen", "Photo": "Foto", "Photos & videos": "Foto's en video's", + "Pick an emojiโ€ฆ": "Kies een emojiโ€ฆ", "Pin": "Vastmaken", "Pin a message to see it here": "Maak een bericht vast om het hier te zien", "Pinned by {{ name }}": "Vastgemaakt door {{ name }}", @@ -487,6 +504,7 @@ "replyCount_one": "1 antwoord", "replyCount_other": "{{ count }} antwoorden", "Resend": "Opnieuw verzenden", + "Retry": "Opnieuw proberen", "Retry upload": "Upload opnieuw proberen", "Review all options available in this poll": "Bekijk alle beschikbare opties in deze poll", "Review comments submitted with poll answers": "Bekijk reacties die met pollantwoorden zijn ingediend", @@ -497,6 +515,7 @@ "Save for later": "Bewaren voor later", "Saved for later": "Bewaard voor later", "Search": "Zoeken", + "Search emoji": "Emoji zoeken", "Search GIFs": "GIF's zoeken", "search-results-header-filter-source-button-label--channels": "kanalen", "search-results-header-filter-source-button-label--messages": "berichten", @@ -534,12 +553,14 @@ "Slow mode, wait {{ seconds }}s...": "Langzame modus, wacht {{ seconds }}s...", "Slow wait, wait {{ seconds }}s": "Langzame modus, wacht {{ seconds }}s", "Slow wait, wait {{ seconds }}s...": "Langzame modus, wacht {{ seconds }}s...", + "Smileys & People": "Smileys en mensen", "Some of the files will not be accepted": "Sommige bestanden zullen niet worden geaccepteerd", "Start typing to search": "Begin met typen om te zoeken", "Stop sharing": "Delen stoppen", "Submit": "Versturen", "Suggest a new option to add to this poll": "Stel een nieuwe optie voor om aan deze poll toe te voegen", "Suggest an option": "Stel een optie voor", + "Symbols": "Symbolen", "Tap to remove": "Tik om te verwijderen", "Tap to remove: {{ reactionName }}": "Tik om te verwijderen: {{ reactionName }}", "Thinking...": "Denken...", @@ -578,6 +599,7 @@ "Translated": "Vertaald", "Translated from {{ language }}": "Vertaald uit {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Reizen en plaatsen", "Type a number from 2 to 10": "Typ een getal van 2 tot 10", "Unarchive": "Uit archief halen", "unban-command-args": "[@gebruikersnaam]", @@ -630,27 +652,5 @@ "Wait until all attachments have uploaded": "Wacht tot alle bijlagen zijn geรผpload", "Waiting for networkโ€ฆ": "Wachten op netwerkโ€ฆ", "You": "Jij", - "You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt", - "Search emoji": "Emoji zoeken", - "aria/Clear emoji search": "Emoji zoekopdracht wissen", - "No emoji found": "Geen emoji gevonden", - "aria/Choose default skin tone": "Standaard huidskleur kiezen", - "Frequently used": "Veelgebruikt", - "Smileys & People": "Smileys en mensen", - "Animals & Nature": "Dieren en natuur", - "Food & Drink": "Eten en drinken", - "Activities": "Activiteiten", - "Travel & Places": "Reizen en plaatsen", - "Objects": "Objecten", - "Symbols": "Symbolen", - "Flags": "Vlaggen", - "Default": "Standaard", - "Light": "Licht", - "Medium-Light": "Middellicht", - "Medium": "Middel", - "Medium-Dark": "Middeldonker", - "Dark": "Donker", - "Failed to load emojis": "Emoji's konden niet worden geladen", - "Retry": "Opnieuw proberen", - "Pick an emojiโ€ฆ": "Kies een emojiโ€ฆ" + "You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt" } diff --git a/src/i18n/pt.json b/src/i18n/pt.json index f9f0872c46..81fc36b73e 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -53,6 +53,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} votou: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“Localizaรงรฃo compartilhada", "Actions": "Actions", + "Activities": "Atividades", "Add": "Adicionar", "Add {{ count }} members_one": "Adicionar {{ count }} membro", "Add {{ count }} members_many": "Adicionar {{ count }} membros", @@ -76,6 +77,7 @@ "Also sent in channel": "Tambรฉm enviado no canal", "An error has occurred during recording": "Ocorreu um erro durante a gravaรงรฃo", "An error has occurred during the recording processing": "Ocorreu um erro durante o processamento da gravaรงรฃo", + "Animals & Nature": "Animais e natureza", "Anonymous": "Anรดnimo", "Anonymous poll": "Enquete anรดnima", "Archive": "Arquivar", @@ -95,6 +97,8 @@ "aria/Channel list": "Lista de canais", "aria/Channel search results": "Resultados de pesquisa de canais", "aria/Chat view tabs": "Abas da visualizaรงรฃo do chat", + "aria/Choose default skin tone": "Escolher tom de pele padrรฃo", + "aria/Clear emoji search": "Limpar pesquisa de emoji", "aria/Clear search": "Limpar pesquisa", "aria/Close callout dialog": "Fechar diรกlogo de destaque", "aria/Close thread": "Fechar tรณpico", @@ -218,6 +222,8 @@ "Create a question, add options, and configure poll settings": "Crie uma pergunta, adicione opรงรตes e configure as definiรงรตes da enquete", "Create poll": "Criar enquete", "Current location": "Localizaรงรฃo atual", + "Dark": "Escuro", + "Default": "Padrรฃo", "Delete": "Excluir", "Delete chat": "Excluir chat", "Delete for me": "Excluir para mim", @@ -286,6 +292,7 @@ "Failed to jump to the first unread message": "Falha ao pular para a primeira mensagem nรฃo lida", "Failed to leave channel": "Falha ao sair do canal", "Failed to load channels": "Falha ao carregar os canais", + "Failed to load emojis": "Falha ao carregar os emojis", "Failed to load more channels": "Falha ao carregar mais canais", "Failed to mark channel as read": "Falha ao marcar o canal como lido", "Failed to play the recording": "Falha ao reproduzir a gravaรงรฃo", @@ -304,6 +311,9 @@ "fileCount_other": "{{ count }} arquivos", "Files": "Arquivos", "Flag": "Reportar", + "Flags": "Bandeiras", + "Food & Drink": "Comida e bebida", + "Frequently used": "Usados com frequรชncia", "Generating...": "Gerando...", "giphy-command-args": "[texto]", "giphy-command-description": "Postar um gif aleatรณrio no canal", @@ -378,6 +388,7 @@ "Leave chat": "Sair do canal", "Left channel": "Canal abandonado", "Let others add options": "Permitir que outros adicionem opรงรตes", + "Light": "Claro", "Limit votes per person": "Limitar votos por pessoa", "Link": "Link", "linkCount_one": "Link", @@ -397,6 +408,9 @@ "Mark as unread": "Marcar como nรฃo lida", "Maximum number of votes (from 2 to 10)": "Nรบmero mรกximo de votos (de 2 a 10)", "Maximum votes per person": "Mรกximo de votos por pessoa", + "Medium": "Mรฉdio", + "Medium-Dark": "Mรฉdio escuro", + "Medium-Light": "Mรฉdio claro", "Member detail": "Detalhes do membro", "mention/Channel": "Canal", "mention/Channel Description": "Notificar todos neste canal", @@ -427,6 +441,7 @@ "Next image": "Prรณxima imagem", "No chats here yetโ€ฆ": "Ainda nรฃo hรก conversas aqui...", "No conversations yet": "Ainda nรฃo hรก conversas", + "No emoji found": "Nenhum emoji encontrado", "No files": "Nenhum arquivo", "No items exist": "Nรฃo existem itens", "No member found": "Nenhum membro encontrado", @@ -438,6 +453,7 @@ "Nobody will be able to vote in this poll anymore.": "Ninguรฉm mais poderรก votar nesta pesquisa.", "Nothing yet...": "Nada ainda...", "Notify all {{ role }} members": "Notificar todos os membros com a funรงรฃo {{ role }}", + "Objects": "Objetos", "Offline": "Offline", "Ok": "OK", "Online": "Online", @@ -457,6 +473,7 @@ "People matching": "Pessoas correspondentes", "Photo": "Foto", "Photos & videos": "Fotos e vรญdeos", + "Pick an emojiโ€ฆ": "Escolha um emojiโ€ฆ", "Pin": "Fixar", "Pin a message to see it here": "Fixe uma mensagem para vรช-la aqui", "Pinned by {{ name }}": "Fixado por {{ name }}", @@ -504,6 +521,7 @@ "replyCount_many": "{{ count }} respostas", "replyCount_other": "{{ count }} respostas", "Resend": "Reenviar", + "Retry": "Tentar novamente", "Retry upload": "Tentar enviar novamente", "Review all options available in this poll": "Revise todas as opรงรตes disponรญveis nesta enquete", "Review comments submitted with poll answers": "Revise comentรกrios enviados com respostas da enquete", @@ -514,6 +532,7 @@ "Save for later": "Salvar para depois", "Saved for later": "Salvo para depois", "Search": "Buscar", + "Search emoji": "Pesquisar emoji", "Search GIFs": "Pesquisar GIFs", "search-results-header-filter-source-button-label--channels": "canais", "search-results-header-filter-source-button-label--messages": "mensagens", @@ -551,12 +570,14 @@ "size limit": "limite de tamanho", "Slow Mode ON": "Modo lento LIGADO", "Slow mode, wait {{ seconds }}s...": "Modo lento, aguarde {{ seconds }} s...", + "Smileys & People": "Smileys e pessoas", "Some of the files will not be accepted": "Alguns arquivos nรฃo serรฃo aceitos", "Start typing to search": "Comece a digitar para pesquisar", "Stop sharing": "Parar de compartilhar", "Submit": "Enviar", "Suggest a new option to add to this poll": "Sugira uma nova opรงรฃo para adicionar a esta enquete", "Suggest an option": "Sugerir uma opรงรฃo", + "Symbols": "Sรญmbolos", "Tap to remove": "Toque para remover", "Tap to remove: {{ reactionName }}": "Toque para remover: {{ reactionName }}", "Thinking...": "Pensando...", @@ -597,6 +618,7 @@ "Translated": "Traduzido", "Translated from {{ language }}": "Traduzido de {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Viagens e lugares", "Type a number from 2 to 10": "Digite um nรบmero de 2 a 10", "Unarchive": "Desarquivar", "unban-command-args": "[@nomedeusuรกrio]", @@ -652,27 +674,5 @@ "Wait until all attachments have uploaded": "Espere atรฉ que todos os anexos tenham sido carregados", "Waiting for networkโ€ฆ": "Aguardando redeโ€ฆ", "You": "Vocรช", - "You've reached the maximum number of files": "Vocรช atingiu o nรบmero mรกximo de arquivos", - "Search emoji": "Pesquisar emoji", - "aria/Clear emoji search": "Limpar pesquisa de emoji", - "No emoji found": "Nenhum emoji encontrado", - "aria/Choose default skin tone": "Escolher tom de pele padrรฃo", - "Frequently used": "Usados com frequรชncia", - "Smileys & People": "Smileys e pessoas", - "Animals & Nature": "Animais e natureza", - "Food & Drink": "Comida e bebida", - "Activities": "Atividades", - "Travel & Places": "Viagens e lugares", - "Objects": "Objetos", - "Symbols": "Sรญmbolos", - "Flags": "Bandeiras", - "Default": "Padrรฃo", - "Light": "Claro", - "Medium-Light": "Mรฉdio claro", - "Medium": "Mรฉdio", - "Medium-Dark": "Mรฉdio escuro", - "Dark": "Escuro", - "Failed to load emojis": "Falha ao carregar os emojis", - "Retry": "Tentar novamente", - "Pick an emojiโ€ฆ": "Escolha um emojiโ€ฆ" + "You've reached the maximum number of files": "Vocรช atingiu o nรบmero mรกximo de arquivos" } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 871524ef96..fb4281e7ac 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -64,6 +64,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} ะฟั€ะพะณะพะปะพัะพะฒะฐะป(ะฐ): {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“ะžะฑั‰ะตะต ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะต", "Actions": "Actions", + "Activities": "ะะบั‚ะธะฒะฝะพัั‚ะธ", "Add": "ะ”ะพะฑะฐะฒะธั‚ัŒ", "Add {{ count }} members_one": "ะ”ะพะฑะฐะฒะธั‚ัŒ {{ count }} ัƒั‡ะฐัั‚ะฝะธะบะฐ", "Add {{ count }} members_few": "ะ”ะพะฑะฐะฒะธั‚ัŒ {{ count }} ัƒั‡ะฐัั‚ะฝะธะบะพะฒ", @@ -88,6 +89,7 @@ "Also sent in channel": "ะขะฐะบะถะต ะพั‚ะฟั€ะฐะฒะปะตะฝะพ ะฒ ะบะฐะฝะฐะป", "An error has occurred during recording": "ะŸั€ะพะธะทะพัˆะปะฐ ะพัˆะธะฑะบะฐ ะฒะพ ะฒั€ะตะผั ะทะฐะฟะธัะธ", "An error has occurred during the recording processing": "ะŸั€ะพะธะทะพัˆะปะฐ ะพัˆะธะฑะบะฐ ะฒะพ ะฒั€ะตะผั ะพะฑั€ะฐะฑะพั‚ะบะธ ะทะฐะฟะธัะธ", + "Animals & Nature": "ะ–ะธะฒะพั‚ะฝั‹ะต ะธ ะฟั€ะธั€ะพะดะฐ", "Anonymous": "ะะฝะพะฝะธะผ", "Anonymous poll": "ะะฝะพะฝะธะผะฝั‹ะน ะพะฟั€ะพั", "Archive": "Aั€ั…ะธะฒะธั€ะพะฒะฐั‚ัŒ", @@ -107,6 +109,8 @@ "aria/Channel list": "ะกะฟะธัะพะบ ะบะฐะฝะฐะปะพะฒ", "aria/Channel search results": "ะ ะตะทัƒะปัŒั‚ะฐั‚ั‹ ะฟะพะธัะบะฐ ะฟะพ ะบะฐะฝะฐะปะฐะผ", "aria/Chat view tabs": "ะ’ะบะปะฐะดะบะธ ะฒะธะดะฐ ั‡ะฐั‚ะฐ", + "aria/Choose default skin tone": "ะ’ั‹ะฑั€ะฐั‚ัŒ ั‚ะพะฝ ะบะพะถะธ ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ", + "aria/Clear emoji search": "ะžั‡ะธัั‚ะธั‚ัŒ ะฟะพะธัะบ ัะผะพะดะทะธ", "aria/Clear search": "ะžั‡ะธัั‚ะธั‚ัŒ ะฟะพะธัะบ", "aria/Close callout dialog": "ะ—ะฐะบั€ั‹ั‚ัŒ ะดะธะฐะปะพะณ ะฒั‹ะฝะพัะบะธ", "aria/Close thread": "ะ—ะฐะบั€ั‹ั‚ัŒ ั‚ะตะผัƒ", @@ -230,6 +234,8 @@ "Create a question, add options, and configure poll settings": "ะกะพะทะดะฐะนั‚ะต ะฒะพะฟั€ะพั, ะดะพะฑะฐะฒัŒั‚ะต ะฒะฐั€ะธะฐะฝั‚ั‹ ะธ ะฝะฐัั‚ั€ะพะนั‚ะต ะฟะฐั€ะฐะผะตั‚ั€ั‹ ะพะฟั€ะพัะฐ", "Create poll": "ะกะพะทะดะฐั‚ัŒ ะพะฟั€ะพั", "Current location": "ะขะตะบัƒั‰ะตะต ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะต", + "Dark": "ะขั‘ะผะฝั‹ะน", + "Default": "ะŸะพ ัƒะผะพะปั‡ะฐะฝะธัŽ", "Delete": "ะฃะดะฐะปะธั‚ัŒ", "Delete chat": "ะฃะดะฐะปะธั‚ัŒ ั‡ะฐั‚", "Delete for me": "ะฃะดะฐะปะธั‚ัŒ ะดะปั ะผะตะฝั", @@ -298,6 +304,7 @@ "Failed to jump to the first unread message": "ะะต ัƒะดะฐะปะพััŒ ะฟะตั€ะตะนั‚ะธ ะบ ะฟะตั€ะฒะพะผัƒ ะฝะตะฟั€ะพั‡ะธั‚ะฐะฝะฝะพะผัƒ ัะพะพะฑั‰ะตะฝะธัŽ", "Failed to leave channel": "ะะต ัƒะดะฐะปะพััŒ ะฟะพะบะธะฝัƒั‚ัŒ ะบะฐะฝะฐะป", "Failed to load channels": "ะะต ัƒะดะฐะปะพััŒ ะทะฐะณั€ัƒะทะธั‚ัŒ ะบะฐะฝะฐะปั‹", + "Failed to load emojis": "ะะต ัƒะดะฐะปะพััŒ ะทะฐะณั€ัƒะทะธั‚ัŒ ัะผะพะดะทะธ", "Failed to load more channels": "ะะต ัƒะดะฐะปะพััŒ ะทะฐะณั€ัƒะทะธั‚ัŒ ะฑะพะปัŒัˆะต ะบะฐะฝะฐะปะพะฒ", "Failed to mark channel as read": "ะะต ัƒะดะฐะปะพััŒ ะฟะพะผะตั‚ะธั‚ัŒ ะบะฐะฝะฐะป ะบะฐะบ ะฟั€ะพั‡ะธั‚ะฐะฝะฝั‹ะน", "Failed to play the recording": "ะะต ัƒะดะฐะปะพััŒ ะฒะพัะฟั€ะพะธะทะฒะตัั‚ะธ ะทะฐะฟะธััŒ", @@ -320,6 +327,9 @@ "fileCount_three": "{{ count }} ั„ะฐะนะปะฐ", "Files": "ะคะฐะนะปั‹", "Flag": "ะŸะพะถะฐะปะพะฒะฐั‚ัŒัั", + "Flags": "ะคะปะฐะณะธ", + "Food & Drink": "ะ•ะดะฐ ะธ ะฝะฐะฟะธั‚ะบะธ", + "Frequently used": "ะงะฐัั‚ะพ ะธัะฟะพะปัŒะทัƒะตะผั‹ะต", "Generating...": "ะ“ะตะฝะตั€ะธั€ัƒัŽ...", "giphy-command-args": "[ั‚ะตะบัั‚]", "giphy-command-description": "ะžะฟัƒะฑะปะธะบะพะฒะฐั‚ัŒ ัะปัƒั‡ะฐะนะฝัƒัŽ GIF-ะฐะฝะธะผะฐั†ะธัŽ ะฒ ะบะฐะฝะฐะปะต", @@ -395,6 +405,7 @@ "Leave chat": "ะŸะพะบะธะฝัƒั‚ัŒ ะบะฐะฝะฐะป", "Left channel": "ะšะฐะฝะฐะป ะฟะพะบะธะฝัƒั‚", "Let others add options": "ะ ะฐะทั€ะตัˆะธั‚ัŒ ะดั€ัƒะณะธะผ ะดะพะฑะฐะฒะปัั‚ัŒ ะฒะฐั€ะธะฐะฝั‚ั‹", + "Light": "ะกะฒะตั‚ะปั‹ะน", "Limit votes per person": "ะžะณั€ะฐะฝะธั‡ะธั‚ัŒ ะณะพะปะพัะฐ ะฝะฐ ั‡ะตะปะพะฒะตะบะฐ", "Link": "ะ›ะธะฝะบ", "linkCount_one": "{{ count }} ะปะธะฝะบ", @@ -415,6 +426,9 @@ "Mark as unread": "ะžั‚ะผะตั‚ะธั‚ัŒ ะบะฐะบ ะฝะตะฟั€ะพั‡ะธั‚ะฐะฝะฝะพะต", "Maximum number of votes (from 2 to 10)": "ะœะฐะบัะธะผะฐะปัŒะฝะพะต ะบะพะปะธั‡ะตัั‚ะฒะพ ะณะพะปะพัะพะฒ (ะพั‚ 2 ะดะพ 10)", "Maximum votes per person": "ะœะฐะบัะธะผัƒะผ ะณะพะปะพัะพะฒ ะฝะฐ ั‡ะตะปะพะฒะตะบะฐ", + "Medium": "ะกั€ะตะดะฝะธะน", + "Medium-Dark": "ะฃะผะตั€ะตะฝะฝะพ-ั‚ั‘ะผะฝั‹ะน", + "Medium-Light": "ะฃะผะตั€ะตะฝะฝะพ-ัะฒะตั‚ะปั‹ะน", "Member detail": "ะกะฒะตะดะตะฝะธั ะพะฑ ัƒั‡ะฐัั‚ะฝะธะบะต", "mention/Channel": "ะšะฐะฝะฐะป", "mention/Channel Description": "ะฃะฒะตะดะพะผะธั‚ัŒ ะฒัะตั… ะฒ ัั‚ะพะผ ะบะฐะฝะฐะปะต", @@ -445,6 +459,7 @@ "Next image": "ะกะปะตะดัƒัŽั‰ะตะต ะธะทะพะฑั€ะฐะถะตะฝะธะต", "No chats here yetโ€ฆ": "ะ—ะดะตััŒ ะตั‰ะต ะฝะตั‚ ั‡ะฐั‚ะพะฒ...", "No conversations yet": "ะŸะพะบะฐ ะฝะตั‚ ะฑะตัะตะด", + "No emoji found": "ะญะผะพะดะทะธ ะฝะต ะฝะฐะนะดะตะฝั‹", "No files": "ะะตั‚ ั„ะฐะนะปะพะฒ", "No items exist": "ะญะปะตะผะตะฝั‚ะพะฒ ะฝะตั‚", "No member found": "ะฃั‡ะฐัั‚ะฝะธะบ ะฝะต ะฝะฐะนะดะตะฝ", @@ -456,6 +471,7 @@ "Nobody will be able to vote in this poll anymore.": "ะะธะบั‚ะพ ะฑะพะปัŒัˆะต ะฝะต ัะผะพะถะตั‚ ะณะพะปะพัะพะฒะฐั‚ัŒ ะฒ ัั‚ะพะผ ะพะฟั€ะพัะต.", "Nothing yet...": "ะŸะพะบะฐ ะฝะธั‡ะตะณะพ ะฝะตั‚...", "Notify all {{ role }} members": "ะฃะฒะตะดะพะผะธั‚ัŒ ะฒัะตั… ัƒั‡ะฐัั‚ะฝะธะบะพะฒ ั ั€ะพะปัŒัŽ {{ role }}", + "Objects": "ะžะฑัŠะตะบั‚ั‹", "Offline": "ะะต ะฒ ัะตั‚ะธ", "Ok": "ะžะบ", "Online": "ะ’ ัะตั‚ะธ", @@ -475,6 +491,7 @@ "People matching": "ะกะพะฒะฟะฐะดะฐัŽั‰ะธะต ะปัŽะดะธ", "Photo": "ะคะพั‚ะพ", "Photos & videos": "ะคะพั‚ะพ ะธ ะฒะธะดะตะพ", + "Pick an emojiโ€ฆ": "ะ’ั‹ะฑะตั€ะธั‚ะต ัะผะพะดะทะธโ€ฆ", "Pin": "ะ—ะฐะบั€ะตะฟะธั‚ัŒ", "Pin a message to see it here": "ะ—ะฐะบั€ะตะฟะธั‚ะต ัะพะพะฑั‰ะตะฝะธะต, ั‡ั‚ะพะฑั‹ ัƒะฒะธะดะตั‚ัŒ ะตะณะพ ะทะดะตััŒ", "Pinned by {{ name }}": "ะ—ะฐะบั€ะตะฟะปะตะฝะพ: {{ name }}", @@ -525,6 +542,7 @@ "replyCount_many": "{{ count }} ะพั‚ะฒะตั‚ะพะฒ", "replyCount_other": "{{ count }} ะพั‚ะฒะตั‚ะพะฒ", "Resend": "ะžั‚ะฟั€ะฐะฒะธั‚ัŒ ะฟะพะฒั‚ะพั€ะฝะพ", + "Retry": "ะŸะพะฒั‚ะพั€ะธั‚ัŒ", "Retry upload": "ะŸะพะฒั‚ะพั€ะธั‚ัŒ ะทะฐะณั€ัƒะทะบัƒ", "Review all options available in this poll": "ะŸั€ะพัะผะพั‚ั€ะธั‚ะต ะฒัะต ะฒะฐั€ะธะฐะฝั‚ั‹, ะดะพัั‚ัƒะฟะฝั‹ะต ะฒ ัั‚ะพะผ ะพะฟั€ะพัะต", "Review comments submitted with poll answers": "ะŸั€ะพัะผะพั‚ั€ะธั‚ะต ะบะพะผะผะตะฝั‚ะฐั€ะธะธ, ะพั‚ะฟั€ะฐะฒะปะตะฝะฝั‹ะต ะฒะผะตัั‚ะต ั ะพั‚ะฒะตั‚ะฐะผะธ ะฒ ะพะฟั€ะพัะต", @@ -535,6 +553,7 @@ "Save for later": "ะกะพั…ั€ะฐะฝะธั‚ัŒ ะฝะฐ ะฟะพั‚ะพะผ", "Saved for later": "ะกะพั…ั€ะฐะฝะตะฝะพ ะฝะฐ ะฟะพั‚ะพะผ", "Search": "ะŸะพะธัะบ", + "Search emoji": "ะŸะพะธัะบ ัะผะพะดะทะธ", "Search GIFs": "ะŸะพะธัะบ GIF", "search-results-header-filter-source-button-label--channels": "ะบะฐะฝะฐะปั‹", "search-results-header-filter-source-button-label--messages": "ัะพะพะฑั‰ะตะฝะธั", @@ -574,12 +593,14 @@ "size limit": "ะพะณั€ะฐะฝะธั‡ะตะฝะธะต ั€ะฐะทะผะตั€ะฐ", "Slow Mode ON": "ะœะตะดะปะตะฝะฝั‹ะน ั€ะตะถะธะผ ะฒะบะปัŽั‡ะตะฝ", "Slow mode, wait {{ seconds }}s...": "ะœะตะดะปะตะฝะฝั‹ะน ั€ะตะถะธะผ: ะฟะพะดะพะถะดะธั‚ะต {{ seconds }} ั...", + "Smileys & People": "ะกะผะฐะนะปะธะบะธ ะธ ะปัŽะดะธ", "Some of the files will not be accepted": "ะะตะบะพั‚ะพั€ั‹ะต ั„ะฐะนะปั‹ ะฝะต ะฑัƒะดัƒั‚ ะฟั€ะธะฝัั‚ั‹", "Start typing to search": "ะะฐั‡ะฝะธั‚ะต ะฒะฒะพะดะธั‚ัŒ ะดะปั ะฟะพะธัะบะฐ", "Stop sharing": "ะŸั€ะตะบั€ะฐั‚ะธั‚ัŒ ะดะตะปะธั‚ัŒัั", "Submit": "ะžั‚ะฟั€ะฐะฒะธั‚ัŒ", "Suggest a new option to add to this poll": "ะŸั€ะตะดะปะพะถะธั‚ะต ะฝะพะฒั‹ะน ะฒะฐั€ะธะฐะฝั‚ ะดะปั ะดะพะฑะฐะฒะปะตะฝะธั ะฒ ัั‚ะพั‚ ะพะฟั€ะพั", "Suggest an option": "ะŸั€ะตะดะปะพะถะธั‚ัŒ ะฒะฐั€ะธะฐะฝั‚", + "Symbols": "ะกะธะผะฒะพะปั‹", "Tap to remove": "ะะฐะถะผะธั‚ะต, ั‡ั‚ะพะฑั‹ ัƒะดะฐะปะธั‚ัŒ", "Tap to remove: {{ reactionName }}": "ะะฐะถะผะธั‚ะต, ั‡ั‚ะพะฑั‹ ัƒะดะฐะปะธั‚ัŒ: {{ reactionName }}", "Thinking...": "ะ”ัƒะผะฐัŽ...", @@ -622,6 +643,7 @@ "Translated": "ะŸะตั€ะตะฒะตะดะตะฝะพ", "Translated from {{ language }}": "ะŸะตั€ะตะฒะตะดะตะฝะพ ั {{ language }}", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "ะŸัƒั‚ะตัˆะตัั‚ะฒะธั ะธ ะผะตัั‚ะฐ", "Type a number from 2 to 10": "ะ’ะฒะตะดะธั‚ะต ั‡ะธัะปะพ ะพั‚ 2 ะดะพ 10", "Unarchive": "ะฃะดะฐะปะธั‚ัŒ ะธะท ะฐั€ั…ะธะฒะฐ", "unban-command-args": "[@ะธะผัะฟะพะปัŒะทะพะฒะฐั‚ะตะปั]", @@ -680,27 +702,5 @@ "Wait until all attachments have uploaded": "ะŸะพะดะพะถะดะธั‚ะต, ะฟะพะบะฐ ะฒัะต ะฒะปะพะถะตะฝะธั ะทะฐะณั€ัƒะทัั‚ัั", "Waiting for networkโ€ฆ": "ะžะถะธะดะฐะฝะธะต ัะตั‚ะธโ€ฆ", "You": "ะ’ั‹", - "You've reached the maximum number of files": "ะ’ั‹ ะดะพัั‚ะธะณะปะธ ะผะฐะบัะธะผะฐะปัŒะฝะพะณะพ ะบะพะปะธั‡ะตัั‚ะฒะฐ ั„ะฐะนะปะพะฒ", - "Search emoji": "ะŸะพะธัะบ ัะผะพะดะทะธ", - "aria/Clear emoji search": "ะžั‡ะธัั‚ะธั‚ัŒ ะฟะพะธัะบ ัะผะพะดะทะธ", - "No emoji found": "ะญะผะพะดะทะธ ะฝะต ะฝะฐะนะดะตะฝั‹", - "aria/Choose default skin tone": "ะ’ั‹ะฑั€ะฐั‚ัŒ ั‚ะพะฝ ะบะพะถะธ ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ", - "Frequently used": "ะงะฐัั‚ะพ ะธัะฟะพะปัŒะทัƒะตะผั‹ะต", - "Smileys & People": "ะกะผะฐะนะปะธะบะธ ะธ ะปัŽะดะธ", - "Animals & Nature": "ะ–ะธะฒะพั‚ะฝั‹ะต ะธ ะฟั€ะธั€ะพะดะฐ", - "Food & Drink": "ะ•ะดะฐ ะธ ะฝะฐะฟะธั‚ะบะธ", - "Activities": "ะะบั‚ะธะฒะฝะพัั‚ะธ", - "Travel & Places": "ะŸัƒั‚ะตัˆะตัั‚ะฒะธั ะธ ะผะตัั‚ะฐ", - "Objects": "ะžะฑัŠะตะบั‚ั‹", - "Symbols": "ะกะธะผะฒะพะปั‹", - "Flags": "ะคะปะฐะณะธ", - "Default": "ะŸะพ ัƒะผะพะปั‡ะฐะฝะธัŽ", - "Light": "ะกะฒะตั‚ะปั‹ะน", - "Medium-Light": "ะฃะผะตั€ะตะฝะฝะพ-ัะฒะตั‚ะปั‹ะน", - "Medium": "ะกั€ะตะดะฝะธะน", - "Medium-Dark": "ะฃะผะตั€ะตะฝะฝะพ-ั‚ั‘ะผะฝั‹ะน", - "Dark": "ะขั‘ะผะฝั‹ะน", - "Failed to load emojis": "ะะต ัƒะดะฐะปะพััŒ ะทะฐะณั€ัƒะทะธั‚ัŒ ัะผะพะดะทะธ", - "Retry": "ะŸะพะฒั‚ะพั€ะธั‚ัŒ", - "Pick an emojiโ€ฆ": "ะ’ั‹ะฑะตั€ะธั‚ะต ัะผะพะดะทะธโ€ฆ" + "You've reached the maximum number of files": "ะ’ั‹ ะดะพัั‚ะธะณะปะธ ะผะฐะบัะธะผะฐะปัŒะฝะพะณะพ ะบะพะปะธั‡ะตัั‚ะฒะฐ ั„ะฐะนะปะพะฒ" } diff --git a/src/i18n/tr.json b/src/i18n/tr.json index bf21f39a79..3959df4a38 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -43,6 +43,7 @@ "๐Ÿ“Š {{votedBy}} voted: {{pollOptionText}}": "๐Ÿ“Š {{votedBy}} oy verdi: {{pollOptionText}}", "๐Ÿ“Shared location": "๐Ÿ“PaylaลŸฤฑlan konum", "Actions": "Actions", + "Activities": "Etkinlikler", "Add": "Ekle", "Add {{ count }} members_one": "{{ count }} รผye ekle", "Add {{ count }} members_other": "{{ count }} รผye ekle", @@ -65,6 +66,7 @@ "Also sent in channel": "Kanala da gรถnderildi", "An error has occurred during recording": "Kayฤฑt sฤฑrasฤฑnda bir hata oluลŸtu", "An error has occurred during the recording processing": "Kayฤฑt iลŸlemi sฤฑrasฤฑnda bir hata oluลŸtu", + "Animals & Nature": "Hayvanlar ve DoฤŸa", "Anonymous": "Anonim", "Anonymous poll": "Anonim anket", "Archive": "ArลŸivle", @@ -84,6 +86,8 @@ "aria/Channel list": "Kanal listesi", "aria/Channel search results": "Kanal arama sonuรงlarฤฑ", "aria/Chat view tabs": "Sohbet gรถrรผnรผmรผ sekmeleri", + "aria/Choose default skin tone": "Varsayฤฑlan ten rengini seรง", + "aria/Clear emoji search": "Emoji aramasฤฑnฤฑ temizle", "aria/Clear search": "Aramayฤฑ temizle", "aria/Close callout dialog": "Bilgi balonu iletiลŸim kutusunu kapat", "aria/Close thread": "Konuyu kapat", @@ -207,6 +211,8 @@ "Create a question, add options, and configure poll settings": "Bir soru oluลŸturun, seรงenekler ekleyin ve anket ayarlarฤฑnฤฑ yapฤฑlandฤฑrฤฑn", "Create poll": "Anket oluลŸtur", "Current location": "Mevcut konum", + "Dark": "Koyu", + "Default": "Varsayฤฑlan", "Delete": "Sil", "Delete chat": "Sohbeti sil", "Delete for me": "Benim iรงin sil", @@ -275,6 +281,7 @@ "Failed to jump to the first unread message": "ฤฐlk okunmamฤฑลŸ mesaja atlamada hata oluลŸtu", "Failed to leave channel": "Kanaldan รงฤฑkฤฑlamadฤฑ", "Failed to load channels": "Kanallar yรผklenemedi", + "Failed to load emojis": "Emojiler yรผklenemedi", "Failed to load more channels": "Daha fazla kanal yรผklenemedi", "Failed to mark channel as read": "Kanalฤฑ okundu olarak iลŸaretleme baลŸarฤฑsฤฑz oldu", "Failed to play the recording": "Kayฤฑt oynatฤฑlamadฤฑ", @@ -292,6 +299,9 @@ "fileCount_other": "{{ count }} dosya", "Files": "Dosyalar", "Flag": "Bayrak", + "Flags": "Bayraklar", + "Food & Drink": "Yiyecek ve ฤฐรงecek", + "Frequently used": "Sฤฑk kullanฤฑlanlar", "Generating...": "OluลŸturuluyor...", "giphy-command-args": "[metin]", "giphy-command-description": "Rastgele bir gif'i kanala gรถnder", @@ -365,6 +375,7 @@ "Leave chat": "Kanaldan ayrฤฑl", "Left channel": "Kanaldan ayrฤฑldฤฑnฤฑz", "Let others add options": "BaลŸkalarฤฑnฤฑn seรงenek eklemesine izin ver", + "Light": "Aรงฤฑk", "Limit votes per person": "KiลŸi baลŸฤฑna oy sฤฑnฤฑrฤฑ", "Link": "BaฤŸlantฤฑ", "linkCount_one": "BaฤŸlantฤฑ", @@ -383,6 +394,9 @@ "Mark as unread": "OkunmamฤฑลŸ olarak iลŸaretle", "Maximum number of votes (from 2 to 10)": "Maksimum oy sayฤฑsฤฑ (2 ile 10 arasฤฑ)", "Maximum votes per person": "KiลŸi baลŸฤฑna maksimum oy", + "Medium": "Orta", + "Medium-Dark": "Orta koyu", + "Medium-Light": "Orta aรงฤฑk", "Member detail": "รœye detayฤฑ", "mention/Channel": "Kanal", "mention/Channel Description": "Bu kanaldaki herkesi bildir", @@ -413,6 +427,7 @@ "Next image": "Sonraki gรถrsel", "No chats here yetโ€ฆ": "Henรผz burada sohbet yok...", "No conversations yet": "Henรผz konuลŸma yok", + "No emoji found": "Emoji bulunamadฤฑ", "No files": "Dosya yok", "No items exist": "Hiรง รถฤŸe yok", "No member found": "รœye bulunamadฤฑ", @@ -424,6 +439,7 @@ "Nobody will be able to vote in this poll anymore.": "Artฤฑk bu ankette kimse oy kullanamayacak.", "Nothing yet...": "ลžimdilik hiรงbir ลŸey...", "Notify all {{ role }} members": "{{ role }} rolรผndeki tรผm รผyelere bildir", + "Objects": "Nesneler", "Offline": "ร‡evrimdฤฑลŸฤฑ", "Ok": "Tamam", "Online": "ร‡evrimiรงi", @@ -443,6 +459,7 @@ "People matching": "EลŸleลŸen kiลŸiler", "Photo": "FotoฤŸraf", "Photos & videos": "FotoฤŸraflar ve videolar", + "Pick an emojiโ€ฆ": "Emoji seรงโ€ฆ", "Pin": "Sabitle", "Pin a message to see it here": "Burada gรถrmek iรงin bir mesaj sabitle", "Pinned by {{ name }}": "{{ name }} sabitledi", @@ -487,6 +504,7 @@ "replyCount_one": "1 cevap", "replyCount_other": "{{ count }} cevap", "Resend": "Tekrar gรถnder", + "Retry": "Yeniden dene", "Retry upload": "Yรผklemeyi yeniden dene", "Review all options available in this poll": "Bu anketteki tรผm mevcut seรงenekleri inceleyin", "Review comments submitted with poll answers": "Anket yanฤฑtlarฤฑyla gรถnderilen yorumlarฤฑ inceleyin", @@ -497,6 +515,7 @@ "Save for later": "Daha sonra kaydet", "Saved for later": "Daha sonra kaydedildi", "Search": "Arama", + "Search emoji": "Emoji ara", "Search GIFs": "GIF ara", "search-results-header-filter-source-button-label--channels": "kanallar", "search-results-header-filter-source-button-label--messages": "mesajlar", @@ -532,12 +551,14 @@ "size limit": "boyut sฤฑnฤฑrฤฑ", "Slow Mode ON": "YavaลŸ Mod Aรงฤฑk", "Slow mode, wait {{ seconds }}s...": "YavaลŸ mod, {{ seconds }} sn bekleyin...", + "Smileys & People": "Suratlar ve ฤฐnsanlar", "Some of the files will not be accepted": "Bazฤฑ dosyalar kabul edilmeyecek", "Start typing to search": "Aramak iรงin yazmaya baลŸlayฤฑn", "Stop sharing": "PaylaลŸฤฑmฤฑ durdur", "Submit": "Gรถnder", "Suggest a new option to add to this poll": "Bu ankete eklenecek yeni bir seรงenek รถnerin", "Suggest an option": "Bir seรงenek รถnerin", + "Symbols": "Semboller", "Tap to remove": "Kaldฤฑrmak iรงin dokunun", "Tap to remove: {{ reactionName }}": "Kaldฤฑrmak iรงin dokunun: {{ reactionName }}", "Thinking...": "DรผลŸรผnรผyor...", @@ -576,6 +597,7 @@ "Translated": "ร‡evrildi", "Translated from {{ language }}": "{{ language }} dilinden รงevrildi", "translationBuilderTopic/notification": "{{value, notification}}", + "Travel & Places": "Seyahat ve Yerler", "Type a number from 2 to 10": "2 ile 10 arasฤฑnda bir sayฤฑ yazฤฑn", "Unarchive": "ArลŸivden รงฤฑkar", "unban-command-args": "[@kullanฤฑcฤฑadฤฑ]", @@ -628,27 +650,5 @@ "Wait until all attachments have uploaded": "Tรผm ekler yรผklenene kadar bekleyin", "Waiting for networkโ€ฆ": "AฤŸ bekleniyorโ€ฆ", "You": "Sen", - "You've reached the maximum number of files": "Maksimum dosya sayฤฑsฤฑna ulaลŸtฤฑnฤฑz", - "Search emoji": "Emoji ara", - "aria/Clear emoji search": "Emoji aramasฤฑnฤฑ temizle", - "No emoji found": "Emoji bulunamadฤฑ", - "aria/Choose default skin tone": "Varsayฤฑlan ten rengini seรง", - "Frequently used": "Sฤฑk kullanฤฑlanlar", - "Smileys & People": "Suratlar ve ฤฐnsanlar", - "Animals & Nature": "Hayvanlar ve DoฤŸa", - "Food & Drink": "Yiyecek ve ฤฐรงecek", - "Activities": "Etkinlikler", - "Travel & Places": "Seyahat ve Yerler", - "Objects": "Nesneler", - "Symbols": "Semboller", - "Flags": "Bayraklar", - "Default": "Varsayฤฑlan", - "Light": "Aรงฤฑk", - "Medium-Light": "Orta aรงฤฑk", - "Medium": "Orta", - "Medium-Dark": "Orta koyu", - "Dark": "Koyu", - "Failed to load emojis": "Emojiler yรผklenemedi", - "Retry": "Yeniden dene", - "Pick an emojiโ€ฆ": "Emoji seรงโ€ฆ" + "You've reached the maximum number of files": "Maksimum dosya sayฤฑsฤฑna ulaลŸtฤฑnฤฑz" } From d162162de4ccae46fa0d335b4db59b11ee815bdd Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 13 Jul 2026 11:29:44 +0200 Subject: [PATCH 29/36] refactor(emojis): slim down the StreamEmojiPicker public API `StreamEmojiPicker` is unreleased and `emoji-mart` is back as a deprecated engine, so its emoji-mart-shaped option surface is no longer needed. Replace the `pickerProps` bag with a small set of flat props and drop the low-value knobs. - Remove the `pickerProps` object and the `warnUnsupportedPickerProps` machinery (TypeScript now rejects unknown props); hoist the keepers to flat props: `theme`, `style`, `categories`, `exceptEmojis`, `autoFocus`, `onClickOutside`. - Drop the layout/cosmetic/niche knobs (`perLine`, `navPosition`, `previewPosition`, `searchPosition`, `skinTonePosition`, `maxFrequentRows`, `previewEmoji`, `noResultsEmoji`, `emojiVersion`, `noCountryFlags`). The panel now uses one fixed layout and the column count is driven by the `--str-chat__emoji-picker-per-line` CSS token (default 9). - Delete `options.ts` and `hooks/pickerLayout.ts`; simplify `filterEmojiData` to `exceptEmojis` only. - Update the vite example (engine + auto-focus controls only), remove the now-dead SCSS, and refresh the AI.md emoji docs. Skin-tone and frequently-used props and the shell customization props are unchanged. --- AI.md | 39 ++--- examples/vite/src/App.tsx | 7 +- examples/vite/src/AppSettings/state.ts | 14 -- .../tabs/EmojiPicker/EmojiPickerTab.tsx | 83 ++-------- src/plugins/Emojis/StreamEmojiPicker.tsx | 66 +++----- .../__tests__/StreamEmojiPicker.test.tsx | 34 +---- src/plugins/Emojis/__tests__/options.test.ts | 47 ------ .../Emojis/components/EmojiPickerPanel.tsx | 143 ++++++------------ .../__tests__/EmojiPickerPanel.test.tsx | 57 +------ .../data/__tests__/filterEmojiData.test.ts | 47 ++---- src/plugins/Emojis/data/filterEmojiData.ts | 39 +---- .../hooks/__tests__/pickerLayout.test.ts | 45 ------ src/plugins/Emojis/hooks/pickerLayout.ts | 42 ----- src/plugins/Emojis/options.ts | 141 ----------------- src/plugins/Emojis/styling/EmojiPicker.scss | 21 --- 15 files changed, 112 insertions(+), 713 deletions(-) delete mode 100644 src/plugins/Emojis/__tests__/options.test.ts delete mode 100644 src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts delete mode 100644 src/plugins/Emojis/hooks/pickerLayout.ts delete mode 100644 src/plugins/Emojis/options.ts diff --git a/AI.md b/AI.md index 88c8733973..6a32312568 100644 --- a/AI.md +++ b/AI.md @@ -274,27 +274,16 @@ import 'stream-chat-react/css/emoji-picker.css'; - On `StreamEmojiPicker`, skin tone and "frequently used" are integrator-managed props (`skinTone`/`onSkinToneChange`, `frequentlyUsedEmoji`/`onFrequentlyUsedChange`); the SDK does not persist them. See `examples/vite/` for a localStorage example. -- On `StreamEmojiPicker`, `pickerProps` accepts a curated set of emoji-mart-compatible `Picker` options (plus - `theme` and `style`): - - **Layout**: `navPosition` / `previewPosition` (`'top' | 'bottom' | 'none'`), - `searchPosition` (`'sticky' | 'static' | 'none'`), `skinTonePosition` - (`'preview' | 'search' | 'none'`). - - **Grid & content**: `perLine` (default `9`), `categories` (filter + reorder; - `'frequent'` always prepends), `maxFrequentRows` (default `1`). - - **Filtering & polish**: `exceptEmojis`, `emojiVersion`, `noCountryFlags`, - `previewEmoji`, `noResultsEmoji`, `autoFocus`, `onClickOutside`. - - Divergences from emoji-mart's defaults: `autoFocus` defaults to `true`; `emojiVersion` - is unfiltered by default (the bundled set 15); `previewEmoji` / `noResultsEmoji` default - to the SDK's placeholder / empty state; `noCountryFlags` is opt-in (no Windows - auto-detect); `categories` cannot reposition `'frequent'`. - - Not supported (rejected by the type; ignored with a console warning at runtime): image - sets (`set`, `getSpritesheetURL`), `custom` emoji, `data`, `i18n` / `locale`, - `dynamicWidth`, `icons`, `categoryIcons`. Sizing knobs (`emojiSize`, `emojiButtonSize`, - โ€ฆ) are `--str-chat__emoji-picker-*` CSS variables, not props. Skin tone uses the - first-class `skinTone` / `defaultSkinTone` props (not emoji-mart's `skin`). Try these - live in the "Emoji Picker" settings tab of `examples/vite/`. +- `StreamEmojiPicker` exposes a small, flat set of props (no emoji-mart-style + `pickerProps` bag): `theme` (`'auto'` โ€” the default, inherits the ancestor SDK theme โ€” + `'light'`, or `'dark'`), `style`, `categories` (filter + reorder; `'frequent'` always + prepends and cannot be repositioned), `exceptEmojis`, `autoFocus` (default `true`), and + `onClickOutside`. Layout and sizing are CSS, not props: set + `--str-chat__emoji-picker-per-line` (default `9`) for the column count and the other + `--str-chat__emoji-picker-*` tokens for sizing/colors. Emoji-mart-only features (image + `set`s, `custom` emoji, `data`, `i18n` / `locale`, โ€ฆ) aren't available on the built-in + picker โ€” keep using the deprecated `EmojiPicker` if you still need them. Try it live in + the "Emoji Picker" settings tab of `examples/vite/`. - To let users **react with any emoji** (the reaction selector's `+` button), fill `reactionOptions.extended` with the full emoji set. It also gates display โ€” a reaction @@ -321,9 +310,11 @@ import 'stream-chat-react/css/emoji-picker.css'; - **Migrating from emoji-mart** (the deprecated `EmojiPicker`): swap `EmojiPicker` โ†’ `StreamEmojiPicker`, then remove the `emoji-mart` / `@emoji-mart/react` / `@emoji-mart/data` installs and any `init({ data })` call. The deprecated `EmojiPicker` - still accepts raw emoji-mart `pickerProps` (e.g. `set`, `emojiSize`); `StreamEmojiPicker` - uses the curated `pickerProps` above. Autocomplete (`createTextComposerEmojiMiddleware`) - and reactions (`mapEmojiMartData`) are engine-agnostic and need no changes. + takes a raw emoji-mart `pickerProps` bag (e.g. `set`, `emojiSize`); `StreamEmojiPicker` + instead exposes flat props (`theme`, `categories`, `exceptEmojis`, `autoFocus`, โ€ฆ) and + moves layout/sizing to `--str-chat__emoji-picker-*` CSS tokens. Autocomplete + (`createTextComposerEmojiMiddleware`) and reactions (`mapEmojiMartData`) are + engine-agnostic and need no changes. **Reference**: See `examples/tutorial/src/6-emoji-picker/` diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 4049e02a65..2b8f9aa91d 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -226,6 +226,7 @@ const EmojiPickerWithCustomOptions = ( return ( { setFrequentlyUsedEmoji(ids); @@ -235,12 +236,8 @@ const EmojiPickerWithCustomOptions = ( setSkinTone(tone); writeStored(EMOJI_SKIN_TONE_KEY, tone); }} - pickerProps={{ - ...props.pickerProps, - ...pickerOptions, - theme: mode, - }} skinTone={skinTone} + theme={mode} /> ); }; diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index d46b951832..94f4f8aed5 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -14,26 +14,12 @@ export type ChatViewSettingsState = { export type EmojiPickerSettingsState = { autoFocus: boolean; engine: 'emoji-mart' | 'stream'; - maxFrequentRows: number; - navPosition: 'top' | 'bottom' | 'none'; - noCountryFlags: boolean; - perLine: number; - previewPosition: 'top' | 'bottom' | 'none'; - searchPosition: 'sticky' | 'static' | 'none'; - skinTonePosition: 'preview' | 'search' | 'none'; }; // Mirrors the SDK's EmojiPicker defaults; the settings tab resets to this. export const DEFAULT_EMOJI_PICKER_SETTINGS: EmojiPickerSettingsState = { autoFocus: true, engine: 'stream', - maxFrequentRows: 1, - navPosition: 'top', - noCountryFlags: false, - perLine: 9, - previewPosition: 'bottom', - searchPosition: 'sticky', - skinTonePosition: 'preview', }; export type ThemeSettingsState = { diff --git a/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx b/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx index 763b6b4a01..80588281b6 100644 --- a/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx +++ b/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx @@ -62,27 +62,23 @@ function Field({ ); } -const numberOptions = (values: number[]) => - values.map((value) => ({ label: String(value), value })); - /** * Always-open picker wired to the current settings, so tweaks show instantly without * opening the composer. Skin tone and frequently-used are local to the preview โ€” - * selecting an emoji here feeds the "frequently used" row so `maxFrequentRows` can be - * exercised too. + * selecting an emoji here feeds the "frequently used" row. */ const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) => { const { mode } = useAppSettingsSelector((state) => state.theme); - const { engine, ...pickerOptions } = options; + const { autoFocus, engine } = options; const [skinTone, setSkinTone] = useState(0); const [frequentlyUsedIds, setFrequentlyUsedIds] = useState([]); - // The deprecated emoji-mart picker renders inline too and honors the same - // emoji-mart-compatible option names, so the same controls drive both engines. + // The deprecated emoji-mart picker renders inline too and honors the same option + // names, so the shared controls drive both engines. if (engine === 'emoji-mart') { return ( (await import('@emoji-mart/data')).default} onEmojiSelect={() => undefined} theme={mode} @@ -92,20 +88,20 @@ const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) return ( setFrequentlyUsedIds((ids) => [emoji.id, ...ids.filter((id) => id !== emoji.id)]) } onSkinToneChange={setSkinTone} - options={{ ...pickerOptions, exceptEmojis: [] }} skinToneIndex={skinTone} /> ); }; /** - * Playground for the built-in EmojiPicker's `pickerProps`. Each control writes to the - * app settings store; the live preview (and the composer's picker via + * Playground for the built-in `StreamEmojiPicker`. Each control writes to the app + * settings store; the live preview (and the composer's picker via * `EmojiPickerWithCustomOptions`) reflect the change instantly. */ export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => { @@ -118,7 +114,7 @@ export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => {
@@ -147,58 +143,6 @@ export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => { ]} value={emojiPicker.engine} /> - - label='Navigation position' - onSelect={(navPosition) => update({ navPosition })} - options={[ - { label: 'Top', value: 'top' }, - { label: 'Bottom', value: 'bottom' }, - { label: 'None', value: 'none' }, - ]} - value={emojiPicker.navPosition} - /> - - label='Preview position' - onSelect={(previewPosition) => update({ previewPosition })} - options={[ - { label: 'Top', value: 'top' }, - { label: 'Bottom', value: 'bottom' }, - { label: 'None', value: 'none' }, - ]} - value={emojiPicker.previewPosition} - /> - - label='Search position' - onSelect={(searchPosition) => update({ searchPosition })} - options={[ - { label: 'Sticky', value: 'sticky' }, - { label: 'Static', value: 'static' }, - { label: 'None', value: 'none' }, - ]} - value={emojiPicker.searchPosition} - /> - - label='Skin-tone position' - onSelect={(skinTonePosition) => update({ skinTonePosition })} - options={[ - { label: 'Preview', value: 'preview' }, - { label: 'Search', value: 'search' }, - { label: 'None', value: 'none' }, - ]} - value={emojiPicker.skinTonePosition} - /> - - label='Emoji per line' - onSelect={(perLine) => update({ perLine })} - options={numberOptions([7, 8, 9, 10])} - value={emojiPicker.perLine} - /> - - label='Frequently-used rows' - onSelect={(maxFrequentRows) => update({ maxFrequentRows })} - options={numberOptions([1, 2, 3, 4])} - value={emojiPicker.maxFrequentRows} - /> label='Auto-focus search' onSelect={(autoFocus) => update({ autoFocus })} @@ -208,15 +152,6 @@ export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => { ]} value={emojiPicker.autoFocus} /> - - label='Country flags' - onSelect={(noCountryFlags) => update({ noCountryFlags })} - options={[ - { label: 'Show', value: false }, - { label: 'Hide', value: true }, - ]} - value={emojiPicker.noCountryFlags} - />
Live preview
diff --git a/src/plugins/Emojis/StreamEmojiPicker.tsx b/src/plugins/Emojis/StreamEmojiPicker.tsx index 61e250e254..77a3bdc1dd 100644 --- a/src/plugins/Emojis/StreamEmojiPicker.tsx +++ b/src/plugins/Emojis/StreamEmojiPicker.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { type CSSProperties, useEffect, useRef, useState } from 'react'; import { useMessageComposerContext, useTranslationContext } from '../../context'; import { @@ -12,46 +12,29 @@ import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useI import { EmojiPickerPanel } from './components'; import { useFrequentlyUsedEmoji } from './hooks/useFrequentlyUsedEmoji'; import { useSkinTone } from './hooks/useSkinTone'; -import { - type EmojiPickerPassthroughProps, - resolveEmojiPickerOptions, - warnUnsupportedPickerProps, -} from './options'; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; -export type { - EmojiPickerNavPosition, - EmojiPickerPassthroughProps, - EmojiPickerPreviewPosition, - EmojiPickerSearchPosition, - EmojiPickerSkinTonePosition, -} from './options'; - export type StreamEmojiPickerProps = { ButtonIconComponent?: React.ComponentType; buttonClassName?: string; pickerContainerClassName?: string; wrapperClassName?: string; closeOnEmojiSelect?: boolean; - /** - * Presentation + curated layout/behavior options for the picker panel, using - * emoji-mart-compatible names (`theme`, `style`, `perLine`, `navPosition`, - * `previewPosition`, `searchPosition`, `skinTonePosition`, `categories`, - * `exceptEmojis`, `emojiVersion`, `maxFrequentRows`, `noCountryFlags`, - * `previewEmoji`, `noResultsEmoji`, `autoFocus`, `onClickOutside`). - * - * Not every emoji-mart `Picker` option is supported: image sets (`set`, - * `getSpritesheetURL`), `custom` emoji, `data`, `i18n`/`locale`, `dynamicWidth`, - * `icons`, and `categoryIcons` are rejected by the type and ignored (with a console - * warning) at runtime; sizing knobs (`emojiSize`, โ€ฆ) are CSS tokens instead. See the - * emoji migration notes in `AI.md`. - */ - pickerProps?: EmojiPickerPassthroughProps; - /** - * Floating UI placement (default: 'top-end') for the picker popover - */ + /** Floating UI placement (default: 'top-end') for the picker popover. */ placement?: PopperLikePlacement; + /** Focus the search input when the picker opens (default `true`). */ + autoFocus?: boolean; + /** Category ids to show, in order. Defaults to the dataset order. `frequent` always prepends. */ + categories?: string[]; + /** Emoji ids to exclude from the grid and search. */ + exceptEmojis?: string[]; + /** Called when a pointer press lands outside the open picker. */ + onClickOutside?: () => void; + /** Inline styles applied to the picker panel root. */ + style?: CSSProperties; + /** Color theme. 'auto' (default) inherits the ancestor SDK theme; 'light'/'dark' force it. */ + theme?: 'auto' | 'light' | 'dark'; /** Uncontrolled initial skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ defaultSkinTone?: number; /** @@ -114,19 +97,10 @@ export const StreamEmojiPicker = (props: StreamEmojiPickerProps) => { const { pickerContainerClassName, wrapperClassName } = classNames; const { ButtonIconComponent = IconEmoji } = props; - const pickerStyle = props.pickerProps?.style; - const options = resolveEmojiPickerOptions(props.pickerProps); // Latest-ref so the click-outside listener isn't re-attached when the callback identity // changes between renders. - const onClickOutsideRef = useRef(props.pickerProps?.onClickOutside); - onClickOutsideRef.current = props.pickerProps?.onClickOutside; - - const pickerPropsKeys = Object.keys(props.pickerProps ?? {}); - useEffect(() => { - warnUnsupportedPickerProps(props.pickerProps); - // Re-check only when the set of provided keys changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pickerPropsKeys.join(',')]); + const onClickOutsideRef = useRef(props.onClickOutside); + onClickOutsideRef.current = props.onClickOutside; useEffect(() => { if (!popperElement || !referenceElement) return; @@ -160,6 +134,9 @@ export const StreamEmojiPicker = (props: StreamEmojiPickerProps) => { style={{ left: x ?? 0, position: strategy, top: y ?? 0 }} > { setDisplayPicker(false); @@ -177,10 +154,9 @@ export const StreamEmojiPicker = (props: StreamEmojiPickerProps) => { } }} onSkinToneChange={setSkinTone} - options={options} skinToneIndex={skinTone} - style={pickerStyle} - theme={props.pickerProps?.theme} + style={props.style} + theme={props.theme} />
)} diff --git a/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx index f3c270fe4f..85b595b2d4 100644 --- a/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx +++ b/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx @@ -132,40 +132,10 @@ describe('StreamEmojiPicker session state', () => { }); }); -describe('StreamEmojiPicker pickerProps', () => { - it('warns about unsupported (emoji-mart) pickerProps, but not about theme/style', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const { unmount } = render( - , - ); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('set')); - unmount(); - - warn.mockClear(); - render(); - expect(warn).not.toHaveBeenCalled(); - - warn.mockRestore(); - }); - - it('does not warn when only supported (curated) picker options are passed', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - render( - , - ); - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); - }); - +describe('StreamEmojiPicker props', () => { it('calls onClickOutside when a pointer press lands outside the open picker', () => { const onClickOutside = vi.fn(); - render(); + render(); openPicker(); fireEvent.pointerDown(document.body); expect(onClickOutside).toHaveBeenCalledTimes(1); diff --git a/src/plugins/Emojis/__tests__/options.test.ts b/src/plugins/Emojis/__tests__/options.test.ts deleted file mode 100644 index 0cc3e66aeb..0000000000 --- a/src/plugins/Emojis/__tests__/options.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - DEFAULT_EMOJI_PICKER_OPTIONS, - resolveEmojiPickerOptions, - warnUnsupportedPickerProps, -} from '../options'; - -describe('resolveEmojiPickerOptions', () => { - it('returns the documented defaults when nothing is passed', () => { - expect(resolveEmojiPickerOptions()).toEqual(DEFAULT_EMOJI_PICKER_OPTIONS); - expect(DEFAULT_EMOJI_PICKER_OPTIONS.autoFocus).toBe(true); - expect(DEFAULT_EMOJI_PICKER_OPTIONS.perLine).toBe(9); - expect(DEFAULT_EMOJI_PICKER_OPTIONS.maxFrequentRows).toBe(1); - expect(DEFAULT_EMOJI_PICKER_OPTIONS.navPosition).toBe('top'); - }); - - it('overrides only the provided keys and clamps perLine/maxFrequentRows', () => { - const resolved = resolveEmojiPickerOptions({ - maxFrequentRows: -3, - navPosition: 'bottom', - perLine: 0, - }); - expect(resolved.navPosition).toBe('bottom'); - expect(resolved.perLine).toBe(1); // clamped to >= 1 - expect(resolved.maxFrequentRows).toBe(0); // clamped to >= 0 - expect(resolved.previewPosition).toBe('bottom'); // untouched default - }); -}); - -describe('warnUnsupportedPickerProps', () => { - it('warns about emoji-mart-only keys but not supported ones', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - warnUnsupportedPickerProps({ perLine: 9, set: 'apple', theme: 'dark' }); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('set')); - expect(warn.mock.calls[0][0]).not.toContain('perLine'); - warn.mockRestore(); - }); - - it('points styling knobs at the CSS token instead of ignoring them', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - warnUnsupportedPickerProps({ emojiSize: 20 }); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('--str-chat__emoji-picker-emoji-size'), - ); - warn.mockRestore(); - }); -}); diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index 39b9dfe45f..98e875155c 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -16,16 +16,17 @@ import { import { useDebouncedValue } from '../hooks/useDebouncedValue'; import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav'; -import { resolvePickerLayout } from '../hooks/pickerLayout'; import { buildEmojiSearchData, runSearch } from '../search'; import type { EmojiDataEmoji } from '../data'; import { filterEmojiData } from '../data/filterEmojiData'; -import { - DEFAULT_EMOJI_PICKER_OPTIONS, - type ResolvedEmojiPickerOptions, -} from '../options'; import { useTranslationContext } from '../../../context'; +// The grid renders 9 emoji per row by default; integrators change the column count via +// the `--str-chat__emoji-picker-per-line` CSS token (the SCSS falls back to 9), so the +// panel sets no inline width. The "frequently used" section is capped to a single row. +const DEFAULT_COLUMNS = 9; +const FREQUENT_ROWS = 1; + export type EmojiSelection = { id: string; name: string; @@ -34,7 +35,13 @@ export type EmojiSelection = { export type EmojiPickerPanelProps = { onEmojiSelect: (emoji: EmojiSelection) => void; + /** Focus the search input when the panel mounts (default `true`). */ + autoFocus?: boolean; + /** Category ids to show, in order. Defaults to the dataset order. `frequent` always prepends. */ + categories?: string[]; className?: string; + /** Emoji ids to exclude from the grid and search. */ + exceptEmojis?: string[]; /** * Ordered list of recently used emoji ids (most recent first), rendered as the * "frequently used" section. This is a controlled value โ€” the owner (EmojiPicker) @@ -45,8 +52,6 @@ export type EmojiPickerPanelProps = { onClose?: () => void; /** Called with the new skin tone index when the user changes it. */ onSkinToneChange?: (skinTone: number) => void; - /** Resolved (fully-defaulted) picker options โ€” layout, filtering, and grid config. */ - options?: ResolvedEmojiPickerOptions; /** Active skin tone index (0 = default, 1โ€“5 = light โ†’ dark). Controlled by the owner. */ skinToneIndex?: number; style?: CSSProperties; @@ -81,12 +86,14 @@ export const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { * shell rather than held here. */ export const EmojiPickerPanel = ({ + autoFocus = true, + categories: categoryFilter, className, + exceptEmojis, frequentlyUsedIds = [], onClose, onEmojiSelect, onSkinToneChange, - options = DEFAULT_EMOJI_PICKER_OPTIONS, skinToneIndex = 0, style, theme, @@ -96,15 +103,8 @@ export const EmojiPickerPanel = ({ // One filtered view of the dataset feeds both the grid and the search index so // exclusions apply consistently. Returns the same reference when no filter is set. const filteredData = useMemo( - () => - data - ? filterEmojiData(data, { - emojiVersion: options.emojiVersion, - exceptEmojis: options.exceptEmojis, - noCountryFlags: options.noCountryFlags, - }) - : data, - [data, options.emojiVersion, options.exceptEmojis, options.noCountryFlags], + () => (data ? filterEmojiData(data, { exceptEmojis }) : data), + [data, exceptEmojis], ); const [previewedEmoji, setPreviewedEmoji] = useState(null); const [activeCategoryId, setActiveCategoryId] = useState(undefined); @@ -123,9 +123,9 @@ export const EmojiPickerPanel = ({ label: t(EMOJI_CATEGORY_META[category.id]?.labelKey ?? category.id), })); // `categories` option: keep only the requested ids, in the requested order. - if (!options.categories) return built; + if (!categoryFilter) return built; const byId = new Map(built.map((category) => [category.id, category])); - return options.categories + return categoryFilter .map((id) => { if (!byId.has(id)) { console.warn( @@ -135,30 +135,22 @@ export const EmojiPickerPanel = ({ return byId.get(id); }) .filter((category): category is EmojiPickerCategory => Boolean(category)); - }, [filteredData, options.categories, t]); + }, [filteredData, categoryFilter, t]); const categories = useMemo(() => { if (!filteredData || !frequentlyUsedIds.length) return baseCategories; const frequent: EmojiPickerCategory = { - // Capped to `perLine ร— maxFrequentRows` items (default one row) so the section - // grows to at most the configured number of rows as more emoji are used. + // Capped to a single row so the section stays one row wide as more emoji are used. emojis: resolveFrequentlyUsedEmoji( filteredData, frequentlyUsedIds, - options.perLine * options.maxFrequentRows, + DEFAULT_COLUMNS * FREQUENT_ROWS, ), id: 'frequent', label: t(EMOJI_CATEGORY_META.frequent.labelKey), }; return frequent.emojis.length ? [frequent, ...baseCategories] : baseCategories; - }, [ - baseCategories, - filteredData, - frequentlyUsedIds, - options.maxFrequentRows, - options.perLine, - t, - ]); + }, [baseCategories, filteredData, frequentlyUsedIds, t]); const scrollToCategory = useCallback((categoryId: string) => { emojiGridRef.current?.scrollToCategory(categoryId); @@ -212,60 +204,6 @@ export const EmojiPickerPanel = ({ const isSearching = searchedEmojis !== null; - // Region placement + skin-tone fallbacks are resolved by a pure helper. - const pickerLayout = resolvePickerLayout(options); - const idlePreviewEmoji = options.previewEmoji - ? (filteredData?.emojis[options.previewEmoji] ?? null) - : null; - const noResultsGlyph = options.noResultsEmoji - ? (filteredData?.emojis[options.noResultsEmoji]?.skins[0]?.native ?? undefined) - : undefined; - - const nav = ( - - ); - const skinToneSelector = ( - - ); - const searchInput = pickerLayout.search ? ( - - ) : null; - // The skin-tone selector shares the search row only when it belongs there; otherwise - // the bare input renders (unchanged default), avoiding an extra wrapper. - const searchRow = - searchInput && pickerLayout.skinTone === 'search' ? ( -
- {searchInput} - {skinToneSelector} -
- ) : ( - searchInput - ); - const previewRow = (position: 'top' | 'bottom') => - pickerLayout.preview === position ? ( -
- - {pickerLayout.skinTone === 'preview' ? skinToneSelector : null} -
- ) : null; - const footerSkinRow = - pickerLayout.skinTone === 'footer' ? ( -
{skinToneSelector}
- ) : null; - return (
{data ? ( <> - {pickerLayout.nav === 'top' ? nav : null} - {previewRow('top')} - {searchRow} + +
) : ( - + ) ) : ( )}
- {previewRow('bottom')} - {footerSkinRow} - {pickerLayout.nav === 'bottom' ? nav : null} +
+ + +
) : error ? (
diff --git a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx index 15987dca7f..10af9adc0d 100644 --- a/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx +++ b/src/plugins/Emojis/components/__tests__/EmojiPickerPanel.test.tsx @@ -40,7 +40,6 @@ vi.mock('../EmojiGrid', async () => { }); import { EmojiPickerPanel, themeClassName } from '../EmojiPickerPanel'; -import { DEFAULT_EMOJI_PICKER_OPTIONS } from '../../options'; const DATA = { aliases: {}, @@ -113,12 +112,7 @@ describe('EmojiPickerPanel option: exceptEmojis', () => { }); it('removes excluded emoji from the grid, keeping the rest', () => { - render( - {}} - options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, exceptEmojis: ['smile'] }} - />, - ); + render( {}} />); expect(screen.getByText('Grinning')).toBeInTheDocument(); expect(screen.queryByText('Smile')).not.toBeInTheDocument(); }); @@ -155,64 +149,21 @@ describe('EmojiPickerPanel option: categories', () => { }); it('shows only the requested categories, in the requested order', () => { - render( - {}} - options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, categories: ['nature'] }} - />, - ); + render( {}} />); expect(screen.getAllByRole('tab')).toHaveLength(1); expect(screen.getByText('Dog')).toBeInTheDocument(); expect(screen.queryByText('Grinning')).not.toBeInTheDocument(); }); }); -describe('EmojiPickerPanel layout positions', () => { +describe('EmojiPickerPanel option: autoFocus', () => { beforeEach(() => { hookState.data = DATA; hookState.error = false; }); - it('omits the search input when searchPosition is none', () => { - render( - {}} - options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, searchPosition: 'none' }} - />, - ); - expect(screen.queryByPlaceholderText('Search emoji')).not.toBeInTheDocument(); - }); - - it('omits the category nav when navPosition is none', () => { - render( - {}} - options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, navPosition: 'none' }} - />, - ); - expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); - }); - - it('omits the skin-tone selector when skinTonePosition is none', () => { - render( - {}} - onSkinToneChange={() => {}} - options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, skinTonePosition: 'none' }} - />, - ); - expect( - screen.queryByRole('button', { name: 'aria/Choose default skin tone' }), - ).not.toBeInTheDocument(); - }); - it('does not focus the search input when autoFocus is false', () => { - render( - {}} - options={{ ...DEFAULT_EMOJI_PICKER_OPTIONS, autoFocus: false }} - />, - ); + render( {}} />); expect(screen.getByPlaceholderText('Search emoji')).not.toHaveFocus(); }); }); diff --git a/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts b/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts index 391e686dc0..6ba2a3a354 100644 --- a/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts +++ b/src/plugins/Emojis/data/__tests__/filterEmojiData.test.ts @@ -1,11 +1,11 @@ import type { EmojiData } from '../types'; -import { filterEmojiData, isCountryFlag } from '../filterEmojiData'; +import { filterEmojiData } from '../filterEmojiData'; const DATA: EmojiData = { aliases: {}, categories: [ { emojis: ['grinning', 'smile'], id: 'people' }, - { emojis: ['us', 'fr', 'checkered_flag'], id: 'flags' }, + { emojis: ['us', 'checkered_flag'], id: 'flags' }, ], emojis: { checkered_flag: { @@ -15,13 +15,6 @@ const DATA: EmojiData = { skins: [{ native: '๐Ÿ', unified: '1f3c1' }], version: 1, }, - fr: { - id: 'fr', - keywords: [], - name: 'France', - skins: [{ native: '๐Ÿ‡ซ๐Ÿ‡ท', unified: '1f1eb-1f1f7' }], - version: 1, - }, grinning: { id: 'grinning', keywords: [], @@ -46,44 +39,22 @@ const DATA: EmojiData = { }, }; -describe('isCountryFlag', () => { - it('is true only for regional-indicator pairs', () => { - expect(isCountryFlag(DATA.emojis.us)).toBe(true); - expect(isCountryFlag(DATA.emojis.fr)).toBe(true); - expect(isCountryFlag(DATA.emojis.checkered_flag)).toBe(false); - expect(isCountryFlag(DATA.emojis.grinning)).toBe(false); - }); -}); - describe('filterEmojiData', () => { - it('returns the same reference when no filters apply', () => { + it('returns the same reference when nothing is excluded', () => { expect(filterEmojiData(DATA, {})).toBe(DATA); + expect(filterEmojiData(DATA, { exceptEmojis: [] })).toBe(DATA); }); - it('drops exceptEmojis and prunes them from categories', () => { + it('drops exceptEmojis and prunes them from their category', () => { const out = filterEmojiData(DATA, { exceptEmojis: ['smile'] }); expect(out.emojis.smile).toBeUndefined(); - expect(out.categories.find((c) => c.id === 'people')?.emojis).toEqual(['grinning']); - }); - - it('drops emoji newer than emojiVersion', () => { - const out = filterEmojiData(DATA, { emojiVersion: 1 }); - expect(out.emojis.smile).toBeUndefined(); // version 13 > 1 expect(out.emojis.grinning).toBeDefined(); + expect(out.categories.find((c) => c.id === 'people')?.emojis).toEqual(['grinning']); }); - it('drops country flags but keeps non-country flags when noCountryFlags', () => { - const out = filterEmojiData(DATA, { noCountryFlags: true }); - expect(out.emojis.us).toBeUndefined(); - expect(out.emojis.fr).toBeUndefined(); - expect(out.emojis.checkered_flag).toBeDefined(); - }); - - it('removes a category entirely when all its emoji are filtered out', () => { - const out = filterEmojiData(DATA, { - exceptEmojis: ['checkered_flag'], - noCountryFlags: true, - }); + it('removes a category entirely when all its emoji are excluded', () => { + const out = filterEmojiData(DATA, { exceptEmojis: ['us', 'checkered_flag'] }); expect(out.categories.find((c) => c.id === 'flags')).toBeUndefined(); + expect(out.categories.find((c) => c.id === 'people')).toBeDefined(); }); }); diff --git a/src/plugins/Emojis/data/filterEmojiData.ts b/src/plugins/Emojis/data/filterEmojiData.ts index 2b8ca6ce01..540471f5e5 100644 --- a/src/plugins/Emojis/data/filterEmojiData.ts +++ b/src/plugins/Emojis/data/filterEmojiData.ts @@ -1,49 +1,24 @@ -import type { EmojiData, EmojiDataEmoji } from './types'; - -const REGIONAL_INDICATOR_START = 0x1f1e6; -const REGIONAL_INDICATOR_END = 0x1f1ff; - -/** - * A country flag's `unified` is a pair of Unicode regional-indicator codepoints - * (e.g. `1f1fa-1f1f8` for ๐Ÿ‡บ๐Ÿ‡ธ). Non-country flags (๐Ÿ, ๐Ÿด, ๐Ÿšฉ) fall outside that range. - */ -export const isCountryFlag = (emoji: EmojiDataEmoji): boolean => { - const unified = emoji.skins[0]?.unified ?? ''; - const points = unified.split('-').map((part) => parseInt(part, 16)); - return ( - points.length > 0 && - points.every((cp) => cp >= REGIONAL_INDICATOR_START && cp <= REGIONAL_INDICATOR_END) - ); -}; +import type { EmojiData } from './types'; type FilterEmojiDataOptions = { - emojiVersion?: number; exceptEmojis?: string[]; - noCountryFlags?: boolean; }; /** - * Returns a dataset with emoji removed per the options, pruning any category left empty. - * Returns the SAME reference when no filter applies, so callers memoize cheaply and the - * picker renders identically to the unfiltered dataset. + * Returns a dataset with the excluded emoji removed, pruning any category left empty. + * Returns the SAME reference when nothing is excluded, so callers memoize cheaply and + * the picker renders identically to the unfiltered dataset. */ export const filterEmojiData = ( data: EmojiData, - { emojiVersion, exceptEmojis = [], noCountryFlags = false }: FilterEmojiDataOptions, + { exceptEmojis = [] }: FilterEmojiDataOptions, ): EmojiData => { - if (!exceptEmojis.length && emojiVersion == null && !noCountryFlags) return data; + if (!exceptEmojis.length) return data; const excluded = new Set(exceptEmojis); - const keep = (emoji: EmojiDataEmoji) => { - if (excluded.has(emoji.id)) return false; - if (emojiVersion != null && emoji.version > emojiVersion) return false; - if (noCountryFlags && isCountryFlag(emoji)) return false; - return true; - }; - const emojis: EmojiData['emojis'] = {}; for (const id of Object.keys(data.emojis)) { - if (keep(data.emojis[id])) emojis[id] = data.emojis[id]; + if (!excluded.has(id)) emojis[id] = data.emojis[id]; } const categories = data.categories .map((category) => ({ diff --git a/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts b/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts deleted file mode 100644 index 1d374f15cd..0000000000 --- a/src/plugins/Emojis/hooks/__tests__/pickerLayout.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { DEFAULT_EMOJI_PICKER_OPTIONS } from '../../options'; -import { resolvePickerLayout } from '../pickerLayout'; - -const layout = (over: Partial = {}) => - resolvePickerLayout({ ...DEFAULT_EMOJI_PICKER_OPTIONS, ...over }); - -describe('resolvePickerLayout', () => { - it('maps the defaults (nav top, preview bottom, search sticky, skin in preview)', () => { - expect(layout()).toEqual({ - nav: 'top', - preview: 'bottom', - search: 'sticky', - skinTone: 'preview', - }); - }); - - it('maps `none` to null for each region', () => { - expect(layout({ navPosition: 'none' }).nav).toBeNull(); - expect(layout({ previewPosition: 'none' }).preview).toBeNull(); - expect(layout({ searchPosition: 'none' }).search).toBeNull(); - expect(layout({ skinTonePosition: 'none' }).skinTone).toBeNull(); - }); - - it('falls back skin-tone to the preview row when search is hidden', () => { - expect(layout({ searchPosition: 'none', skinTonePosition: 'search' }).skinTone).toBe( - 'preview', - ); - }); - - it('falls back skin-tone to a standalone footer when preview is hidden', () => { - expect( - layout({ previewPosition: 'none', skinTonePosition: 'preview' }).skinTone, - ).toBe('footer'); - }); - - it('falls back to footer when both search and preview are hidden', () => { - expect( - layout({ - previewPosition: 'none', - searchPosition: 'none', - skinTonePosition: 'search', - }).skinTone, - ).toBe('footer'); - }); -}); diff --git a/src/plugins/Emojis/hooks/pickerLayout.ts b/src/plugins/Emojis/hooks/pickerLayout.ts deleted file mode 100644 index 36c05a9c2c..0000000000 --- a/src/plugins/Emojis/hooks/pickerLayout.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ResolvedEmojiPickerOptions } from '../options'; - -export type PickerLayout = { - nav: 'top' | 'bottom' | null; - preview: 'top' | 'bottom' | null; - search: 'sticky' | 'static' | null; - skinTone: 'search' | 'preview' | 'footer' | null; -}; - -/** - * Pure resolver deciding where each picker region renders. `'none'` positions become - * `null` (omit the region). The skin-tone selector normally lives with the search row - * (`skinTonePosition: 'search'`) or the preview row (`'preview'`); when its host region - * is hidden it falls back โ€” to the preview row, then to a standalone footer โ€” so the - * selector never silently disappears when the integrator hides search or preview. - */ -export const resolvePickerLayout = ({ - navPosition, - previewPosition, - searchPosition, - skinTonePosition, -}: Pick< - ResolvedEmojiPickerOptions, - 'navPosition' | 'previewPosition' | 'searchPosition' | 'skinTonePosition' ->): PickerLayout => { - const nav = navPosition === 'none' ? null : navPosition; - const preview = previewPosition === 'none' ? null : previewPosition; - const search = searchPosition === 'none' ? null : searchPosition; - - let skinTone: PickerLayout['skinTone']; - if (skinTonePosition === 'none') { - skinTone = null; - } else if (skinTonePosition === 'search') { - if (search) skinTone = 'search'; - else if (preview) skinTone = 'preview'; - else skinTone = 'footer'; - } else { - skinTone = preview ? 'preview' : 'footer'; - } - - return { nav, preview, search, skinTone }; -}; diff --git a/src/plugins/Emojis/options.ts b/src/plugins/Emojis/options.ts deleted file mode 100644 index a386f8610b..0000000000 --- a/src/plugins/Emojis/options.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { CSSProperties } from 'react'; - -export type EmojiPickerNavPosition = 'top' | 'bottom' | 'none'; -export type EmojiPickerPreviewPosition = 'top' | 'bottom' | 'none'; -export type EmojiPickerSearchPosition = 'sticky' | 'static' | 'none'; -export type EmojiPickerSkinTonePosition = 'preview' | 'search' | 'none'; - -export type EmojiPickerPassthroughProps = { - /** Focus the search input when the picker opens (default `true`). */ - autoFocus?: boolean; - /** Category ids to show, in order. Defaults to the dataset order. `frequent` always prepends. */ - categories?: string[]; - /** Hide emoji introduced after this Unicode emoji version. */ - emojiVersion?: number; - /** Emoji ids to exclude from the grid and search. */ - exceptEmojis?: string[]; - /** Max rows in the "frequently used" section (default `1`). */ - maxFrequentRows?: number; - /** Category navigation placement (default `'top'`). */ - navPosition?: EmojiPickerNavPosition; - /** Hide country-flag emoji (default `false`). */ - noCountryFlags?: boolean; - /** Emoji id shown in the empty-search state. */ - noResultsEmoji?: string; - /** Called when a pointer press lands outside the picker. */ - onClickOutside?: () => void; - /** Emoji per row (default `9`). */ - perLine?: number; - /** Emoji id shown in the preview when nothing is hovered/focused. */ - previewEmoji?: string; - /** Preview placement (default `'bottom'`). */ - previewPosition?: EmojiPickerPreviewPosition; - /** Search input placement (default `'sticky'`). */ - searchPosition?: EmojiPickerSearchPosition; - /** Skin-tone selector placement (default `'preview'`). */ - skinTonePosition?: EmojiPickerSkinTonePosition; - /** Inline styles applied to the picker panel root. */ - style?: CSSProperties; - /** Color theme. 'auto' (default) inherits the ancestor SDK theme; 'light'/'dark' force it. */ - theme?: 'auto' | 'light' | 'dark'; -}; - -export type ResolvedEmojiPickerOptions = { - autoFocus: boolean; - categories?: string[]; - emojiVersion?: number; - exceptEmojis: string[]; - maxFrequentRows: number; - navPosition: EmojiPickerNavPosition; - noCountryFlags: boolean; - noResultsEmoji?: string; - perLine: number; - previewEmoji?: string; - previewPosition: EmojiPickerPreviewPosition; - searchPosition: EmojiPickerSearchPosition; - skinTonePosition: EmojiPickerSkinTonePosition; -}; - -export const DEFAULT_EMOJI_PICKER_OPTIONS: ResolvedEmojiPickerOptions = { - autoFocus: true, - exceptEmojis: [], - maxFrequentRows: 1, - navPosition: 'top', - noCountryFlags: false, - perLine: 9, - previewPosition: 'bottom', - searchPosition: 'sticky', - skinTonePosition: 'preview', -}; - -export const resolveEmojiPickerOptions = ( - pickerProps?: EmojiPickerPassthroughProps, -): ResolvedEmojiPickerOptions => { - const p = pickerProps ?? {}; - const d = DEFAULT_EMOJI_PICKER_OPTIONS; - return { - autoFocus: p.autoFocus ?? d.autoFocus, - categories: p.categories, - emojiVersion: p.emojiVersion, - exceptEmojis: p.exceptEmojis ?? d.exceptEmojis, - maxFrequentRows: Math.max(0, Math.floor(p.maxFrequentRows ?? d.maxFrequentRows)), - navPosition: p.navPosition ?? d.navPosition, - noCountryFlags: p.noCountryFlags ?? d.noCountryFlags, - noResultsEmoji: p.noResultsEmoji, - perLine: Math.max(1, Math.floor(p.perLine ?? d.perLine)), - previewEmoji: p.previewEmoji, - previewPosition: p.previewPosition ?? d.previewPosition, - searchPosition: p.searchPosition ?? d.searchPosition, - skinTonePosition: p.skinTonePosition ?? d.skinTonePosition, - }; -}; - -const SUPPORTED_PICKER_PROP_KEYS = [ - 'autoFocus', - 'categories', - 'emojiVersion', - 'exceptEmojis', - 'maxFrequentRows', - 'navPosition', - 'noCountryFlags', - 'noResultsEmoji', - 'onClickOutside', - 'perLine', - 'previewEmoji', - 'previewPosition', - 'searchPosition', - 'skinTonePosition', - 'style', - 'theme', -]; - -/** emoji-mart styling knobs โ†’ the CSS token that replaces each. */ -const STYLING_KNOB_TOKENS: Record = { - emojiButtonColors: '--str-chat__emoji-picker-hover-background-color', - emojiButtonRadius: '--str-chat__radius-4 (on .str-chat__emoji-picker__emoji)', - emojiButtonSize: '--str-chat__emoji-picker-emoji-size', - emojiSize: '--str-chat__emoji-picker-emoji-size', -}; - -export const warnUnsupportedPickerProps = ( - pickerProps?: Record, -): void => { - if (!pickerProps) return; - const keys = Object.keys(pickerProps); - const styling = keys.filter((key) => key in STYLING_KNOB_TOKENS); - const ignored = keys.filter( - (key) => !SUPPORTED_PICKER_PROP_KEYS.includes(key) && !(key in STYLING_KNOB_TOKENS), - ); - if (styling.length) { - console.warn( - `[stream-chat-react] EmojiPicker: ${styling.join(', ')} are emoji-mart styling props. ` + - `Use the matching CSS token instead (e.g. ${STYLING_KNOB_TOKENS[styling[0]]}).`, - ); - } - if (ignored.length) { - console.warn( - `[stream-chat-react] EmojiPicker ignored unsupported pickerProps: ${ignored.join(', ')}. ` + - 'These emoji-mart Picker options are not available in the built-in picker.', - ); - } -}; diff --git a/src/plugins/Emojis/styling/EmojiPicker.scss b/src/plugins/Emojis/styling/EmojiPicker.scss index a5678361a1..bebc525562 100644 --- a/src/plugins/Emojis/styling/EmojiPicker.scss +++ b/src/plugins/Emojis/styling/EmojiPicker.scss @@ -99,21 +99,6 @@ $emoji-picker-border-radius: 12px; flex: none; } - // Search + skin-tone selector share one row (only when skinTonePosition='search'). - &__search-row { - display: flex; - align-items: center; - - .str-chat__emoji-picker__search { - flex: 1 1 auto; - } - - .str-chat__emoji-picker__skin-tone-toggle, - .str-chat__emoji-picker__skin-tones { - margin-inline-end: var(--str-chat__spacing-sm); - } - } - &__empty { display: flex; flex-direction: column; @@ -257,12 +242,6 @@ $emoji-picker-border-radius: 12px; border-block-start: 1px solid var(--str-chat__emoji-picker-separator-color); } - // Preview positioned at the top: divider below it instead of above. - &__footer--top { - border-block-start: none; - border-block-end: 1px solid var(--str-chat__emoji-picker-separator-color); - } - &__preview { display: flex; flex: 1 1 auto; From 5912c6493fb78e2c4eabfad5d78fdf1a73378a3b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 13 Jul 2026 14:25:12 +0200 Subject: [PATCH 30/36] feat(emojis): add StreamEmojiPicker compound components + useEmojiPickerContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the built-in emoji picker as compound components so integrators can rearrange or replace parts, while `StreamEmojiPicker` (called directly) stays the unchanged zero-config preset built on top of them. - Add `StreamEmojiPicker.Root` (provider + dialog container owning dataset load, filtering, search, and container-level a11y โ€” dialog role, Escape-to-close, theme class) and the slots `StreamEmojiPicker.Nav` / `.Search` / `.Grid` / `.Preview` / `.SkinTone`. - Expose `useEmojiPickerContext()` with the public contract: read-only data (`categories`, `searchResults`, `status`, `skinTones`, `resolveNative`) and report-back setters (`selectEmoji`, `setQuery`, `setSkinTone`, `setActiveCategory`, `requestScrollToCategory`). Hover-preview state lives in an internal, non-exported context so it doesn't re-render the grid and a custom grid doesn't implicitly drive the default preview. - Convert each slot from prop-driven to context-driven; `EmojiPickerPanel` becomes the default composition. Behavior, a11y, keyboard nav, virtualization, and bundle optionality are unchanged โ€” all pre-existing tests stay green. - `autoFocus` moves to the panel's own props (it's a search behavior), so `Root` no longer advertises a prop it ignores. --- src/plugins/Emojis/StreamEmojiPicker.tsx | 25 +- .../__tests__/StreamEmojiPicker.test.tsx | 2 +- .../Emojis/__tests__/entry-exports.test.ts | 15 + src/plugins/Emojis/components/CategoryNav.tsx | 30 +- src/plugins/Emojis/components/EmojiButton.tsx | 10 +- src/plugins/Emojis/components/EmojiGrid.tsx | 119 ++++--- .../Emojis/components/EmojiPickerPanel.tsx | 295 ++---------------- .../Emojis/components/EmojiPickerRoot.tsx | 288 +++++++++++++++++ src/plugins/Emojis/components/PreviewPane.tsx | 32 +- src/plugins/Emojis/components/SearchInput.tsx | 41 ++- .../Emojis/components/SkinToneSelector.tsx | 28 +- .../components/__tests__/CategoryNav.test.tsx | 44 +++ .../components/__tests__/EmojiButton.test.tsx | 53 +++- .../components/__tests__/EmojiGrid.test.tsx | 129 ++++---- .../__tests__/EmojiPickerPanel.test.tsx | 28 +- .../components/__tests__/PreviewPane.test.tsx | 53 ++-- .../components/__tests__/SearchInput.test.tsx | 50 +++ .../__tests__/SkinToneSelector.test.tsx | 40 ++- src/plugins/Emojis/components/index.ts | 2 + src/plugins/Emojis/components/skinTones.ts | 4 +- .../Emojis/context/EmojiPickerContext.tsx | 45 ++- .../context/EmojiPickerPreviewContext.tsx | 30 ++ .../__tests__/EmojiPickerContext.test.tsx | 70 +++++ src/plugins/Emojis/index.ts | 4 + 24 files changed, 894 insertions(+), 543 deletions(-) create mode 100644 src/plugins/Emojis/components/EmojiPickerRoot.tsx create mode 100644 src/plugins/Emojis/components/__tests__/CategoryNav.test.tsx create mode 100644 src/plugins/Emojis/components/__tests__/SearchInput.test.tsx create mode 100644 src/plugins/Emojis/context/EmojiPickerPreviewContext.tsx create mode 100644 src/plugins/Emojis/context/__tests__/EmojiPickerContext.test.tsx diff --git a/src/plugins/Emojis/StreamEmojiPicker.tsx b/src/plugins/Emojis/StreamEmojiPicker.tsx index 77a3bdc1dd..30509c9af3 100644 --- a/src/plugins/Emojis/StreamEmojiPicker.tsx +++ b/src/plugins/Emojis/StreamEmojiPicker.tsx @@ -9,7 +9,13 @@ import { } from '../../components'; import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive'; -import { EmojiPickerPanel } from './components'; +import { CategoryNav } from './components/CategoryNav'; +import { EmojiGrid } from './components/EmojiGrid'; +import { EmojiPickerPanel } from './components/EmojiPickerPanel'; +import { EmojiPickerRoot } from './components/EmojiPickerRoot'; +import { PreviewPane } from './components/PreviewPane'; +import { SearchInput } from './components/SearchInput'; +import { SkinToneSelector } from './components/SkinToneSelector'; import { useFrequentlyUsedEmoji } from './hooks/useFrequentlyUsedEmoji'; import { useSkinTone } from './hooks/useSkinTone'; @@ -61,7 +67,7 @@ const classNames: Pick< wrapperClassName: 'str-chat__message-textarea-emoji-picker', }; -export const StreamEmojiPicker = (props: StreamEmojiPickerProps) => { +const StreamEmojiPickerComponent = (props: StreamEmojiPickerProps) => { const { t } = useTranslationContext('EmojiPicker'); const { textareaRef } = useMessageComposerContext('EmojiPicker'); const { textComposer } = useMessageComposerController(); @@ -179,3 +185,18 @@ export const StreamEmojiPicker = (props: StreamEmojiPickerProps) => {
); }; + +/** + * The built-in emoji picker. Rendered directly, it is the batteries-included preset + * (toggle button + popover + the standard panel). For a custom layout, compose the parts + * โ€” `StreamEmojiPicker.Root` wrapping any arrangement of `.Nav` / `.Search` / `.Grid` / + * `.Preview` / `.SkinTone` (and your own slots via `useEmojiPickerContext`). + */ +export const StreamEmojiPicker = Object.assign(StreamEmojiPickerComponent, { + Grid: EmojiGrid, + Nav: CategoryNav, + Preview: PreviewPane, + Root: EmojiPickerRoot, + Search: SearchInput, + SkinTone: SkinToneSelector, +}); diff --git a/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx b/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx index 85b595b2d4..aa9ae3c271 100644 --- a/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx +++ b/src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx @@ -5,7 +5,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; // stub. This lets us assert that the owner (EmojiPicker) preserves skin tone and // frequently-used across open/close โ€” without depending on the real panel's // virtualized grid + async data load, which don't render reliably in jsdom. -vi.mock('../components', () => ({ +vi.mock('../components/EmojiPickerPanel', () => ({ EmojiPickerPanel: ({ frequentlyUsedIds = [], onEmojiSelect, diff --git a/src/plugins/Emojis/__tests__/entry-exports.test.ts b/src/plugins/Emojis/__tests__/entry-exports.test.ts index 509670a8a6..3754d341e0 100644 --- a/src/plugins/Emojis/__tests__/entry-exports.test.ts +++ b/src/plugins/Emojis/__tests__/entry-exports.test.ts @@ -1,8 +1,23 @@ import * as emojis from '../index'; +import { StreamEmojiPicker } from '../StreamEmojiPicker'; describe('emojis entry exports', () => { it('exports both the deprecated EmojiPicker and the StreamEmojiPicker successor', () => { expect(typeof emojis.EmojiPicker).toBe('function'); expect(typeof emojis.StreamEmojiPicker).toBe('function'); }); + + it('exposes the compound API + context hook', () => { + expect(typeof emojis.useEmojiPickerContext).toBe('function'); + for (const part of [ + 'Grid', + 'Nav', + 'Preview', + 'Root', + 'Search', + 'SkinTone', + ] as const) { + expect(typeof StreamEmojiPicker[part]).toBe('function'); + } + }); }); diff --git a/src/plugins/Emojis/components/CategoryNav.tsx b/src/plugins/Emojis/components/CategoryNav.tsx index cea8cd3be8..8a12d07ea3 100644 --- a/src/plugins/Emojis/components/CategoryNav.tsx +++ b/src/plugins/Emojis/components/CategoryNav.tsx @@ -1,28 +1,23 @@ import { type KeyboardEvent, useRef } from 'react'; import clsx from 'clsx'; import { EMOJI_CATEGORY_META } from './categories'; -import type { EmojiPickerCategory } from './EmojiGrid'; - -export type CategoryNavProps = { - categories: EmojiPickerCategory[]; - onNavigate: (categoryId: string) => void; - activeCategoryId?: string; -}; +import { useEmojiPickerContext } from '../context/EmojiPickerContext'; const NAV_KEYS = ['ArrowRight', 'ArrowLeft', 'Home', 'End']; /** * Top navigation bar with one tab per category (role="tablist"). Clicking a tab * scrolls its section into view; Left/Right/Home/End move focus between tabs with a - * roving tabindex; the active tab reflects the currently visible section. + * roving tabindex; the active tab reflects the currently visible section. Reads the + * category model + active category from context and reports clicks via + * `requestScrollToCategory`. The active tab is suppressed during search. */ -export const CategoryNav = ({ - activeCategoryId, - categories, - onNavigate, -}: CategoryNavProps) => { +export const CategoryNav = () => { + const { activeCategoryId, categories, isSearching, requestScrollToCategory } = + useEmojiPickerContext('CategoryNav'); const navRef = useRef(null); - const rovingId = activeCategoryId ?? categories[0]?.id; + const active = isSearching ? undefined : activeCategoryId; + const rovingId = active ?? categories[0]?.id; const onKeyDown = (event: KeyboardEvent) => { if (!NAV_KEYS.includes(event.key)) return; @@ -51,13 +46,12 @@ export const CategoryNav = ({ {categories.map(({ id, label }) => ( ); }); diff --git a/src/plugins/Emojis/components/EmojiGrid.tsx b/src/plugins/Emojis/components/EmojiGrid.tsx index 1f2d242f4b..124d44884d 100644 --- a/src/plugins/Emojis/components/EmojiGrid.tsx +++ b/src/plugins/Emojis/components/EmojiGrid.tsx @@ -1,6 +1,9 @@ -import { forwardRef, useImperativeHandle, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { EmojiButton } from './EmojiButton'; +import { EmptyResults } from './EmptyResults'; +import { useEmojiPickerContext } from '../context/EmojiPickerContext'; +import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav'; import type { EmojiDataEmoji } from '../data'; export type EmojiPickerCategory = { @@ -9,15 +12,6 @@ export type EmojiPickerCategory = { label: string; }; -export type EmojiGridHandle = { - scrollToCategory: (categoryId: string) => void; -}; - -export type EmojiGridProps = { - categories: EmojiPickerCategory[]; - onActiveCategoryChange?: (categoryId: string) => void; -}; - const CategorySection = ({ category }: { category: EmojiPickerCategory }) => (
( ); /** - * The category-grouped emoji grid. Virtualized at the category level with - * react-virtuoso: only the categories in (and near) the viewport are mounted, which - * keeps opening the picker fast without giving up section headers, scroll-spy, or - * per-category scrolling. `scrollToCategory` is exposed imperatively for the nav. + * The context-driven body of the picker: while browsing, a category-grouped grid, + * virtualized at the category level with react-virtuoso (only categories in/near the + * viewport mount); while searching, a flat result list or the empty state. Scrolls to + * the category named by `scrollTarget`, reports the visible category via + * `setActiveCategory` (scroll-spy), and owns 2D roving keyboard navigation over its + * cells. A custom Grid slot forfeits these mechanics but keeps reading the same context. */ -export const EmojiGrid = forwardRef(function EmojiGrid( - { categories, onActiveCategoryChange }, - ref, -) { +export const EmojiGrid = () => { + const { categories, isSearching, scrollTarget, searchResults, setActiveCategory } = + useEmojiPickerContext('EmojiGrid'); const virtuosoRef = useRef(null); const atBottomRef = useRef(false); + const bodyRef = useRef(null); - useImperativeHandle( - ref, - () => ({ - scrollToCategory: (categoryId: string) => { - const index = categories.findIndex((category) => category.id === categoryId); - if (index >= 0) virtuosoRef.current?.scrollToIndex({ align: 'start', index }); - }, - }), + const scrollToCategory = useCallback( + (categoryId: string) => { + const index = categories.findIndex((category) => category.id === categoryId); + if (index >= 0) virtuosoRef.current?.scrollToIndex({ align: 'start', index }); + }, [categories], ); + // Nav clicks (and any consumer) request a scroll via `scrollTarget`; the nonce makes a + // repeat request to the same category re-fire. + useEffect(() => { + if (scrollTarget) scrollToCategory(scrollTarget.categoryId); + }, [scrollTarget, scrollToCategory]); + + // Keyboard nav can target a category that virtualization has unmounted; give it the + // category order + a way to scroll one into view so focus can traverse the whole set. + const { onKeyDown } = useGridKeyboardNav(bodyRef, { categories, scrollToCategory }); + + if (isSearching) { + return ( +
+ {searchResults && searchResults.length ? ( +
+
+
+ {searchResults.map((emoji) => ( + + ))} +
+
+
+ ) : ( + + )} +
+ ); + } + return ( - { - atBottomRef.current = atBottom; - // The last category is often short and never reaches the top of the viewport, - // so scroll-spy alone would never mark it active โ€” pin it while at the bottom. - if (atBottom) { +
+ { + atBottomRef.current = atBottom; + // The last category is often short and never reaches the top of the viewport, + // so scroll-spy alone would never mark it active โ€” pin it while at the bottom. const last = categories[categories.length - 1]; - if (last) onActiveCategoryChange?.(last.id); - } - }} - className='str-chat__emoji-picker__grid' - data={categories} - itemContent={(_index, category) => } - rangeChanged={({ startIndex }) => { - // While pinned at the bottom, atBottomStateChange owns the active category. - if (atBottomRef.current) return; - const category = categories[startIndex]; - if (category) onActiveCategoryChange?.(category.id); - }} - ref={virtuosoRef} - style={{ height: '100%' }} - /> + if (atBottom && last) setActiveCategory(last.id); + }} + className='str-chat__emoji-picker__grid' + data={categories} + itemContent={(_index, category) => } + rangeChanged={({ startIndex }) => { + // While pinned at the bottom, atBottomStateChange owns the active category. + if (atBottomRef.current) return; + const category = categories[startIndex]; + if (category) setActiveCategory(category.id); + }} + ref={virtuosoRef} + style={{ height: '100%' }} + /> +
); -}); +}; diff --git a/src/plugins/Emojis/components/EmojiPickerPanel.tsx b/src/plugins/Emojis/components/EmojiPickerPanel.tsx index 98e875155c..d30a7d4ffe 100644 --- a/src/plugins/Emojis/components/EmojiPickerPanel.tsx +++ b/src/plugins/Emojis/components/EmojiPickerPanel.tsx @@ -1,288 +1,29 @@ -import { type CSSProperties, useCallback, useMemo, useRef, useState } from 'react'; -import clsx from 'clsx'; import { CategoryNav } from './CategoryNav'; -import { EmojiButton } from './EmojiButton'; -import { EmojiGrid, type EmojiGridHandle, type EmojiPickerCategory } from './EmojiGrid'; -import { EMOJI_CATEGORY_META } from './categories'; -import { resolveFrequentlyUsedEmoji } from './frequentlyUsed'; -import { EmptyResults } from './EmptyResults'; +import { EmojiGrid } from './EmojiGrid'; +import { EmojiPickerRoot, type EmojiPickerRootProps } from './EmojiPickerRoot'; import { PreviewPane } from './PreviewPane'; import { SearchInput } from './SearchInput'; import { SkinToneSelector } from './SkinToneSelector'; -import { - type EmojiPickerContextValue, - EmojiPickerProvider, -} from '../context/EmojiPickerContext'; -import { useDebouncedValue } from '../hooks/useDebouncedValue'; -import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; -import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav'; -import { buildEmojiSearchData, runSearch } from '../search'; -import type { EmojiDataEmoji } from '../data'; -import { filterEmojiData } from '../data/filterEmojiData'; -import { useTranslationContext } from '../../../context'; -// The grid renders 9 emoji per row by default; integrators change the column count via -// the `--str-chat__emoji-picker-per-line` CSS token (the SCSS falls back to 9), so the -// panel sets no inline width. The "frequently used" section is capped to a single row. -const DEFAULT_COLUMNS = 9; -const FREQUENT_ROWS = 1; - -export type EmojiSelection = { - id: string; - name: string; - native: string; -}; - -export type EmojiPickerPanelProps = { - onEmojiSelect: (emoji: EmojiSelection) => void; +export type EmojiPickerPanelProps = Omit & { /** Focus the search input when the panel mounts (default `true`). */ autoFocus?: boolean; - /** Category ids to show, in order. Defaults to the dataset order. `frequent` always prepends. */ - categories?: string[]; - className?: string; - /** Emoji ids to exclude from the grid and search. */ - exceptEmojis?: string[]; - /** - * Ordered list of recently used emoji ids (most recent first), rendered as the - * "frequently used" section. This is a controlled value โ€” the owner (EmojiPicker) - * holds it above the panel's mount so it survives the picker opening/closing. - */ - frequentlyUsedIds?: string[]; - /** Called when the panel requests to close (e.g. the Escape key). */ - onClose?: () => void; - /** Called with the new skin tone index when the user changes it. */ - onSkinToneChange?: (skinTone: number) => void; - /** Active skin tone index (0 = default, 1โ€“5 = light โ†’ dark). Controlled by the owner. */ - skinToneIndex?: number; - style?: CSSProperties; - theme?: 'auto' | 'light' | 'dark'; -}; - -const noop = () => undefined; - -/** - * Maps the `theme` prop to the class applied on the panel root. `light`/`dark` force - * an absolute theme (the forced-light case is backed by a full light-variable override - * in EmojiPicker.scss, since the SDK has no `.str-chat__theme-light` variable set); - * `auto`/undefined applies no class and inherits the ancestor `.str-chat__theme-*`. - * Exported for testing the class contract the theme CSS relies on. - */ -export const themeClassName = (theme: EmojiPickerPanelProps['theme']) => { - if (theme === 'light') return 'str-chat__theme-light'; - if (theme === 'dark') return 'str-chat__theme-dark'; - // 'auto' (default): inherit the ancestor `.str-chat__theme-*`. - return undefined; }; /** - * The native React emoji picker panel that replaces emoji-mart's `` - * web component. Loads the vendored dataset, renders search, category navigation, - * the emoji grid, a preview and a skin-tone selector, and emits the resolved native - * emoji on selection. - * - * Skin tone and frequently-used are fully controlled (`skinToneIndex`, - * `frequentlyUsedIds`, `onSkinToneChange`): the panel is mounted only while the - * picker is open, so this session state is owned by the always-mounted `EmojiPicker` - * shell rather than held here. + * The batteries-included emoji picker panel: `EmojiPickerRoot` wired to the standard slot + * arrangement (category nav, search, grid, then a footer with the preview and skin-tone + * selector). This is the default composition `StreamEmojiPicker` renders; for a custom + * layout, render `StreamEmojiPicker.Root` with your own arrangement of slots instead. */ -export const EmojiPickerPanel = ({ - autoFocus = true, - categories: categoryFilter, - className, - exceptEmojis, - frequentlyUsedIds = [], - onClose, - onEmojiSelect, - onSkinToneChange, - skinToneIndex = 0, - style, - theme, -}: EmojiPickerPanelProps) => { - const { t } = useTranslationContext('EmojiPickerPanel'); - const { data, error, retry } = useEmojiPickerState(); - // One filtered view of the dataset feeds both the grid and the search index so - // exclusions apply consistently. Returns the same reference when no filter is set. - const filteredData = useMemo( - () => (data ? filterEmojiData(data, { exceptEmojis }) : data), - [data, exceptEmojis], - ); - const [previewedEmoji, setPreviewedEmoji] = useState(null); - const [activeCategoryId, setActiveCategoryId] = useState(undefined); - const [query, setQuery] = useState(''); - // Debounce the query that drives the search so typing doesn't re-scan the index and - // re-render up to 90 result cells on every keystroke (clearing still exits at once). - const debouncedQuery = useDebouncedValue(query, 120); - const emojiGridRef = useRef(null); - const bodyRef = useRef(null); - - const baseCategories = useMemo(() => { - if (!filteredData) return []; - const built = filteredData.categories.map((category) => ({ - emojis: category.emojis.map((id) => filteredData.emojis[id]).filter(Boolean), - id: category.id, - label: t(EMOJI_CATEGORY_META[category.id]?.labelKey ?? category.id), - })); - // `categories` option: keep only the requested ids, in the requested order. - if (!categoryFilter) return built; - const byId = new Map(built.map((category) => [category.id, category])); - return categoryFilter - .map((id) => { - if (!byId.has(id)) { - console.warn( - `[stream-chat-react] EmojiPicker: unknown category id "${id}" ignored.`, - ); - } - return byId.get(id); - }) - .filter((category): category is EmojiPickerCategory => Boolean(category)); - }, [filteredData, categoryFilter, t]); - - const categories = useMemo(() => { - if (!filteredData || !frequentlyUsedIds.length) return baseCategories; - const frequent: EmojiPickerCategory = { - // Capped to a single row so the section stays one row wide as more emoji are used. - emojis: resolveFrequentlyUsedEmoji( - filteredData, - frequentlyUsedIds, - DEFAULT_COLUMNS * FREQUENT_ROWS, - ), - id: 'frequent', - label: t(EMOJI_CATEGORY_META.frequent.labelKey), - }; - return frequent.emojis.length ? [frequent, ...baseCategories] : baseCategories; - }, [baseCategories, filteredData, frequentlyUsedIds, t]); - - const scrollToCategory = useCallback((categoryId: string) => { - emojiGridRef.current?.scrollToCategory(categoryId); - }, []); - - // Keyboard nav can target a category that virtualization has unmounted; give it the - // category order + a way to scroll one into view so focus can traverse the whole set. - const { focusFirst, onKeyDown: onGridKeyDown } = useGridKeyboardNav(bodyRef, { - categories, - scrollToCategory, - }); - - const searchIndex = useMemo( - () => (filteredData ? buildEmojiSearchData(filteredData) : []), - [filteredData], - ); - - // `null` when not searching; otherwise the (possibly empty) list of matches. Clearing - // the field (empty live `query`) exits search immediately; a non-empty query is taken - // from the debounced value. - const searchedEmojis = useMemo(() => { - const trimmed = query.trim() && debouncedQuery.trim(); - if (!trimmed || !filteredData) return null; - return (runSearch(searchIndex, trimmed) ?? []) - .map((result) => filteredData.emojis[result.id]) - .filter(Boolean); - }, [debouncedQuery, filteredData, query, searchIndex]); - - const onSelectEmoji = useCallback( - (emoji: EmojiDataEmoji) => { - const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? ''; - if (!native) return; - onEmojiSelect({ id: emoji.id, name: emoji.name, native }); - }, - [onEmojiSelect, skinToneIndex], - ); - - const contextValue = useMemo( - () => ({ onSelectEmoji, setPreviewedEmoji, skinToneIndex }), - [onSelectEmoji, skinToneIndex], - ); - - const handleNavigate = useCallback((categoryId: string) => { - setQuery(''); // navigating a category exits search - setActiveCategoryId(categoryId); - // Defer so the (virtualized) category view has mounted before we scroll to it. - requestAnimationFrame(() => { - emojiGridRef.current?.scrollToCategory(categoryId); - }); - }, []); - - const isSearching = searchedEmojis !== null; - - return ( - -
{ - if (event.key === 'Escape') { - event.stopPropagation(); - onClose?.(); - } - }} - role='dialog' - style={style} - > - {data ? ( - <> - - -
- {isSearching ? ( - searchedEmojis.length ? ( -
-
-
- {searchedEmojis.map((emoji) => ( - - ))} -
-
-
- ) : ( - - ) - ) : ( - - )} -
-
- - -
- - ) : error ? ( -
-

- {t('Failed to load emojis')} -

- -
- ) : ( -
- )} -
- - ); -}; +export const EmojiPickerPanel = (props: EmojiPickerPanelProps) => ( + + + + +
+ + +
+
+); diff --git a/src/plugins/Emojis/components/EmojiPickerRoot.tsx b/src/plugins/Emojis/components/EmojiPickerRoot.tsx new file mode 100644 index 0000000000..d111391773 --- /dev/null +++ b/src/plugins/Emojis/components/EmojiPickerRoot.tsx @@ -0,0 +1,288 @@ +import { + type CSSProperties, + type ReactNode, + useCallback, + useMemo, + useState, +} from 'react'; +import clsx from 'clsx'; +import { type EmojiPickerCategory } from './EmojiGrid'; +import { EMOJI_CATEGORY_META } from './categories'; +import { resolveFrequentlyUsedEmoji } from './frequentlyUsed'; +import { SKIN_TONES } from './skinTones'; +import { + type EmojiPickerContextValue, + EmojiPickerProvider, +} from '../context/EmojiPickerContext'; +import { EmojiPickerPreviewProvider } from '../context/EmojiPickerPreviewContext'; +import { useDebouncedValue } from '../hooks/useDebouncedValue'; +import { useEmojiPickerState } from '../hooks/useEmojiPickerState'; +import { buildEmojiSearchData, runSearch } from '../search'; +import type { EmojiDataEmoji } from '../data'; +import { filterEmojiData } from '../data/filterEmojiData'; +import { useTranslationContext } from '../../../context'; + +// The grid renders 9 emoji per row by default; integrators change the column count via +// the `--str-chat__emoji-picker-per-line` CSS token (the SCSS falls back to 9). The +// "frequently used" section is capped to a single row. +const DEFAULT_COLUMNS = 9; +const FREQUENT_ROWS = 1; + +export type EmojiSelection = { + id: string; + name: string; + native: string; +}; + +export type EmojiPickerRootProps = { + onEmojiSelect: (emoji: EmojiSelection) => void; + /** Category ids to show, in order. Defaults to the dataset order. `frequent` always prepends. */ + categories?: string[]; + /** The picker slots to render (nav / search / grid / preview / skin-tone, in any arrangement). */ + children?: ReactNode; + className?: string; + /** Emoji ids to exclude from the grid and search. */ + exceptEmojis?: string[]; + /** + * Ordered list of recently used emoji ids (most recent first), rendered as the + * "frequently used" section. This is a controlled value โ€” the owner (EmojiPicker) + * holds it above the panel's mount so it survives the picker opening/closing. + */ + frequentlyUsedIds?: string[]; + /** Called when the picker requests to close (e.g. the Escape key). */ + onClose?: () => void; + /** Called with the new skin tone index when the user changes it. */ + onSkinToneChange?: (skinTone: number) => void; + /** Active skin tone index (0 = default, 1โ€“5 = light โ†’ dark). Controlled by the owner. */ + skinToneIndex?: number; + style?: CSSProperties; + theme?: 'auto' | 'light' | 'dark'; +}; + +const noop = () => undefined; + +/** + * Maps the `theme` prop to the class applied on the panel root. `light`/`dark` force + * an absolute theme (the forced-light case is backed by a full light-variable override + * in EmojiPicker.scss, since the SDK has no `.str-chat__theme-light` variable set); + * `auto`/undefined applies no class and inherits the ancestor `.str-chat__theme-*`. + * Exported for testing the class contract the theme CSS relies on. + */ +export const themeClassName = (theme: EmojiPickerRootProps['theme']) => { + if (theme === 'light') return 'str-chat__theme-light'; + if (theme === 'dark') return 'str-chat__theme-dark'; + // 'auto' (default): inherit the ancestor `.str-chat__theme-*`. + return undefined; +}; + +/** + * The provider + dialog container for the emoji picker. Loads the vendored dataset, owns + * all shared picker state, and exposes it to its slot `children` via EmojiPickerContext + * (plus an internal preview context for hover state). Also owns the container-level a11y + * that survives any recomposition: `role="dialog"`, Escape-to-close, and the theme class. + * While the dataset is loading or failed, it renders a loading / error+retry region in + * place of the slots. `StreamEmojiPicker` renders this with the default slot arrangement; + * integrators can render it directly with a custom arrangement of slots. + * + * Skin tone and frequently-used are fully controlled (`skinToneIndex`, + * `frequentlyUsedIds`, `onSkinToneChange`): the picker is mounted only while open, so + * this session state is owned by the always-mounted shell rather than held here. + */ +export const EmojiPickerRoot = ({ + categories: categoryFilter, + children, + className, + exceptEmojis, + frequentlyUsedIds = [], + onClose, + onEmojiSelect, + onSkinToneChange, + skinToneIndex = 0, + style, + theme, +}: EmojiPickerRootProps) => { + const { t } = useTranslationContext('EmojiPickerRoot'); + const { data, error, retry } = useEmojiPickerState(); + const status: 'error' | 'loading' | 'ready' = data + ? 'ready' + : error + ? 'error' + : 'loading'; + // One filtered view of the dataset feeds both the grid and the search index so + // exclusions apply consistently. Returns the same reference when no filter is set. + const filteredData = useMemo( + () => (data ? filterEmojiData(data, { exceptEmojis }) : data), + [data, exceptEmojis], + ); + const [previewedEmoji, setPreviewedEmoji] = useState(null); + const [activeCategoryId, setActiveCategoryId] = useState(undefined); + const [query, setQuery] = useState(''); + const [scrollTarget, setScrollTarget] = useState<{ + categoryId: string; + nonce: number; + } | null>(null); + // Debounce the query that drives the search so typing doesn't re-scan the index and + // re-render up to 90 result cells on every keystroke (clearing still exits at once). + const debouncedQuery = useDebouncedValue(query, 120); + + const baseCategories = useMemo(() => { + if (!filteredData) return []; + const built = filteredData.categories.map((category) => ({ + emojis: category.emojis.map((id) => filteredData.emojis[id]).filter(Boolean), + id: category.id, + label: t(EMOJI_CATEGORY_META[category.id]?.labelKey ?? category.id), + })); + // `categories` option: keep only the requested ids, in the requested order. + if (!categoryFilter) return built; + const byId = new Map(built.map((category) => [category.id, category])); + return categoryFilter + .map((id) => { + if (!byId.has(id)) { + console.warn( + `[stream-chat-react] EmojiPicker: unknown category id "${id}" ignored.`, + ); + } + return byId.get(id); + }) + .filter((category): category is EmojiPickerCategory => Boolean(category)); + }, [filteredData, categoryFilter, t]); + + const categories = useMemo(() => { + if (!filteredData || !frequentlyUsedIds.length) return baseCategories; + const frequent: EmojiPickerCategory = { + // Capped to a single row so the section stays one row wide as more emoji are used. + emojis: resolveFrequentlyUsedEmoji( + filteredData, + frequentlyUsedIds, + DEFAULT_COLUMNS * FREQUENT_ROWS, + ), + id: 'frequent', + label: t(EMOJI_CATEGORY_META.frequent.labelKey), + }; + return frequent.emojis.length ? [frequent, ...baseCategories] : baseCategories; + }, [baseCategories, filteredData, frequentlyUsedIds, t]); + + const searchIndex = useMemo( + () => (filteredData ? buildEmojiSearchData(filteredData) : []), + [filteredData], + ); + + // `null` when not searching; otherwise the (possibly empty) list of matches. Clearing + // the field (empty live `query`) exits search immediately; a non-empty query is taken + // from the debounced value. + const searchedEmojis = useMemo(() => { + const trimmed = query.trim() && debouncedQuery.trim(); + if (!trimmed || !filteredData) return null; + return (runSearch(searchIndex, trimmed) ?? []) + .map((result) => filteredData.emojis[result.id]) + .filter(Boolean); + }, [debouncedQuery, filteredData, query, searchIndex]); + + const isSearching = searchedEmojis !== null; + + const resolveNative = useCallback( + (emoji: EmojiDataEmoji) => + emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? '', + [skinToneIndex], + ); + + const selectEmoji = useCallback( + (emoji: EmojiDataEmoji) => { + const native = resolveNative(emoji); + if (native) onEmojiSelect({ id: emoji.id, name: emoji.name, native }); + }, + [onEmojiSelect, resolveNative], + ); + + const setActiveCategory = useCallback( + (categoryId: string) => setActiveCategoryId(categoryId), + [], + ); + + const requestScrollToCategory = useCallback((categoryId: string) => { + setQuery(''); // navigating a category exits search + setActiveCategoryId(categoryId); + // A "scroll here" signal for the grid; the nonce re-fires a repeat of the same id. + setScrollTarget((prev) => ({ categoryId, nonce: (prev?.nonce ?? 0) + 1 })); + }, []); + + const contextValue = useMemo( + () => ({ + activeCategoryId: activeCategoryId ?? categories[0]?.id ?? '', + categories, + isSearching, + query, + requestScrollToCategory, + resolveNative, + retry, + scrollTarget, + searchResults: searchedEmojis, + selectEmoji, + setActiveCategory, + setQuery, + setSkinTone: onSkinToneChange ?? noop, + skinToneIndex, + skinTones: SKIN_TONES, + status, + }), + [ + activeCategoryId, + categories, + isSearching, + onSkinToneChange, + query, + requestScrollToCategory, + resolveNative, + retry, + scrollTarget, + searchedEmojis, + selectEmoji, + setActiveCategory, + skinToneIndex, + status, + ], + ); + + const previewValue = useMemo( + () => ({ previewedEmoji, setPreviewedEmoji }), + [previewedEmoji], + ); + + return ( + + +
{ + if (event.key === 'Escape') { + event.stopPropagation(); + onClose?.(); + } + }} + role='dialog' + style={style} + > + {status === 'ready' ? ( + children + ) : status === 'error' ? ( +
+

+ {t('Failed to load emojis')} +

+ +
+ ) : ( +
+ )} +
+ + + ); +}; diff --git a/src/plugins/Emojis/components/PreviewPane.tsx b/src/plugins/Emojis/components/PreviewPane.tsx index 6186d9785c..94df83f274 100644 --- a/src/plugins/Emojis/components/PreviewPane.tsx +++ b/src/plugins/Emojis/components/PreviewPane.tsx @@ -1,40 +1,30 @@ import { useEmojiPickerContext } from '../context/EmojiPickerContext'; +import { useEmojiPickerPreviewContext } from '../context/EmojiPickerPreviewContext'; import { useTranslationContext } from '../../../context'; -import type { EmojiDataEmoji } from '../data'; - -export type PreviewPaneProps = { - emoji: EmojiDataEmoji | null; - /** Emoji shown at rest (nothing hovered/focused) instead of the text placeholder. */ - placeholderEmoji?: EmojiDataEmoji | null; -}; /** * Footer preview of the hovered/focused emoji: a large glyph, its name, and its * `:shortcode:` (so users learn the token that drives `:` autocomplete). Falls back to - * a "Pick an emojiโ€ฆ" placeholder (like emoji-mart) so the footer is never empty. - * Receives the previewed emoji as a prop (kept out of context) so hovering does not - * re-render the emoji grid. + * a "Pick an emojiโ€ฆ" placeholder (like emoji-mart) so the footer is never empty. Reads + * the previewed emoji from the internal preview context (kept out of the public context + * so hovering does not re-render the emoji grid). */ -export const PreviewPane = ({ emoji, placeholderEmoji }: PreviewPaneProps) => { +export const PreviewPane = () => { const { t } = useTranslationContext('EmojiPickerPreview'); - const { skinToneIndex } = useEmojiPickerContext('PreviewPane'); - // Prefer the hovered/focused emoji, then a configured resting emoji, then the text - // placeholder. - const shown = emoji ?? placeholderEmoji ?? null; + const { resolveNative } = useEmojiPickerContext('PreviewPane'); + const { previewedEmoji } = useEmojiPickerPreviewContext(); return (
- {shown ? shown.name : t('Pick an emojiโ€ฆ')} + {previewedEmoji ? previewedEmoji.name : t('Pick an emojiโ€ฆ')} - {shown ? ( - {`:${shown.id}:`} + {previewedEmoji ? ( + {`:${previewedEmoji.id}:`} ) : null}
diff --git a/src/plugins/Emojis/components/SearchInput.tsx b/src/plugins/Emojis/components/SearchInput.tsx index 461bbce6ea..71390d6255 100644 --- a/src/plugins/Emojis/components/SearchInput.tsx +++ b/src/plugins/Emojis/components/SearchInput.tsx @@ -1,28 +1,23 @@ -import { useEffect, useRef } from 'react'; +import { type KeyboardEvent, useEffect, useRef } from 'react'; import { Button, IconSearch, IconXCircle, VisuallyHidden } from '../../../components'; import { useStableId } from '../../../components/UtilityComponents/useStableId'; +import { useEmojiPickerContext } from '../context/EmojiPickerContext'; import { useTranslationContext } from '../../../context'; export type SearchInputProps = { - onChange: (value: string) => void; - value: string; /** Focus the input on mount when the picker opens (default `true`). */ autoFocus?: boolean; - /** Called when ArrowDown is pressed, to move focus into the emoji grid. */ - onArrowDown?: () => void; }; /** * Search box for the emoji picker. Mirrors the SDK's SearchBar structure (icon + - * labelled input + clear button) and receives focus when the picker opens. + * labelled input + clear button) and receives focus when the picker opens. Reads/reports + * the query through context; ArrowDown moves focus to the first emoji cell within the + * enclosing picker dialog (the default grid's DOM contract). */ -export const SearchInput = ({ - autoFocus = true, - onArrowDown, - onChange, - value, -}: SearchInputProps) => { +export const SearchInput = ({ autoFocus = true }: SearchInputProps) => { const { t } = useTranslationContext('EmojiPickerSearchInput'); + const { query, setQuery } = useEmojiPickerContext('SearchInput'); const inputId = useStableId(); const inputRef = useRef(null); @@ -30,6 +25,13 @@ export const SearchInput = ({ if (autoFocus) inputRef.current?.focus(); }, [autoFocus]); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'ArrowDown') return; + event.preventDefault(); + const dialog = event.currentTarget.closest('[role="dialog"]'); + dialog?.querySelector('.str-chat__emoji-picker__emoji')?.focus(); + }; + return (
, + ); + const input = screen.getByPlaceholderText('Search emoji'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(screen.getByRole('button', { name: '๐Ÿ˜€' })).toHaveFocus(); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx b/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx index 1ba3db6d9d..0c6c350740 100644 --- a/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx +++ b/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx @@ -1,17 +1,36 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import { SKIN_TONES } from '../skinTones'; +const { ctx } = vi.hoisted(() => ({ + ctx: { + setSkinTone: vi.fn(), + skinToneIndex: 0, + skinTones: [] as { glyph: string; labelKey: string }[], + }, +})); +vi.mock('../../context/EmojiPickerContext', () => ({ + useEmojiPickerContext: () => ctx, +})); vi.mock('../../../../context', () => ({ useTranslationContext: () => ({ t: (key: string) => key }), })); import { SkinToneSelector } from '../SkinToneSelector'; +const setup = (skinToneIndex: number) => { + ctx.skinToneIndex = skinToneIndex; + ctx.skinTones = SKIN_TONES; + vi.spyOn(ctx, 'setSkinTone').mockImplementation(); + return ctx.setSkinTone; +}; + const openGroup = () => fireEvent.click(screen.getByRole('button', { name: 'aria/Choose default skin tone' })); describe('SkinToneSelector radiogroup keyboard navigation', () => { it('moves focus to the checked tone when the group opens, with roving tabindex', () => { - render(); + setup(2); + render(); openGroup(); const radios = screen.getAllByRole('radio'); @@ -21,34 +40,35 @@ describe('SkinToneSelector radiogroup keyboard navigation', () => { }); it('selects the next/previous tone with arrow keys (selection follows focus)', () => { - const onSelect = vi.fn(); - render(); + const setSkinTone = setup(0); + render(); openGroup(); const group = screen.getByRole('radiogroup'); fireEvent.keyDown(group, { key: 'ArrowRight' }); - expect(onSelect).toHaveBeenLastCalledWith(1); + expect(setSkinTone).toHaveBeenLastCalledWith(1); fireEvent.keyDown(group, { key: 'ArrowLeft' }); - expect(onSelect).toHaveBeenLastCalledWith(5); // wraps from 0 to the last tone + expect(setSkinTone).toHaveBeenLastCalledWith(5); // wraps from 0 to the last tone }); it('jumps to the first/last tone with Home/End', () => { - const onSelect = vi.fn(); - render(); + const setSkinTone = setup(2); + render(); openGroup(); const group = screen.getByRole('radiogroup'); fireEvent.keyDown(group, { key: 'End' }); - expect(onSelect).toHaveBeenLastCalledWith(5); + expect(setSkinTone).toHaveBeenLastCalledWith(5); fireEvent.keyDown(group, { key: 'Home' }); - expect(onSelect).toHaveBeenLastCalledWith(0); + expect(setSkinTone).toHaveBeenLastCalledWith(0); }); it('collapses on Escape without bubbling to close the whole picker', () => { + setup(0); const onOuterKeyDown = vi.fn(); render(
- +
, ); openGroup(); diff --git a/src/plugins/Emojis/components/index.ts b/src/plugins/Emojis/components/index.ts index a8bf208ec6..e6d3637f25 100644 --- a/src/plugins/Emojis/components/index.ts +++ b/src/plugins/Emojis/components/index.ts @@ -1 +1,3 @@ export * from './EmojiPickerPanel'; +export * from './EmojiPickerRoot'; +export type { EmojiPickerCategory } from './EmojiGrid'; diff --git a/src/plugins/Emojis/components/skinTones.ts b/src/plugins/Emojis/components/skinTones.ts index 68838fd241..3fd248258c 100644 --- a/src/plugins/Emojis/components/skinTones.ts +++ b/src/plugins/Emojis/components/skinTones.ts @@ -1,7 +1,9 @@ // Skin-tone swatches for the picker. Index 0 is the default (no modifier); 1โ€“5 are // light โ†’ dark. The glyph is a hand emoji carrying the matching Fitzpatrick skin // tone modifier, mirroring emoji-mart's selector. `labelKey` is translated via t(). -export const SKIN_TONES: Array<{ glyph: string; labelKey: string }> = [ +export type SkinTone = { glyph: string; labelKey: string }; + +export const SKIN_TONES: SkinTone[] = [ { glyph: 'โœ‹', labelKey: 'Default' }, { glyph: 'โœ‹๐Ÿป', labelKey: 'Light' }, { glyph: 'โœ‹๐Ÿผ', labelKey: 'Medium-Light' }, diff --git a/src/plugins/Emojis/context/EmojiPickerContext.tsx b/src/plugins/Emojis/context/EmojiPickerContext.tsx index b74b683b30..539632a170 100644 --- a/src/plugins/Emojis/context/EmojiPickerContext.tsx +++ b/src/plugins/Emojis/context/EmojiPickerContext.tsx @@ -1,10 +1,41 @@ import { createContext, useContext } from 'react'; import type { EmojiDataEmoji } from '../data'; +import type { EmojiPickerCategory } from '../components/EmojiGrid'; +import type { SkinTone } from '../components/skinTones'; export type EmojiPickerContextValue = { - onSelectEmoji: (emoji: EmojiDataEmoji) => void; - setPreviewedEmoji: (emoji: EmojiDataEmoji | null) => void; + /** Currently-focused category id (browse mode). Always a valid id; never empty once loaded. */ + activeCategoryId: string; + /** The browse model โ€” filtered dataset, `frequent` prepended. */ + categories: EmojiPickerCategory[]; + /** `true` while a search query is active (=== `searchResults !== null`). */ + isSearching: boolean; + /** The live search query. */ + query: string; + /** Focus + scroll a category into view (sets active, bumps `scrollTarget`, clears search). */ + requestScrollToCategory: (categoryId: string) => void; + /** Resolves an emoji's native glyph for the active skin tone. */ + resolveNative: (emoji: EmojiDataEmoji) => string; + /** Retry loading the dataset after an error. */ + retry: () => void; + /** Advanced: a "scroll here" signal for a scrolling grid (nonce re-fires repeats). May evolve. */ + scrollTarget: { categoryId: string; nonce: number } | null; + /** Search matches, or `null` when not searching (`[]` = no matches). */ + searchResults: EmojiDataEmoji[] | null; + /** Report the user's emoji choice back to the SDK (inserts it). */ + selectEmoji: (emoji: EmojiDataEmoji) => void; + /** Set the active (highlighted) category without scrolling โ€” used by scroll-spy. */ + setActiveCategory: (categoryId: string) => void; + /** Feed the search query. */ + setQuery: (query: string) => void; + /** Feed the active skin tone index. */ + setSkinTone: (skinToneIndex: number) => void; + /** Active skin tone index (0 = default, 1โ€“5 = light โ†’ dark). */ skinToneIndex: number; + /** The available skin tones (glyph + i18n key) for a skin-tone selector. */ + skinTones: SkinTone[]; + /** Dataset load status. */ + status: 'error' | 'loading' | 'ready'; }; const EmojiPickerContext = createContext(undefined); @@ -12,15 +43,17 @@ const EmojiPickerContext = createContext(un export const EmojiPickerProvider = EmojiPickerContext.Provider; /** - * Shares the stable picker callbacks and the active skin tone with the picker's - * child components. Deliberately excludes transient state (like the previewed - * emoji) so consuming the context does not re-render the whole emoji grid. + * The public emoji-picker contract shared with every slot (`useEmojiPickerContext()`). + * Exposes the data the SDK owns + the report-back setters a slot feeds; deliberately + * carries no behavioral coordination (scroll mechanics, keyboard, hover) โ€” those stay + * private to each slot. Hover-preview state lives in a separate internal context so + * consuming this one does not re-render the emoji grid. */ export const useEmojiPickerContext = (componentName = 'EmojiPicker') => { const context = useContext(EmojiPickerContext); if (!context) { throw new Error( - `The ${componentName} component must be rendered within an EmojiPickerPanel.`, + `The ${componentName} component must be rendered within a StreamEmojiPicker.Root.`, ); } return context; diff --git a/src/plugins/Emojis/context/EmojiPickerPreviewContext.tsx b/src/plugins/Emojis/context/EmojiPickerPreviewContext.tsx new file mode 100644 index 0000000000..615f54597f --- /dev/null +++ b/src/plugins/Emojis/context/EmojiPickerPreviewContext.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext } from 'react'; +import type { EmojiDataEmoji } from '../data'; + +export type EmojiPickerPreviewContextValue = { + previewedEmoji: EmojiDataEmoji | null; + setPreviewedEmoji: (emoji: EmojiDataEmoji | null) => void; +}; + +const EmojiPickerPreviewContext = createContext< + EmojiPickerPreviewContextValue | undefined +>(undefined); + +export const EmojiPickerPreviewProvider = EmojiPickerPreviewContext.Provider; + +/** + * Internal (NOT exported from the `emojis` entry): the hovered/focused emoji shared + * between the default grid (writes) and the default preview (reads). Kept out of the + * public `EmojiPickerContext` so hover changes don't re-render the grid, and so a custom + * grid does not implicitly drive the default preview โ€” hover-preview is a private + * mechanic of the default components. + */ +export const useEmojiPickerPreviewContext = () => { + const context = useContext(EmojiPickerPreviewContext); + if (!context) { + throw new Error( + 'Emoji preview components must be rendered within a StreamEmojiPicker.Root.', + ); + } + return context; +}; diff --git a/src/plugins/Emojis/context/__tests__/EmojiPickerContext.test.tsx b/src/plugins/Emojis/context/__tests__/EmojiPickerContext.test.tsx new file mode 100644 index 0000000000..c7254ac787 --- /dev/null +++ b/src/plugins/Emojis/context/__tests__/EmojiPickerContext.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +const { hookState } = vi.hoisted(() => ({ + hookState: { data: null as unknown, error: false, retry: vi.fn() }, +})); +vi.mock('../../hooks/useEmojiPickerState', () => ({ + useEmojiPickerState: () => hookState, +})); +vi.mock('../../../../context', () => ({ + useTranslationContext: () => ({ t: (k: string) => k }), +})); + +import { EmojiPickerRoot } from '../../components/EmojiPickerRoot'; +import { useEmojiPickerContext } from '../EmojiPickerContext'; + +const DATA = { + aliases: {}, + categories: [{ emojis: ['grinning'], id: 'people' }], + emojis: { + grinning: { + id: 'grinning', + keywords: [], + name: 'Grinning', + skins: [{ native: '๐Ÿ˜€', unified: '1f600' }], + version: 1, + }, + }, +}; + +function Probe() { + const ctx = useEmojiPickerContext(); + return ( + <> + {ctx.status} + {ctx.categories.map((c) => c.id).join(',')} + {ctx.skinTones.length} + {ctx.resolveNative(ctx.categories[0].emojis[0])} + + ); +} + +describe('EmojiPickerRoot context', () => { + beforeEach(() => { + hookState.data = DATA; + hookState.error = false; + }); + + it('exposes the full contract to slot consumers', () => { + render( + {}}> + + , + ); + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + expect(screen.getByTestId('cats')).toHaveTextContent('people'); + expect(screen.getByTestId('tones')).toHaveTextContent('6'); + expect(screen.getByTestId('native')).toHaveTextContent('๐Ÿ˜€'); + }); + + it('closes on Escape from the dialog container', () => { + const onClose = vi.fn(); + render( + {}}> + slot + , + ); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/Emojis/index.ts b/src/plugins/Emojis/index.ts index 75470a3e9d..97d4effdad 100644 --- a/src/plugins/Emojis/index.ts +++ b/src/plugins/Emojis/index.ts @@ -1,5 +1,9 @@ export * from './compat'; export * from './components'; +export { + type EmojiPickerContextValue, + useEmojiPickerContext, +} from './context/EmojiPickerContext'; export * from './data'; export * from './EmojiPicker'; export * from './middleware'; From c46e9390983afa39b4ba9bb1c8610438650c3822 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 13 Jul 2026 14:25:19 +0200 Subject: [PATCH 31/36] docs(emojis): document StreamEmojiPicker composition + add a composed-grid example - AI.md: add a composition section (`StreamEmojiPicker.Root` + slots + `useEmojiPickerContext`), the "SDK owns data + selection; you own presentation + behavior" rule, and a paged-grid example. - vite example: add a "Stream (composed)" engine option that assembles the picker from `StreamEmojiPicker.Root` + the built-in nav/search/preview/skin-tone with a custom `PagedGrid`, demonstrating built-in-Nav + custom-Grid interop through shared state. --- AI.md | 54 +++++++++++++ examples/vite/src/AppSettings/state.ts | 2 +- .../tabs/EmojiPicker/EmojiPickerTab.tsx | 78 +++++++++++++++++-- 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/AI.md b/AI.md index 6a32312568..83b229940b 100644 --- a/AI.md +++ b/AI.md @@ -285,6 +285,60 @@ import 'stream-chat-react/css/emoji-picker.css'; picker โ€” keep using the deprecated `EmojiPicker` if you still need them. Try it live in the "Emoji Picker" settings tab of `examples/vite/`. +- **Custom layouts (composition).** For a non-standard arrangement, or to replace a part, + compose the picker from its slots instead of rearranging props. Rendered directly, + `StreamEmojiPicker` is the preset; `StreamEmojiPicker.Root` + the slots let you assemble + it yourself: + + ```tsx + import { StreamEmojiPicker, useEmojiPickerContext } from 'stream-chat-react/emojis'; + + + + + + + ; + ``` + + The rule: **the SDK owns the data + the selection; you own presentation + behavior.** + `Root` exposes everything through `useEmojiPickerContext()` โ€” read-only data + (`categories`, `searchResults`, `status`, `skinTones`, `resolveNative`) and report-back + setters (`selectEmoji`, `setQuery`, `setSkinTone`, `setActiveCategory` / + `requestScrollToCategory`). A custom slot is just a component that calls the hook; when + you replace a slot, that slot's mechanics (scrolling, scroll-spy, roving keyboard, ARIA, + virtualization, hover-preview) become yours, but it keeps talking to the others through + the shared state. Example โ€” a paged grid driven by the built-in `Nav`: + + ```tsx + function PagedGrid() { + const { + categories, + activeCategoryId, + searchResults, + isSearching, + selectEmoji, + resolveNative, + } = useEmojiPickerContext(); + const emojis = isSearching + ? (searchResults ?? []) + : ((categories.find((c) => c.id === activeCategoryId) ?? categories[0])?.emojis ?? + []); + return ( +
+ {emojis.map((e) => ( + + ))} +
+ ); + } + ``` + + `Root` always keeps the container-level a11y (dialog role, Escape-to-close, + focus-return) and the loading/error states, regardless of how you recompose the inside. + - To let users **react with any emoji** (the reaction selector's `+` button), fill `reactionOptions.extended` with the full emoji set. It also gates display โ€” a reaction whose type isn't in `quick`/`extended` is not rendered. Load it lazily from the emojis diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 94f4f8aed5..ee4bbbba31 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -13,7 +13,7 @@ export type ChatViewSettingsState = { export type EmojiPickerSettingsState = { autoFocus: boolean; - engine: 'emoji-mart' | 'stream'; + engine: 'emoji-mart' | 'stream' | 'stream-composed'; }; // Mirrors the SDK's EmojiPicker defaults; the settings tab resets to this. diff --git a/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx b/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx index 80588281b6..0120c2e22c 100644 --- a/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx +++ b/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx @@ -1,7 +1,12 @@ import EmojiMartPickerImport from '@emoji-mart/react'; import { type ComponentType, useState } from 'react'; import { Button } from 'stream-chat-react'; -import { EmojiPickerPanel, type EmojiSelection } from 'stream-chat-react/emojis'; +import { + EmojiPickerPanel, + type EmojiSelection, + StreamEmojiPicker, + useEmojiPickerContext, +} from 'stream-chat-react/emojis'; import { appSettingsStore, DEFAULT_EMOJI_PICKER_SETTINGS, @@ -62,10 +67,52 @@ function Field({ ); } +/** + * A custom "paged" grid slot โ€” renders only the active category (a layout the default + * scrolling grid can't do), driven entirely by context: it reads `activeCategoryId` + * (which the built-in Nav sets) and reports selection via `selectEmoji`. Demonstrates + * that a replacement slot only needs the SDK's data + report-back APIs. + */ +const PagedGrid = () => { + const { + activeCategoryId, + categories, + isSearching, + resolveNative, + searchResults, + selectEmoji, + } = useEmojiPickerContext(); + const emojis = isSearching + ? (searchResults ?? []) + : ((categories.find((category) => category.id === activeCategoryId) ?? categories[0]) + ?.emojis ?? []); + + return ( +
+
+
+ {emojis.map((emoji) => ( + + ))} +
+
+
+ ); +}; + /** * Always-open picker wired to the current settings, so tweaks show instantly without * opening the composer. Skin tone and frequently-used are local to the preview โ€” - * selecting an emoji here feeds the "frequently used" row. + * selecting an emoji here feeds the "frequently used" row. The `stream-composed` engine + * shows the same picker assembled from `StreamEmojiPicker.Root` + slots, swapping in the + * custom `PagedGrid` while keeping the built-in nav/search/preview/skin-tone. */ const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) => { const { mode } = useAppSettingsSelector((state) => state.theme); @@ -73,6 +120,9 @@ const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) const [skinTone, setSkinTone] = useState(0); const [frequentlyUsedIds, setFrequentlyUsedIds] = useState([]); + const recordUse = (emoji: EmojiSelection) => + setFrequentlyUsedIds((ids) => [emoji.id, ...ids.filter((id) => id !== emoji.id)]); + // The deprecated emoji-mart picker renders inline too and honors the same option // names, so the shared controls drive both engines. if (engine === 'emoji-mart') { @@ -86,13 +136,30 @@ const EmojiPickerPreview = ({ options }: { options: EmojiPickerSettingsState }) ); } + if (engine === 'stream-composed') { + return ( + + + + +
+ + +
+
+ ); + } + return ( - setFrequentlyUsedIds((ids) => [emoji.id, ...ids.filter((id) => id !== emoji.id)]) - } + onEmojiSelect={recordUse} onSkinToneChange={setSkinTone} skinToneIndex={skinTone} /> @@ -139,6 +206,7 @@ export const EmojiPickerTab = ({ close }: EmojiPickerTabProps) => { onSelect={(engine) => update({ engine })} options={[ { label: 'Stream (native)', value: 'stream' }, + { label: 'Stream (composed)', value: 'stream-composed' }, { label: 'emoji-mart (deprecated)', value: 'emoji-mart' }, ]} value={emojiPicker.engine} From fdce557edc1bd47b5fe62020c8a3d09c3e9de065 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 13 Jul 2026 15:18:47 +0200 Subject: [PATCH 32/36] fix(examples): scroll the composed emoji grid instead of overflowing The vite example's custom `PagedGrid` used the bare `str-chat__emoji-picker__grid` class, which has no CSS rule of its own (it is only react-virtuoso's className), so its non-virtualized cells overflowed the fixed-height picker body and overlapped the preview footer. Use the SDK's scrollable `str-chat__emoji-picker__grid-container` class (`block-size: 100%` + `overflow-y`), the same one the built-in search-results view uses. --- .../vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx b/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx index 0120c2e22c..d4f99e7143 100644 --- a/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx +++ b/examples/vite/src/AppSettings/tabs/EmojiPicker/EmojiPickerTab.tsx @@ -87,9 +87,12 @@ const PagedGrid = () => { : ((categories.find((category) => category.id === activeCategoryId) ?? categories[0]) ?.emojis ?? []); + // A plain (non-virtualized) grid must bring its own scroll โ€” the default grid gets it + // from Virtuoso. `__grid-container` is the SDK's scrollable body class (block-size:100% + // + overflow-y), the same one the built-in search-results view uses. return (
-
+
{emojis.map((emoji) => ( -
, - ); - const input = screen.getByPlaceholderText('Search emoji'); - fireEvent.keyDown(input, { key: 'ArrowDown' }); - expect(screen.getByRole('button', { name: '๐Ÿ˜€' })).toHaveFocus(); - }); -}); diff --git a/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx b/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx index 0c6c350740..d7fdff8e63 100644 --- a/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx +++ b/src/plugins/Emojis/components/__tests__/SkinToneSelector.test.tsx @@ -39,7 +39,7 @@ describe('SkinToneSelector radiogroup keyboard navigation', () => { expect(radios[0]).toHaveAttribute('tabindex', '-1'); }); - it('selects the next/previous tone with arrow keys (selection follows focus)', () => { + it('moves the selection with Arrow/Home/End keys (selection follows focus)', () => { const setSkinTone = setup(0); render(); openGroup(); @@ -49,14 +49,6 @@ describe('SkinToneSelector radiogroup keyboard navigation', () => { expect(setSkinTone).toHaveBeenLastCalledWith(1); fireEvent.keyDown(group, { key: 'ArrowLeft' }); expect(setSkinTone).toHaveBeenLastCalledWith(5); // wraps from 0 to the last tone - }); - - it('jumps to the first/last tone with Home/End', () => { - const setSkinTone = setup(2); - render(); - openGroup(); - - const group = screen.getByRole('radiogroup'); fireEvent.keyDown(group, { key: 'End' }); expect(setSkinTone).toHaveBeenLastCalledWith(5); fireEvent.keyDown(group, { key: 'Home' }); diff --git a/src/plugins/Emojis/context/__tests__/EmojiPickerContext.test.tsx b/src/plugins/Emojis/context/__tests__/EmojiPickerContext.test.tsx index c7254ac787..0021c90fc3 100644 --- a/src/plugins/Emojis/context/__tests__/EmojiPickerContext.test.tsx +++ b/src/plugins/Emojis/context/__tests__/EmojiPickerContext.test.tsx @@ -13,9 +13,11 @@ vi.mock('../../../../context', () => ({ import { EmojiPickerRoot } from '../../components/EmojiPickerRoot'; import { useEmojiPickerContext } from '../EmojiPickerContext'; +// `grinning` has no skin variants; `wave` has all six, so resolveNative can be exercised +// across tones (this is where the skin-tone resolution lives now, not in EmojiButton). const DATA = { aliases: {}, - categories: [{ emojis: ['grinning'], id: 'people' }], + categories: [{ emojis: ['grinning', 'wave'], id: 'people' }], emojis: { grinning: { id: 'grinning', @@ -24,20 +26,46 @@ const DATA = { skins: [{ native: '๐Ÿ˜€', unified: '1f600' }], version: 1, }, + wave: { + id: 'wave', + keywords: [], + name: 'Waving Hand', + skins: [ + { native: '๐Ÿ‘‹', unified: '1f44b' }, + { native: '๐Ÿ‘‹๐Ÿป', unified: '1f44b-1f3fb' }, + { native: '๐Ÿ‘‹๐Ÿผ', unified: '1f44b-1f3fc' }, + { native: '๐Ÿ‘‹๐Ÿฝ', unified: '1f44b-1f3fd' }, + { native: '๐Ÿ‘‹๐Ÿพ', unified: '1f44b-1f3fe' }, + { native: '๐Ÿ‘‹๐Ÿฟ', unified: '1f44b-1f3ff' }, + ], + version: 1, + }, }, }; -function Probe() { +const ContractProbe = () => { const ctx = useEmojiPickerContext(); return ( <> {ctx.status} {ctx.categories.map((c) => c.id).join(',')} {ctx.skinTones.length} - {ctx.resolveNative(ctx.categories[0].emojis[0])} ); -} +}; + +const NativeProbe = () => { + const { categories, resolveNative } = useEmojiPickerContext(); + return ( +
    + {categories[0]?.emojis.map((e) => ( +
  • + {resolveNative(e)} +
  • + ))} +
+ ); +}; describe('EmojiPickerRoot context', () => { beforeEach(() => { @@ -48,13 +76,23 @@ describe('EmojiPickerRoot context', () => { it('exposes the full contract to slot consumers', () => { render( {}}> - + , ); expect(screen.getByTestId('status')).toHaveTextContent('ready'); expect(screen.getByTestId('cats')).toHaveTextContent('people'); expect(screen.getByTestId('tones')).toHaveTextContent('6'); - expect(screen.getByTestId('native')).toHaveTextContent('๐Ÿ˜€'); + }); + + it('resolveNative returns the toned glyph for skin-capable emoji, the default otherwise', () => { + render( + {}} skinToneIndex={5}> + + , + ); + expect(screen.getByTestId('native-wave')).toHaveTextContent('๐Ÿ‘‹๐Ÿฟ'); + // No skin variants โ†’ falls back to skins[0] regardless of the active tone. + expect(screen.getByTestId('native-grinning')).toHaveTextContent('๐Ÿ˜€'); }); it('closes on Escape from the dialog container', () => { From eeb8f6d28cf9ec8980eb6561419766a4ad1e3873 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 13 Jul 2026 16:50:23 +0200 Subject: [PATCH 35/36] fix(emojis): stop the emoji grid re-scrolling when categories change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll-to-category effect depended on `scrollToCategory`, whose identity changes with `categories`. Selecting an emoji rebuilds the frequently-used category, so the effect re-fired and yanked the grid back to the last nav-clicked category. Key the effect on the `scrollTarget` nonce alone via a latest-ref, and defer a frame so a freshly-mounted (search โ†’ browse) grid has laid out before scrolling. --- src/plugins/Emojis/components/EmojiGrid.tsx | 19 +++- .../components/__tests__/EmojiGrid.test.tsx | 103 +++++++++++++----- 2 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src/plugins/Emojis/components/EmojiGrid.tsx b/src/plugins/Emojis/components/EmojiGrid.tsx index 8a66a23959..ef809a7965 100644 --- a/src/plugins/Emojis/components/EmojiGrid.tsx +++ b/src/plugins/Emojis/components/EmojiGrid.tsx @@ -52,11 +52,24 @@ export const EmojiGrid = () => { [categories], ); + // Latest-ref so the scroll effect can key on the `scrollTarget` nonce alone. Depending + // on `scrollToCategory` (whose identity changes with `categories`) would re-fire the + // scroll on unrelated updates โ€” e.g. selecting an emoji updates frequently-used, which + // rebuilds `categories` and would otherwise yank the grid back to the last-scrolled + // category. + const scrollToCategoryRef = useRef(scrollToCategory); + scrollToCategoryRef.current = scrollToCategory; + // Nav clicks (and any consumer) request a scroll via `scrollTarget`; the nonce makes a - // repeat request to the same category re-fire. + // repeat request to the same category re-fire. Deferred a frame so a freshly-mounted + // (search โ†’ browse) grid has laid out before we scroll to the requested category. useEffect(() => { - if (scrollTarget) scrollToCategory(scrollTarget.categoryId); - }, [scrollTarget, scrollToCategory]); + if (!scrollTarget) return; + const frame = requestAnimationFrame(() => + scrollToCategoryRef.current(scrollTarget.categoryId), + ); + return () => cancelAnimationFrame(frame); + }, [scrollTarget]); // Keyboard nav can target a category that virtualization has unmounted; give it the // category order + a way to scroll one into view so focus can traverse the whole set. diff --git a/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx index 6777f3178e..8d2aec4456 100644 --- a/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx +++ b/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx @@ -1,38 +1,47 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -// Capture the Virtuoso scroll callbacks so scroll-spy is testable, and render items -// synchronously so the browse view's a11y tree is assertable (jsdom has no layout). +// Capture the Virtuoso scroll callbacks + imperative scroll so scroll-spy and +// scroll-to-category are testable, and render items synchronously so the browse view's +// a11y tree is assertable (jsdom has no layout). const { virtuoso } = vi.hoisted(() => ({ - virtuoso: {} as { + virtuoso: { scrollToIndex: vi.fn() } as { atBottomStateChange?: (atBottom: boolean) => void; rangeChanged?: (range: { endIndex: number; startIndex: number }) => void; + scrollToIndex: ReturnType; }, })); -vi.mock('react-virtuoso', () => ({ - Virtuoso: ({ - atBottomStateChange, - data = [], - itemContent, - rangeChanged, - }: { - atBottomStateChange?: (atBottom: boolean) => void; - data?: unknown[]; - itemContent?: (index: number, item: unknown) => React.ReactNode; - rangeChanged?: (range: { endIndex: number; startIndex: number }) => void; - }) => { - virtuoso.atBottomStateChange = atBottomStateChange; - virtuoso.rangeChanged = rangeChanged; - return ( -
- {data.map((item, index) => ( - {itemContent?.(index, item)} - ))} -
- ); - }, -})); +vi.mock('react-virtuoso', async () => { + const { forwardRef, useImperativeHandle } = await import('react'); + return { + Virtuoso: forwardRef(function Virtuoso( + { + atBottomStateChange, + data = [], + itemContent, + rangeChanged, + }: { + atBottomStateChange?: (atBottom: boolean) => void; + data?: unknown[]; + itemContent?: (index: number, item: unknown) => React.ReactNode; + rangeChanged?: (range: { endIndex: number; startIndex: number }) => void; + }, + ref, + ) { + virtuoso.atBottomStateChange = atBottomStateChange; + virtuoso.rangeChanged = rangeChanged; + useImperativeHandle(ref, () => ({ scrollToIndex: virtuoso.scrollToIndex })); + return ( +
+ {data.map((item, index) => ( + {itemContent?.(index, item)} + ))} +
+ ); + }), + }; +}); const { ctx, preview } = vi.hoisted(() => ({ ctx: {} as Record, @@ -62,7 +71,7 @@ const base = () => ({ categories: [] as EmojiPickerCategory[], isSearching: false, resolveNative: (e: { skins: { native: string }[] }) => e.skins[0]?.native ?? '', - scrollTarget: null, + scrollTarget: null as { categoryId: string; nonce: number } | null, searchResults: null, selectEmoji: vi.fn(), setActiveCategory: vi.fn(), @@ -116,3 +125,43 @@ describe('EmojiGrid scroll-spy', () => { expect(ctx.setActiveCategory).toHaveBeenLastCalledWith('flags'); }); }); + +describe('EmojiGrid scroll-to-category', () => { + beforeEach(() => { + // Run the scroll's requestAnimationFrame defer synchronously so it's assertable. + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 0; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); + virtuoso.scrollToIndex.mockClear(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('scrolls to a requested category once, and not when categories change', () => { + ctx.categories = [ + { emojis: [emoji('grinning', '๐Ÿ˜€')], id: 'people', label: 'People' }, + { emojis: [emoji('checkered_flag', '๐Ÿ')], id: 'flags', label: 'Flags' }, + ]; + ctx.scrollTarget = { categoryId: 'flags', nonce: 1 }; + const { rerender } = render(); + expect(virtuoso.scrollToIndex).toHaveBeenCalledTimes(1); + expect(virtuoso.scrollToIndex).toHaveBeenLastCalledWith({ align: 'start', index: 1 }); + + // A categories change (e.g. frequently-used updating after a selection) must NOT + // re-fire the scroll โ€” otherwise the grid jumps back to the last-requested category. + ctx.categories = [ + { emojis: [emoji('clock', '๐Ÿ•')], id: 'frequent', label: 'Frequently used' }, + ...(ctx.categories as EmojiPickerCategory[]), + ]; + rerender(); + expect(virtuoso.scrollToIndex).toHaveBeenCalledTimes(1); + + // A fresh scroll request (new nonce) does scroll again. + ctx.scrollTarget = { categoryId: 'flags', nonce: 2 }; + rerender(); + expect(virtuoso.scrollToIndex).toHaveBeenCalledTimes(2); + }); +}); From ccb2772e541994accb1a691d73d8ee4bcab98c59 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 13 Jul 2026 17:06:02 +0200 Subject: [PATCH 36/36] perf(emojis): isolate emoji cells from hot picker state via a cell context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmojiButton is memoized and mounts once per emoji (hundreds at a time), but it subscribed to the public EmojiPickerContext. memo guards prop changes, not context changes, so every mounted cell re-rendered whenever a hot field changed identity โ€” activeCategoryId on each scroll-spy tick, query/searchResults on each keystroke, scrollTarget on each nav click โ€” none of which a cell reads. Move the two cold values a cell needs (resolveNative, selectEmoji) into a dedicated internal EmojiPickerCellContext and subscribe the cell there, mirroring the existing preview-context split. Cells now re-render only when the skin tone or onEmojiSelect changes. Both values remain on the public context for custom grid slots, so there is no public API change. --- src/plugins/Emojis/components/EmojiButton.tsx | 8 +- .../Emojis/components/EmojiPickerRoot.tsx | 76 +++++++----- .../components/__tests__/EmojiButton.test.tsx | 117 ++++++++++++++++++ .../components/__tests__/EmojiGrid.test.tsx | 3 + .../Emojis/context/EmojiPickerCellContext.tsx | 36 ++++++ 5 files changed, 204 insertions(+), 36 deletions(-) create mode 100644 src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx create mode 100644 src/plugins/Emojis/context/EmojiPickerCellContext.tsx diff --git a/src/plugins/Emojis/components/EmojiButton.tsx b/src/plugins/Emojis/components/EmojiButton.tsx index 7d34e69e9c..a0ac73886f 100644 --- a/src/plugins/Emojis/components/EmojiButton.tsx +++ b/src/plugins/Emojis/components/EmojiButton.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { useEmojiPickerContext } from '../context/EmojiPickerContext'; +import { useEmojiPickerCellContext } from '../context/EmojiPickerCellContext'; import { useEmojiPickerPreviewContext } from '../context/EmojiPickerPreviewContext'; import type { EmojiDataEmoji } from '../data'; @@ -9,10 +9,12 @@ export type EmojiButtonProps = { /** * A single selectable emoji cell rendering the native unicode glyph for the active - * skin tone. Memoized because the grid can render ~1800 of these. + * skin tone. Memoized because the grid can render ~1800 of these โ€” and it subscribes to + * the cold cell context (not the public picker context) so scroll-spy/search updates, + * which a cell reads none of, never re-render the grid. */ export const EmojiButton = memo(function EmojiButton({ emoji }: EmojiButtonProps) { - const { resolveNative, selectEmoji } = useEmojiPickerContext(); + const { resolveNative, selectEmoji } = useEmojiPickerCellContext(); const { setPreviewedEmoji } = useEmojiPickerPreviewContext(); return ( diff --git a/src/plugins/Emojis/components/EmojiPickerRoot.tsx b/src/plugins/Emojis/components/EmojiPickerRoot.tsx index 1a95f94aa5..8bb7b90ad4 100644 --- a/src/plugins/Emojis/components/EmojiPickerRoot.tsx +++ b/src/plugins/Emojis/components/EmojiPickerRoot.tsx @@ -10,6 +10,7 @@ import { type EmojiPickerCategory } from './EmojiGrid'; import { EMOJI_CATEGORY_META } from './categories'; import { resolveFrequentlyUsedEmoji } from './frequentlyUsed'; import { SKIN_TONES } from './skinTones'; +import { EmojiPickerCellProvider } from '../context/EmojiPickerCellContext'; import { type EmojiPickerContextValue, EmojiPickerProvider, @@ -248,41 +249,50 @@ export const EmojiPickerRoot = ({ [previewedEmoji], ); + // The cold subset the default cell subscribes to, so the grid's ~hundreds of cells + // don't re-render on hot state (scroll-spy active category, live query, scroll target). + const cellValue = useMemo( + () => ({ resolveNative, selectEmoji }), + [resolveNative, selectEmoji], + ); + return ( - -
{ - if (event.key === 'Escape') { - event.stopPropagation(); - onClose?.(); - } - }} - role='dialog' - style={style} - > - {status === 'ready' ? ( - children - ) : status === 'error' ? ( -
-

- {t('Failed to load emojis')} -

- -
- ) : ( -
- )} -
- + + +
{ + if (event.key === 'Escape') { + event.stopPropagation(); + onClose?.(); + } + }} + role='dialog' + style={style} + > + {status === 'ready' ? ( + children + ) : status === 'error' ? ( +
+

+ {t('Failed to load emojis')} +

+ +
+ ) : ( +
+ )} +
+ + ); }; diff --git a/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx new file mode 100644 index 0000000000..65974d9f5b --- /dev/null +++ b/src/plugins/Emojis/components/__tests__/EmojiButton.test.tsx @@ -0,0 +1,117 @@ +import { useMemo, useState } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { EmojiButton } from '../EmojiButton'; +import { + type EmojiPickerContextValue, + EmojiPickerProvider, +} from '../../context/EmojiPickerContext'; +import { EmojiPickerCellProvider } from '../../context/EmojiPickerCellContext'; +import { EmojiPickerPreviewProvider } from '../../context/EmojiPickerPreviewContext'; + +type Emoji = { skins: { native: string }[] }; + +const emoji = (id: string, native: string, name = id) => ({ + id, + keywords: [], + name, + skins: [{ native, unified: '' }], + version: 1, +}); + +const STABLE_EMOJI = emoji('grinning', '๐Ÿ˜€', 'Grinning'); +const selectEmoji = vi.fn(); +// Called once per EmojiButton render (in its body via resolveNative), so its call count +// is a render counter that survives the resolveNative reference changing (skin tone). +const renderProbe = vi.fn(); +const countingResolveNative = (e: Emoji) => { + renderProbe(); + return e.skins[0]?.native ?? ''; +}; + +const previewValue = { previewedEmoji: null, setPreviewedEmoji: vi.fn() }; + +const baseMain = (activeCategoryId: string): EmojiPickerContextValue => ({ + activeCategoryId, + categories: [], + isSearching: false, + query: '', + requestScrollToCategory: vi.fn(), + resolveNative: countingResolveNative, + retry: vi.fn(), + scrollTarget: null, + searchResults: null, + selectEmoji, + setActiveCategory: vi.fn(), + setQuery: vi.fn(), + setSkinTone: vi.fn(), + skinToneIndex: 0, + skinTones: [], + status: 'ready', +}); + +// `resolveNative`/`selectEmoji` live in BOTH the public context (custom grids) and the +// cell context (the default cell); a cell must subscribe to the cold cell context so hot +// state changes never reach it. `scroll-spy` mutates only hot public state; `skin-tone` +// bumps the tone the cell resolver closes over, changing its identity โ€” mirroring Root, +// where resolveNative is a useCallback keyed on skinToneIndex. +function Harness() { + const [hotCategory, setHotCategory] = useState('people'); + const [tone, setTone] = useState(0); + const mainValue = useMemo(() => baseMain(hotCategory), [hotCategory]); + const cellResolveNative = useMemo( + () => (e: Emoji) => { + renderProbe(); + return e.skins[tone]?.native ?? e.skins[0]?.native ?? ''; + }, + [tone], + ); + const cellValue = useMemo( + () => ({ resolveNative: cellResolveNative, selectEmoji }), + [cellResolveNative], + ); + + return ( + + + + + + + + + + ); +} + +describe('EmojiButton context subscription', () => { + beforeEach(() => { + renderProbe.mockClear(); + }); + + it('does not re-render when hot picker state (scroll-spy active category) changes', () => { + render(); + const initial = renderProbe.mock.calls.length; + expect(initial).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: 'scroll-spy' })); + + // The cell reads none of the hot fields, so a scroll-spy update must not re-render it. + expect(renderProbe).toHaveBeenCalledTimes(initial); + }); + + it('re-renders when the resolved glyph changes (skin tone)', () => { + render(); + const initial = renderProbe.mock.calls.length; + + fireEvent.click(screen.getByRole('button', { name: 'skin-tone' })); + + expect(renderProbe.mock.calls.length).toBeGreaterThan(initial); + }); +}); diff --git a/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx b/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx index 8d2aec4456..df4c04a08f 100644 --- a/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx +++ b/src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx @@ -50,6 +50,9 @@ const { ctx, preview } = vi.hoisted(() => ({ vi.mock('../../context/EmojiPickerContext', () => ({ useEmojiPickerContext: () => ctx, })); +vi.mock('../../context/EmojiPickerCellContext', () => ({ + useEmojiPickerCellContext: () => ctx, +})); vi.mock('../../context/EmojiPickerPreviewContext', () => ({ useEmojiPickerPreviewContext: () => preview, })); diff --git a/src/plugins/Emojis/context/EmojiPickerCellContext.tsx b/src/plugins/Emojis/context/EmojiPickerCellContext.tsx new file mode 100644 index 0000000000..544248b09b --- /dev/null +++ b/src/plugins/Emojis/context/EmojiPickerCellContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext } from 'react'; +import type { EmojiDataEmoji } from '../data'; + +export type EmojiPickerCellContextValue = { + /** Resolves an emoji's native glyph for the active skin tone. */ + resolveNative: (emoji: EmojiDataEmoji) => string; + /** Report the user's emoji choice back to the SDK (inserts it). */ + selectEmoji: (emoji: EmojiDataEmoji) => void; +}; + +const EmojiPickerCellContext = createContext( + undefined, +); + +export const EmojiPickerCellProvider = EmojiPickerCellContext.Provider; + +/** + * Internal (NOT exported from the `emojis` entry): the cold subset of the picker + * contract the default emoji cell needs โ€” resolving a glyph and reporting a selection. + * The grid can mount hundreds of cells, and React context has no partial subscription: + * a cell reading the public `EmojiPickerContext` would re-render on every change to its + * hot fields (scroll-spy `activeCategoryId`, the live `query`, `scrollTarget`), even + * though the cell reads none of them. These two values change only when the skin tone or + * `onEmojiSelect` changes โ€” the cases where a cell genuinely must re-render โ€” so the + * default `EmojiButton` subscribes here instead. The same values remain on the public + * `EmojiPickerContext` for custom grid slots. + */ +export const useEmojiPickerCellContext = () => { + const context = useContext(EmojiPickerCellContext); + if (!context) { + throw new Error( + 'Emoji picker cells must be rendered within a StreamEmojiPicker.Root.', + ); + } + return context; +};