diff --git a/containers/GroupScreenAddition.tsx b/containers/GroupScreenAddition.tsx index 00e5ebee1..a16f3183b 100644 --- a/containers/GroupScreenAddition.tsx +++ b/containers/GroupScreenAddition.tsx @@ -27,7 +27,6 @@ import type { ConversationTopic } from "@xmtp/react-native-sdk"; import * as Haptics from "expo-haptics"; import { FC, useCallback, useMemo, useState } from "react"; import { - Alert, Platform, StyleSheet, TouchableOpacity, @@ -42,6 +41,7 @@ import { saveGroupInviteLink, saveInviteIdByGroupId, } from "../features/GroupInvites/groupInvites.utils"; +import { captureErrorWithToast } from "@/utils/capture-error"; type GroupScreenAdditionProps = { topic: ConversationTopic; @@ -113,8 +113,9 @@ export const GroupScreenAddition: FC = ({ setSnackMessage(translate("group_invite_link_created_copied")); }) .catch((err) => { - console.error("Error creating group invite", err); - Alert.alert("An error occurred"); + captureErrorWithToast(err, { + message: translate("group_opertation_an_error_occurred"), + }); }); }, [ currentAccount, diff --git a/containers/GroupScreenDescription.tsx b/containers/GroupScreenDescription.tsx index 2142bc514..defaecb78 100644 --- a/containers/GroupScreenDescription.tsx +++ b/containers/GroupScreenDescription.tsx @@ -1,3 +1,4 @@ +import { captureErrorWithToast } from "@/utils/capture-error"; import { useCurrentAccount } from "@data/store/accountsStore"; import { useGroupDescription } from "@hooks/useGroupDescription"; import { useGroupMembers } from "@hooks/useGroupMembers"; @@ -9,11 +10,9 @@ import { getAddressIsSuperAdmin, } from "@utils/groupUtils/adminUtils"; import { memberCanUpdateGroup } from "@utils/groupUtils/memberCanUpdateGroup"; -import logger from "@utils/logger"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { FC, useCallback, useMemo, useState } from "react"; import { - Alert, Pressable, StyleSheet, Text, @@ -58,8 +57,9 @@ export const GroupScreenDescription: FC = ({ try { await setGroupDescription(editedDescription); } catch (e) { - logger.error(e); - Alert.alert("An error occurred"); + captureErrorWithToast(e, { + message: translate("group_opertation_an_error_occurred"), + }); } }, [editedDescription, setGroupDescription]); diff --git a/containers/GroupScreenName.tsx b/containers/GroupScreenName.tsx index d78ce48f6..d3cad687b 100644 --- a/containers/GroupScreenName.tsx +++ b/containers/GroupScreenName.tsx @@ -1,4 +1,6 @@ import { useGroupName } from "@/hooks/useGroupName"; +import { translate } from "@/i18n"; +import { captureErrorWithToast } from "@/utils/capture-error"; import { useCurrentAccount } from "@data/store/accountsStore"; import { useGroupMembers } from "@hooks/useGroupMembers"; import { useGroupPermissions } from "@hooks/useGroupPermissions"; @@ -8,12 +10,10 @@ import { getAddressIsSuperAdmin, } from "@utils/groupUtils/adminUtils"; import { memberCanUpdateGroup } from "@utils/groupUtils/memberCanUpdateGroup"; -import logger from "@utils/logger"; import { formatGroupName } from "@utils/str"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import React, { FC, useCallback, useMemo, useState } from "react"; import { - Alert, Pressable, StyleSheet, Text, @@ -51,8 +51,9 @@ export const GroupScreenName: FC = ({ topic }) => { setEditing(false); await updateGroupName(editedName); } catch (e) { - logger.error(e); - Alert.alert("An error occurred"); + captureErrorWithToast(e, { + message: translate("group_opertation_an_error_occurred"), + }); } }, [editedName, updateGroupName]); const canEditGroupName = memberCanUpdateGroup( diff --git a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-list.tsx b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-list.tsx index b31e1fcda..445b21f86 100644 --- a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-list.tsx +++ b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-list.tsx @@ -2,7 +2,7 @@ import { AnimatedVStack } from "@/design-system/VStack"; import { BottomSheetFlashList } from "@design-system/BottomSheet/BottomSheetFlashList"; import { BottomSheetFlatList } from "@design-system/BottomSheet/BottomSheetFlatList"; import { ListRenderItem as FlashListRenderItem } from "@shopify/flash-list"; -import { CategorizedEmojisRecord } from "@utils/emojis/interfaces"; +import { ICategorizedEmojisRecord } from "@utils/emojis/emoji-types"; import React, { FC, useCallback, useEffect } from "react"; import { ListRenderItem, @@ -20,7 +20,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { EmojiRow } from "./conversation-message-context-menu-emoji-picker-row"; type EmojiRowListProps = { - emojis: CategorizedEmojisRecord[]; + emojis: ICategorizedEmojisRecord[]; ListHeader?: React.ReactNode; onPress: (emoji: string) => void; }; @@ -51,8 +51,8 @@ export const EmojiRowList: FC = ({ ); }, [emojis.length, height, windowHeight]); - const renderItem: ListRenderItem & - FlashListRenderItem = useCallback( + const renderItem: ListRenderItem & + FlashListRenderItem = useCallback( ({ item }) => , [onPress] ); diff --git a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-row.tsx b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-row.tsx index 3eb55e015..f26fe3c98 100644 --- a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-row.tsx +++ b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-row.tsx @@ -1,15 +1,15 @@ -import { CategorizedEmojisRecord, Emoji } from "@utils/emojis/interfaces"; +import { ICategorizedEmojisRecord, IEmoji } from "@utils/emojis/emoji-types"; import { FC, memo, useMemo } from "react"; import { Platform, Pressable, StyleSheet, Text, View } from "react-native"; type EmojiRowProps = { - item: CategorizedEmojisRecord; + item: ICategorizedEmojisRecord; onPress: (emoji: string) => void; }; export const EmojiRow: FC = memo(({ item, onPress }) => { const items = useMemo(() => { - const sliced: (string | Emoji)[] = item.emojis.slice(0, 6); + const sliced: (string | IEmoji)[] = item.emojis.slice(0, 6); while (sliced.length < 6) { sliced.push(""); } diff --git a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker.tsx b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker.tsx index 492fb3610..843a5e42c 100644 --- a/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker.tsx +++ b/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker.tsx @@ -1,5 +1,6 @@ import { EmojiRowList } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-list"; import { messageContextMenuEmojiPickerBottomSheetRef } from "@/features/conversation/conversation-message/conversation-message-context-menu/conversation-message-context-menu-emoji-picker/conversation-message-context-menu-emoji-picker-utils"; +import { emojiTrie } from "@/utils/emojis/emoji-trie"; import { BottomSheetContentContainer } from "@design-system/BottomSheet/BottomSheetContentContainer"; import { BottomSheetHeader } from "@design-system/BottomSheet/BottomSheetHeader"; import { BottomSheetModal } from "@design-system/BottomSheet/BottomSheetModal"; @@ -9,16 +10,14 @@ import { VStack } from "@design-system/VStack"; import { translate } from "@i18n"; import { ThemedStyle, useAppTheme } from "@theme/useAppTheme"; import { emojis } from "@utils/emojis/emojis"; -import { CategorizedEmojisRecord, Emoji } from "@utils/emojis/interfaces"; -import { matchSorter } from "match-sorter"; -import { debounce } from "perfect-debounce"; -import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { ICategorizedEmojisRecord, IEmoji } from "@utils/emojis/emoji-types"; +import { memo, useCallback, useRef, useState } from "react"; import { TextInput, TextStyle, ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; const flatEmojis = emojis.flatMap((category) => category.data); -const categorizedEmojis: CategorizedEmojisRecord[] = []; +const categorizedEmojis: ICategorizedEmojisRecord[] = []; emojis.forEach((category, index) => { for (let i = 0; i < category.data.length; i += 6) { const slicedEmojis = category.data.slice(i, i + 6).map((emoji) => emoji); @@ -30,8 +29,8 @@ emojis.forEach((category, index) => { } }); -const sliceEmojis = (emojis: Emoji[]) => { - const slicedEmojis: CategorizedEmojisRecord[] = []; +const sliceEmojis = (emojis: IEmoji[]) => { + const slicedEmojis: ICategorizedEmojisRecord[] = []; for (let i = 0; i < emojis.length; i += 6) { const sliced = emojis.slice(i, i + 6).map((emoji) => emoji); slicedEmojis.push({ @@ -43,19 +42,6 @@ const sliceEmojis = (emojis: Emoji[]) => { return slicedEmojis; }; -const filterEmojis = (text: string) => { - const cleanedSearch = text.toLowerCase().trim(); - if (cleanedSearch.length === 0) { - return defaultEmojis; - } - return sliceEmojis( - matchSorter(flatEmojis, cleanedSearch, { - keys: ["keywords", "name", "emoji"], - threshold: matchSorter.rankings.CONTAINS, // Use a less strict threshold - }) - ); -}; - const defaultEmojis = sliceEmojis(flatEmojis); export const MessageContextMenuEmojiPicker = memo( @@ -84,28 +70,26 @@ export const MessageContextMenuEmojiPicker = memo( [onSelectReaction, closeMenu] ); - const debouncedFilter = useMemo( - () => - debounce((value: string) => { - const filtered = filterEmojis(value); - setFilteredReactions(filtered); - setHasInput(value.length > 0); - }, 300), - [] - ); - - const onTextInputChange = useCallback( - (value: string) => { - if (value.trim() === "") { - // Reset immediately when input is cleared - setFilteredReactions(defaultEmojis); - setHasInput(false); - } else { - debouncedFilter(value); - } - }, - [debouncedFilter] - ); + const onTextInputChange = useCallback((value: string) => { + if (value.trim() === "") { + // Reset immediately when input is cleared + setFilteredReactions(defaultEmojis); + setHasInput(false); + } else { + const emojiSet = new Set(); + const emojis = emojiTrie.findAllWithPrefix(value); + const dedupedEmojis = emojis.filter((emoji) => { + if (emojiSet.has(emoji.emoji)) { + return false; + } + emojiSet.add(emoji.emoji); + return true; + }); + const sliced = sliceEmojis(dedupedEmojis); + setFilteredReactions(sliced); + setHasInput(true); + } + }, []); return ( (); + +emojis.forEach((emojiSections) => { + emojiSections.data.forEach((emoji) => { + emoji.keywords.forEach((keyword) => { + emojiTrie.insert(keyword, emoji); + }); + }); +}); diff --git a/utils/emojis/interfaces.ts b/utils/emojis/emoji-types.ts similarity index 55% rename from utils/emojis/interfaces.ts rename to utils/emojis/emoji-types.ts index 8b7f46b77..720ae19b2 100644 --- a/utils/emojis/interfaces.ts +++ b/utils/emojis/emoji-types.ts @@ -1,12 +1,13 @@ -export type Emoji = { +export type IEmoji = { emoji: string; name: string; + v: string; toneEnabled: boolean; keywords: string[]; }; -export type CategorizedEmojisRecord = { +export type ICategorizedEmojisRecord = { id: string; category: string; - emojis: Emoji[]; + emojis: IEmoji[]; }; diff --git a/utils/trie.ts b/utils/trie.ts new file mode 100644 index 000000000..f255abbda --- /dev/null +++ b/utils/trie.ts @@ -0,0 +1,125 @@ +export class TrieNode { + children: Map>; + isEndOfWord: boolean; + value: T | null; + + constructor() { + this.children = new Map(); + this.isEndOfWord = false; + this.value = null; + } +} + +export class Trie { + private root: TrieNode; + + constructor() { + this.root = new TrieNode(); + } + + insert(word: string, value: T): void { + let current = this.root; + + for (const char of word) { + if (!current.children.has(char)) { + current.children.set(char, new TrieNode()); + } + current = current.children.get(char)!; + } + + current.isEndOfWord = true; + current.value = value; + } + + search(rawWord: string): T | null { + const word = rawWord.toLowerCase().trim(); + let current = this.root; + + for (const char of word) { + if (!current.children.has(char)) { + return null; + } + current = current.children.get(char)!; + } + + return current.isEndOfWord ? current.value : null; + } + + startsWith(prefix: string): boolean { + let current = this.root; + + for (const char of prefix) { + if (!current.children.has(char)) { + return false; + } + current = current.children.get(char)!; + } + + return true; + } + + findAllWithPrefix(rawPrefix: string): T[] { + const prefix = rawPrefix.toLowerCase().trim(); + const results: T[] = []; + let current = this.root; + + // Navigate to the node representing the prefix + for (const char of prefix) { + if (!current.children.has(char)) { + return results; + } + current = current.children.get(char)!; + } + + // Collect all values under this node + this.collectValues(current, results); + return results; + } + + private collectValues(node: TrieNode, results: T[]): void { + if (node.isEndOfWord && node.value !== null) { + results.push(node.value); + } + + for (const child of node.children.values()) { + this.collectValues(child, results); + } + } + + delete(word: string): boolean { + return this.deleteRecursive(this.root, word, 0); + } + + private deleteRecursive( + current: TrieNode, + word: string, + index: number + ): boolean { + if (index === word.length) { + if (!current.isEndOfWord) { + return false; + } + current.isEndOfWord = false; + current.value = null; + return current.children.size === 0; + } + + const char = word[index]; + if (!current.children.has(char)) { + return false; + } + + const shouldDeleteChild = this.deleteRecursive( + current.children.get(char)!, + word, + index + 1 + ); + + if (shouldDeleteChild) { + current.children.delete(char); + return current.children.size === 0 && !current.isEndOfWord; + } + + return false; + } +} diff --git a/yarn.lock b/yarn.lock index 4fb6ba605..60a5111c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1844,13 +1844,6 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/runtime@^7.23.8": - version "7.25.6" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2" - integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ== - dependencies: - regenerator-runtime "^0.14.0" - "@babel/runtime@^7.25.0": version "7.25.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb" @@ -17464,14 +17457,6 @@ marky@^1.2.2: resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0" integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q== -match-sorter@^6.3.4: - version "6.3.4" - resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.4.tgz#afa779d8e922c81971fbcb4781c7003ace781be7" - integrity sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg== - dependencies: - "@babel/runtime" "^7.23.8" - remove-accents "0.5.0" - matcher@^1.0.0: version "1.1.1" resolved "https://registry.npmjs.org/matcher/-/matcher-1.1.1.tgz" @@ -20765,11 +20750,6 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" -remove-accents@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.5.0.tgz#77991f37ba212afba162e375b627631315bed687" - integrity sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A== - remove-trailing-slash@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz#be2285a59f39c74d1bce4f825950061915e3780d"