import { Text } from './Text'; import { font, radius, spacing, useColors } from './styles'; import { Search, X, Smile, User, Cat, UtensilsCrossed, Plane, Gamepad2, Lightbulb, Hash, Flag, } from 'lucide-react-native'; import React, { useRef, useState, useCallback, useMemo, useEffect } from 'react'; import { BackHandler, Modal, Pressable, ScrollView, StyleSheet, TextInput, View, Text as RNText, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStrings } from '@/common/hooks/useStrings'; import type { Emoji } from 'emojibase'; const CATEGORY_IDS = [ 'smileys', 'people', 'animals', 'food', 'travel', 'activities', 'objects', 'symbols', 'flags', ] as const; const CATEGORIES = [ { group: 0, id: 'smileys' as const, icon: Smile }, { group: 1, id: 'people' as const, icon: User }, { group: 3, id: 'animals' as const, icon: Cat }, { group: 4, id: 'food' as const, icon: UtensilsCrossed }, { group: 5, id: 'travel' as const, icon: Plane }, { group: 6, id: 'activities' as const, icon: Gamepad2 }, { group: 7, id: 'objects' as const, icon: Lightbulb }, { group: 8, id: 'symbols' as const, icon: Hash }, { group: 9, id: 'flags' as const, icon: Flag }, ] as const; const SKIN_TONES = [ { tone: undefined, color: '#FFCC4D' }, { tone: 1, color: '#FADCBC' }, { tone: 2, color: '#E0BB95' }, { tone: 3, color: '#BF8F68' }, { tone: 4, color: '#9B643D' }, { tone: 5, color: '#594539' }, ]; let cachedData: Emoji[] | null = null; let cachedSections: { id: (typeof CATEGORY_IDS)[number]; emojis: Emoji[] }[] | null = null; export function preloadIconData(): void { if (!cachedData) { cachedSections = null; import('emojibase-data/en/data.json').then(module => { cachedData = module.default as Emoji[]; buildSections(cachedData); }); } } async function loadEmojiData(): Promise { if (cachedData) return cachedData; const module = await import('emojibase-data/en/data.json'); cachedData = module.default as Emoji[]; return cachedData; } function shouldExclude(emoji: Emoji): boolean { if (emoji.group === 2) return true; const code = parseInt(emoji.hexcode.split('-')[0], 16); return code >= 0x1f1e6 && code <= 0x1f1ff; } function buildSections(data: Emoji[]): { id: (typeof CATEGORY_IDS)[number]; emojis: Emoji[] }[] { if (cachedSections) return cachedSections; const groups: Record = {}; for (const emoji of data) { if (shouldExclude(emoji)) continue; const g = emoji.group ?? 0; if (!groups[g]) groups[g] = []; groups[g].push(emoji); } cachedSections = CATEGORIES.filter(c => c.group >= 0 && groups[c.group]?.length).map(c => ({ id: c.id, emojis: groups[c.group], })); return cachedSections; } type Props = { visible: boolean; onClose: () => void; onSelect: (emoji: string) => void; }; export function IconPicker({ visible, onClose, onSelect }: Props) { const c = useColors(); const strings = useStrings(); const insets = useSafeAreaInsets(); const scrollRef = useRef(null); const sectionOffsets = useRef>({}); const isScrollingToSection = useRef(false); const [search, setSearch] = useState(''); const [skinTone, setSkinTone] = useState(undefined); const [showSkinPicker, setShowSkinPicker] = useState(false); const [sections, setSections] = useState< { id: (typeof CATEGORY_IDS)[number]; emojis: Emoji[] }[] >([]); const [ready, setReady] = useState(false); const [activeSection, setActiveSection] = useState(null); useEffect(() => { if (visible && !ready) { loadEmojiData().then(data => { const built = buildSections(data); setSections(built); setReady(true); if (built.length > 0) setActiveSection(built[0].id); }); } }, [visible, ready]); const [prevVisible, setPrevVisible] = useState(visible); if (visible !== prevVisible) { setPrevVisible(visible); if (!visible) { setSearch(''); setShowSkinPicker(false); } } useEffect(() => { if (!visible) return; const handler = () => { onClose(); return true; }; const sub = BackHandler.addEventListener('hardwareBackPress', handler); return () => sub.remove(); }, [visible, onClose]); const searchResults = useMemo(() => { if (!search.trim() || !cachedData) return null; const q = search.toLowerCase(); return cachedData.filter( e => !shouldExclude(e) && (e.label.toLowerCase().includes(q) || e.tags?.some(t => t.toLowerCase().includes(q))) ); }, [search]); const getEmojiWithSkin = useCallback( (emoji: Emoji): string => { if (!skinTone || !emoji.skins) return emoji.emoji; const skinned = emoji.skins.find(s => s.tone === skinTone); return skinned?.emoji ?? emoji.emoji; }, [skinTone] ); const handleSelect = useCallback( (emoji: Emoji) => { onSelect(getEmojiWithSkin(emoji)); }, [onSelect, getEmojiWithSkin] ); const handleCategoryPress = useCallback((id: string) => { setActiveSection(id); const offset = sectionOffsets.current[id]; if (offset !== undefined) { isScrollingToSection.current = true; scrollRef.current?.scrollTo({ y: offset, animated: true }); setTimeout(() => { isScrollingToSection.current = false; }, 500); } }, []); const handleScroll = useCallback((e: { nativeEvent: { contentOffset: { y: number } } }) => { if (isScrollingToSection.current) return; const y = e.nativeEvent.contentOffset.y + 50; const ids = Object.keys(sectionOffsets.current); for (let i = ids.length - 1; i >= 0; i--) { if (sectionOffsets.current[ids[i]] <= y) { setActiveSection(ids[i]); return; } } if (ids.length > 0) setActiveSection(ids[0]); }, []); const handleSectionLayout = useCallback((id: string, y: number) => { sectionOffsets.current[id] = y; }, []); const renderEmojiGrid = (emojis: Emoji[]) => { const rows: Emoji[][] = []; for (let i = 0; i < emojis.length; i += 8) { rows.push(emojis.slice(i, i + 8)); } return rows.map((row, rowIdx) => ( {row.map(emoji => ( handleSelect(emoji)} style={styles.emojiBtn}> {getEmojiWithSkin(emoji)} ))} )); }; return ( {search.length > 0 && ( setSearch('')} hitSlop={8}> )} setShowSkinPicker(!showSkinPicker)} style={[styles.skinBtn, { backgroundColor: c.tagBg }]} > s.tone === skinTone)?.color }, ]} /> {showSkinPicker && ( {SKIN_TONES.map(s => ( { setSkinTone(s.tone); setShowSkinPicker(false); }} style={[ styles.skinOption, skinTone === s.tone && { backgroundColor: c.accent + '30' }, ]} > ))} )} {!ready ? ( {[0, 1, 2].map(section => ( {[0, 1, 2].map(row => ( {[0, 1, 2, 3, 4, 5, 6, 7].map(cell => ( ))} ))} ))} ) : ( {searchResults ? ( searchResults.length > 0 ? ( {strings.userCustomLogs.iconPicker.results} {renderEmojiGrid(searchResults)} ) : ( {strings.userCustomLogs.iconPicker.noResults} ) ) : ( <> {sections.map(section => ( handleSectionLayout(section.id, e.nativeEvent.layout.y)} > {strings.userCustomLogs.iconPicker.categories[section.id]} {renderEmojiGrid(section.emojis)} ))} )} )} {CATEGORIES.map(cat => { const Icon = cat.icon; const isActive = activeSection === cat.id; return ( handleCategoryPress(cat.id)} style={styles.tabBtn} > ); })} ); } const styles = StyleSheet.create({ container: { flex: 1 }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, gap: 8, }, searchBox: { flex: 1, flexDirection: 'row', alignItems: 'center', borderRadius: radius.md, paddingHorizontal: 12, height: 40, gap: 8, }, searchInput: { flex: 1, fontSize: font.md, padding: 0 }, skinBtn: { width: 40, height: 40, borderRadius: radius.md, alignItems: 'center', justifyContent: 'center', }, skinDot: { width: 20, height: 20, borderRadius: 10 }, closeBtn: { padding: 4 }, skinPicker: { flexDirection: 'row', justifyContent: 'center', marginHorizontal: 16, marginBottom: 8, paddingVertical: 8, borderRadius: radius.md, gap: 8, }, skinOption: { padding: 8, borderRadius: radius.sm }, skinDotLarge: { width: 28, height: 28, borderRadius: 14 }, skeletonContainer: { paddingHorizontal: 8, paddingTop: 8 }, skeletonHeader: { width: 60, height: 12, borderRadius: 4, marginVertical: 8, marginHorizontal: 8, }, skeletonCell: { flex: 1, height: 32, margin: 4, borderRadius: 8 }, sectionHeader: { paddingVertical: 8, paddingHorizontal: 8 }, sectionTitle: { fontSize: 12, fontWeight: '600', letterSpacing: 0.5, textTransform: 'uppercase' }, row: { flexDirection: 'row', height: 44 }, emojiBtn: { width: '12.5%', fontSize: 28, textAlign: 'center', textAlignVertical: 'center', lineHeight: 44, }, empty: { textAlign: 'center', paddingVertical: spacing.xl, fontSize: font.md }, tabBar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingTop: 8, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(128,128,128,0.2)', }, tabScroll: { flexDirection: 'row', paddingHorizontal: 8, gap: 4 }, tabBtn: { padding: 10, borderRadius: radius.md }, });