mirror of
https://github.com/hanzo-io/chat.git
synced 2026-10-30 18:57:58 +00:00
192 lines
6.5 KiB
TypeScript
192 lines
6.5 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { View, Text, Dimensions, Pressable } from 'react-native';
|
|
import SearchInput from '../SearchInput/SearchInput';
|
|
import { Image } from 'expo-image';
|
|
import useFileURL from '@hooks/useFileURL';
|
|
import Categories from './Categories';
|
|
import { emojis as EMOJIS, categories as EMOJI_CATEGORIES } from "./emojis.json";
|
|
import { useDebounce } from '@raven/lib/hooks/useDebounce';
|
|
import { BottomSheetFlashList } from '@gorhom/bottom-sheet';
|
|
|
|
const DEFAULT_X_PADDING = 6;
|
|
|
|
export type CategoryType = 'people' | 'nature' | 'foods' | 'activity' | 'places' | 'objects' | 'symbols' | 'flags' | "custom"
|
|
|
|
interface EmojiData {
|
|
id: string,
|
|
name: string,
|
|
keywords: string[],
|
|
skins: {
|
|
unified: string,
|
|
native: string
|
|
}[],
|
|
version: number,
|
|
emoticons?: string[]
|
|
|
|
}
|
|
|
|
export interface Emoji {
|
|
id: string;
|
|
name: string;
|
|
unified: string;
|
|
native: string;
|
|
keywords: string[];
|
|
emoticons?: string[];
|
|
src?: string;
|
|
}
|
|
|
|
export interface CustomEmoji {
|
|
id: string;
|
|
name: string;
|
|
emojis: {
|
|
id: string;
|
|
name: string;
|
|
keywords: string[];
|
|
skins: {
|
|
src: string;
|
|
}[];
|
|
}[];
|
|
}
|
|
|
|
interface EmojiPickerProps {
|
|
customEmojis?: CustomEmoji[] | undefined;
|
|
onSelect: (emoji: Emoji) => void;
|
|
perLine: number;
|
|
defaultCategory?: CategoryType;
|
|
}
|
|
|
|
const EmojiPicker = ({ customEmojis, onSelect, perLine, defaultCategory = "people" }: EmojiPickerProps) => {
|
|
const [category, setCategory] = useState<CategoryType>(defaultCategory);
|
|
const [searchText, setSearchText] = useState<string>('');
|
|
const debouncedText = useDebounce(searchText)
|
|
|
|
const emojis: Record<string, Emoji[]> = useMemo(() => {
|
|
|
|
const emojisByCategory = EMOJI_CATEGORIES.reduce((acc: any, category) => {
|
|
const categoryId = category.id;
|
|
const categoryEmojis: Emoji[] = []
|
|
category.emojis.forEach((emojiId) => {
|
|
const emoji = (EMOJIS as Record<string, EmojiData>)[emojiId as keyof typeof EMOJIS];
|
|
if (!emoji) return;
|
|
categoryEmojis.push({
|
|
id: emoji.id,
|
|
name: emoji.name,
|
|
native: emoji.skins[0].native,
|
|
unified: emoji.skins[0].unified,
|
|
keywords: emoji.keywords,
|
|
emoticons: emoji.emoticons
|
|
})
|
|
})
|
|
|
|
acc[categoryId] = categoryEmojis;
|
|
return acc;
|
|
}, {});
|
|
|
|
if (customEmojis && customEmojis.length > 0) {
|
|
const customCategoryEmojis = customEmojis.flatMap(customCat =>
|
|
customCat.emojis.map(emoji => ({
|
|
id: emoji.id,
|
|
name: emoji.name,
|
|
keywords: emoji.keywords,
|
|
src: emoji.skins[0].src,
|
|
}))
|
|
);
|
|
emojisByCategory['custom'] = customCategoryEmojis;
|
|
}
|
|
|
|
|
|
return emojisByCategory
|
|
}, [customEmojis])
|
|
|
|
const filteredEmojis = useMemo(() => {
|
|
const emojisByCategory = emojis[category];
|
|
|
|
if (!debouncedText) return emojisByCategory;
|
|
|
|
const allEmojis = Object.values(emojis).flat();
|
|
|
|
return allEmojis.filter((emoji: Emoji) => {
|
|
const searchLower = debouncedText.toLowerCase();
|
|
return (
|
|
(emoji.keywords?.some((keyword: string) => keyword.toLowerCase().includes(searchLower)) || false) ||
|
|
((emoji.emoticons || []).some((emoticon: string) => emoticon.toLowerCase().includes(searchLower))) ||
|
|
(emoji.name?.toLowerCase().includes(searchLower) || false) ||
|
|
(emoji.id?.includes(searchLower) || false) ||
|
|
(emoji.unified?.includes(searchLower) || false) ||
|
|
(emoji.native?.includes(searchLower) || false)
|
|
);
|
|
});
|
|
}, [emojis, debouncedText, category]);
|
|
|
|
const { emojiContainerSize } = getEmojiDimensions(DEFAULT_X_PADDING, perLine);
|
|
|
|
return (
|
|
<View className="flex-1">
|
|
<Categories
|
|
onCategorySelect={setCategory}
|
|
activeCategory={category}
|
|
disabledActiveCategory={!!searchText}
|
|
isCustomEmojis={!!customEmojis?.length}
|
|
/>
|
|
<View className='py-3'>
|
|
<SearchInput
|
|
placeholder="Search"
|
|
onChangeText={setSearchText}
|
|
value={searchText}
|
|
/>
|
|
</View>
|
|
<BottomSheetFlashList
|
|
data={filteredEmojis}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={({ item, ...props }) => (
|
|
<EmojiItem emoji={item} onSelect={onSelect} perLine={perLine} {...props} />
|
|
)}
|
|
numColumns={perLine}
|
|
showsVerticalScrollIndicator={false}
|
|
showsHorizontalScrollIndicator={false}
|
|
estimatedItemSize={emojiContainerSize}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default EmojiPicker;
|
|
|
|
const EmojiItem = ({ emoji, onSelect, perLine }: { emoji: Emoji, onSelect: (emoji: Emoji) => void, perLine: number }) => {
|
|
const source = useFileURL(emoji.src);
|
|
const onReact = () => onSelect(emoji);
|
|
const { emojiContainerSize, emojiSize } = getEmojiDimensions(DEFAULT_X_PADDING, perLine);
|
|
|
|
if (!emoji.native) {
|
|
return (
|
|
<Pressable
|
|
className='ios:active:bg-linkColor rounded-full'
|
|
style={{ width: emojiContainerSize, height: emojiContainerSize, justifyContent: "center", alignItems: "center" }}
|
|
onPress={onReact}
|
|
hitSlop={10}
|
|
>
|
|
<Image source={source} style={{ width: emojiSize, height: emojiSize }} contentFit='scale-down' contentPosition={'center'} />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
className='ios:active:bg-linkColor rounded-full'
|
|
style={{ width: emojiContainerSize, height: emojiContainerSize, justifyContent: "center", alignItems: "center" }}
|
|
onPress={onReact}
|
|
hitSlop={10}
|
|
>
|
|
<Text style={{ fontSize: emojiSize }}>{emoji.native}</Text>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
const getEmojiDimensions = (xPadding: number, emojisPerRow: number) => {
|
|
const dimensions = Dimensions.get("screen").width - xPadding;
|
|
const emojiContainerSize = dimensions / emojisPerRow;
|
|
return {
|
|
emojiContainerSize,
|
|
emojiSize: emojiContainerSize * 0.65,
|
|
};
|
|
}; |