From f5ece323435bf8da748e55e006553cbe9face4bb Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 2 Jul 2026 14:30:11 +0200 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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" }