import { Categories, CategoryConfig, EmojiData, SuggestionMode, } from '../types/exposedTypes'; import { CustomEmoji } from './customEmojiConfig'; export { Categories }; const categoriesOrdered: Categories[] = [ Categories.SUGGESTED, Categories.CUSTOM, Categories.SMILEYS_PEOPLE, Categories.ANIMALS_NATURE, Categories.FOOD_DRINK, Categories.TRAVEL_PLACES, Categories.ACTIVITIES, Categories.OBJECTS, Categories.SYMBOLS, Categories.FLAGS, ]; export const SuggestedRecent: CategoryConfig = { name: 'Recently Used', category: Categories.SUGGESTED, }; export type CustomCategoryConfig = { category: Categories.CUSTOM; name: string; /** * Selects the `CustomEmoji` group rendered in this section. Each entry * may carry its own `name` and `icon`, so multiple custom sections can * coexist — each placed wherever its entry sits in `categories`. * Entries without a group share the ungrouped customs bucket. */ group?: string; }; /** * Stable identity for a category entry. Custom groups are namespaced so * duplicate `{ category: CUSTOM }` configs no longer collide in React * keys, scroll targets, and active-tab detection. */ export function categoryIdFromCategoryConfig( categoryConfig: CategoryConfig, ): string { const category = categoryFromCategoryConfig(categoryConfig); if (isCustomGroupConfig(categoryConfig)) { return `${category}:${categoryConfig.group}`; } return category; } function isCustomGroupConfig( categoryConfig: CategoryConfig, ): categoryConfig is CustomCategoryConfig & { group: string } { if (categoryConfig.category !== Categories.CUSTOM) { return false; } const group = (categoryConfig as CustomCategoryConfig).group; return typeof group === 'string' && group.length > 0; } /** * The `CustomEmoji` group rendered by a category entry, if any. */ export function customGroupFromCategoryConfig( categoryConfig: CategoryConfig, ): string | undefined { if (isCustomGroupConfig(categoryConfig)) { return categoryConfig.group; } return undefined; } const configByCategory: Record = { [Categories.SUGGESTED]: { category: Categories.SUGGESTED, name: 'Frequently Used', }, [Categories.CUSTOM]: { category: Categories.CUSTOM, name: 'Custom Emojis', }, [Categories.SMILEYS_PEOPLE]: { category: Categories.SMILEYS_PEOPLE, name: 'Smileys & People', }, [Categories.ANIMALS_NATURE]: { category: Categories.ANIMALS_NATURE, name: 'Animals & Nature', }, [Categories.FOOD_DRINK]: { category: Categories.FOOD_DRINK, name: 'Food & Drink', }, [Categories.TRAVEL_PLACES]: { category: Categories.TRAVEL_PLACES, name: 'Travel & Places', }, [Categories.ACTIVITIES]: { category: Categories.ACTIVITIES, name: 'Activities', }, [Categories.OBJECTS]: { category: Categories.OBJECTS, name: 'Objects', }, [Categories.SYMBOLS]: { category: Categories.SYMBOLS, name: 'Symbols', }, [Categories.FLAGS]: { category: Categories.FLAGS, name: 'Flags', }, }; export function baseCategoriesConfig( modifiers?: Record, ): CategoriesConfig { return categoriesOrdered.map((category) => { return { ...configByCategory[category], ...(modifiers && modifiers[category] && modifiers[category]), }; }); } export function categoryFromCategoryConfig(category: CategoryConfig) { return category.category; } export function categoryNameFromCategoryConfig(category: CategoryConfig) { return category.name; } export type CategoriesConfig = CategoryConfig[]; export type UserCategoryConfig = Array; export function mergeCategoriesConfig( userCategoriesConfig: UserCategoryConfig = [], modifiers: CategoryConfigModifiers = {}, emojiData?: EmojiData, customEmojis: CustomEmoji[] = [], ): CategoriesConfig { const extra = ((): Record => { // 1. Start with localized categories from emojiData const fromData = emojiData?.categories ? (Object.fromEntries( Object.entries(emojiData.categories).filter(([, config]) => !!config), ) as Record) : ({} as Record); // 2. Handle "Recently Used" override for explicit 'recent' mode if (modifiers.suggestionMode === SuggestionMode.RECENT) { const recentConfig = ( emojiData?.categories as Record )?.['suggested_recent']; fromData[Categories.SUGGESTED] = recentConfig ? { category: Categories.SUGGESTED, name: recentConfig.name } : SuggestedRecent; } return fromData; })(); const base = baseCategoriesConfig(extra); const explicit: CategoriesConfig = userCategoriesConfig?.length ? userCategoriesConfig.map(mapUserEntry) : base; // Groups without a `categories` entry get an appended section so their // emojis always render somewhere — unless the user opted out of customs // entirely by listing `categories` without CUSTOM. `categories` remains // the override for order, display name, and icon. // https://github.com/ealush/emoji-picker-react/issues/510 const customInPlay = !userCategoriesConfig?.length || userCategoriesConfig.some( entry => entry === Categories.CUSTOM || (typeof entry !== 'string' && entry.category === Categories.CUSTOM), ); const listedGroups = new Set( explicit .map(entry => customGroupFromCategoryConfig(entry)) .filter((group): group is string => !!group), ); const appended: CategoriesConfig = !customInPlay ? [] : Array.from( new Set( customEmojis .map(emoji => emoji.group) .filter((group): group is string => !!group), ), ) .filter(group => !listedGroups.has(group)) .map(group => ({ category: Categories.CUSTOM, name: group, group, })); // Collapse duplicate entries (same identity twice renders the same // section twice under one React key); first occurrence wins. const seen = new Set(); return explicit.concat(appended).filter(entry => { const id = categoryIdFromCategoryConfig(entry); if (seen.has(id)) { return false; } seen.add(id); return true; }); function mapUserEntry(category: Categories | CategoryConfig) { if (typeof category === 'string') { return getBaseConfigByCategory(category, extra[category]); } return { ...getBaseConfigByCategory(category.category, extra[category.category]), ...category, }; } } function getBaseConfigByCategory( category: Categories, modifier: CategoryConfig = {} as CategoryConfig, ) { return Object.assign(configByCategory[category], modifier); } type CategoryConfigModifiers = { suggestionMode?: SuggestionMode; };