diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx index 5acf810977..7d41c8570c 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/index.tsx @@ -1,6 +1,6 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext } from 'react'; import { SafeAreaView, View } from 'react-native'; -import { Channel, MessageInput, MessageList, useAttachmentPickerContext } from 'stream-chat-expo'; +import { Channel, MessageInput, MessageList } from 'stream-chat-expo'; import { Stack, useRouter } from 'expo-router'; import { AuthProgressLoader } from '../../../components/AuthProgressLoader'; import { AppContext } from '../../../context/AppContext'; @@ -9,13 +9,8 @@ import { useHeaderHeight } from '@react-navigation/elements'; export default function ChannelScreen() { const router = useRouter(); const { setThread, channel } = useContext(AppContext); - const { setTopInset } = useAttachmentPickerContext(); const headerHeight = useHeaderHeight(); - useEffect(() => { - setTopInset(headerHeight); - }, [headerHeight, setTopInset]); - if (!channel) { return ; } diff --git a/examples/ExpoMessaging/components/ChatWrapper.tsx b/examples/ExpoMessaging/components/ChatWrapper.tsx index 258ab2c358..b82d143ee0 100644 --- a/examples/ExpoMessaging/components/ChatWrapper.tsx +++ b/examples/ExpoMessaging/components/ChatWrapper.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useRef } from 'react'; +import React, { PropsWithChildren } from 'react'; import { Chat, OverlayProvider, @@ -8,7 +8,6 @@ import { } from 'stream-chat-expo'; import { AuthProgressLoader } from './AuthProgressLoader'; import { STREAM_API_KEY, user, userToken } from '../constants'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStreamChatTheme } from '../useStreamChatTheme'; const streami18n = new Streami18n({ @@ -20,7 +19,6 @@ SqliteClient.logger = (level, message, extraData) => { }; export const ChatWrapper = ({ children }: PropsWithChildren<{}>) => { - const { bottom } = useSafeAreaInsets(); const chatClient = useCreateChatClient({ apiKey: STREAM_API_KEY, userData: user, @@ -33,12 +31,8 @@ export const ChatWrapper = ({ children }: PropsWithChildren<{}>) => { } return ( - - + + {children} diff --git a/examples/ExpoMessaging/yarn.lock b/examples/ExpoMessaging/yarn.lock index 035bba5488..bb8636f731 100644 --- a/examples/ExpoMessaging/yarn.lock +++ b/examples/ExpoMessaging/yarn.lock @@ -1404,6 +1404,11 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.27.1": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" + integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== + "@babel/template@^7.20.7", "@babel/template@^7.21.9": version "7.21.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.21.9.tgz#bf8dad2859130ae46088a99c1f265394877446fb" @@ -2581,6 +2586,11 @@ dependencies: "@types/yargs-parser" "*" +"@ungap/structured-clone@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + "@urql/core@^5.0.0", "@urql/core@^5.0.6": version "5.0.8" resolved "https://registry.yarnpkg.com/@urql/core/-/core-5.0.8.tgz#eba39eaa2bf9a0a963383e87a65cba7a9ca794bd" @@ -4120,6 +4130,13 @@ i18next@^21.10.0: dependencies: "@babel/runtime" "^7.17.2" +i18next@^25.2.1: + version "25.2.1" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.2.1.tgz#23cf8794904f551f577558d93c84b0fb6cd489a2" + integrity sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw== + dependencies: + "@babel/runtime" "^7.27.1" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -6110,10 +6127,10 @@ stream-buffers@2.2.x: version "0.0.0" uid "" -stream-chat-react-native-core@7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.1.2.tgz#5870a1188ecbf8c3b705d74379d19ff77efce2c5" - integrity sha512-Ob+V8tt+7L+7BRkWyWbFlju6E/8MAoB/NUZ8ENtEEijq5QBNWnVvZctQSZuekIOVrfoP9EenlIOPadHnN/mvYA== +stream-chat-react-native-core@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.2.0.tgz#9c60f0235a84f22077dd56c57a532e19701dcd15" + integrity sha512-DXToshO7/6Bu+Rk03fTeP3W3LFlkOpRph/Iu9OtAU0QzDdG2IgMa1tNhiDEmFmJHjWROjFZtBmyV1Cuk4vFiAQ== dependencies: "@gorhom/bottom-sheet" "^5.1.6" dayjs "1.11.13" @@ -6133,10 +6150,10 @@ stream-chat-react-native-core@7.1.2: version "0.0.0" uid "" -stream-chat@^9.7.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.7.0.tgz#8302a4dfd2b68115c57cd0a102976542a79cf132" - integrity sha512-8K4RQAUFfznCxpJ5CMIrMQQLroaZ1snB4aR/Xnwa9UpxNCzn3kIi61AVkfsaHTHGojPz5LA3c3faVb251u4HnA== +stream-chat@^9.7.0, stream-chat@^9.9.0: + version "9.9.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.9.0.tgz#59ac996e6e0ca6b3e3a0041954ae99a43541ec13" + integrity sha512-4tqedL7NfDhwJIKRBKGdvNu4x0ifKO+qxyc9TEWe+LLaW3Qed4txKysrVKnDfj/rx3iZuIwrMV7VeW5yxZfP5w== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 1128c7bbf3..9a14e01c44 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -3,10 +3,12 @@ import { DevSettings, LogBox, Platform, useColorScheme } from 'react-native'; import { createDrawerNavigator } from '@react-navigation/drawer'; import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; -import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Chat, + createTextComposerEmojiMiddleware, OverlayProvider, + setupCommandUIMiddlewares, SqliteClient, ThemeProvider, useOverlayContext, @@ -34,8 +36,12 @@ import { OneOnOneChannelDetailScreen } from './src/screens/OneOnOneChannelDetail import { SharedGroupsScreen } from './src/screens/SharedGroupsScreen'; import { ThreadScreen } from './src/screens/ThreadScreen'; import { UserSelectorScreen } from './src/screens/UserSelectorScreen'; +import { init, SearchIndex } from 'emoji-mart'; +import data from '@emoji-mart/data'; -import type { LocalMessage, StreamChat } from 'stream-chat'; +import type { LocalMessage, StreamChat, TextComposerMiddleware } from 'stream-chat'; + +init({ data }); if (__DEV__) { DevSettings.addMenuItem('Reset local DB (offline storage)', () => { @@ -118,6 +124,31 @@ const App = () => { }; }, []); + useEffect(() => { + if (!chatClient) { + return; + } + chatClient.setMessageComposerSetupFunction(({ composer }) => { + composer.updateConfig({ + drafts: { + enabled: true, + }, + }); + + setupCommandUIMiddlewares(composer); + + composer.textComposer.middlewareExecutor.insert({ + middleware: [ + createTextComposerEmojiMiddleware({ + emojiSearchIndex: SearchIndex, + }) as TextComposerMiddleware, + ], + position: { after: 'stream-io/text-composer/mentions-middleware' }, + unique: true, + }); + }); + }, [chatClient]); + return ( !!message.ai_generated; const DrawerNavigatorWrapper: React.FC<{ chatClient: StreamChat; }> = ({ chatClient }) => { - const { bottom } = useSafeAreaInsets(); const streamChatTheme = useStreamChatTheme(); return ( - + 1.0) - SDWebImage/Core (~> 5.10) - SocketRocket (0.7.1) - - stream-chat-react-native (7.1.2): + - stream-chat-react-native (7.2.0): - DoubleConversion - glog - hermes-engine @@ -2872,7 +2872,7 @@ SPEC CHECKSUMS: SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: 45d46c6a3188edf8dfe9c85cd884457ccc232b74 + stream-chat-react-native: 2de2866392bfecadf005ec5f0b1f882b613b89ba Yoga: b2eaabf17044cd4273a661b14eb83f9fd2c90491 PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index def1f231b4..f0ae24be80 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -23,6 +23,7 @@ "clean-all": "yarn clean && rm -rf node_modules && rm -rf ios/Pods && rm -rf vendor && bundle install && yarn install && cd ios && bundle exec pod install && cd -" }, "dependencies": { + "@emoji-mart/data": "^1.2.1", "@notifee/react-native": "^9.1.8", "@op-engineering/op-sqlite": "^14.0.4", "@react-native-async-storage/async-storage": "^2.2.0", @@ -36,6 +37,8 @@ "@react-navigation/native": "^7.1.10", "@react-navigation/stack": "^7.3.3", "react": "19.0.0", + "emoji-mart": "^5.6.0", + "lodash.mergewith": "^4.6.2", "react-native": "^0.79.3", "react-native-audio-recorder-player": "^3.6.13", "react-native-blob-util": "^0.22.2", @@ -64,6 +67,7 @@ "@react-native/typescript-config": "0.79.3", "@rnx-kit/metro-config": "^2.1.0", "@types/jest": "^29.5.14", + "@types/lodash.mergewith": "^4.6.9", "@types/react": "^19.0.0", "@types/react-test-renderer": "^19.0.0", "eslint": "^9.28.0", diff --git a/examples/SampleApp/src/components/BottomTabs.tsx b/examples/SampleApp/src/components/BottomTabs.tsx index bd42c623e8..0e7b8faa8b 100644 --- a/examples/SampleApp/src/components/BottomTabs.tsx +++ b/examples/SampleApp/src/components/BottomTabs.tsx @@ -11,6 +11,7 @@ import { MentionsTab } from '../icons/MentionsTab'; import type { BottomTabBarProps } from '@react-navigation/bottom-tabs'; import type { Route } from '@react-navigation/native'; +import { DraftsTab } from '../icons/DraftsTab'; const styles = StyleSheet.create({ notification: { @@ -44,6 +45,13 @@ const getTab = (key: string) => { notification: , title: 'Chats', }; + case 'DraftsScreen': + return { + icon: , + iconActive: , + title: 'Drafts', + notification: , + }; case 'ThreadsScreen': return { icon: , diff --git a/examples/SampleApp/src/components/ChannelPreview.tsx b/examples/SampleApp/src/components/ChannelPreview.tsx index eab30d5f08..a14ea3e49b 100644 --- a/examples/SampleApp/src/components/ChannelPreview.tsx +++ b/examples/SampleApp/src/components/ChannelPreview.tsx @@ -71,9 +71,7 @@ const CustomChannelPreviewStatus = ( ); }; -export const ChannelPreview: React.FC = ( - props, -) => { +export const ChannelPreview: React.FC = (props) => { const { channel } = props; const { setOverlay } = useAppOverlayContext(); diff --git a/examples/SampleApp/src/components/DraftsList.tsx b/examples/SampleApp/src/components/DraftsList.tsx new file mode 100644 index 0000000000..fb6cc3cdcd --- /dev/null +++ b/examples/SampleApp/src/components/DraftsList.tsx @@ -0,0 +1,234 @@ +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; +import { DraftsIcon } from '../icons/DraftIcon'; +import { + FileTypes, + MessagePreview, + TranslationContextValue, + useChatContext, + useStateStore, + useTheme, + useTranslationContext, +} from 'stream-chat-react-native'; +import { DraftManagerState, DraftsManager } from '../utils/DraftsManager'; +import { useCallback, useEffect, useMemo } from 'react'; +import dayjs from 'dayjs'; +import { useIsFocused, useNavigation } from '@react-navigation/native'; +import { ChannelResponse, DraftMessage, DraftResponse, MessageResponseBase } from 'stream-chat'; + +export type DraftItemProps = { + type?: 'channel' | 'thread'; + channel?: ChannelResponse; + date?: string; + message: DraftMessage; + // TODO: Fix the type for thread + thread?: MessageResponseBase; +}; + +export const attachmentTypeIconMap = { + audio: '🔈', + file: '📄', + image: '📷', + video: '🎥', + voiceRecording: '🎙️', +} as const; + +const getPreviewFromMessage = ({ + t, + draftMessage, +}: { + t: TranslationContextValue['t']; + draftMessage: DraftMessage; +}) => { + if (draftMessage.attachments?.length) { + const attachment = draftMessage?.attachments?.at(0); + + const attachmentIcon = attachment + ? `${ + attachmentTypeIconMap[ + (attachment.type as keyof typeof attachmentTypeIconMap) ?? 'file' + ] ?? attachmentTypeIconMap.file + } ` + : ''; + + if (attachment?.type === FileTypes.VoiceRecording) { + return [ + { bold: false, text: attachmentIcon }, + { + bold: false, + text: t('Voice message'), + }, + ]; + } + return [ + { bold: false, text: attachmentIcon }, + { + bold: false, + text: + attachment?.type === FileTypes.Image + ? attachment?.fallback + ? attachment?.fallback + : 'N/A' + : attachment?.title + ? attachment?.title + : 'N/A', + }, + ]; + } + + if (draftMessage.text) { + return [ + { + bold: false, + text: draftMessage.text, + }, + ]; + } +}; + +export const DraftItem = ({ type, channel, date, message, thread }: DraftItemProps) => { + const { + theme: { + colors: { grey }, + }, + } = useTheme(); + const navigation = useNavigation(); + const { client } = useChatContext(); + const { t } = useTranslationContext(); + const channelName = channel?.name ? channel.name : 'Channel'; + + const onNavigationHandler = async () => { + if (channel?.type && channel.id) { + const resultChannel = client.channel(channel?.type, channel?.id); + await resultChannel?.watch(); + + if (type === 'thread' && thread?.id) { + navigation.navigate('ThreadScreen', { + thread, + channel: resultChannel, + }); + } else if (type === 'channel') { + navigation.navigate('ChannelScreen', { channel: resultChannel }); + } + } + }; + + const previews = useMemo(() => { + return getPreviewFromMessage({ draftMessage: message, t }); + }, [message, t]); + + return ( + [styles.itemContainer, { opacity: pressed ? 0.8 : 1 }]} + onPress={onNavigationHandler} + > + + + {type === 'channel' ? `# ${channelName}` : `Thread in # ${channelName}`} + + {dayjs(date).fromNow()} + + + + + + + + + ); +}; + +const selector = (nextValue: DraftManagerState) => + ({ + isLoading: nextValue.pagination.isLoading, + isLoadingNext: nextValue.pagination.isLoadingNext, + drafts: nextValue.drafts, + }) as const; + +const renderItem = ({ item }: { item: DraftResponse }) => ( + +); + +const renderEmptyComponent = () => ( + No drafts available +); + +export const DraftsList = () => { + const isFocused = useIsFocused(); + const { client } = useChatContext(); + const draftsManager = useMemo(() => new DraftsManager({ client }), [client]); + + useEffect(() => { + if (isFocused) { + draftsManager.activate(); + } else { + draftsManager.deactivate(); + } + }, [draftsManager, isFocused]); + + useEffect(() => { + draftsManager.registerSubscriptions(); + + return () => { + draftsManager.deactivate(); + draftsManager.unregisterSubscriptions(); + }; + }, [draftsManager]); + + const { isLoading, drafts } = useStateStore(draftsManager.state, selector); + + const onRefresh = useCallback(() => { + draftsManager.reload({ force: true }); + }, [draftsManager]); + + const onEndReached = useCallback(() => { + draftsManager.loadNextPage(); + }, [draftsManager]); + + return ( + item.message.id} + renderItem={renderItem} + onRefresh={onRefresh} + ListEmptyComponent={renderEmptyComponent} + onEndReached={onEndReached} + /> + ); +}; + +const styles = StyleSheet.create({ + itemContainer: { + paddingVertical: 8, + marginHorizontal: 8, + borderBottomWidth: 1, + borderBottomColor: '#ccc', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + name: { + fontSize: 16, + fontWeight: 'bold', + }, + date: {}, + content: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 4, + }, + icon: {}, + text: { + marginLeft: 8, + flexShrink: 1, + }, +}); diff --git a/examples/SampleApp/src/components/ScreenHeader.tsx b/examples/SampleApp/src/components/ScreenHeader.tsx index 9ae7db41ba..aa17025d0a 100644 --- a/examples/SampleApp/src/components/ScreenHeader.tsx +++ b/examples/SampleApp/src/components/ScreenHeader.tsx @@ -1,8 +1,8 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { StyleProp, StyleSheet, Text, TouchableOpacity, View, ViewStyle } from 'react-native'; import { CompositeNavigationProp, useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useAttachmentPickerContext, useTheme } from 'stream-chat-react-native'; +import { useTheme } from 'stream-chat-react-native'; import { ChannelsUnreadCountBadge } from './UnreadCountBadge'; @@ -122,13 +122,6 @@ export const ScreenHeader: React.FC = (props) => { }, } = useTheme(); const insets = useSafeAreaInsets(); - const { setTopInset } = useAttachmentPickerContext(); - - useEffect(() => { - if (setTopInset) { - setTopInset(HEADER_CONTENT_HEIGHT + insets.top); - } - }, [insets.top, setTopInset]); return ( = ({ height = 29, width = 30 }) => { + const { + theme: { + colors: { grey }, + }, + } = useTheme(); + return ( + + + + ); +}; diff --git a/examples/SampleApp/src/icons/DraftsTab.tsx b/examples/SampleApp/src/icons/DraftsTab.tsx new file mode 100644 index 0000000000..1ba2174b8a --- /dev/null +++ b/examples/SampleApp/src/icons/DraftsTab.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Svg, { Path } from 'react-native-svg'; +import { useTheme } from 'stream-chat-react-native'; + +import { IconProps } from '../utils/base'; + +export const DraftsTab: React.FC = ({ active, height = 29, width = 30 }) => { + const { + theme: { + colors: { black, grey }, + }, + } = useTheme(); + return ( + + + + ); +}; diff --git a/examples/SampleApp/src/screens/ChannelListScreen.tsx b/examples/SampleApp/src/screens/ChannelListScreen.tsx index b1aa577a47..e7b5e4ef96 100644 --- a/examples/SampleApp/src/screens/ChannelListScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelListScreen.tsx @@ -59,11 +59,7 @@ const baseFilters = { type: 'messaging', }; -const sort: ChannelSort = [ - { pinned_at: -1 }, - { last_message_at: -1 }, - { updated_at: -1 }, -]; +const sort: ChannelSort = [{ pinned_at: -1 }, { last_message_at: -1 }, { updated_at: -1 }]; const options = { presence: true, diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 3e2c2added..8f52c86ce4 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import type { Channel as StreamChatChannel } from 'stream-chat'; +import type { LocalMessage, Channel as StreamChatChannel } from 'stream-chat'; import { RouteProp, useFocusEffect, useNavigation } from '@react-navigation/native'; import { Channel, @@ -14,21 +14,17 @@ import { useTypingString, AITypingIndicatorView, } from 'stream-chat-react-native'; -import { Platform, StyleSheet, View } from 'react-native'; +import { Platform, Pressable, StyleSheet, View } from 'react-native'; import type { StackNavigationProp } from '@react-navigation/stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAppContext } from '../context/AppContext'; import { ScreenHeader } from '../components/ScreenHeader'; -import { TouchableOpacity } from 'react-native-gesture-handler'; import { useChannelMembersStatus } from '../hooks/useChannelMembersStatus'; import type { StackNavigatorParamList } from '../types'; import { NetworkDownIndicator } from '../components/NetworkDownIndicator'; - -const styles = StyleSheet.create({ - flex: { flex: 1 }, -}); +import { useCreateDraftFocusEffect } from '../utils/useCreateDraftFocusEffect.tsx'; export type ChannelScreenNavigationProp = StackNavigationProp< StackNavigatorParamList, @@ -53,44 +49,53 @@ const ChannelHeader: React.FC = ({ channel }) => { const navigation = useNavigation(); const typing = useTypingString(); - if (!channel || !chatClient) { - return null; - } - const isOneOnOneConversation = channel && Object.values(channel.state.members).length === 2 && channel.id?.indexOf('!members-') === 0; + const onBackPress = useCallback(() => { + if (!navigation.canGoBack()) { + // if no previous screen was present in history, go to the list screen + // this can happen when opened through push notification + navigation.reset({ index: 0, routes: [{ name: 'MessagingScreen' }] }); + } else { + navigation.goBack(); + } + }, [navigation]); + + useCreateDraftFocusEffect(); + + const onRightContentPress = useCallback(() => { + closePicker(); + if (isOneOnOneConversation) { + navigation.navigate('OneOnOneChannelDetailScreen', { + channel, + }); + } else { + navigation.navigate('GroupChannelDetailsScreen', { + channel, + }); + } + }, [channel, closePicker, isOneOnOneConversation, navigation]); + + if (!channel || !chatClient) { + return null; + } + return ( { - if (!navigation.canGoBack()) { - // if no previous screen was present in history, go to the list screen - // this can happen when opened through push notification - navigation.reset({ index: 0, routes: [{ name: 'MessagingScreen' }] }); - } else { - navigation.goBack(); - } - }} + onBack={onBackPress} // eslint-disable-next-line react/no-unstable-nested-components RightContent={() => ( - { - closePicker(); - if (isOneOnOneConversation) { - navigation.navigate('OneOnOneChannelDetailScreen', { - channel, - }); - } else { - navigation.navigate('GroupChannelDetailsScreen', { - channel, - }); - } - }} + ({ + opacity: pressed ? 0.5 : 1, + })} > - + )} showUnreadCountBadge Subtitle={isOnline ? undefined : NetworkDownIndicator} @@ -115,12 +120,9 @@ export const ChannelScreen: React.FC = ({ }, } = useTheme(); - const [channel, setChannel] = useState( - channelFromProp, - ); + const [channel, setChannel] = useState(channelFromProp); - const [selectedThread, setSelectedThread] = - useState(); + const [selectedThread, setSelectedThread] = useState(); useEffect(() => { const initChannel = async () => { @@ -133,7 +135,7 @@ export const ChannelScreen: React.FC = ({ if (!newChannel?.initialized) { await newChannel?.watch(); } - } catch(error) { + } catch (error) { console.log('An error has occurred while watching the channel: ', error); } setChannel(newChannel); @@ -146,13 +148,16 @@ export const ChannelScreen: React.FC = ({ setSelectedThread(undefined); }); - const onThreadSelect = useCallback((thread) => { - setSelectedThread(thread); - navigation.navigate('ThreadScreen', { - channel, - thread, - }); - }, [channel, navigation]); + const onThreadSelect = useCallback( + (thread: LocalMessage | null) => { + setSelectedThread(thread); + navigation.navigate('ThreadScreen', { + channel, + thread, + }); + }, + [channel, navigation], + ); if (!channel || !chatClient) { return null; @@ -172,12 +177,14 @@ export const ChannelScreen: React.FC = ({ thread={selectedThread} > - + ); }; + +const styles = StyleSheet.create({ + flex: { flex: 1 }, +}); diff --git a/examples/SampleApp/src/screens/ChatScreen.tsx b/examples/SampleApp/src/screens/ChatScreen.tsx index 0c94cf78f2..89408668d3 100644 --- a/examples/SampleApp/src/screens/ChatScreen.tsx +++ b/examples/SampleApp/src/screens/ChatScreen.tsx @@ -11,6 +11,7 @@ import type { RouteProp } from '@react-navigation/native'; import type { StackNavigationProp } from '@react-navigation/stack'; import type { BottomTabNavigatorParamList, StackNavigatorParamList } from '../types'; +import { DraftsScreen } from './DraftScreen'; const Tab = createBottomTabNavigator(); @@ -32,5 +33,6 @@ export const ChatScreen: React.FC = () => ( name='ThreadsScreen' options={{ headerShown: false }} /> + ); diff --git a/examples/SampleApp/src/screens/DraftScreen.tsx b/examples/SampleApp/src/screens/DraftScreen.tsx new file mode 100644 index 0000000000..82b69dcde7 --- /dev/null +++ b/examples/SampleApp/src/screens/DraftScreen.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { StackNavigationProp } from '@react-navigation/stack'; +import { BottomTabNavigatorParamList } from '../types'; +import { StyleSheet, View } from 'react-native'; +import { useTheme } from 'stream-chat-react-native'; +import { ChatScreenHeader } from '../components/ChatScreenHeader'; +import { DraftsList } from '../components/DraftsList'; + +export type DraftsScreenProps = { + navigation: StackNavigationProp; +}; + +export const DraftsScreen: React.FC = () => { + const { + theme: { + colors: { white_snow }, + }, + } = useTheme(); + + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + emptyIndicatorContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + emptyIndicatorText: { + fontSize: 14, + paddingTop: 28, + }, +}); diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index ff4d89e2e9..3d6fc4b4a4 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -1,14 +1,7 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { - Channel, - Thread, - ThreadType, - useAttachmentPickerContext, - useTheme, - useTypingString, -} from 'stream-chat-react-native'; +import { Channel, Thread, ThreadType, useTheme, useTypingString } from 'stream-chat-react-native'; import { useStateStore } from 'stream-chat-react-native'; import { ScreenHeader } from '../components/ScreenHeader'; @@ -17,6 +10,7 @@ import type { RouteProp } from '@react-navigation/native'; import type { StackNavigatorParamList } from '../types'; import { LocalMessage, ThreadState, UserResponse } from 'stream-chat'; +import { useCreateDraftFocusEffect } from '../utils/useCreateDraftFocusEffect.tsx'; const selector = (nextValue: ThreadState) => ({ parentMessage: nextValue.parentMessage }) as const; @@ -46,6 +40,8 @@ const ThreadHeader: React.FC = ({ thread }) => { subtitleText = (parentMessage?.user as UserResponse)?.name; } + useCreateDraftFocusEffect(); + return ( = ({ colors: { white }, }, } = useTheme(); - const { setSelectedImages } = useAttachmentPickerContext(); - - useEffect(() => { - setSelectedImages([]); - return () => setSelectedImages([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); return ( diff --git a/examples/SampleApp/src/types.ts b/examples/SampleApp/src/types.ts index aeec68cf85..f4df358b6d 100644 --- a/examples/SampleApp/src/types.ts +++ b/examples/SampleApp/src/types.ts @@ -49,6 +49,7 @@ export type UserSelectorParamList = { export type BottomTabNavigatorParamList = { ChatScreen: undefined; + DraftsScreen: undefined; MentionsScreen: undefined; ThreadsScreen: undefined; }; diff --git a/examples/SampleApp/src/utils/DraftsManager.ts b/examples/SampleApp/src/utils/DraftsManager.ts new file mode 100644 index 0000000000..110defd506 --- /dev/null +++ b/examples/SampleApp/src/utils/DraftsManager.ts @@ -0,0 +1,279 @@ +import { DraftFilters, DraftResponse, DraftSort, Pager, StateStore, StreamChat } from 'stream-chat'; +import { WithSubscriptions } from './WithSubscription'; + +export type QueryDraftOptions = Pager & { + filter?: DraftFilters; + sort?: DraftSort; + user_id?: string; +}; + +const MAX_QUERY_DRAFTS_LIMIT = 25; + +export const DRAFT_MANAGER_INITIAL_STATE = { + active: false, + isDraftsOrderStale: false, + drafts: [], + pagination: { + isLoading: false, + isLoadingNext: false, + nextCursor: null, + }, + ready: false, +}; + +export type DraftManagerState = { + active: boolean; + isDraftsOrderStale: boolean; + pagination: DraftManagerPagination; + ready: boolean; + drafts: DraftResponse[]; +}; + +export type DraftManagerPagination = { + isLoading: boolean; + isLoadingNext: boolean; + nextCursor: string | null; +}; + +export class DraftsManager extends WithSubscriptions { + public readonly state: StateStore; + private client: StreamChat; + private draftsByIdGetterCache: { + drafts: DraftManagerState['drafts']; + draftsById: Record; + }; + + constructor({ client }: { client: StreamChat }) { + super(); + this.client = client; + this.state = new StateStore(DRAFT_MANAGER_INITIAL_STATE); + this.draftsByIdGetterCache = { drafts: [], draftsById: {} }; + } + + public get draftsById() { + const { drafts } = this.state.getLatestValue(); + + if (drafts === this.draftsByIdGetterCache.drafts) { + return this.draftsByIdGetterCache.draftsById; + } + + const draftsById = drafts.reduce>((newDraftsById, draft) => { + newDraftsById[draft.message.id] = draft; + return newDraftsById; + }, {}); + + this.draftsByIdGetterCache.drafts = drafts; + this.draftsByIdGetterCache.draftsById = draftsById; + + return draftsById; + } + + public resetState = () => { + this.state.next(DRAFT_MANAGER_INITIAL_STATE); + }; + + public activate = () => { + this.state.partialNext({ active: true }); + }; + + public deactivate = () => { + this.state.partialNext({ active: false }); + }; + + private subscribeDraftUpdated = () => + this.client.on('draft.updated', (event) => { + if (!event.draft) { + return; + } + + const draftData = event.draft; + + const { drafts } = this.state.getLatestValue(); + + const newDrafts = [...drafts]; + + let existingDraftIndex = -1; + + if (draftData.parent_id) { + existingDraftIndex = drafts.findIndex( + (draft) => + draft.parent_id === draftData.parent_id && + draft.channel?.cid === draftData.channel?.cid, + ); + } else { + existingDraftIndex = drafts.findIndex( + (draft) => draft.channel?.cid === draftData.channel?.cid, + ); + } + + if (existingDraftIndex !== -1) { + newDrafts[existingDraftIndex] = draftData; + } else { + newDrafts.push(draftData); + } + + this.state.partialNext({ drafts: newDrafts }); + }).unsubscribe; + + private subscribeDraftDeleted = () => + this.client.on('draft.deleted', (event) => { + if (!event.draft) { + return; + } + + const { drafts } = this.state.getLatestValue(); + + const newDrafts = [...drafts]; + + const draftData = event.draft; + + let existingDraftIndex = -1; + + if (draftData.parent_id) { + existingDraftIndex = drafts.findIndex( + (draft) => + draft.parent_id === draftData.parent_id && + draft.channel_cid === draftData.channel_cid, + ); + } else { + existingDraftIndex = drafts.findIndex( + (draft) => draft.channel_cid === draftData.channel_cid, + ); + } + + if (existingDraftIndex !== -1) { + newDrafts.splice(existingDraftIndex, 1); + } + + this.state.partialNext({ + drafts: newDrafts, + }); + }).unsubscribe; + + private subscribeReloadOnActivation = () => + this.state.subscribeWithSelector( + (nextValue) => ({ active: nextValue.active }), + ({ active }) => { + if (active) { + this.reload(); + } + }, + ); + + public registerSubscriptions = () => { + if (this.hasSubscriptions) { + return; + } + this.addUnsubscribeFunction(this.subscribeReloadOnActivation()); + this.addUnsubscribeFunction(this.subscribeDraftUpdated()); + this.addUnsubscribeFunction(this.subscribeDraftDeleted()); + }; + + public unregisterSubscriptions = () => { + return super.unregisterSubscriptions(); + }; + + public reload = async ({ force = false } = {}) => { + const { drafts, isDraftsOrderStale, pagination, ready } = this.state.getLatestValue(); + if (pagination.isLoading) { + return; + } + if (!force && ready && !isDraftsOrderStale) { + return; + } + const limit = drafts.length; + + try { + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoading: true, + }, + })); + + const response = await this.queryDrafts({ + limit: Math.min(limit, MAX_QUERY_DRAFTS_LIMIT) || MAX_QUERY_DRAFTS_LIMIT, + }); + + const nextDrafts: DraftResponse[] = []; + + for (const incomingDraft of response.drafts) { + const existingDraft = this.draftsById[incomingDraft.message.id]; + + if (existingDraft) { + // Reuse draft instances if possible + nextDrafts.push(existingDraft); + } else { + nextDrafts.push(incomingDraft); + } + } + + this.state.next((current) => ({ + ...current, + drafts: nextDrafts, + unseenDraftIds: [], + isDraftOrderStale: false, + pagination: { + ...current.pagination, + isLoading: false, + nextCursor: response.next ?? null, + }, + ready: true, + })); + } catch (error) { + this.client.logger('error', (error as Error).message); + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoading: false, + }, + })); + } + }; + + public queryDrafts = async (options: QueryDraftOptions = {}) => { + const response = await this.client.queryDrafts({ + limit: MAX_QUERY_DRAFTS_LIMIT, + ...options, + }); + return response; + }; + + public loadNextPage = async (options: QueryDraftOptions = {}) => { + const { pagination } = this.state.getLatestValue(); + + if (pagination.isLoadingNext || !pagination.nextCursor) { + return; + } + + try { + this.state.partialNext({ pagination: { ...pagination, isLoadingNext: true } }); + + const response = await this.queryDrafts({ + ...options, + next: pagination.nextCursor, + }); + + this.state.next((current) => ({ + ...current, + drafts: response.drafts.length ? current.drafts.concat(response.drafts) : current.drafts, + pagination: { + ...current.pagination, + nextCursor: response.next ?? null, + isLoadingNext: false, + }, + })); + } catch (error) { + this.client.logger('error', (error as Error).message); + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoadingNext: false, + }, + })); + } + }; +} diff --git a/examples/SampleApp/src/utils/WithSubscription.ts b/examples/SampleApp/src/utils/WithSubscription.ts new file mode 100644 index 0000000000..1c62b7b96e --- /dev/null +++ b/examples/SampleApp/src/utils/WithSubscription.ts @@ -0,0 +1,51 @@ +import { Unsubscribe } from 'stream-chat'; + +/** + * @private + * Class to use as a template for subscribable entities. + */ +export abstract class WithSubscriptions { + private unsubscribeFunctions: Set = new Set(); + /** + * Workaround for the missing TS keyword - ensures that inheritants + * overriding `unregisterSubscriptions` call the base method and return + * its unique symbol value. + */ + private static symbol = Symbol(WithSubscriptions.name); + + public abstract registerSubscriptions(): void; + + /** + * Returns a boolean, provides information of whether `registerSubscriptions` + * method has already been called for this instance. + */ + public get hasSubscriptions() { + return this.unsubscribeFunctions.size > 0; + } + + public addUnsubscribeFunction(unsubscribeFunction: Unsubscribe) { + this.unsubscribeFunctions.add(unsubscribeFunction); + } + + /** + * If you re-declare `unregisterSubscriptions` method within your class + * make sure to run the original too. + * + * @example + * ```ts + * class T extends WithSubscriptions { + * ... + * public unregisterSubscriptions = () => { + * this.customThing(); + * return super.unregisterSubscriptions(); + * } + * } + * ``` + */ + public unregisterSubscriptions(): typeof WithSubscriptions.symbol { + this.unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); + this.unsubscribeFunctions.clear(); + + return WithSubscriptions.symbol; + } +} diff --git a/examples/SampleApp/src/utils/useCreateDraftFocusEffect.tsx b/examples/SampleApp/src/utils/useCreateDraftFocusEffect.tsx new file mode 100644 index 0000000000..a75871553b --- /dev/null +++ b/examples/SampleApp/src/utils/useCreateDraftFocusEffect.tsx @@ -0,0 +1,16 @@ +import { useCallback } from 'react'; +import { useMessageComposer } from 'stream-chat-react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; + +export const useCreateDraftFocusEffect = () => { + const navigation = useNavigation(); + const messageComposer = useMessageComposer(); + + useFocusEffect( + useCallback(() => { + return navigation.addListener('beforeRemove', async () => { + await messageComposer.createDraft(); + }); + }, [navigation, messageComposer]), + ); +}; diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 037f7bf276..7e6c0f0844 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -790,7 +790,7 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/runtime@^7.27.6": +"@babel/runtime@^7.27.1", "@babel/runtime@^7.27.6": version "7.27.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== @@ -902,6 +902,11 @@ dependencies: tslib "^2.4.0" +"@emoji-mart/data@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c" + integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56" @@ -2545,6 +2550,18 @@ "@types/ms" "*" "@types/node" "*" +"@types/lodash.mergewith@^4.6.9": + version "4.6.9" + resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.9.tgz#7093028a36de3cae4495d03b9d92c351cab1f8bf" + integrity sha512-fgkoCAOF47K7sxrQ7Mlud2TH023itugZs2bUg8h/KzT+BnZNrR2jAOmaokbLunHNnobXVWOezAeNn/lZqwxkcw== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.17.16" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.16.tgz#94ae78fab4a38d73086e962d0b65c30d816bfb0a" + integrity sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g== + "@types/ms@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" @@ -4054,6 +4071,11 @@ emittery@^0.13.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== +emoji-mart@^5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023" + integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow== + emoji-regex@^10.4.0: version "10.4.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4" @@ -5102,6 +5124,13 @@ i18next@^21.10.0: dependencies: "@babel/runtime" "^7.17.2" +i18next@^25.2.1: + version "25.2.1" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.2.1.tgz#23cf8794904f551f577558d93c84b0fb6cd489a2" + integrity sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw== + dependencies: + "@babel/runtime" "^7.27.1" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -6258,6 +6287,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.mergewith@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" + integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== + lodash.once@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" @@ -7867,10 +7901,10 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat-react-native-core@7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.1.2.tgz#5870a1188ecbf8c3b705d74379d19ff77efce2c5" - integrity sha512-Ob+V8tt+7L+7BRkWyWbFlju6E/8MAoB/NUZ8ENtEEijq5QBNWnVvZctQSZuekIOVrfoP9EenlIOPadHnN/mvYA== +stream-chat-react-native-core@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.2.0.tgz#9c60f0235a84f22077dd56c57a532e19701dcd15" + integrity sha512-DXToshO7/6Bu+Rk03fTeP3W3LFlkOpRph/Iu9OtAU0QzDdG2IgMa1tNhiDEmFmJHjWROjFZtBmyV1Cuk4vFiAQ== dependencies: "@gorhom/bottom-sheet" "^5.1.6" dayjs "1.11.13" @@ -7895,9 +7929,24 @@ stream-chat-react-native-core@7.1.2: uid "" stream-chat@^9.7.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.7.0.tgz#8302a4dfd2b68115c57cd0a102976542a79cf132" - integrity sha512-8K4RQAUFfznCxpJ5CMIrMQQLroaZ1snB4aR/Xnwa9UpxNCzn3kIi61AVkfsaHTHGojPz5LA3c3faVb251u4HnA== + version "9.8.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.8.0.tgz#52782073a3923367fe97638fde39ce18e4eed28a" + integrity sha512-iKFVFOKWuW2/GTWBOps9YWZoQBlXdJ05FiOKXI/AnCMCGzOpmvEyaoCtsktvdeMaetmZojVPbw/5jomP36Qg0Q== + dependencies: + "@types/jsonwebtoken" "^9.0.8" + "@types/ws" "^8.5.14" + axios "^1.6.0" + base64-js "^1.5.1" + form-data "^4.0.0" + isomorphic-ws "^5.0.0" + jsonwebtoken "^9.0.2" + linkifyjs "^4.2.0" + ws "^8.18.1" + +stream-chat@^9.9.0: + version "9.9.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.9.0.tgz#59ac996e6e0ca6b3e3a0041954ae99a43541ec13" + integrity sha512-4tqedL7NfDhwJIKRBKGdvNu4x0ifKO+qxyc9TEWe+LLaW3Qed4txKysrVKnDfj/rx3iZuIwrMV7VeW5yxZfP5w== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/examples/TypeScriptMessaging/App.tsx b/examples/TypeScriptMessaging/App.tsx index 3135dd4dd7..021521e668 100644 --- a/examples/TypeScriptMessaging/App.tsx +++ b/examples/TypeScriptMessaging/App.tsx @@ -1,9 +1,17 @@ import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { I18nManager, LogBox, Platform, SafeAreaView, useColorScheme, View } from 'react-native'; +import { + DevSettings, + I18nManager, + LogBox, + Platform, + SafeAreaView, + useColorScheme, + View, +} from 'react-native'; import { DarkTheme, DefaultTheme, NavigationContainer, RouteProp } from '@react-navigation/native'; import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'; import { useHeaderHeight } from '@react-navigation/elements'; -import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Channel as ChannelType, ChannelSort } from 'stream-chat'; import { Channel, @@ -16,7 +24,6 @@ import { Streami18n, Thread, ThreadContextValue, - useAttachmentPickerContext, useCreateChatClient, useOverlayContext, } from 'stream-chat-react-native'; @@ -36,9 +43,12 @@ const options = { I18nManager.forceRTL(false); -SqliteClient.logger = (level, message, extraData) => { - console.log(level, `SqliteClient: ${message}`, extraData); -}; +if (__DEV__) { + DevSettings.addMenuItem('Reset local DB (offline storage)', () => { + SqliteClient.resetDB(); + console.info('Local DB reset'); + }); +} const apiKey = 'q95x9hkbyd6p'; const userToken = @@ -53,11 +63,7 @@ const filters = { type: 'messaging', }; -const sort: ChannelSort = [ - { pinned_at: -1 }, - { last_message_at: -1 }, - { updated_at: -1 }, -]; +const sort: ChannelSort = [{ pinned_at: -1 }, { last_message_at: -1 }, { updated_at: -1 }]; /** * Start playing with streami18n instance here: @@ -100,7 +106,6 @@ const EmptyHeader = () => <>; const ChannelScreen: React.FC = ({ navigation }) => { const { channel, setThread, thread } = useContext(AppContext); const headerHeight = useHeaderHeight(); - const { setTopInset } = useAttachmentPickerContext(); const { overlay } = useOverlayContext(); useEffect(() => { @@ -109,10 +114,6 @@ const ChannelScreen: React.FC = ({ navigation }) => { }); }, [overlay, navigation]); - useEffect(() => { - setTopInset(headerHeight); - }, [headerHeight, setTopInset]); - if (channel === undefined) { return null; } @@ -193,18 +194,50 @@ const Stack = createStackNavigator(); type AppContextType = { channel: ChannelType | undefined; setChannel: React.Dispatch>; - setThread: React.Dispatch< - React.SetStateAction - >; + setThread: React.Dispatch>; thread: ThreadContextValue['thread'] | undefined; }; const AppContext = React.createContext({} as AppContextType); +const StackNavigator = () => { + const { channel } = useContext(AppContext); + + return ( + + ({ + headerBackTitle: 'Back', + headerRight: EmptyHeader, + headerTitle: channel?.data?.name, + })} + /> + + ({ + headerBackTitle: 'Back', + headerRight: EmptyHeader, + })} + /> + + ); +}; + const App = () => { - const { bottom } = useSafeAreaInsets(); const theme = useStreamChatTheme(); - const { channel } = useContext(AppContext); const chatClient = useCreateChatClient({ apiKey, @@ -212,43 +245,27 @@ const App = () => { tokenOrProvider: userToken, }); + useEffect(() => { + if (!chatClient) { + return; + } + chatClient.setMessageComposerSetupFunction(({ composer }) => { + composer.updateConfig({ + drafts: { + enabled: true, + }, + }); + }); + }, [chatClient]); + if (!chatClient) { return ; } return ( - + - - ({ - headerBackTitle: 'Back', - headerRight: EmptyHeader, - headerTitle: channel?.data?.name, - })} - /> - - ({ headerLeft: EmptyHeader })} - /> - + ); diff --git a/examples/TypeScriptMessaging/Gemfile.lock b/examples/TypeScriptMessaging/Gemfile.lock index ab4e43b9ac..63f01a67fa 100644 --- a/examples/TypeScriptMessaging/Gemfile.lock +++ b/examples/TypeScriptMessaging/Gemfile.lock @@ -18,6 +18,8 @@ GEM json (>= 1.5.1) atomos (0.1.3) base64 (0.2.0) + benchmark (0.3.0) + bigdecimal (3.1.5) claide (1.1.0) cocoapods (1.14.3) addressable (~> 2.8) @@ -69,8 +71,10 @@ GEM i18n (1.14.1) concurrent-ruby (~> 1.0) json (2.7.1) + logger (1.6.0) minitest (5.22.2) molinillo (0.8.0) + mutex_m (0.2.0) nanaimo (0.3.0) nap (1.1.0) netrc (0.11.0) @@ -96,8 +100,12 @@ PLATFORMS DEPENDENCIES activesupport (>= 6.1.7.5, != 7.1.0) + benchmark + bigdecimal cocoapods (>= 1.13, != 1.15.1, != 1.15.0) concurrent-ruby (< 1.3.4) + logger + mutex_m xcodeproj (< 1.26.0) RUBY VERSION diff --git a/examples/TypeScriptMessaging/ios/Podfile.lock b/examples/TypeScriptMessaging/ios/Podfile.lock index b5631d61c6..209c60b85e 100644 --- a/examples/TypeScriptMessaging/ios/Podfile.lock +++ b/examples/TypeScriptMessaging/ios/Podfile.lock @@ -2227,7 +2227,7 @@ PODS: - ReactCommon/turbomodule/core - Yoga - SocketRocket (0.7.1) - - stream-chat-react-native (7.1.1): + - stream-chat-react-native (7.2.0): - DoubleConversion - glog - hermes-engine @@ -2611,9 +2611,9 @@ SPEC CHECKSUMS: RNShare: 339d241dcefeef0099e6681ee1d08634ff669ff7 RNSVG: 50819276c95d91ccd8fbe5cfea7e09a416c9beaa SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: 0247d7d48d21bdee9c12d5ad63fd329fbd7b0d81 + stream-chat-react-native: d220084fa0f8014131a67eda553f0ecdd282380a Yoga: 29f74a5b77dca8c37669e1e1e867e5f4e12407df PODFILE CHECKSUM: 6b7a4b74915b42bfe4ffddaf67cbf5e7a2bfeab3 -COCOAPODS: 1.16.2 +COCOAPODS: 1.14.3 diff --git a/examples/TypeScriptMessaging/yarn.lock b/examples/TypeScriptMessaging/yarn.lock index e9202b4fb8..642e50a128 100644 --- a/examples/TypeScriptMessaging/yarn.lock +++ b/examples/TypeScriptMessaging/yarn.lock @@ -790,7 +790,7 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/runtime@^7.27.6": +"@babel/runtime@^7.27.1", "@babel/runtime@^7.27.6": version "7.27.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== @@ -982,14 +982,6 @@ "@eslint/core" "^0.15.0" levn "^0.4.1" -"@gorhom/bottom-sheet@^5.1.1": - version "5.1.1" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.1.tgz#43ecb9e7b4d4ca4b4cefdf3b6b497f7715f350bc" - integrity sha512-Y8FiuRmeZYaP+ZGQ0axDwWrrKqVp4ByYRs1D2fTJTxHMt081MHHRQsqmZ3SK7AFp3cSID+vTqnD8w/KAASpy+w== - dependencies: - "@gorhom/portal" "1.0.14" - invariant "^2.2.4" - "@gorhom/bottom-sheet@^5.1.6": version "5.1.6" resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.6.tgz#92365894ae4d4eefdbaa577408cfaf62463a9490" @@ -3377,11 +3369,6 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" -dayjs@1.10.5: - version "1.10.5" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.5.tgz#5600df4548fc2453b3f163ebb2abbe965ccfb986" - integrity sha512-BUFis41ikLz+65iH6LHQCDm4YPMj5r1YFLdupPIyM4SGcXMmtiLQ7U37i+hGS8urIuqe7I/ou3IS1jVc4nbN4g== - dayjs@1.11.13, dayjs@^1.8.15: version "1.11.13" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" @@ -3563,7 +3550,7 @@ emittery@^0.13.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== -emoji-regex@^10.3.0, emoji-regex@^10.4.0: +emoji-regex@^10.4.0: version "10.4.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4" integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw== @@ -4558,13 +4545,20 @@ hyochan-welcome@^1.0.0: resolved "https://registry.yarnpkg.com/hyochan-welcome/-/hyochan-welcome-1.0.1.tgz#a949de8bc3c1e18fe096016bc273aa191c844971" integrity sha512-WRZNH5grESkOXP/r7xc7TMhO9cUqxaJIuZcQDAjzHWs6viGP+sWtVbiBigxc9YVRrw3hnkESQWwzqg+oOga65A== -i18next@^21.10.0, i18next@^21.6.14: +i18next@^21.10.0: version "21.10.0" resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.10.0.tgz#85429af55fdca4858345d0e16b584ec29520197d" integrity sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg== dependencies: "@babel/runtime" "^7.17.2" +i18next@^25.2.1: + version "25.2.1" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.2.1.tgz#23cf8794904f551f577558d93c84b0fb6cd489a2" + integrity sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw== + dependencies: + "@babel/runtime" "^7.27.1" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -5637,7 +5631,7 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkifyjs@^4.1.1, linkifyjs@^4.2.0: +linkifyjs@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.2.0.tgz#9dd30222b9cbabec9c950e725ec00031c7fa3f08" integrity sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw== @@ -6017,7 +6011,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== -mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.34, mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -6725,13 +6719,6 @@ react-native-svg@^15.12.0: css-tree "^1.1.3" warn-once "0.1.1" -react-native-url-polyfill@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-1.3.0.tgz#c1763de0f2a8c22cc3e959b654c8790622b6ef6a" - integrity sha512-w9JfSkvpqqlix9UjDvJjm1EjSt652zVQ6iwCIj1cVVkwXf4jQhQgTNXY6EVTwuAmUjg6BC6k9RHCBynoLFo3IQ== - dependencies: - whatwg-url-without-unicode "8.0.0-3" - react-native-url-polyfill@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589" @@ -7280,24 +7267,24 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat-react-native-core@7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.1.1.tgz#b22faf35fa5defd24c730873aeba30c172556089" - integrity sha512-9AkSKWzywN2FfsMgDfeoCatr/qoG+zJzM2u5j3PU6WU7qIhZtM/7+2UB0WKAY7fA5MjaoMEzV1mBF+hILP1KOw== +stream-chat-react-native-core@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.2.0.tgz#9c60f0235a84f22077dd56c57a532e19701dcd15" + integrity sha512-DXToshO7/6Bu+Rk03fTeP3W3LFlkOpRph/Iu9OtAU0QzDdG2IgMa1tNhiDEmFmJHjWROjFZtBmyV1Cuk4vFiAQ== dependencies: - "@gorhom/bottom-sheet" "^5.1.1" - dayjs "1.10.5" - emoji-regex "^10.3.0" - i18next "^21.6.14" + "@gorhom/bottom-sheet" "^5.1.6" + dayjs "1.11.13" + emoji-regex "^10.4.0" + i18next "^21.10.0" intl-pluralrules "^2.0.1" - linkifyjs "^4.1.1" + linkifyjs "^4.3.1" lodash-es "4.17.21" - mime-types "^2.1.34" + mime-types "^2.1.35" path "0.12.7" react-native-markdown-package "1.8.2" - react-native-url-polyfill "^1.3.0" - stream-chat "^9.3.0" - use-sync-external-store "^1.4.0" + react-native-url-polyfill "^2.0.0" + stream-chat "^9.7.0" + use-sync-external-store "^1.5.0" "stream-chat-react-native-core@link:../../package": version "0.0.0" @@ -7307,10 +7294,10 @@ stream-chat-react-native-core@7.1.1: version "0.0.0" uid "" -stream-chat@^9.3.0: - version "9.3.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.3.0.tgz#35ca4db9e841eb92d07413ae156de0500ad77b23" - integrity sha512-S73B3HrvmQvJjq58Zjo50vh74juhsWsVRpT+OBjGAxSGxlA+ITkZ3vKs8Y/r2eDK7mBTMmX5QCruFaDJH5dRuw== +stream-chat@^9.7.0: + version "9.8.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.8.0.tgz#52782073a3923367fe97638fde39ce18e4eed28a" + integrity sha512-iKFVFOKWuW2/GTWBOps9YWZoQBlXdJ05FiOKXI/AnCMCGzOpmvEyaoCtsktvdeMaetmZojVPbw/5jomP36Qg0Q== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -7322,10 +7309,10 @@ stream-chat@^9.3.0: linkifyjs "^4.2.0" ws "^8.18.1" -stream-chat@^9.7.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.7.0.tgz#8302a4dfd2b68115c57cd0a102976542a79cf132" - integrity sha512-8K4RQAUFfznCxpJ5CMIrMQQLroaZ1snB4aR/Xnwa9UpxNCzn3kIi61AVkfsaHTHGojPz5LA3c3faVb251u4HnA== +stream-chat@^9.9.0: + version "9.10.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.10.0.tgz#6867afe05f29c05bf6b42aee0d611d72408d0cb9" + integrity sha512-T+2Qct1hOjco03ouLz6YPlx0gpSl6abNvvmOjq99Xn9LtInWrxQ0E7YOgV0tpcWTtVqMogCy94zHJvoO/mXV7Q== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -7784,11 +7771,6 @@ use-latest-callback@^0.2.3: resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.3.tgz#2d644d3063040b9bc2d4c55bb525a13ae3de9e16" integrity sha512-7vI3fBuyRcP91pazVboc4qu+6ZqM8izPWX9k7cRnT8hbD5svslcknsh3S9BUhaK11OmgTV4oWZZVSeQAiV53SQ== -use-sync-external-store@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc" - integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw== - use-sync-external-store@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" diff --git a/package/expo-package/yarn.lock b/package/expo-package/yarn.lock index 611ab74a91..cbf98bbf06 100644 --- a/package/expo-package/yarn.lock +++ b/package/expo-package/yarn.lock @@ -1058,7 +1058,12 @@ "@babel/plugin-transform-modules-commonjs" "^7.24.7" "@babel/plugin-transform-typescript" "^7.24.7" -"@babel/runtime@^7.17.2", "@babel/runtime@^7.20.0": +"@babel/runtime@^7.17.2": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" + integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== + +"@babel/runtime@^7.20.0": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== @@ -3839,10 +3844,10 @@ stream-buffers@2.2.x: resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg== -stream-chat-react-native-core@7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.1.2.tgz#5870a1188ecbf8c3b705d74379d19ff77efce2c5" - integrity sha512-Ob+V8tt+7L+7BRkWyWbFlju6E/8MAoB/NUZ8ENtEEijq5QBNWnVvZctQSZuekIOVrfoP9EenlIOPadHnN/mvYA== +stream-chat-react-native-core@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.2.0.tgz#9c60f0235a84f22077dd56c57a532e19701dcd15" + integrity sha512-DXToshO7/6Bu+Rk03fTeP3W3LFlkOpRph/Iu9OtAU0QzDdG2IgMa1tNhiDEmFmJHjWROjFZtBmyV1Cuk4vFiAQ== dependencies: "@gorhom/bottom-sheet" "^5.1.6" dayjs "1.11.13" @@ -3859,9 +3864,9 @@ stream-chat-react-native-core@7.1.2: use-sync-external-store "^1.5.0" stream-chat@^9.7.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.7.0.tgz#8302a4dfd2b68115c57cd0a102976542a79cf132" - integrity sha512-8K4RQAUFfznCxpJ5CMIrMQQLroaZ1snB4aR/Xnwa9UpxNCzn3kIi61AVkfsaHTHGojPz5LA3c3faVb251u4HnA== + version "9.9.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.9.0.tgz#59ac996e6e0ca6b3e3a0041954ae99a43541ec13" + integrity sha512-4tqedL7NfDhwJIKRBKGdvNu4x0ifKO+qxyc9TEWe+LLaW3Qed4txKysrVKnDfj/rx3iZuIwrMV7VeW5yxZfP5w== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/package/jest-setup.js b/package/jest-setup.js index 1112edb696..75c0c8f5a3 100644 --- a/package/jest-setup.js +++ b/package/jest-setup.js @@ -25,6 +25,7 @@ registerNativeHandlers({ unsubscribe: () => {}, }), pickDocument: () => null, + pickImage: () => null, saveFile: () => null, SDK: 'stream-chat-react-native', shareImage: () => null, diff --git a/package/native-package/package.json b/package/native-package/package.json index 954d3eae20..92982ba51f 100644 --- a/package/native-package/package.json +++ b/package/native-package/package.json @@ -28,12 +28,12 @@ "@react-native-clipboard/clipboard": ">=1.14.1", "@react-native-documents/picker": ">=10.1.1", "@stream-io/flat-list-mvcp": ">=0.10.3", - "react-native": ">=0.71.0", - "react-native-audio-recorder-player": ">=3.6.4", - "react-native-blob-util": ">=0.19.9", - "react-native-haptic-feedback": ">=2.2.0", + "react-native": ">=0.73.0", + "react-native-audio-recorder-player": ">=3.6.13", + "react-native-blob-util": ">=0.21.1", + "react-native-haptic-feedback": ">=2.3.0", "react-native-image-picker": ">=7.1.2", - "react-native-share": ">=10.2.1", + "react-native-share": ">=11.0.0", "react-native-video": ">=6.4.2" }, "peerDependenciesMeta": { diff --git a/package/native-package/yarn.lock b/package/native-package/yarn.lock index f2cc9d7164..8ee2221f08 100644 --- a/package/native-package/yarn.lock +++ b/package/native-package/yarn.lock @@ -2611,10 +2611,10 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat-react-native-core@7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.1.2.tgz#5870a1188ecbf8c3b705d74379d19ff77efce2c5" - integrity sha512-Ob+V8tt+7L+7BRkWyWbFlju6E/8MAoB/NUZ8ENtEEijq5QBNWnVvZctQSZuekIOVrfoP9EenlIOPadHnN/mvYA== +stream-chat-react-native-core@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.2.0.tgz#9c60f0235a84f22077dd56c57a532e19701dcd15" + integrity sha512-DXToshO7/6Bu+Rk03fTeP3W3LFlkOpRph/Iu9OtAU0QzDdG2IgMa1tNhiDEmFmJHjWROjFZtBmyV1Cuk4vFiAQ== dependencies: "@gorhom/bottom-sheet" "^5.1.6" dayjs "1.11.13" diff --git a/package/package.json b/package/package.json index f2227db222..1c1400a309 100644 --- a/package/package.json +++ b/package/package.json @@ -67,9 +67,10 @@ }, "dependencies": { "@gorhom/bottom-sheet": "^5.1.6", + "@ungap/structured-clone": "^1.3.0", "dayjs": "1.11.13", "emoji-regex": "^10.4.0", - "i18next": "^21.10.0", + "i18next": "^25.2.1", "intl-pluralrules": "^2.0.1", "linkifyjs": "^4.3.1", "lodash-es": "4.17.21", @@ -77,20 +78,28 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.7.0", + "stream-chat": "^9.9.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { - "@op-engineering/op-sqlite": ">=9.3.0", + "@emoji-mart/data": ">=1.1.0", + "@op-engineering/op-sqlite": ">=14.0.0", "@react-native-community/netinfo": ">=11.3.1", - "react-native": ">=0.71.0", - "react-native-gesture-handler": ">=2.16.1", + "emoji-mart": ">=5.4.0", + "react-native": ">=0.73.0", + "react-native-gesture-handler": ">=2.18.0", "react-native-reanimated": ">=3.16.0", - "react-native-svg": ">=13.6.0" + "react-native-svg": ">=15.8.0" }, "peerDependenciesMeta": { "@op-engineering/op-sqlite": { "optional": true + }, + "emoji-mart": { + "optional": true + }, + "@emoji-mart/data": { + "optional": true } }, "devDependencies": { @@ -111,6 +120,7 @@ "@types/mime-types": "2.1.4", "@types/react": "^19.0.0", "@types/react-test-renderer": "19.0.0", + "@types/ungap__structured-clone": "^1.2.0", "@types/use-sync-external-store": "^1.5.0", "@types/uuid": "^10.0.0", "babel-eslint": "10.1.0", diff --git a/package/src/__tests__/offline-support/optimistic-update.js b/package/src/__tests__/offline-support/optimistic-update.js index 7cf6fc00b8..6ea1be1062 100644 --- a/package/src/__tests__/offline-support/optimistic-update.js +++ b/package/src/__tests__/offline-support/optimistic-update.js @@ -270,14 +270,20 @@ export const OptimisticUpdates = () => { it('pending task should exist if sendMessage request fails', async () => { const newMessage = generateMessage(); + jest.spyOn(channel.messageComposer, 'compose').mockResolvedValue({ + localMessage: newMessage, + message: newMessage, + options: {}, + }); + render( - + { useMockedApis(chatClient, [erroredPostApi()]); try { - await sendMessage({ customMessageData: newMessage }); + await sendMessage(); } catch (e) { // do nothing } @@ -446,6 +452,12 @@ export const OptimisticUpdates = () => { it('send message pending task should be executed after connection is recovered', async () => { const newMessage = generateMessage(); + jest.spyOn(channel.messageComposer, 'compose').mockResolvedValue({ + localMessage: newMessage, + message: newMessage, + options: {}, + }); + // initialValue is needed as a prop to trick the message input ctx into thinking // we are sending a message. render( @@ -455,7 +467,7 @@ export const OptimisticUpdates = () => { callback={async ({ sendMessage }) => { useMockedApis(chatClient, [erroredPostApi()]); try { - await sendMessage({ customMessageData: newMessage }); + await sendMessage(); } catch (e) { // do nothing } diff --git a/package/src/components/Attachment/AudioAttachment.tsx b/package/src/components/Attachment/AudioAttachment.tsx index bd051c4434..59681bb41c 100644 --- a/package/src/components/Attachment/AudioAttachment.tsx +++ b/package/src/components/Attachment/AudioAttachment.tsx @@ -4,6 +4,8 @@ import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; +import { AudioAttachment as StreamAudioAttachment } from 'stream-chat'; + import { useTheme } from '../../contexts'; import { useAudioPlayer } from '../../hooks/useAudioPlayer'; import { Audio, Pause, Play } from '../../icons'; @@ -15,18 +17,25 @@ import { VideoProgressData, VideoSeekResponse, } from '../../native'; -import { AudioUpload, FileTypes } from '../../types/types'; +import { AudioConfig, FileTypes } from '../../types/types'; import { getTrimmedAttachmentTitle } from '../../utils/getTrimmedAttachmentTitle'; import { ProgressControl } from '../ProgressControl/ProgressControl'; import { WaveProgressBar } from '../ProgressControl/WaveProgressBar'; dayjs.extend(duration); +export type AudioAttachmentType = AudioConfig & + Pick & { + id: string; + type: 'audio' | 'voiceRecording'; + }; + export type AudioAttachmentProps = { - item: Omit; + item: AudioAttachmentType; onLoad: (index: string, duration: number) => void; onPlayPause: (index: string, pausedStatus?: boolean) => void; onProgress: (index: string, progress: number) => void; + titleMaxLength?: number; hideProgressBar?: boolean; showSpeedSettings?: boolean; testID?: string; @@ -48,6 +57,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { onProgress, showSpeedSettings = false, testID, + titleMaxLength, } = props; const { changeAudioSpeed, pauseAudio, playAudio, seekAudio } = useAudioPlayer({ soundRef }); const isExpoCLI = NativeHandlers.SDK === 'stream-chat-expo'; @@ -180,9 +190,9 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { useEffect(() => { if (isExpoCLI) { const initiateSound = async () => { - if (item && item.file && item.file.uri && NativeHandlers.Sound?.initializeSound) { + if (item && item.asset_url && NativeHandlers.Sound?.initializeSound) { soundRef.current = await NativeHandlers.Sound.initializeSound( - { uri: item.file.uri }, + { uri: item.asset_url }, { pitchCorrectionQuality: 'high', progressUpdateIntervalMillis: 100, @@ -255,7 +265,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { }, colors: { accent_blue, black, grey_dark, grey_whisper, static_black, static_white, white }, messageInput: { - fileUploadPreview: { filenameText }, + fileAttachmentUploadPreview: { filenameText }, }, }, } = useTheme(); @@ -318,7 +328,9 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { filenameText, ]} > - {getTrimmedAttachmentTitle(item.file.name)} + {item.type === FileTypes.VoiceRecording + ? 'Recording' + : getTrimmedAttachmentTitle(item.title, titleMaxLength)} @@ -326,14 +338,14 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { {!hideProgressBar && ( - {item.file.waveform_data ? ( + {item.waveform_data ? ( ) : ( { rate={currentSpeed} soundRef={soundRef as RefObject} testID='sound-player' - uri={item.file.uri} + uri={item.asset_url} /> )} diff --git a/package/src/components/Attachment/FileAttachmentGroup.tsx b/package/src/components/Attachment/FileAttachmentGroup.tsx index 507feb998b..a16fe5a7fa 100644 --- a/package/src/components/Attachment/FileAttachmentGroup.tsx +++ b/package/src/components/Attachment/FileAttachmentGroup.tsx @@ -113,17 +113,8 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte isSoundPackageAvailable() ? ( - {t('Error loading')} + {t('Error loading')} ); diff --git a/package/src/components/Attachment/__tests__/Giphy.test.js b/package/src/components/Attachment/__tests__/Giphy.test.js index df2060d89f..f721eb2571 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.js +++ b/package/src/components/Attachment/__tests__/Giphy.test.js @@ -1,6 +1,7 @@ import React from 'react'; import { + act, cleanup, fireEvent, render, @@ -177,11 +178,15 @@ describe('Giphy', () => { await waitFor(() => screen.getByTestId(`${attachment.actions[2].value}-action-button`)); - expect(screen.getByTestId('giphy-action-attachment')).toContainElement( - screen.getByTestId(`${attachment.actions[2].value}-action-button`), - ); + await waitFor(() => { + expect(screen.getByTestId('giphy-action-attachment')).toContainElement( + screen.getByTestId(`${attachment.actions[2].value}-action-button`), + ); + }); - user.press(screen.getByTestId(`${attachment.actions[2].value}-action-button`)); + act(() => { + user.press(screen.getByTestId(`${attachment.actions[2].value}-action-button`)); + }); await waitFor(() => { expect(handleAction).toHaveBeenCalledTimes(1); @@ -202,11 +207,15 @@ describe('Giphy', () => { await waitFor(() => screen.getByTestId(`${attachment.actions[1].value}-action-button`)); - expect(screen.getByTestId('giphy-action-attachment')).toContainElement( - screen.getByTestId(`${attachment.actions[1].value}-action-button`), - ); + await waitFor(() => { + expect(screen.getByTestId('giphy-action-attachment')).toContainElement( + screen.getByTestId(`${attachment.actions[1].value}-action-button`), + ); + }); - user.press(screen.getByTestId(`${attachment.actions[1].value}-action-button`)); + act(() => { + user.press(screen.getByTestId(`${attachment.actions[1].value}-action-button`)); + }); await waitFor(() => { expect(handleAction).toHaveBeenCalledTimes(1); @@ -227,11 +236,15 @@ describe('Giphy', () => { await waitFor(() => screen.getByTestId(`${attachment.actions[0].value}-action-button`)); - expect(screen.getByTestId('giphy-action-attachment')).toContainElement( - screen.getByTestId(`${attachment.actions[0].value}-action-button`), - ); + await waitFor(() => { + expect(screen.getByTestId('giphy-action-attachment')).toContainElement( + screen.getByTestId(`${attachment.actions[0].value}-action-button`), + ); + }); - user.press(screen.getByTestId(`${attachment.actions[0].value}-action-button`)); + act(() => { + user.press(screen.getByTestId(`${attachment.actions[0].value}-action-button`)); + }); await waitFor(() => { expect(handleAction).toHaveBeenCalledTimes(1); @@ -303,8 +316,13 @@ describe('Giphy', () => { expect(screen.queryByTestId('giphy-attachment')).toBeTruthy(); }); - fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'error'); - expect(screen.getByAccessibilityHint('image-loading-error')).toBeTruthy(); + act(() => { + fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'error'); + }); + + await waitFor(() => { + expect(screen.getByAccessibilityHint('image-loading-error')).toBeTruthy(); + }); }); it('should render a loading indicator in giphy image and when successful render the image', async () => { @@ -321,13 +339,22 @@ describe('Giphy', () => { expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); }); - fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'onLoadStart'); + act(() => { + fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'onLoadStart'); + }); - expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByAccessibilityHint('image-loading')).toBeTruthy(); + }); - fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'onLoadFinish'); + act(() => { + fireEvent(screen.getByLabelText('Giphy Attachment Image'), 'onLoad'); + }); waitForElementToBeRemoved(() => screen.getByAccessibilityHint('image-loading')); - expect(screen.getByLabelText('Giphy Attachment Image')).toBeTruthy(); + + await waitFor(() => { + expect(screen.getByLabelText('Giphy Attachment Image')).toBeTruthy(); + }); }); }); diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 2aeb3d6bb9..18ff1f11ad 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BackHandler, Keyboard, Platform, StyleSheet } from 'react-native'; import BottomSheetOriginal from '@gorhom/bottom-sheet'; @@ -11,10 +11,8 @@ import type { AttachmentPickerErrorProps } from './components/AttachmentPickerEr import { renderAttachmentPickerItem } from './components/AttachmentPickerItem'; -import { - AttachmentPickerContextValue, - useAttachmentPickerContext, -} from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; +import { useAttachmentPickerContext } from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; +import { MessageInputContextValue } from '../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useScreenDimensions } from '../../hooks/useScreenDimensions'; import { NativeHandlers } from '../../native'; @@ -31,7 +29,7 @@ const styles = StyleSheet.create({ }); export type AttachmentPickerProps = Pick< - AttachmentPickerContextValue, + MessageInputContextValue, | 'AttachmentPickerBottomSheetHandle' | 'attachmentPickerBottomSheetHandleHeight' | 'attachmentSelectionBarHeight' @@ -40,13 +38,15 @@ export type AttachmentPickerProps = Pick< /** * Custom UI component to render error component while opening attachment picker. * - * **Default** [AttachmentPickerError](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx) + * **Default** + * [AttachmentPickerError](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx) */ AttachmentPickerError: React.ComponentType; /** * Custom UI component to render error image for attachment picker * - * **Default** [AttachmentPickerErrorImage](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerErrorImage.tsx) + * **Default** + * [AttachmentPickerErrorImage](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerErrorImage.tsx) */ AttachmentPickerErrorImage: React.ComponentType; /** @@ -54,9 +54,11 @@ export type AttachmentPickerProps = Pick< */ AttachmentPickerIOSSelectMorePhotos: React.ComponentType; /** - * Custom UI component to render overlay component, that shows up on top of [selected image](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) (with tick mark) + * Custom UI component to render overlay component, that shows up on top of [selected + * image](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) (with tick mark) * - * **Default** [ImageOverlaySelectedComponent](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx) + * **Default** + * [ImageOverlaySelectedComponent](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx) */ ImageOverlaySelectedComponent: React.ComponentType; attachmentPickerErrorButtonText?: string; @@ -87,17 +89,8 @@ export const AttachmentPicker = React.forwardRef( colors: { white }, }, } = useTheme(); - const { - closePicker, - maxNumberOfFiles, - selectedFiles, - selectedImages, - selectedPicker, - setSelectedFiles, - setSelectedImages, - setSelectedPicker, - topInset, - } = useAttachmentPickerContext(); + const { closePicker, selectedPicker, setSelectedPicker, topInset } = + useAttachmentPickerContext(); const { vh: screenVh } = useScreenDimensions(); const fullScreenHeight = screenVh(100); @@ -157,7 +150,8 @@ export const AttachmentPicker = React.forwardRef( if (!NativeHandlers.oniOS14GalleryLibrarySelectionChange) { return; } - // ios 14 library selection change event is fired when user reselects the images that are permitted to be readable by the app + // ios 14 library selection change event is fired when user reselects the images that are permitted to be + // readable by the app const { unsubscribe } = NativeHandlers.oniOS14GalleryLibrarySelectionChange(() => { // we reset the cursor and has next page to true to facilitate fetching of the first page of photos again hasNextPageRef.current = true; @@ -182,8 +176,7 @@ export const AttachmentPicker = React.forwardRef( const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); return () => backHandler.remove(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedPicker, closePicker]); + }, [selectedPicker, closePicker, setSelectedPicker]); useEffect(() => { const onKeyboardOpenHandler = () => { @@ -207,8 +200,7 @@ export const AttachmentPicker = React.forwardRef( Keyboard.removeListener(keyboardShowEvent, onKeyboardOpenHandler); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [closePicker, selectedPicker]); + }, [closePicker, selectedPicker, setSelectedPicker]); useEffect(() => { if (currentIndex < 0) { @@ -232,26 +224,21 @@ export const AttachmentPicker = React.forwardRef( !loadingPhotos ) { getMorePhotos(); - // we do this only once on open for avoiding to request permissions in rationale dialog again and again on Android + // we do this only once on open for avoiding to request permissions in rationale dialog again and again on + // Android attemptedToLoadPhotosOnOpenRef.current = true; } }, [currentIndex, selectedPicker, getMorePhotos, loadingPhotos]); - const selectedPhotos = photos.map((asset) => ({ - asset, - ImageOverlaySelectedComponent, - maxNumberOfFiles, - numberOfAttachmentPickerImageColumns, - numberOfUploads: selectedFiles.length + selectedImages.length, - // `id` is available for Expo MediaLibrary while Cameraroll doesn't share id therefore we use `uri` - selected: - selectedImages.some((image) => image.uri === asset.uri) || - selectedFiles.some((file) => file.uri === asset.uri), - selectedFiles, - selectedImages, - setSelectedFiles, - setSelectedImages, - })); + const selectedPhotos = useMemo( + () => + photos.map((asset) => ({ + asset, + ImageOverlaySelectedComponent, + numberOfAttachmentPickerImageColumns, + })), + [photos, ImageOverlaySelectedComponent, numberOfAttachmentPickerImageColumns], + ); const handleHeight = attachmentPickerBottomSheetHandleHeight; @@ -302,6 +289,7 @@ export const AttachmentPicker = React.forwardRef( numColumns={numberOfAttachmentPickerImageColumns ?? 3} onEndReached={photoError ? undefined : getMorePhotos} renderItem={renderAttachmentPickerItem} + testID={'attachment-picker-list'} /> {selectedPicker === 'images' && photoError && ( diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx index 3e3f9beabe..008da8c640 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx @@ -78,14 +78,14 @@ export const AttachmentPickerError = (props: AttachmentPickerErrorProps) => { {attachmentPickerErrorText || - t('Please enable access to your photos and videos so you can share them.')} + t('Please enable access to your photos and videos so you can share them.')} - {attachmentPickerErrorButtonText || t('Allow access to your Gallery')} + {attachmentPickerErrorButtonText || t('Allow access to your Gallery')} ); diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerIOSSelectMorePhotos.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerIOSSelectMorePhotos.tsx index ed0598fd91..1654a8b52f 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerIOSSelectMorePhotos.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerIOSSelectMorePhotos.tsx @@ -22,7 +22,7 @@ export const AttachmentPickerIOSSelectMorePhotos = () => { onPress={NativeHandlers.iOS14RefreshGallerySelection} style={[styles.container, { backgroundColor: white }]} > - {t('Select More Photos')} + {t('Select More Photos')} ); }; diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx index 0eccbfd1a3..fb3bc84acb 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx @@ -2,7 +2,11 @@ import React from 'react'; import { Alert, ImageBackground, StyleSheet, Text, View } from 'react-native'; -import { AttachmentPickerContextValue } from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; +import { FileReference, isLocalImageAttachment, isLocalVideoAttachment } from 'stream-chat'; + +import { useAttachmentManagerState } from '../../../contexts/messageInputContext/hooks/useAttachmentManagerState'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { useViewport } from '../../../hooks/useViewport'; @@ -10,33 +14,26 @@ import { Recorder } from '../../../icons'; import type { File } from '../../../types/types'; import { getDurationLabelFromDuration } from '../../../utils/utils'; import { BottomSheetTouchableOpacity } from '../../BottomSheetCompatibility/BottomSheetTouchableOpacity'; -type AttachmentPickerItemType = Pick< - AttachmentPickerContextValue, - 'selectedFiles' | 'setSelectedFiles' | 'setSelectedImages' | 'selectedImages' | 'maxNumberOfFiles' -> & { + +type AttachmentPickerItemType = { asset: File; ImageOverlaySelectedComponent: React.ComponentType; - numberOfUploads: number; - selected: boolean; numberOfAttachmentPickerImageColumns?: number; }; -type AttachmentImageProps = Omit; -type AttachmentVideoProps = Omit; - -const AttachmentVideo = (props: AttachmentVideoProps) => { - const { - asset, - ImageOverlaySelectedComponent, - maxNumberOfFiles, - numberOfAttachmentPickerImageColumns, - numberOfUploads, - selected, - selectedFiles, - setSelectedFiles, - } = props; +const AttachmentVideo = (props: AttachmentPickerItemType) => { + const { asset, ImageOverlaySelectedComponent, numberOfAttachmentPickerImageColumns } = props; const { vw } = useViewport(); const { t } = useTranslationContext(); + const messageComposer = useMessageComposer(); + const { uploadNewFile } = useMessageInputContext(); + const { attachmentManager } = messageComposer; + const { attachments, availableUploadSlots } = useAttachmentManagerState(); + const videoUploads = attachments.filter((attachment) => isLocalVideoAttachment(attachment)); + + const selected = videoUploads.some( + (attachment) => (attachment.localMetadata.file as FileReference).uri === asset.uri, + ); const { theme: { @@ -51,22 +48,20 @@ const AttachmentVideo = (props: AttachmentVideoProps) => { const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2; - const updateSelectedFiles = () => { - if (numberOfUploads >= maxNumberOfFiles) { - Alert.alert(t('Maximum number of files reached')); - return; - } - setSelectedFiles([...selectedFiles, asset]); - }; - - const onPressVideo = () => { + const onPressVideo = async () => { if (selected) { - setSelectedFiles((files) => - // `id` is available for Expo MediaLibrary while Cameraroll doesn't share id therefore we use `uri` - files.filter((file) => file.uri !== uri), + const attachment = videoUploads.find( + (attachment) => (attachment.localMetadata.file as FileReference).uri === uri, ); + if (attachment) { + attachmentManager.removeAttachments([attachment.localMetadata.id]); + } } else { - updateSelectedFiles(); + if (!availableUploadSlots) { + Alert.alert(t('Maximum number of files reached')); + return; + } + await uploadNewFile(asset); } }; @@ -101,17 +96,8 @@ const AttachmentVideo = (props: AttachmentVideoProps) => { ); }; -const AttachmentImage = (props: AttachmentImageProps) => { - const { - asset, - ImageOverlaySelectedComponent, - maxNumberOfFiles, - numberOfAttachmentPickerImageColumns, - numberOfUploads, - selected, - selectedImages, - setSelectedImages, - } = props; +const AttachmentImage = (props: AttachmentPickerItemType) => { + const { asset, ImageOverlaySelectedComponent, numberOfAttachmentPickerImageColumns } = props; const { theme: { attachmentPicker: { image, imageOverlay }, @@ -119,26 +105,34 @@ const AttachmentImage = (props: AttachmentImageProps) => { }, } = useTheme(); const { vw } = useViewport(); - const { t } = useTranslationContext(); + const { uploadNewFile } = useMessageInputContext(); + const messageComposer = useMessageComposer(); + const { attachmentManager } = messageComposer; + const { attachments, availableUploadSlots } = useAttachmentManagerState(); + const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); + + const selected = imageUploads.some( + (attachment) => attachment.localMetadata.previewUri === asset.uri, + ); const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2; const { uri } = asset; - const updateSelectedImages = () => { - if (numberOfUploads >= maxNumberOfFiles) { - Alert.alert(t('Maximum number of files reached')); - return; - } - setSelectedImages([...selectedImages, asset]); - }; - - const onPressImage = () => { + const onPressImage = async () => { if (selected) { - // `id` is available for Expo MediaLibrary while Cameraroll doesn't share id therefore we use `uri` - setSelectedImages((images) => images.filter((image) => image.uri !== uri)); + const attachment = imageUploads.find( + (attachment) => attachment.localMetadata.previewUri === uri, + ); + if (attachment) { + await attachmentManager.removeAttachments([attachment.localMetadata.id]); + } } else { - updateSelectedImages(); + if (!availableUploadSlots) { + Alert.alert('Maximum number of files reached'); + return; + } + await uploadNewFile(asset); } }; @@ -166,18 +160,7 @@ const AttachmentImage = (props: AttachmentImageProps) => { }; export const renderAttachmentPickerItem = ({ item }: { item: AttachmentPickerItemType }) => { - const { - asset, - ImageOverlaySelectedComponent, - maxNumberOfFiles, - numberOfAttachmentPickerImageColumns, - numberOfUploads, - selected, - selectedFiles, - selectedImages, - setSelectedFiles, - setSelectedImages, - } = item; + const { asset, ImageOverlaySelectedComponent, numberOfAttachmentPickerImageColumns } = item; /** * Expo Media Library - Result of asset type @@ -192,12 +175,7 @@ export const renderAttachmentPickerItem = ({ item }: { item: AttachmentPickerIte ); } @@ -206,12 +184,7 @@ export const renderAttachmentPickerItem = ({ item }: { item: AttachmentPickerIte ); }; diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx index 31e0c8c59a..f2bceb7e61 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx @@ -20,27 +20,22 @@ const styles = StyleSheet.create({ }); export const AttachmentPickerSelectionBar = () => { + const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); + const { attachmentSelectionBarHeight, CameraSelectorIcon, - closePicker, CreatePollIcon, FileSelectorIcon, - ImageSelectorIcon, - selectedPicker, - setSelectedPicker, - VideoRecorderSelectorIcon, - } = useAttachmentPickerContext(); - - const { hasCameraPicker, hasFilePicker, hasImagePicker, - imageUploads, + ImageSelectorIcon, openPollCreationDialog, pickFile, sendMessage, takeAndUploadImage, + VideoRecorderSelectorIcon, } = useMessageInputContext(); const { threadList } = useChannelContext(); const { hasCreatePoll } = useMessagesContext(); @@ -73,6 +68,18 @@ export const AttachmentPickerSelectionBar = () => { openPollCreationDialog?.({ sendMessage }); }; + const onCameraPickerPress = () => { + setSelectedPicker(undefined); + closePicker(); + takeAndUploadImage(Platform.OS === 'android' ? 'image' : 'mixed'); + }; + + const onVideoRecorderPickerPress = () => { + setSelectedPicker(undefined); + closePicker(); + takeAndUploadImage('video'); + }; + return ( {hasImagePicker ? ( @@ -82,10 +89,7 @@ export const AttachmentPickerSelectionBar = () => { testID='upload-photo-touchable' > - + ) : null} @@ -96,42 +100,29 @@ export const AttachmentPickerSelectionBar = () => { testID='upload-file-touchable' > - + ) : null} {hasCameraPicker ? ( { - takeAndUploadImage(Platform.OS === 'android' ? 'image' : 'mixed'); - }} + onPress={onCameraPickerPress} testID='take-photo-touchable' > - + ) : null} {hasCameraPicker && Platform.OS === 'android' ? ( { - takeAndUploadImage('video'); - }} + onPress={onVideoRecorderPickerPress} testID='take-photo-touchable' > - + ) : null} diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index 66444b63e8..d5933cd5f7 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -1,69 +1,36 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { I18nManager, StyleSheet, TextInput, TextInputProps } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + I18nManager, + NativeSyntheticEvent, + StyleSheet, + TextInput, + TextInputContentSizeChangeEventData, + TextInputProps, + TextInputSelectionChangeEventData, +} from 'react-native'; -import throttle from 'lodash/throttle'; +import { MessageComposerConfig, TextComposerState } from 'stream-chat'; +import { + ChannelContextValue, + useChannelContext, +} from '../../contexts/channelContext/ChannelContext'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { MessageInputContextValue, useMessageInputContext, } from '../../contexts/messageInputContext/MessageInputContext'; -import { - isSuggestionCommand, - isSuggestionEmoji, - isSuggestionUser, - Suggestion, - SuggestionCommand, - SuggestionsContextValue, - SuggestionUser, - useSuggestionsContext, -} from '../../contexts/suggestionsContext/SuggestionsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; -import type { Emoji } from '../../emoji-data'; -import { - isCommandTrigger, - isEmojiTrigger, - isMentionTrigger, - Trigger, -} from '../../utils/ACITriggerSettings'; +import { useStateStore } from '../../hooks/useStateStore'; -const styles = StyleSheet.create({ - inputBox: { - flex: 1, - fontSize: 16, - includeFontPadding: false, // for android vertical text centering - padding: 0, // removal of default text input padding on android - paddingTop: 0, // removal of iOS top padding for weird centering - textAlignVertical: 'center', // for android vertical text centering - }, -}); - -const computeCaretPosition = (token: string, startOfTokenPosition: number) => - startOfTokenPosition + token.length; - -const isCommand = (text: string) => text[0] === '/' && text.split(' ').length <= 1; - -type AutoCompleteInputPropsWithContext = Pick< - MessageInputContextValue, - | 'additionalTextInputProps' - | 'autoCompleteSuggestionsLimit' - | 'giphyActive' - | 'giphyEnabled' - | 'maxMessageLength' - | 'mentionAllAppUsersEnabled' - | 'mentionAllAppUsersQuery' - | 'numberOfLines' - | 'onChange' - | 'setGiphyActive' - | 'setInputBoxRef' - | 'text' - | 'triggerSettings' -> & - Pick & +type AutoCompleteInputPropsWithContext = TextInputProps & + Pick & + Pick & Pick & { /** * This is currently passed in from MessageInput to avoid rerenders @@ -72,335 +39,91 @@ type AutoCompleteInputPropsWithContext = Pick< cooldownActive?: boolean; }; -export type AutoCompleteInputProps = Partial< - Omit< - AutoCompleteInputPropsWithContext, - | 'triggerSettings' - | 'mentionAllAppUsersQuery' - | 'mentionAllAppUsersEnabled' - | 'autoCompleteSuggestionsLimit' - > ->; +type AutoCompleteInputProps = Partial; -const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => { - const { - additionalTextInputProps, - autoCompleteSuggestionsLimit, - closeSuggestions, - cooldownActive = false, - giphyActive, - giphyEnabled, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, - maxMessageLength, - numberOfLines, - onChange, - openSuggestions, - setGiphyActive, - setInputBoxRef, - t, - text, - triggerSettings, - updateSuggestions: updateSuggestionsContext, - } = props; +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, + text: state.text, +}); - const isTrackingStarted = useRef(false); - const selectionEnd = useRef(0); +const configStateSelector = (state: MessageComposerConfig) => ({ + enabled: state.text.enabled, +}); + +const MAX_NUMBER_OF_LINES = 5; + +const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => { + const { channel, cooldownActive = false, setInputBoxRef, t, ...rest } = props; + const [localText, setLocalText] = useState(''); const [textHeight, setTextHeight] = useState(0); + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command, text } = useStateStore(textComposer.state, textComposerStateSelector); + const { enabled } = useStateStore(messageComposer.configState, configStateSelector); - const { - theme: { - colors: { black, grey }, - messageInput: { inputBox }, - }, - } = useTheme(); + const maxMessageLength = useMemo(() => { + return channel.getConfig()?.max_message_length; + }, [channel]); - const handleChange = async (newText: string, fromUpdate = false) => { - if (!fromUpdate) { - onChange(newText); - } else { - await handleSuggestionsThrottled(newText); - } - }; + const numberOfLines = useMemo(() => { + return props.numberOfLines ?? MAX_NUMBER_OF_LINES; + }, [props.numberOfLines]); useEffect(() => { - handleChange(text, true); - // eslint-disable-next-line react-hooks/exhaustive-deps + setLocalText(text); }, [text]); - const startTracking = (trigger: Trigger) => { - const triggerSetting = triggerSettings[trigger]; - if (triggerSetting) { - isTrackingStarted.current = true; - const { type } = triggerSetting; - openSuggestions(type); - } - }; - - const stopTracking = () => { - isTrackingStarted.current = false; - closeSuggestions(); - }; - - const updateSuggestions = async ({ - query, - trigger, - }: { - query: Suggestion['name']; - trigger: Trigger; - }) => { - if (isMentionTrigger(trigger)) { - const triggerSetting = triggerSettings[trigger]; - if (triggerSetting) { - await triggerSetting.dataProvider( - query as SuggestionUser['name'], - text, - (data, queryCallback) => { - if (query === queryCallback) { - updateSuggestionsContext({ - data, - onSelect: (item) => onSelectSuggestion({ item, trigger }), - queryText: query, - }); - } - }, - { - limit: autoCompleteSuggestionsLimit, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, - }, - ); - } - } else if (isCommandTrigger(trigger)) { - const triggerSetting = triggerSettings[trigger]; - if (triggerSetting) { - await triggerSetting.dataProvider( - query as SuggestionCommand['name'], - text, - (data, queryCallback) => { - if (query !== queryCallback) { - return; - } - - updateSuggestionsContext({ - data, - onSelect: (item) => onSelectSuggestion({ item, trigger }), - queryText: query, - }); - }, - { - limit: autoCompleteSuggestionsLimit, - }, - ); - } - } else { - const triggerSetting = triggerSettings[trigger]; - if (triggerSetting) { - await triggerSetting.dataProvider(query as Emoji['name'], text, (data, queryCallback) => { - if (query !== queryCallback) { - return; - } - - updateSuggestionsContext({ - data, - onSelect: (item) => onSelectSuggestion({ item, trigger }), - queryText: query, - }); - }); - } - } - }; - - const handleSelectionChange: TextInputProps['onSelectionChange'] = ({ - nativeEvent: { - selection: { end }, + const handleSelectionChange = useCallback( + (e: NativeSyntheticEvent) => { + const { selection } = e.nativeEvent; + textComposer.setSelection(selection); }, - }) => { - selectionEnd.current = end; - }; - - const onSelectSuggestion = ({ item, trigger }: { item: Suggestion; trigger: Trigger }) => { - if (!trigger || !triggerSettings[trigger]) { - return; - } - - let newTokenString = ''; - if (isCommandTrigger(trigger) && isSuggestionCommand(item)) { - const triggerSetting = triggerSettings[trigger]; - if (triggerSetting) { - newTokenString = `${triggerSetting.output(item).text} `; - } - } - if (isEmojiTrigger(trigger) && isSuggestionEmoji(item)) { - const triggerSetting = triggerSettings[trigger]; - if (triggerSetting) { - newTokenString = `${triggerSetting.output(item).text} `; - } - } - if (isMentionTrigger(trigger) && isSuggestionUser(item)) { - const triggerSetting = triggerSettings[trigger]; - if (triggerSetting) { - newTokenString = `${triggerSetting.output(item).text} `; - } - } - - const textToModify = text.slice(0, selectionEnd.current); - - const startOfTokenPosition = textToModify.lastIndexOf(trigger, selectionEnd.current); - - const newCaretPosition = computeCaretPosition(newTokenString, startOfTokenPosition); - - const modifiedText = `${textToModify.substring(0, startOfTokenPosition)}${newTokenString}`; - - stopTracking(); - - const newText = text.replace(textToModify, modifiedText); - - if (giphyEnabled && newText.startsWith('/giphy ')) { - onChange(newText.slice(7)); // 7 because of '/giphy ' length - setGiphyActive(true); - } else { - onChange(newText); - } - - selectionEnd.current = newCaretPosition || 0; - - if (isMentionTrigger(trigger) && isSuggestionUser(item)) { - const triggerSetting = triggerSettings[trigger]; - if (triggerSetting) { - triggerSetting.callback(item); - } - } - }; - - const handleCommand = async (text: string) => { - if (!isCommand(text)) { - return false; - } - - if (!isTrackingStarted.current) { - startTracking('/'); - } - const actualToken = text.trim().slice(1); - await updateSuggestions({ query: actualToken, trigger: '/' }); - - return true; - }; - - const handleMentions = async ({ tokenMatch }: { tokenMatch: RegExpMatchArray | null }) => { - const lastToken = tokenMatch?.[tokenMatch.length - 1]; - const handleMentionsTrigger = - (lastToken && Object.keys(triggerSettings).find((trigger) => trigger === lastToken[0])) || - null; - - /* - if we lost the trigger token or there is no following character we want to close - the autocomplete - */ - if (!lastToken || lastToken.length <= 0) { - stopTracking(); - return; - } - - const actualToken = lastToken.slice(1); - - // if trigger is not configured step out from the function, otherwise proceed - if (!handleMentionsTrigger) { - return; - } - - if (!isTrackingStarted.current) { - startTracking('@'); - } - - await updateSuggestions({ query: actualToken, trigger: '@' }); - }; - - const handleEmojis = async ({ tokenMatch }: { tokenMatch: RegExpMatchArray | null }) => { - const lastToken = tokenMatch?.[tokenMatch.length - 1].trim(); - const handleEmojisTrigger = - (lastToken && Object.keys(triggerSettings).find((trigger) => trigger === lastToken[0])) || - null; - - /* - if we lost the trigger token or there is no following character we want to close - the autocomplete - */ - if (!lastToken || lastToken.length <= 0) { - stopTracking(); - return; - } - - const actualToken = lastToken.slice(1); - - // if trigger is not configured step out from the function, otherwise proceed - if (!handleEmojisTrigger) { - return; - } + [textComposer], + ); - if (!isTrackingStarted.current) { - startTracking(':'); - } + const onChangeTextHandler = useCallback( + (newText: string) => { + setLocalText(newText); - await updateSuggestions({ query: actualToken, trigger: ':' }); - }; + textComposer.handleChange({ + selection: { + end: newText.length, + start: newText.length, + }, + text: newText, + }); + }, + [textComposer], + ); - const handleSuggestions = async (text: string) => { - if (text === undefined) { - return; - } - if ( - /\s/.test(text.slice(selectionEnd.current - 1, selectionEnd.current)) && - isTrackingStarted.current - ) { - stopTracking(); - } else if (!(await handleCommand(text))) { - const mentionTokenMatch = text - .slice(0, selectionEnd.current) - .match(/(?!^|\W)?@[^\s@]*\s?[^\s@]*$/g); - if (mentionTokenMatch) { - await handleMentions({ tokenMatch: mentionTokenMatch }); - } else { - const emojiTokenMatch = text - .slice(0, selectionEnd.current) - .match(/(?!^|\W)?:\w{2,}[^\s]*\s?[^\s]*$/g); - await handleEmojis({ tokenMatch: emojiTokenMatch }); - } - } - }; + const { + theme: { + colors: { black, grey }, + messageInput: { inputBox }, + }, + } = useTheme(); - const placeholderText = giphyActive - ? t('Search GIFs') - : cooldownActive - ? t('Slow mode ON') - : t('Send a message'); + const placeholderText = useMemo(() => { + return command ? t('Search') : cooldownActive ? t('Slow mode ON') : t('Send a message'); + }, [command, cooldownActive, t]); - const handleSuggestionsThrottled = throttle(handleSuggestions, 100, { - leading: false, - }); + const handleContentSizeChange = useCallback( + ({ + nativeEvent: { contentSize }, + }: NativeSyntheticEvent) => { + setTextHeight(contentSize.height); + }, + [], + ); return ( { - if (giphyEnabled && newText && newText.startsWith('/giphy ')) { - await handleChange(newText.slice(7)); // 7 because of '/giphy' length - setGiphyActive(true); - } else { - await handleChange(newText); - } - }} - onContentSizeChange={({ - nativeEvent: { - contentSize: { height }, - }, - }) => { - if (!textHeight) { - setTextHeight(height); - } - }} + onChangeText={onChangeTextHandler} + onContentSizeChange={handleContentSizeChange} onSelectionChange={handleSelectionChange} placeholder={placeholderText} placeholderTextColor={grey} @@ -415,8 +138,8 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) inputBox, ]} testID='auto-complete-text-input' - value={text} - {...additionalTextInputProps} + value={localText} + {...rest} /> ); }; @@ -425,36 +148,21 @@ const areEqual = ( prevProps: AutoCompleteInputPropsWithContext, nextProps: AutoCompleteInputPropsWithContext, ) => { - const { - cooldownActive: prevCooldownActive, - giphyActive: prevGiphyActive, - t: prevT, - text: prevText, - } = prevProps; - const { - cooldownActive: nextCooldownActive, - giphyActive: nextGiphyActive, - t: nextT, - text: nextText, - } = nextProps; - - const giphyActiveEqual = prevGiphyActive === nextGiphyActive; - if (!giphyActiveEqual) { - return false; - } + const { channel: prevChannel, cooldownActive: prevCooldownActive, t: prevT } = prevProps; + const { channel: nextChannel, cooldownActive: nextCooldownActive, t: nextT } = nextProps; const tEqual = prevT === nextT; if (!tEqual) { return false; } - const textEqual = prevText === nextText; - if (!textEqual) { + const cooldownActiveEqual = prevCooldownActive === nextCooldownActive; + if (!cooldownActiveEqual) { return false; } - const cooldownActiveEqual = prevCooldownActive === nextCooldownActive; - if (!cooldownActiveEqual) { + const channelEqual = prevChannel.cid === nextChannel.cid; + if (!channelEqual) { return false; } @@ -467,48 +175,31 @@ const MemoizedAutoCompleteInput = React.memo( ) as typeof AutoCompleteInputWithContext; export const AutoCompleteInput = (props: AutoCompleteInputProps) => { - const { - giphyEnabled, - additionalTextInputProps, - autoCompleteSuggestionsLimit, - giphyActive, - maxMessageLength, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, - numberOfLines, - onChange, - setGiphyActive, - setInputBoxRef, - text, - triggerSettings, - } = useMessageInputContext(); - const { closeSuggestions, openSuggestions, updateSuggestions } = useSuggestionsContext(); + const { setInputBoxRef } = useMessageInputContext(); const { t } = useTranslationContext(); + const { channel } = useChannelContext(); return ( ); }; +const styles = StyleSheet.create({ + inputBox: { + flex: 1, + fontSize: 16, + includeFontPadding: false, // for android vertical text centering + padding: 0, // removal of default text input padding on android + paddingTop: 0, // removal of iOS top padding for weird centering + textAlignVertical: 'center', // for android vertical text centering + }, +}); + AutoCompleteInput.displayName = 'AutoCompleteInput{messageInput{inputBox}}'; diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionCommandIcon.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionCommandIcon.tsx index a1b155a9a6..3f492e9705 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionCommandIcon.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionCommandIcon.tsx @@ -1,29 +1,41 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; -import type { SuggestionCommand } from '../../contexts/suggestionsContext/SuggestionsContext'; +import { CommandVariants } from 'stream-chat'; + import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { Flag, GiphyIcon, Imgur, Lightning, Mute, Sound, UserAdd, UserDelete } from '../../icons'; -const styles = StyleSheet.create({ - iconContainer: { - alignItems: 'center', - borderRadius: 12, - height: 24, - justifyContent: 'center', - marginRight: 8, - width: 24, - }, -}); +export const SuggestionCommandIcon = ({ name }: { name: CommandVariants }) => { + const { + theme: { + colors: { white }, + }, + } = useTheme(); + + if (name === 'ban') { + return ; + } else if (name === 'flag') { + return ; + } else if (name === 'giphy') { + return ; + } else if (name === 'imgur') { + return ; + } else if (name === 'mute') { + return ; + } else if (name === 'unban') { + return ; + } else if (name === 'unmute') { + return ; + } else { + return ; + } +}; -export const AutoCompleteSuggestionCommandIcon = ({ - name, -}: { - name: SuggestionCommand['name']; -}) => { +export const AutoCompleteSuggestionCommandIcon = ({ name }: { name: CommandVariants }) => { const { theme: { - colors: { accent_blue, white }, + colors: { accent_blue }, messageInput: { suggestions: { command: { iconContainer }, @@ -31,54 +43,29 @@ export const AutoCompleteSuggestionCommandIcon = ({ }, }, } = useTheme(); - switch (name) { - case 'ban': - return ( - - - - ); - case 'flag': - return ( - - - - ); - case 'giphy': - return ( - - - - ); - case 'imgur': - return ( - - - - ); - case 'mute': - return ( - - - - ); - case 'unban': - return ( - - - - ); - case 'unmute': - return ( - - - - ); - default: - return ( - - - - ); - } + + return ( + + + + ); }; + +const styles = StyleSheet.create({ + iconContainer: { + alignItems: 'center', + borderRadius: 12, + height: 24, + justifyContent: 'center', + marginRight: 8, + width: 24, + }, +}); diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionHeader.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionHeader.tsx index b5233d93fd..e26938b27f 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionHeader.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionHeader.tsx @@ -1,35 +1,39 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import type { SuggestionsContextValue } from '../../contexts/suggestionsContext/SuggestionsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { Lightning } from '../../icons/Lightning'; import { Smile } from '../../icons/Smile'; -export type AutoCompleteSuggestionHeaderPropsWithContext = Pick< - SuggestionsContextValue, - 'triggerType' | 'queryText' ->; +export type AutoCompleteSuggestionHeaderProps = { + queryText?: string; + triggerType?: string; +}; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - padding: 8, - }, - title: { - fontSize: 14, - paddingLeft: 8, - }, -}); +export const CommandsHeader: React.FC = () => { + const { + theme: { + colors: { accent_blue, grey }, + messageInput: { + suggestions: { + header: { container, title }, + }, + }, + }, + } = useTheme(); -const AutoCompleteSuggestionHeaderWithContext = ({ - queryText, - triggerType, -}: AutoCompleteSuggestionHeaderPropsWithContext) => { - const { t } = useTranslationContext(); + return ( + + + + {'Instant Commands'} + + + ); +}; + +export const EmojiHeader: React.FC = ({ queryText }) => { const { theme: { colors: { accent_blue, grey }, @@ -41,34 +45,32 @@ const AutoCompleteSuggestionHeaderWithContext = ({ }, } = useTheme(); + return ( + + + + {`Emoji matching "${queryText}"`} + + + ); +}; + +const UnMemoizedAutoCompleteSuggestionHeader = ({ + queryText, + triggerType, +}: AutoCompleteSuggestionHeaderProps) => { if (triggerType === 'command') { - return ( - - - - {t('Instant Commands')} - - - ); + return ; } else if (triggerType === 'emoji') { - return ( - - - - {t('Emoji matching') + ' "' + queryText + '"'} - - - ); - } else if (triggerType === 'mention') { - return null; + return ; } else { return null; } }; const areEqual = ( - prevProps: AutoCompleteSuggestionHeaderPropsWithContext, - nextProps: AutoCompleteSuggestionHeaderPropsWithContext, + prevProps: AutoCompleteSuggestionHeaderProps, + nextProps: AutoCompleteSuggestionHeaderProps, ) => { const { queryText: prevQueryText, triggerType: prevType } = prevProps; const { queryText: nextQueryText, triggerType: nextType } = nextProps; @@ -82,15 +84,14 @@ const areEqual = ( if (!valueEqual) { return false; } + return true; }; const MemoizedAutoCompleteSuggestionHeader = React.memo( - AutoCompleteSuggestionHeaderWithContext, + UnMemoizedAutoCompleteSuggestionHeader, areEqual, -) as typeof AutoCompleteSuggestionHeaderWithContext; - -export type AutoCompleteSuggestionHeaderProps = AutoCompleteSuggestionHeaderPropsWithContext; +); export const AutoCompleteSuggestionHeader = (props: AutoCompleteSuggestionHeaderProps) => ( @@ -98,3 +99,15 @@ export const AutoCompleteSuggestionHeader = (props: AutoCompleteSuggestionHeader AutoCompleteSuggestionHeader.displayName = 'AutoCompleteSuggestionHeader{messageInput{suggestions{Header}}}'; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + padding: 8, + }, + title: { + fontSize: 14, + paddingLeft: 8, + }, +}); diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx index 0ae12208e0..ba0ce85893 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx @@ -1,124 +1,149 @@ -import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import React, { useCallback } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { CommandSuggestion, TextComposerSuggestion, UserSuggestion } from 'stream-chat'; import { AutoCompleteSuggestionCommandIcon } from './AutoCompleteSuggestionCommandIcon'; -import type { - Suggestion, - SuggestionCommand, - SuggestionsContextValue, - SuggestionUser, -} from '../../contexts/suggestionsContext/SuggestionsContext'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import type { Emoji } from '../../emoji-data'; import { AtMentions } from '../../icons/AtMentions'; +import type { Emoji } from '../../types/types'; import { Avatar } from '../Avatar/Avatar'; -export type AutoCompleteSuggestionItemPropsWithContext = Pick< - SuggestionsContextValue, - 'triggerType' -> & { - itemProps: Suggestion; +export type AutoCompleteSuggestionItemProps = { + itemProps: TextComposerSuggestion; + triggerType?: string; }; -const styles = StyleSheet.create({ - args: { - fontSize: 14, - }, - column: { - flex: 1, - justifyContent: 'space-evenly', - paddingLeft: 8, - }, - container: { - alignItems: 'center', - flexDirection: 'row', - paddingHorizontal: 16, - paddingVertical: 8, - }, - name: { - fontSize: 14, - fontWeight: 'bold', - paddingBottom: 2, - }, - tag: { - fontSize: 12, - fontWeight: '600', - }, - text: { - fontSize: 14, - }, - title: { - fontSize: 14, - fontWeight: 'bold', - paddingHorizontal: 8, - }, -}); - -const AutoCompleteSuggestionItemWithContext = ({ - itemProps, - triggerType, -}: AutoCompleteSuggestionItemPropsWithContext) => { +export const MentionSuggestionItem = (item: UserSuggestion) => { + const { id, image, name, online } = item; const { theme: { - colors: { accent_blue, black, grey }, + colors: { accent_blue, black }, messageInput: { suggestions: { - command: { args: argsStyle, container: commandContainer, title }, - emoji: { container: emojiContainer, text }, mention: { avatarSize, column, container: mentionContainer, name: nameStyle }, }, }, }, } = useTheme(); - - if (triggerType === 'mention') { - const { id, image, name, online } = itemProps as SuggestionUser; - return ( - - - - - {name || id} - - - - - ); - } else if (triggerType === 'emoji') { - const { name, unicode } = itemProps as Emoji; - return ( - - - {unicode} - - - {` ${name}`} - - - ); - } else if (triggerType === 'command') { - const { args, name } = itemProps as SuggestionCommand; - return ( - - - - {(name || '').replace(/^\w/, (char) => char.toUpperCase())} - - - {`/${name} ${args}`} + return ( + + + + + {name || id} - ); - } else { - return null; + + + ); +}; + +export const EmojiSuggestionItem = (item: Emoji) => { + const { native, name } = item; + const { + theme: { + colors: { black }, + messageInput: { + suggestions: { + emoji: { container: emojiContainer, text }, + }, + }, + }, + } = useTheme(); + return ( + + + {native} + + + {` ${name}`} + + + ); +}; + +export const CommandSuggestionItem = (item: CommandSuggestion) => { + const { args, name } = item; + const { + theme: { + colors: { black, grey }, + messageInput: { + suggestions: { + command: { args: argsStyle, container: commandContainer, title }, + }, + }, + }, + } = useTheme(); + + return ( + + {name ? : null} + + {(name || '').replace(/^\w/, (char) => char.toUpperCase())} + + + {`/${name} ${args}`} + + + ); +}; + +const SuggestionItem = ({ + item, + triggerType, +}: { + item: TextComposerSuggestion; + triggerType?: string; +}) => { + switch (triggerType) { + case 'mention': + return ; + case 'emoji': + return ; + case 'command': + return ; + default: + return null; } }; +const UnMemoizedAutoCompleteSuggestionItem = ({ + itemProps, + triggerType, +}: AutoCompleteSuggestionItemProps) => { + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + + const { + theme: { + messageInput: { + suggestions: { item: itemStyle }, + }, + }, + } = useTheme(); + + const handlePress = useCallback(async () => { + await textComposer.handleSelect(itemProps); + }, [itemProps, textComposer]); + + return ( + [{ opacity: pressed ? 0.8 : 1 }, itemStyle]} + testID='suggestion-item' + > + + + ); +}; + const areEqual = ( - prevProps: AutoCompleteSuggestionItemPropsWithContext, - nextProps: AutoCompleteSuggestionItemPropsWithContext, + prevProps: AutoCompleteSuggestionItemProps, + nextProps: AutoCompleteSuggestionItemProps, ) => { const { itemProps: prevItemProps, triggerType: prevType } = prevProps; const { itemProps: nextItemProps, triggerType: nextType } = nextProps; @@ -134,15 +159,44 @@ const areEqual = ( }; const MemoizedAutoCompleteSuggestionItem = React.memo( - AutoCompleteSuggestionItemWithContext, + UnMemoizedAutoCompleteSuggestionItem, areEqual, -) as typeof AutoCompleteSuggestionItemWithContext; - -export type AutoCompleteSuggestionItemProps = AutoCompleteSuggestionItemPropsWithContext; +); export const AutoCompleteSuggestionItem = (props: AutoCompleteSuggestionItemProps) => ( ); -AutoCompleteSuggestionItem.displayName = - 'AutoCompleteSuggestionItem{messageInput{suggestions{Item}}}'; +const styles = StyleSheet.create({ + args: { + fontSize: 14, + }, + column: { + flex: 1, + justifyContent: 'space-evenly', + paddingLeft: 8, + }, + container: { + alignItems: 'center', + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 8, + }, + name: { + fontSize: 14, + fontWeight: 'bold', + paddingBottom: 2, + }, + tag: { + fontSize: 12, + fontWeight: '600', + }, + text: { + fontSize: 14, + }, + title: { + fontSize: 14, + fontWeight: 'bold', + paddingHorizontal: 8, + }, +}); diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx index d2ec40e117..1b6e7d8254 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx @@ -1,215 +1,124 @@ -import React, { useMemo, useState } from 'react'; -import { - FlatList, - LayoutChangeEvent, - Pressable, - PressableProps, - PressableStateCallbackType, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; - -import type { AutoCompleteSuggestionHeaderProps } from './AutoCompleteSuggestionHeader'; -import type { AutoCompleteSuggestionItemProps } from './AutoCompleteSuggestionItem'; +import React, { useCallback, useMemo } from 'react'; +import { FlatList, StyleSheet, View } from 'react-native'; + +import { SearchSourceState, TextComposerState, TextComposerSuggestion } from 'stream-chat'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { - isSuggestionUser, - Suggestion, - SuggestionsContextValue, - useSuggestionsContext, -} from '../../contexts/suggestionsContext/SuggestionsContext'; + MessageInputContextValue, + useMessageInputContext, +} from '../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; -const AUTO_COMPLETE_SUGGESTION_LIST_HEADER_HEIGHT = 50; - -type AutoCompleteSuggestionListComponentProps = Pick< - SuggestionsContextValue, - 'queryText' | 'triggerType' -> & { - active: boolean; - data: Suggestion[]; - onSelect: (item: Suggestion) => void; -}; - -export type AutoCompleteSuggestionListPropsWithContext = Pick< - SuggestionsContextValue, - 'AutoCompleteSuggestionHeader' | 'AutoCompleteSuggestionItem' -> & - AutoCompleteSuggestionListComponentProps; +export const DEFAULT_LIST_HEIGHT = 200; -const SuggestionsItem = (props: PressableProps) => { - const { children, style: propsStyle, ...pressableProps } = props; +export type AutoCompleteSuggestionListProps = Partial< + Pick +>; - const style = ({ pressed }: PressableStateCallbackType) => [ - propsStyle as ViewStyle, - { opacity: pressed ? 0.2 : 1 }, - ]; - - return ( - - {children} - - ); -}; +const textComposerStateSelector = (state: TextComposerState) => ({ + suggestions: state.suggestions, + text: state.text, +}); -SuggestionsItem.displayName = 'SuggestionsHeader{messageInput{suggestions}}'; +const searchSourceStateSelector = (nextValue: SearchSourceState) => ({ + items: nextValue.items, +}); -export const AutoCompleteSuggestionListWithContext = ( - props: AutoCompleteSuggestionListPropsWithContext, -) => { - const [itemHeight, setItemHeight] = useState(0); +export const AutoCompleteSuggestionList = ({ + AutoCompleteSuggestionHeader: propsAutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem: propsAutoCompleteSuggestionItem, +}: AutoCompleteSuggestionListProps) => { const { - active, - AutoCompleteSuggestionHeader, - AutoCompleteSuggestionItem, - data, - onSelect, - queryText, - triggerType, - } = props; + AutoCompleteSuggestionHeader: contextAutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem: contextAutoCompleteSuggestionItem, + } = useMessageInputContext(); + + const AutoCompleteSuggestionHeader = + propsAutoCompleteSuggestionHeader ?? contextAutoCompleteSuggestionHeader; + const AutoCompleteSuggestionItem = + propsAutoCompleteSuggestionItem ?? contextAutoCompleteSuggestionItem; + + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { suggestions } = useStateStore(textComposer.state, textComposerStateSelector); + const { items } = useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {}; + const trigger = suggestions?.trigger; + const queryText = suggestions?.query; + + const triggerType = { + '/': 'command', + ':': 'emoji', + '@': 'mention', + }[trigger ?? '']; + + const showList = useMemo(() => { + return items && items?.length > 0; + }, [items]); const { theme: { - colors: { white }, + colors: { black, white }, messageInput: { container: { maxHeight }, - suggestions: { item: itemStyle }, suggestionsListContainer: { flatlist }, }, }, } = useTheme(); - const flatlistHeight = useMemo(() => { - let totalItemHeight; - if (triggerType === 'emoji') { - totalItemHeight = data.length < 7 ? data.length * itemHeight : itemHeight * 6; - } else { - totalItemHeight = data.length < 4 ? data.length * itemHeight : itemHeight * 3; - } - - return triggerType === 'emoji' || triggerType === 'command' - ? totalItemHeight + AUTO_COMPLETE_SUGGESTION_LIST_HEADER_HEIGHT - : totalItemHeight; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [itemHeight, data.length]); - - const renderItem = ({ item }: { item: Suggestion }) => { - switch (triggerType) { - case 'command': - case 'mention': - case 'emoji': - return ( - setItemHeight(event.nativeEvent.layout.height)} - onPress={() => { - onSelect(item); - }} - style={itemStyle} - > - {AutoCompleteSuggestionItem && ( - - )} - - ); - default: - return null; - } - }; - - if (!active || data.length === 0) { + const renderItem = useCallback( + ({ item }: { item: TextComposerSuggestion }) => { + return AutoCompleteSuggestionItem ? ( + + ) : null; + }, + [AutoCompleteSuggestionItem, triggerType], + ); + + const renderHeader = useCallback(() => { + return AutoCompleteSuggestionHeader ? ( + + ) : null; + }, [AutoCompleteSuggestionHeader, queryText, triggerType]); + + if (!showList || !triggerType) { return null; } return ( - + - `${item.name || (isSuggestionUser(item) ? item.id : '')}${index}` - } - ListHeaderComponent={ - AutoCompleteSuggestionHeader ? ( - - ) : null - } + keyExtractor={(item) => item.id} + ListHeaderComponent={renderHeader} renderItem={renderItem} - style={[flatlist, { maxHeight }]} + style={[ + styles.flatlist, + flatlist, + { backgroundColor: white, maxHeight, shadowColor: black }, + ]} + testID={'auto-complete-suggestion-list'} /> ); }; -const areEqual = ( - prevProps: AutoCompleteSuggestionListPropsWithContext, - nextProps: AutoCompleteSuggestionListPropsWithContext, -) => { - const { - active: prevActive, - data: prevData, - queryText: prevQueryText, - triggerType: prevType, - } = prevProps; - const { - active: nextActive, - data: nextData, - queryText: nextQueryText, - triggerType: nextType, - } = nextProps; - - const activeEqual = prevActive === nextActive; - if (!activeEqual) { - return false; - } - - const queryTextEqual = prevQueryText === nextQueryText; - if (!queryTextEqual) { - return false; - } - - const dataEqual = prevData === nextData; - if (!dataEqual) { - return false; - } - - const typeEqual = prevType === nextType; - if (!typeEqual) { - return false; - } - - return true; -}; - -const MemoizedAutoCompleteSuggestionList = React.memo( - AutoCompleteSuggestionListWithContext, - areEqual, -) as typeof AutoCompleteSuggestionListWithContext; - -export type AutoCompleteSuggestionListProps = AutoCompleteSuggestionListComponentProps & { - AutoCompleteSuggestionHeader?: React.ComponentType; - AutoCompleteSuggestionItem?: React.ComponentType; -}; - -export const AutoCompleteSuggestionList = (props: AutoCompleteSuggestionListProps) => { - const { AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem } = useSuggestionsContext(); - - return ( - - ); -}; - const styles = StyleSheet.create({ container: { + maxHeight: DEFAULT_LIST_HEIGHT, + }, + flatlist: { borderRadius: 8, elevation: 3, marginHorizontal: 8, - marginVertical: 8, - shadowOffset: { height: 1, width: 0 }, - shadowOpacity: 0.15, + shadowOffset: { + height: 1, + width: 0, + }, + shadowOpacity: 0.22, + shadowRadius: 2.22, }, }); diff --git a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js index 009f019154..c3591fc026 100644 --- a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js +++ b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js @@ -1,84 +1,187 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react-native'; - -import { SuggestionsProvider } from '../../../contexts/suggestionsContext/SuggestionsContext'; -import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; -import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; -import { generateChannelResponse } from '../../../mock-builders/generator/channel'; -import { generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { ACITriggerSettings } from '../../../utils/ACITriggerSettings'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { OverlayProvider } from '../../../contexts'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; import { AutoCompleteInput } from '../AutoCompleteInput'; +const renderComponent = ({ channelProps, client, props }) => { + return render( + + + + + + + , + ); +}; + describe('AutoCompleteInput', () => { - const clientUser = generateUser(); - let chatClient; + let client; let channel; - const getComponent = (props = {}) => ( - - - - - - - ); + beforeEach(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); - const initializeChannel = async (c) => { - useMockedApis(chatClient, [getOrCreateChannelApi(c)]); + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); - channel = chatClient.channel('messaging'); + it('should render AutoCompleteInput', async () => { + const channelProps = { channel }; + const props = {}; - await channel.watch(); - }; + renderComponent({ channelProps, client, props }); - beforeEach(async () => { - chatClient = await getTestClientWithUser(clientUser); + const { queryByTestId } = screen; + + const input = queryByTestId('auto-complete-text-input'); + + await waitFor(() => { + expect(input).toBeTruthy(); + }); }); - afterEach(() => { - channel = null; + it('should have the editable prop as false when the message composer config is set', async () => { + const channelProps = { channel }; + const props = {}; + + channel.messageComposer.updateConfig({ text: { enabled: false } }); + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + const input = queryByTestId('auto-complete-text-input'); + + await waitFor(() => { + expect(input.props.editable).toBeFalsy(); + }); }); - it('should render AutoCompleteInput and trigger open/close suggestions with / commands', async () => { - const props = { - closeSuggestions: jest.fn(), - openSuggestions: jest.fn(), - }; + it('should have the maxLength same as the one on the config of channel', async () => { + jest.spyOn(channel, 'getConfig').mockReturnValue({ + max_message_length: 10, + }); + const channelProps = { channel }; + const props = {}; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + const input = queryByTestId('auto-complete-text-input'); + + await waitFor(() => { + expect(input.props.maxLength).toBe(10); + }); + }); + + it('should call the textComposer handleChange when the onChangeText is triggered', async () => { + const { textComposer } = channel.messageComposer; + + const spyHandleChange = jest.spyOn(textComposer, 'handleChange'); + + const channelProps = { channel }; + const props = {}; - await initializeChannel(generateChannelResponse()); + renderComponent({ channelProps, client, props }); - const { queryByTestId, rerender } = render(getComponent(props)); + const { queryByTestId } = screen; + + const input = queryByTestId('auto-complete-text-input'); + + act(() => { + fireEvent.changeText(input, 'hello'); + }); await waitFor(() => { - expect(queryByTestId('auto-complete-text-input')).toBeTruthy(); - expect(props.closeSuggestions).toHaveBeenCalledTimes(0); - expect(props.openSuggestions).toHaveBeenCalledTimes(0); + expect(spyHandleChange).toHaveBeenCalled(); + expect(spyHandleChange).toHaveBeenCalledWith({ + selection: { end: 5, start: 5 }, + text: 'hello', + }); + expect(input.props.value).toBe('hello'); }); + }); - rerender(getComponent({ ...props, text: '/' })); + it('should style the text input with maxHeight that is set by the layout', async () => { + const channelProps = { channel }; + const props = { numberOfLines: 10 }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + const input = queryByTestId('auto-complete-text-input'); + + act(() => { + fireEvent(input, 'contentSizeChange', { + nativeEvent: { + contentSize: { height: 100 }, + }, + }); + }); await waitFor(() => { - expect(queryByTestId('auto-complete-text-input')).toBeTruthy(); + expect(input.props.style[1].maxHeight).toBe(1000); }); + }); + + it('should call the textComposer setSelection when the onSelectionChange is triggered', async () => { + const { textComposer } = channel.messageComposer; - rerender(getComponent({ ...props, text: '' })); - rerender(getComponent(props)); + const spySetSelection = jest.spyOn(textComposer, 'setSelection'); + + const channelProps = { channel }; + const props = {}; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + const input = queryByTestId('auto-complete-text-input'); + + act(() => { + fireEvent(input, 'selectionChange', { + nativeEvent: { + selection: { end: 5, start: 5 }, + }, + }); + }); await waitFor(() => { - expect(queryByTestId('auto-complete-text-input')).toBeTruthy(); + expect(spySetSelection).toHaveBeenCalled(); + expect(spySetSelection).toHaveBeenCalledWith({ end: 5, start: 5 }); }); }); - // TODO: figure out how to make tests work for @ mentions with needing to update function state values + // TODO: Add a test for command + it.each([ + { cooldownActive: false, result: 'Send a message' }, + { cooldownActive: true, result: 'Slow mode ON' }, + ])('should have the placeholderText as Slow mode ON when cooldown is active', async (data) => { + const channelProps = { channel }; + const props = { + cooldownActive: data.cooldownActive, + }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + const input = queryByTestId('auto-complete-text-input'); + + await waitFor(() => { + expect(input.props.placeholder).toBe(data.result); + }); + }); }); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 33c1046d34..802e4a2632 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -2,7 +2,6 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useS import { KeyboardAvoidingViewProps, StyleSheet, Text, View } from 'react-native'; import debounce from 'lodash/debounce'; -import omit from 'lodash/omit'; import throttle from 'lodash/throttle'; import { lookup } from 'mime-types'; @@ -12,14 +11,17 @@ import { Channel as ChannelType, EventHandler, LocalMessage, + localMessageToNewMessagePayload, MessageLabel, MessageResponse, Reaction, SendMessageAPIResponse, + SendMessageOptions, StreamChat, Event as StreamEvent, Message as StreamMessage, Thread, + UpdateMessageOptions, } from 'stream-chat'; import { useChannelDataState } from './hooks/useChannelDataState'; @@ -39,11 +41,21 @@ import { useCreateTypingContext } from './hooks/useCreateTypingContext'; import { useMessageListPagination } from './hooks/useMessageListPagination'; import { useTargetedMessage } from './hooks/useTargetedMessage'; -import { MessageContextValue } from '../../contexts'; +import { CameraSelectorIcon as DefaultCameraSelectorIcon } from '../../components/AttachmentPicker/components/CameraSelectorIcon'; +import { FileSelectorIcon as DefaultFileSelectorIcon } from '../../components/AttachmentPicker/components/FileSelectorIcon'; +import { ImageSelectorIcon as DefaultImageSelectorIcon } from '../../components/AttachmentPicker/components/ImageSelectorIcon'; +import { VideoRecorderSelectorIcon as DefaultVideoRecorderSelectorIcon } from '../../components/AttachmentPicker/components/VideoRecorderSelectorIcon'; +import { CreatePollIcon as DefaultCreatePollIcon } from '../../components/Poll/components/CreatePollIcon'; +import { + AttachmentPickerContextValue, + AttachmentPickerProvider, + MessageContextValue, +} from '../../contexts'; import { ChannelContextValue, ChannelProvider } from '../../contexts/channelContext/ChannelContext'; import type { UseChannelStateValue } from '../../contexts/channelsStateContext/useChannelState'; import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; +import { MessageComposerProvider } from '../../contexts/messageComposerContext/MessageComposerContext'; import { InputMessageInputContextValue, MessageInputProvider, @@ -60,10 +72,6 @@ import { PaginatedMessageListContextValue, PaginatedMessageListProvider, } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { - SuggestionsContextValue, - SuggestionsProvider, -} from '../../contexts/suggestionsContext/SuggestionsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, @@ -75,9 +83,10 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; import { TypingProvider } from '../../contexts/typingContext/TypingContext'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback, useViewport } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; +import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; import { LOLReaction, LoveReaction, @@ -96,10 +105,7 @@ import { ChannelUnreadState, FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; -import { removeReservedFields } from '../../utils/removeReservedFields'; import { - defaultEmojiSearchIndex, - generateRandomId, getFileNameFromPath, isBouncedMessage, isLocalUrl, @@ -119,6 +125,13 @@ import { ImageLoadingFailedIndicator as ImageLoadingFailedIndicatorDefault } fro import { ImageLoadingIndicator as ImageLoadingIndicatorDefault } from '../Attachment/ImageLoadingIndicator'; import { ImageReloadIndicator as ImageReloadIndicatorDefault } from '../Attachment/ImageReloadIndicator'; import { VideoThumbnail as VideoThumbnailDefault } from '../Attachment/VideoThumbnail'; +import { AttachmentPicker, AttachmentPickerProps } from '../AttachmentPicker/AttachmentPicker'; +import { AttachmentPickerBottomSheetHandle as DefaultAttachmentPickerBottomSheetHandle } from '../AttachmentPicker/components/AttachmentPickerBottomSheetHandle'; +import { AttachmentPickerError as DefaultAttachmentPickerError } from '../AttachmentPicker/components/AttachmentPickerError'; +import { AttachmentPickerErrorImage as DefaultAttachmentPickerErrorImage } from '../AttachmentPicker/components/AttachmentPickerErrorImage'; +import { AttachmentPickerIOSSelectMorePhotos as DefaultAttachmentPickerIOSSelectMorePhotos } from '../AttachmentPicker/components/AttachmentPickerIOSSelectMorePhotos'; +import { AttachmentPickerSelectionBar as DefaultAttachmentPickerSelectionBar } from '../AttachmentPicker/components/AttachmentPickerSelectionBar'; +import { ImageOverlaySelectedComponent as DefaultImageOverlaySelectedComponent } from '../AttachmentPicker/components/ImageOverlaySelectedComponent'; import { AutoCompleteSuggestionHeader as AutoCompleteSuggestionHeaderDefault } from '../AutoCompleteInput/AutoCompleteSuggestionHeader'; import { AutoCompleteSuggestionItem as AutoCompleteSuggestionItemDefault } from '../AutoCompleteInput/AutoCompleteSuggestionItem'; import { AutoCompleteSuggestionList as AutoCompleteSuggestionListDefault } from '../AutoCompleteInput/AutoCompleteSuggestionList'; @@ -148,26 +161,28 @@ import { ReactionListBottom as ReactionListBottomDefault } from '../Message/Mess import { ReactionListTop as ReactionListTopDefault } from '../Message/MessageSimple/ReactionList/ReactionListTop'; import { StreamingMessageView as DefaultStreamingMessageView } from '../Message/MessageSimple/StreamingMessageView'; import { AttachButton as AttachButtonDefault } from '../MessageInput/AttachButton'; +import { AttachmentUploadPreviewList as AttachmentUploadPreviewDefault } from '../MessageInput/AttachmentUploadPreviewList'; import { CommandsButton as CommandsButtonDefault } from '../MessageInput/CommandsButton'; +import { AttachmentUploadProgressIndicator as AttachmentUploadProgressIndicatorDefault } from '../MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; +import { AudioAttachmentUploadPreview as AudioAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; +import { FileAttachmentUploadPreview as FileAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; +import { ImageAttachmentUploadPreview as ImageAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview'; import { AudioRecorder as AudioRecorderDefault } from '../MessageInput/components/AudioRecorder/AudioRecorder'; import { AudioRecordingButton as AudioRecordingButtonDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingButton'; import { AudioRecordingInProgress as AudioRecordingInProgressDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingInProgress'; import { AudioRecordingLockIndicator as AudioRecordingLockIndicatorDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingLockIndicator'; import { AudioRecordingPreview as AudioRecordingPreviewDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingPreview'; import { AudioRecordingWaveform as AudioRecordingWaveformDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingWaveform'; +import { CommandInput as CommandInputDefault } from '../MessageInput/components/CommandInput'; import { InputEditingStateHeader as InputEditingStateHeaderDefault } from '../MessageInput/components/InputEditingStateHeader'; -import { InputGiphySearch as InputGiphyCommandInputDefault } from '../MessageInput/components/InputGiphySearch'; import { InputReplyStateHeader as InputReplyStateHeaderDefault } from '../MessageInput/components/InputReplyStateHeader'; import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/CooldownTimer'; -import { FileUploadPreview as FileUploadPreviewDefault } from '../MessageInput/FileUploadPreview'; -import { ImageUploadPreview as ImageUploadPreviewDefault } from '../MessageInput/ImageUploadPreview'; import { InputButtons as InputButtonsDefault } from '../MessageInput/InputButtons'; import { MoreOptionsButton as MoreOptionsButtonDefault } from '../MessageInput/MoreOptionsButton'; import { SendButton as SendButtonDefault } from '../MessageInput/SendButton'; import { SendMessageDisallowedIndicator as SendMessageDisallowedIndicatorDefault } from '../MessageInput/SendMessageDisallowedIndicator'; import { ShowThreadMessageInChannelButton as ShowThreadMessageInChannelButtonDefault } from '../MessageInput/ShowThreadMessageInChannelButton'; import { StopMessageStreamingButton as DefaultStopMessageStreamingButton } from '../MessageInput/StopMessageStreamingButton'; -import { UploadProgressIndicator as UploadProgressIndicatorDefault } from '../MessageInput/UploadProgressIndicator'; import { DateHeader as DateHeaderDefault } from '../MessageList/DateHeader'; import { InlineDateSeparator as InlineDateSeparatorDefault } from '../MessageList/InlineDateSeparator'; import { InlineUnreadIndicator as InlineUnreadIndicatorDefault } from '../MessageList/InlineUnreadIndicator'; @@ -243,13 +258,26 @@ const debounceOptions = { }; export type ChannelPropsWithContext = Pick & + Partial> & + Partial< + Pick< + AttachmentPickerProps, + | 'AttachmentPickerError' + | 'AttachmentPickerErrorImage' + | 'AttachmentPickerIOSSelectMorePhotos' + | 'ImageOverlaySelectedComponent' + | 'attachmentPickerErrorButtonText' + | 'attachmentPickerErrorText' + | 'numberOfAttachmentImagesToLoadPerCall' + | 'numberOfAttachmentPickerImageColumns' + > + > & Partial< Pick< ChannelContextValue, | 'EmptyStateIndicator' | 'enableMessageGroupingByUser' | 'enforceUniqueReaction' - | 'giphyEnabled' | 'hideStickyDateHeader' | 'hideDateSeparators' | 'LoadingIndicator' @@ -259,18 +287,7 @@ export type ChannelPropsWithContext = Pick & > > & Pick & - Partial< - Omit< - InputMessageInputContextValue, - 'quotedMessage' | 'editing' | 'clearEditingState' | 'clearQuotedMessageState' | 'sendMessage' - > - > & - Partial< - Pick< - SuggestionsContextValue, - 'AutoCompleteSuggestionHeader' | 'AutoCompleteSuggestionItem' | 'AutoCompleteSuggestionList' - > - > & + Partial> & Pick & Partial< Pick @@ -406,6 +423,7 @@ export type ChannelPropsWithContext = Pick & doSendMessageRequest?: ( channelId: string, messageData: StreamMessage, + options?: SendMessageOptions, ) => Promise; /** * Overrides the Stream default update message request (Advanced usage only) @@ -415,6 +433,7 @@ export type ChannelPropsWithContext = Pick & doUpdateMessageRequest?: ( channelId: string, updatedMessage: Parameters[0], + options?: UpdateMessageOptions, ) => ReturnType; /** * When true, messageList will be scrolled at first unread message, when opened. @@ -450,7 +469,6 @@ export type ChannelPropsWithContext = Pick & * Boolean flag to enable/disable marking the channel as read on mount */ markReadOnMount?: boolean; - maxMessageLength?: number; /** * Load the channel at a specified message instead of the most recent message. */ @@ -474,6 +492,8 @@ export type ChannelPropsWithContext = Pick & >; const ChannelWithContext = (props: PropsWithChildren) => { + const { vh } = useViewport(); + const { additionalKeyboardAvoidingViewProps, additionalPressableProps, @@ -486,8 +506,13 @@ const ChannelWithContext = (props: PropsWithChildren) = AttachButton = AttachButtonDefault, Attachment = AttachmentDefault, AttachmentActions = AttachmentActionsDefault, + AttachmentPickerBottomSheetHandle = DefaultAttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight = 20, + attachmentPickerBottomSheetHeight = vh(45), + AttachmentPickerSelectionBar = DefaultAttachmentPickerSelectionBar, + attachmentSelectionBarHeight = 52, AudioAttachment = AudioAttachmentDefault, - AudioAttachmentUploadPreview = AudioAttachmentDefault, + AudioAttachmentUploadPreview = AudioAttachmentUploadPreviewDefault, AudioRecorder = AudioRecorderDefault, audioRecordingEnabled = false, AudioRecordingInProgress = AudioRecordingInProgressDefault, @@ -497,8 +522,22 @@ const ChannelWithContext = (props: PropsWithChildren) = AutoCompleteSuggestionHeader = AutoCompleteSuggestionHeaderDefault, AutoCompleteSuggestionItem = AutoCompleteSuggestionItemDefault, AutoCompleteSuggestionList = AutoCompleteSuggestionListDefault, - autoCompleteSuggestionsLimit, - autoCompleteTriggerSettings, + AttachmentPickerError = DefaultAttachmentPickerError, + AttachmentPickerErrorImage = DefaultAttachmentPickerErrorImage, + AttachmentPickerIOSSelectMorePhotos = DefaultAttachmentPickerIOSSelectMorePhotos, + AttachmentUploadPreviewList = AttachmentUploadPreviewDefault, + ImageOverlaySelectedComponent = DefaultImageOverlaySelectedComponent, + attachmentPickerErrorButtonText, + attachmentPickerErrorText, + numberOfAttachmentImagesToLoadPerCall = 60, + numberOfAttachmentPickerImageColumns = 3, + + bottomInset = 0, + CameraSelectorIcon = DefaultCameraSelectorIcon, + FileSelectorIcon = DefaultFileSelectorIcon, + CreatePollIcon = DefaultCreatePollIcon, + ImageSelectorIcon = DefaultImageSelectorIcon, + VideoRecorderSelectorIcon = DefaultVideoRecorderSelectorIcon, Card = CardDefault, CardCover, CardFooter, @@ -515,27 +554,24 @@ const ChannelWithContext = (props: PropsWithChildren) = disableKeyboardCompatibleView = false, disableTypingIndicator, dismissKeyboardOnMessageTouch = true, - doDocUploadRequest, - doImageUploadRequest, + doFileUploadRequest, doMarkReadRequest, doSendMessageRequest, doUpdateMessageRequest, - emojiSearchIndex = defaultEmojiSearchIndex, EmptyStateIndicator = EmptyStateIndicatorDefault, enableMessageGroupingByUser = true, enableOfflineSupport, enableSwipeToReply = true, enforceUniqueReaction = false, FileAttachment = FileAttachmentDefault, + FileAttachmentUploadPreview = FileAttachmentUploadPreviewDefault, FileAttachmentGroup = FileAttachmentGroupDefault, FileAttachmentIcon = FileIconDefault, - FileUploadPreview = FileUploadPreviewDefault, FlatList = NativeHandlers.FlatList, forceAlignMessages, Gallery = GalleryDefault, getMessagesGroupStyles, Giphy = GiphyDefault, - giphyEnabled, giphyVersion = 'fixed_height', handleAttachButtonPress, handleBan, @@ -558,18 +594,17 @@ const ChannelWithContext = (props: PropsWithChildren) = hasImagePicker = isImagePickerAvailable() || isImageMediaLibraryAvailable(), hideDateSeparators = false, hideStickyDateHeader = false, + ImageAttachmentUploadPreview = ImageAttachmentUploadPreviewDefault, ImageLoadingFailedIndicator = ImageLoadingFailedIndicatorDefault, ImageLoadingIndicator = ImageLoadingIndicatorDefault, ImageReloadIndicator = ImageReloadIndicatorDefault, - ImageUploadPreview = ImageUploadPreviewDefault, initialScrollToFirstUnreadMessage = false, - initialValue, InlineDateSeparator = InlineDateSeparatorDefault, InlineUnreadIndicator = InlineUnreadIndicatorDefault, Input, InputButtons = InputButtonsDefault, InputEditingStateHeader = InputEditingStateHeaderDefault, - InputGiphySearch = InputGiphyCommandInputDefault, + CommandInput = CommandInputDefault, InputReplyStateHeader = InputReplyStateHeaderDefault, isAttachmentEqual, isMessageAIGenerated = () => false, @@ -583,11 +618,7 @@ const ChannelWithContext = (props: PropsWithChildren) = loadingMoreRecent: loadingMoreRecentProp, markdownRules, markReadOnMount = true, - maxMessageLength: maxMessageLengthProp, - maxNumberOfFiles = 10, maxTimeBetweenGroupedMessages, - mentionAllAppUsersEnabled = false, - mentionAllAppUsersQuery, Message = MessageDefault, MessageActionList = MessageActionListDefault, MessageActionListItem = MessageActionListItemDefault, @@ -632,8 +663,6 @@ const ChannelWithContext = (props: PropsWithChildren) = NetworkDownIndicator = NetworkDownIndicatorDefault, // TODO: Think about this one newMessageStateUpdateThrottleInterval = defaultThrottleInterval, - numberOfLines = 5, - onChangeText, onLongPressMessage, onPressInMessage, onPressMessage, @@ -647,7 +676,6 @@ const ChannelWithContext = (props: PropsWithChildren) = ScrollToBottomButton = ScrollToBottomButtonDefault, selectReaction, SendButton = SendButtonDefault, - sendImageAsync = false, SendMessageDisallowedIndicator = SendMessageDisallowedIndicatorDefault, setInputRef, setThreadMessages, @@ -664,11 +692,13 @@ const ChannelWithContext = (props: PropsWithChildren) = thread: threadFromProps, threadList, threadMessages, + topInset, TypingIndicator = TypingIndicatorDefault, TypingIndicatorContainer = TypingIndicatorContainerDefault, UnreadMessagesNotification = UnreadMessagesNotificationDefault, - UploadProgressIndicator = UploadProgressIndicatorDefault, + AttachmentUploadProgressIndicator = AttachmentUploadProgressIndicatorDefault, UrlPreview = CardDefault, + VideoAttachmentUploadPreview = FileAttachmentUploadPreviewDefault, VideoThumbnail = VideoThumbnailDefault, isOnline, } = props; @@ -686,11 +716,8 @@ const ChannelWithContext = (props: PropsWithChildren) = }, } = useTheme(); const [deleted, setDeleted] = useState(false); - const [editing, setEditing] = useState(undefined); const [error, setError] = useState(false); const [lastRead, setLastRead] = useState(); - - const [quotedMessage, setQuotedMessage] = useState(undefined); const [thread, setThread] = useState(threadProps || null); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); @@ -698,6 +725,8 @@ const ChannelWithContext = (props: PropsWithChildren) = undefined, ); + const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); + const syncingChannelRef = useRef(false); const { highlightedMessageId, setTargetedMessage, targetedMessage } = useTargetedMessage(); @@ -1206,7 +1235,7 @@ const ChannelWithContext = (props: PropsWithChildren) = ); const replaceMessage = useStableCallback( - (oldMessage: MessageResponse, newMessage: MessageResponse) => { + (oldMessage: LocalMessage, newMessage: MessageResponse) => { if (channel) { channel.state.removeMessage(oldMessage); channel.state.addMessageSorted(newMessage, true); @@ -1220,75 +1249,23 @@ const ChannelWithContext = (props: PropsWithChildren) = }, ); - const createMessagePreview = useStableCallback( - ({ - attachments, - mentioned_users, - parent_id, - poll_id, - text, - ...extraFields - }: Partial) => { - // Exclude following properties from message.user within message preview, - // since they could be long arrays and have no meaning as sender of message. - // Storing such large value within user's table may cause sqlite queries to crash. - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { channel_mutes, devices, mutes, ...messageUser } = client.user; - - const preview = { - __html: text, - attachments, - created_at: new Date(), - html: text, - id: `${client.userID}-${generateRandomId()}`, - mentioned_users: - mentioned_users?.map((userId) => ({ - id: userId, - })) || [], - parent_id, - poll_id, - reactions: [], - status: MessageStatusTypes.SENDING, - text, - type: 'regular', - user: { - ...messageUser, - id: client.userID, - }, - ...extraFields, - } as unknown as MessageResponse; - - /** - * This is added to the message for local rendering prior to the message - * being returned from the backend, it is removed when the message is sent - * as quoted_message is a reserved field. - */ - if (preview.quoted_message_id) { - const quotedMessage = channelMessagesState.messages?.find( - (message) => message.id === preview.quoted_message_id, - ); - - preview.quoted_message = quotedMessage as MessageResponse['quoted_message']; - } - return preview; - }, - ); - - const uploadPendingAttachments = useStableCallback(async (message: MessageResponse) => { + const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => { const updatedMessage = { ...message }; if (updatedMessage.attachments?.length) { for (let i = 0; i < updatedMessage.attachments?.length; i++) { const attachment = updatedMessage.attachments[i]; - const image = attachment.originalImage; - const file = attachment.originalFile; - // check if image_url is not a remote url + + // If the attachment is already uploaded, skip it. if ( - attachment.type === FileTypes.Image && - image?.uri && - attachment.image_url && - isLocalUrl(attachment.image_url) + (attachment.image_url && !isLocalUrl(attachment.image_url)) || + (attachment.asset_url && !isLocalUrl(attachment.asset_url)) ) { + continue; + } + + const image = attachment.originalFile; + const file = attachment.originalFile; + if (attachment.type === FileTypes.Image && image?.uri) { const filename = image.name ?? getFileNameFromPath(image.uri); // if any upload is in progress, cancel it const controller = uploadAbortControllerRef.current.get(filename); @@ -1299,8 +1276,8 @@ const ChannelWithContext = (props: PropsWithChildren) = const compressedUri = await compressedImageURI(image, compressImageQuality); const contentType = lookup(filename) || 'multipart/form-data'; - const uploadResponse = doImageUploadRequest - ? await doImageUploadRequest(image, channel) + const uploadResponse = doFileUploadRequest + ? await doFileUploadRequest(image) : await channel.sendImage(compressedUri, filename, contentType); attachment.image_url = uploadResponse.file; @@ -1311,23 +1288,15 @@ const ChannelWithContext = (props: PropsWithChildren) = }); } - if ( - (attachment.type === FileTypes.File || - attachment.type === FileTypes.Audio || - attachment.type === FileTypes.VoiceRecording || - attachment.type === FileTypes.Video) && - attachment.asset_url && - isLocalUrl(attachment.asset_url) && - file?.uri - ) { + if (attachment.type !== FileTypes.Image && file?.uri) { // if any upload is in progress, cancel it const controller = uploadAbortControllerRef.current.get(file.name); if (controller) { controller.abort(); uploadAbortControllerRef.current.delete(file.name); } - const response = doDocUploadRequest - ? await doDocUploadRequest(file, channel) + const response = doFileUploadRequest + ? await doFileUploadRequest(file) : await channel.sendFile(file.uri, file.name, file.type); attachment.asset_url = response.file; if (response.thumb_url) { @@ -1346,18 +1315,28 @@ const ChannelWithContext = (props: PropsWithChildren) = }); const sendMessageRequest = useStableCallback( - async (message: MessageResponse, retrying?: boolean) => { + async ({ + localMessage, + message, + options, + retrying, + }: { + localMessage: LocalMessage; + message: StreamMessage; + options?: SendMessageOptions; + retrying?: boolean; + }) => { let failedMessageUpdated = false; const handleFailedMessage = async () => { if (!failedMessageUpdated) { const updatedMessage = { - ...message, + ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED, }; updateMessage(updatedMessage); threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); - optimisticallyUpdatedNewMessages.delete(message.id); + optimisticallyUpdatedNewMessages.delete(localMessage.id); if (enableOfflineSupport) { await dbApi.updateMessage({ @@ -1374,50 +1353,25 @@ const ChannelWithContext = (props: PropsWithChildren) = await handleFailedMessage(); } - const updatedMessage = await uploadPendingAttachments(message); - const extraFields = omit(updatedMessage, [ - '__html', - 'attachments', - 'created_at', - 'deleted_at', - 'html', - 'id', - 'latest_reactions', - 'mentioned_users', - 'own_reactions', - 'parent_id', - 'quoted_message', - 'reaction_counts', - 'reaction_groups', - 'reactions', - 'status', - 'text', - 'type', - 'updated_at', - 'user', - ]); - const { attachments, id, mentioned_users, parent_id, text } = updatedMessage; + const updatedLocalMessage = await uploadPendingAttachments(localMessage); + const { attachments } = updatedLocalMessage; + const { text, mentioned_users } = message; if (!channel.id) { return; } - const mentionedUserIds = mentioned_users?.map((user) => user.id) || []; - const messageData = { + ...message, attachments, - id, - mentioned_users: mentionedUserIds, - parent_id, - text: patchMessageTextCommand(text ?? '', mentionedUserIds), - ...extraFields, + text: patchMessageTextCommand(text ?? '', mentioned_users ?? []), } as StreamMessage; let messageResponse = {} as SendMessageAPIResponse; if (doSendMessageRequest) { - messageResponse = await doSendMessageRequest(channel?.cid || '', messageData); + messageResponse = await doSendMessageRequest(channel?.cid || '', messageData, options); } else if (channel) { - messageResponse = await channel.sendMessage(messageData); + messageResponse = await channel.sendMessage(messageData, options); } if (messageResponse?.message) { @@ -1432,35 +1386,27 @@ const ChannelWithContext = (props: PropsWithChildren) = }); } if (retrying) { - replaceMessage(message, newMessageResponse); + replaceMessage(localMessage, newMessageResponse); } else { updateMessage(newMessageResponse, {}, true); } } } catch (err) { - console.log(err); + console.log('Error sending message:', err); await handleFailedMessage(); } }, ); const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback( - async (message) => { + async ({ localMessage, message, options }) => { if (channel?.state?.filterErrorMessages) { channel.state.filterErrorMessages(); } - const messagePreview = createMessagePreview({ - ...message, - attachments: message.attachments || [], - }); - - updateMessage(messagePreview, { - commands: [], - messageInput: '', - }); - threadInstance?.upsertReplyLocally?.({ message: messagePreview }); - optimisticallyUpdatedNewMessages.add(messagePreview.id); + updateMessage(localMessage); + threadInstance?.upsertReplyLocally?.({ message: localMessage }); + optimisticallyUpdatedNewMessages.add(localMessage.id); if (enableOfflineSupport) { // While sending a message, we add the message to local db with failed status, so that @@ -1468,57 +1414,43 @@ const ChannelWithContext = (props: PropsWithChildren) = // then user can see that message in failed state and can retry. // If succesfull, it will be updated with received status. await dbApi.upsertMessages({ - messages: [{ ...messagePreview, cid: channel.cid, status: MessageStatusTypes.FAILED }], + messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }], }); } - await sendMessageRequest(messagePreview); + await sendMessageRequest({ localMessage, message, options }); }, ); const retrySendMessage: MessagesContextValue['retrySendMessage'] = useStableCallback( - async (message) => { + async (localMessage) => { const statusPendingMessage = { - ...message, + ...localMessage, status: MessageStatusTypes.SENDING, }; - const messageWithoutReservedFields = removeReservedFields(statusPendingMessage); + const messageWithoutReservedFields = localMessageToNewMessagePayload(statusPendingMessage); // For bounced messages, we don't need to update the message, instead always send a new message. - if (!isBouncedMessage(message)) { + if (!isBouncedMessage(localMessage)) { updateMessage(messageWithoutReservedFields as MessageResponse); } - await sendMessageRequest(messageWithoutReservedFields as MessageResponse, true); + await sendMessageRequest({ + localMessage, + message: messageWithoutReservedFields, + retrying: true, + }); }, ); const editMessage: InputMessageInputContextValue['editMessage'] = useStableCallback( - (updatedMessage) => + ({ localMessage, options }) => doUpdateMessageRequest - ? doUpdateMessageRequest(channel?.cid || '', updatedMessage) - : client.updateMessage(updatedMessage), - ); - - const setEditingState: MessagesContextValue['setEditingState'] = useStableCallback((message) => { - clearQuotedMessageState(); - setEditing(message); - }); - - const setQuotedMessageState: MessagesContextValue['setQuotedMessageState'] = useStableCallback( - (messageOrBoolean) => { - setQuotedMessage(messageOrBoolean); - }, - ); - - const clearEditingState: InputMessageInputContextValue['clearEditingState'] = useStableCallback( - () => setEditing(undefined), + ? doUpdateMessageRequest(channel?.cid || '', localMessage, options) + : client.updateMessage(localMessage, undefined, options), ); - const clearQuotedMessageState: InputMessageInputContextValue['clearQuotedMessageState'] = - useStableCallback(() => setQuotedMessage(undefined)); - /** * Removes the message from local state */ @@ -1700,6 +1632,48 @@ const ChannelWithContext = (props: PropsWithChildren) = } }); + const attachmentPickerProps = useMemo( + () => ({ + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerError, + attachmentPickerErrorButtonText, + AttachmentPickerErrorImage, + attachmentPickerErrorText, + AttachmentPickerIOSSelectMorePhotos, + attachmentSelectionBarHeight, + ImageOverlaySelectedComponent, + numberOfAttachmentImagesToLoadPerCall, + numberOfAttachmentPickerImageColumns, + }), + [ + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerError, + attachmentPickerErrorButtonText, + AttachmentPickerErrorImage, + attachmentPickerErrorText, + AttachmentPickerIOSSelectMorePhotos, + attachmentSelectionBarHeight, + ImageOverlaySelectedComponent, + numberOfAttachmentImagesToLoadPerCall, + numberOfAttachmentPickerImageColumns, + ], + ); + + const attachmentPickerContext = useMemo( + () => ({ + bottomInset, + bottomSheetRef, + closePicker: () => closePicker(bottomSheetRef), + openPicker: () => openPicker(bottomSheetRef), + topInset, + }), + [bottomInset, bottomSheetRef, closePicker, openPicker, topInset], + ); + const ownCapabilitiesContext = useCreateOwnCapabilitiesContext({ channel, overrideCapabilities: overrideOwnCapabilities, @@ -1713,9 +1687,6 @@ const ChannelWithContext = (props: PropsWithChildren) = enableMessageGroupingByUser, enforceUniqueReaction, error, - giphyEnabled: - giphyEnabled ?? - !!(clientChannelConfig?.commands || [])?.some((command) => command.name === 'giphy'), hideDateSeparators, hideStickyDateHeader, highlightedMessageId, @@ -1763,6 +1734,13 @@ const ChannelWithContext = (props: PropsWithChildren) = asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadPreviewList, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -1770,52 +1748,43 @@ const ChannelWithContext = (props: PropsWithChildren) = AudioRecordingLockIndicator, AudioRecordingPreview, AudioRecordingWaveform, - autoCompleteSuggestionsLimit, - autoCompleteTriggerSettings, + AutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem, + AutoCompleteSuggestionList, + CameraSelectorIcon, channelId, - clearEditingState, - clearQuotedMessageState, + CommandInput, CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, - editing, + CreatePollIcon, + doFileUploadRequest, editMessage, - emojiSearchIndex, - FileUploadPreview, + FileAttachmentUploadPreview, + FileSelectorIcon, handleAttachButtonPress, hasCameraPicker, - hasCommands: hasCommands ?? (getChannelConfigSafely()?.commands ?? []).length > 0, + hasCommands: hasCommands ?? !!clientChannelConfig?.commands?.length, hasFilePicker, hasImagePicker, - ImageUploadPreview, - initialValue, + ImageAttachmentUploadPreview, + ImageSelectorIcon, Input, InputButtons, InputEditingStateHeader, - InputGiphySearch, InputReplyStateHeader, - maxMessageLength: maxMessageLengthProp ?? clientChannelConfig?.max_message_length ?? undefined, - maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, MoreOptionsButton, - numberOfLines, - onChangeText, openPollCreationDialog, - quotedMessage, SendButton, - sendImageAsync, sendMessage, SendMessageDisallowedIndicator, setInputRef, - setQuotedMessageState, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }); const messageListContext = useCreatePaginatedMessageListContext({ @@ -1842,7 +1811,6 @@ const ChannelWithContext = (props: PropsWithChildren) = CardFooter, CardHeader, channelId, - clearQuotedMessageState, DateHeader, deletedMessagesVisibilityType, deleteMessage, @@ -1928,8 +1896,6 @@ const ChannelWithContext = (props: PropsWithChildren) = ScrollToBottomButton, selectReaction, sendReaction, - setEditingState, - setQuotedMessageState, shouldShowUnreadUnderlay, StreamingMessageView, supportedReactions, @@ -1942,14 +1908,6 @@ const ChannelWithContext = (props: PropsWithChildren) = VideoThumbnail, }); - const suggestionsContext = useMemo(() => { - return { - AutoCompleteSuggestionHeader, - AutoCompleteSuggestionItem, - AutoCompleteSuggestionList, - }; - }, [AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList]); - const threadContext = useCreateThreadContext({ allowThreadMessagesInChannel, closeThread, @@ -1968,6 +1926,11 @@ const ChannelWithContext = (props: PropsWithChildren) = typing: channelState.typing ?? {}, }); + const messageComposerContext = useMemo( + () => ({ channel, thread, threadInstance }), + [channel, thread, threadInstance], + ); + // TODO: replace the null view with appropriate message. Currently this is waiting a design decision. if (deleted) { return null; @@ -1980,7 +1943,7 @@ const ChannelWithContext = (props: PropsWithChildren) = if (!channel?.cid || !channel.watch) { return ( - {t('Please select a channel first')} + {t('Please select a channel first')} ); } @@ -1998,11 +1961,14 @@ const ChannelWithContext = (props: PropsWithChildren) = - - - {children} - - + + + + {children} + + + + diff --git a/package/src/components/Channel/__tests__/Channel.test.js b/package/src/components/Channel/__tests__/Channel.test.js index 366d94d8ee..80559623f5 100644 --- a/package/src/components/Channel/__tests__/Channel.test.js +++ b/package/src/components/Channel/__tests__/Channel.test.js @@ -48,11 +48,14 @@ const ContextConsumer = ({ context, fn }) => { return ; }; +const channelType = 'messaging'; +const channelId = 'test-channel'; +const channelCid = `${channelType}:${channelId}`; let chatClient; let channel; const user = generateUser({ id: 'id', name: 'name' }); -const messages = [generateMessage({ user })]; +const messages = [generateMessage({ cid: channelCid, user })]; const renderComponent = (props = {}, callback = () => {}, context = ChannelContext) => render( @@ -70,8 +73,11 @@ describe('Channel', () => { beforeEach(async () => { const members = [generateMember({ user })]; const mockedChannel = generateChannelResponse({ + cid: channelCid, + id: channelId, members, messages, + type: channelType, }); chatClient = await getTestClientWithUser(user); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); diff --git a/package/src/components/Channel/__tests__/ownCapabilities.test.js b/package/src/components/Channel/__tests__/ownCapabilities.test.js index 6a8c600a70..876bb5b483 100644 --- a/package/src/components/Channel/__tests__/ownCapabilities.test.js +++ b/package/src/components/Channel/__tests__/ownCapabilities.test.js @@ -343,11 +343,18 @@ describe('Own capabilities', () => { describe(`${allOwnCapabilities.sendLinks} capability`, () => { it(`should not allow sending links when "${allOwnCapabilities.sendLinks}" capability is disabled`, async () => { await generateChannelWithCapabilities([allOwnCapabilities.sendMessage]); - const { queryByTestId } = render( - getComponent({ - initialValue: 'Awesome repository https://github.com/GetStream/stream-chat-react-native', - }), - ); + const { queryByTestId } = render(getComponent()); + + await act(async () => { + const text = 'Awesome repository https://github.com/GetStream/stream-chat-react-native'; + await channel.messageComposer.textComposer.handleChange({ + selection: { + end: text.length, + start: text.length, + }, + text, + }); + }); const sendMessage = jest.fn(); channel.sendMessage = sendMessage; @@ -371,10 +378,20 @@ describe('Own capabilities', () => { mockFn(); return sendMessageApi(); }, - initialValue: 'Awesome repository https://github.com/GetStream/stream-chat-react-native', }), ); + await act(async () => { + const text = 'Awesome repository https://github.com/GetStream/stream-chat-react-native'; + await channel.messageComposer.textComposer.handleChange({ + selection: { + end: text.length, + start: text.length, + }, + text, + }); + }); + act(() => { fireEvent(queryByTestId('send-button'), 'onPress'); }); diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 28549db215..108510e08f 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -10,7 +10,6 @@ export const useCreateChannelContext = ({ enableMessageGroupingByUser, enforceUniqueReaction, error, - giphyEnabled, hideDateSeparators, hideStickyDateHeader, highlightedMessageId, @@ -55,7 +54,6 @@ export const useCreateChannelContext = ({ enableMessageGroupingByUser, enforceUniqueReaction, error, - giphyEnabled, hideDateSeparators, hideStickyDateHeader, highlightedMessageId, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 80e9154f94..247117f34a 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -9,6 +9,13 @@ export const useCreateInputMessageInputContext = ({ asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadPreviewList, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -16,62 +23,50 @@ export const useCreateInputMessageInputContext = ({ AudioRecordingLockIndicator, AudioRecordingPreview, AudioRecordingWaveform, - autoCompleteSuggestionsLimit, - autoCompleteTriggerSettings, + AutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem, + AutoCompleteSuggestionList, channelId, - clearEditingState, - clearQuotedMessageState, + CameraSelectorIcon, + CommandInput, CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, - editing, + CreatePollIcon, + doFileUploadRequest, editMessage, - emojiSearchIndex, - FileUploadPreview, + FileAttachmentUploadPreview, + FileSelectorIcon, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - ImageUploadPreview, - initialValue, + ImageAttachmentUploadPreview, + ImageSelectorIcon, Input, InputButtons, InputEditingStateHeader, - InputGiphySearch, InputReplyStateHeader, - maxMessageLength, - maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, MoreOptionsButton, - numberOfLines, - onChangeText, openPollCreationDialog, - quotedMessage, SendButton, - sendImageAsync, sendMessage, SendMessageDisallowedIndicator, setInputRef, - setQuotedMessageState, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }: InputMessageInputContextValue & { /** * To ensure we allow re-render, when channel is changed */ channelId?: string; }) => { - const editingDep = editing ? editing.id : ''; - const quotedMessageId = quotedMessage ? quotedMessage.id : ''; - const inputMessageInputContext: InputMessageInputContextValue = useMemo( () => ({ additionalTextInputProps, @@ -80,6 +75,13 @@ export const useCreateInputMessageInputContext = ({ asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadPreviewList, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -87,64 +89,46 @@ export const useCreateInputMessageInputContext = ({ AudioRecordingLockIndicator, AudioRecordingPreview, AudioRecordingWaveform, - autoCompleteSuggestionsLimit, - autoCompleteTriggerSettings, - clearEditingState, - clearQuotedMessageState, + AutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem, + AutoCompleteSuggestionList, + CameraSelectorIcon, + CommandInput, CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, - editing, + CreatePollIcon, + doFileUploadRequest, editMessage, - emojiSearchIndex, - FileUploadPreview, + FileAttachmentUploadPreview, + FileSelectorIcon, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - ImageUploadPreview, - initialValue, + ImageAttachmentUploadPreview, + ImageSelectorIcon, Input, InputButtons, InputEditingStateHeader, - InputGiphySearch, InputReplyStateHeader, - maxMessageLength, - maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, MoreOptionsButton, - numberOfLines, - onChangeText, openPollCreationDialog, - quotedMessage, SendButton, - sendImageAsync, sendMessage, SendMessageDisallowedIndicator, setInputRef, - setQuotedMessageState, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [ - compressImageQuality, - channelId, - editingDep, - initialValue, - maxMessageLength, - quotedMessageId, - CreatePollContent, - showPollCreationDialog, - ], + [compressImageQuality, channelId, CreatePollContent, showPollCreationDialog], ); return inputMessageInputContext; diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index b4ff6d4d9e..a6ff1ef0a8 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -12,7 +12,6 @@ export const useCreateMessagesContext = ({ CardFooter, CardHeader, channelId, - clearQuotedMessageState, DateHeader, deletedMessagesVisibilityType, deleteMessage, @@ -97,8 +96,6 @@ export const useCreateMessagesContext = ({ ScrollToBottomButton, selectReaction, sendReaction, - setEditingState, - setQuotedMessageState, shouldShowUnreadUnderlay, StreamingMessageView, supportedReactions, @@ -130,7 +127,6 @@ export const useCreateMessagesContext = ({ CardCover, CardFooter, CardHeader, - clearQuotedMessageState, DateHeader, deletedMessagesVisibilityType, deleteMessage, @@ -215,8 +211,6 @@ export const useCreateMessagesContext = ({ ScrollToBottomButton, selectReaction, sendReaction, - setEditingState, - setQuotedMessageState, shouldShowUnreadUnderlay, StreamingMessageView, supportedReactions, diff --git a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts index 4d5236656a..053c477db7 100644 --- a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts +++ b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts @@ -22,7 +22,7 @@ export const useCreateOwnCapabilitiesContext = ({ ? JSON.stringify(Object.values(overrideCapabilities)) : null; - // Effect to watch for changes in channel.data?.own_capabilities and update the own_capabilties state accordingly. + // Effect to watch for changes in channel.data?.own_capabilities and update the own_capabilities state accordingly. useEffect(() => { setOwnCapabilites(JSON.stringify(channel.data?.own_capabilities as Array)); }, [channel.data?.own_capabilities]); diff --git a/package/src/components/ChannelList/ChannelListHeaderErrorIndicator.tsx b/package/src/components/ChannelList/ChannelListHeaderErrorIndicator.tsx index 391bb65237..ee9773ac75 100644 --- a/package/src/components/ChannelList/ChannelListHeaderErrorIndicator.tsx +++ b/package/src/components/ChannelList/ChannelListHeaderErrorIndicator.tsx @@ -36,7 +36,7 @@ export const ChannelListHeaderErrorIndicator = ({ onPress = () => null }: Header style={[styles.container, { backgroundColor: `${grey_dark}E6` }, container]} > - {t('Error while loading, please reload/refresh')} + {t('Error while loading, please reload/refresh')} ); diff --git a/package/src/components/ChannelList/ChannelListHeaderNetworkDownIndicator.tsx b/package/src/components/ChannelList/ChannelListHeaderNetworkDownIndicator.tsx index 33d95bf87a..5d6a5b690c 100644 --- a/package/src/components/ChannelList/ChannelListHeaderNetworkDownIndicator.tsx +++ b/package/src/components/ChannelList/ChannelListHeaderNetworkDownIndicator.tsx @@ -30,9 +30,7 @@ export const ChannelListHeaderNetworkDownIndicator = () => { style={[styles.container, { backgroundColor: `${grey_dark}E6` }, container]} testID='network-down-indicator' > - - {t('Reconnecting...')} - + {t('Reconnecting...')} ); }; diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.js b/package/src/components/ChannelList/__tests__/ChannelList.test.js index 06dbbd958c..e2b52684c8 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.js +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.js @@ -40,7 +40,7 @@ import { ChannelList } from '../ChannelList'; */ const ChannelPreviewComponent = ({ channel, setActiveChannel }) => ( - {channel.data.name} + {channel.data?.name} {channel.state.messages[0]?.text} ); @@ -157,7 +157,7 @@ describe('ChannelList', () => { return deferredCallForStaleFilter.promise; }); - render( + const { rerender, queryByTestId } = render( , @@ -172,10 +172,10 @@ describe('ChannelList', () => { ); await waitFor(() => { - expect(screen.getByTestId('channel-list')).toBeTruthy(); + expect(queryByTestId('channel-list')).toBeTruthy(); }); - screen.rerender( + rerender( , @@ -189,11 +189,13 @@ describe('ChannelList', () => { expect.anything(), ); - deferredCallForStaleFilter.resolve(staleChannel); - deferredCallForFreshFilter.resolve(freshChannel); + await act(() => { + deferredCallForStaleFilter.resolve(staleChannel); + deferredCallForFreshFilter.resolve(freshChannel); + }); await waitFor(() => { - expect(screen.getByTestId('channel-list')).toBeTruthy(); - expect(screen.getByTestId('new-channel')).toBeTruthy(); + expect(queryByTestId('channel-list')).toBeTruthy(); + expect(queryByTestId('new-channel')).toBeTruthy(); }); }); diff --git a/package/src/components/ChannelList/hooks/listeners/__tests__/useChannelUpdated.test.tsx b/package/src/components/ChannelList/hooks/listeners/__tests__/useChannelUpdated.test.tsx index 705fe1928d..070463c5a9 100644 --- a/package/src/components/ChannelList/hooks/listeners/__tests__/useChannelUpdated.test.tsx +++ b/package/src/components/ChannelList/hooks/listeners/__tests__/useChannelUpdated.test.tsx @@ -1,9 +1,7 @@ import React, { useState } from 'react'; import { Image, Text } from 'react-native'; -import { act } from 'react-test-renderer'; - -import { render, waitFor } from '@testing-library/react-native'; +import { act, render, waitFor } from '@testing-library/react-native'; import type { Channel, ChannelResponse, Event, StreamChat } from 'stream-chat'; import { ChatContext, useChannelUpdated } from '../../../../../index'; diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx index 5b0346640a..690b2f8530 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx @@ -1,17 +1,10 @@ import React from 'react'; -import { StyleSheet, Text } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { LatestMessagePreview } from './hooks/useLatestMessagePreview'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; - -const styles = StyleSheet.create({ - bold: { fontWeight: '600' }, - message: { - flexShrink: 1, - fontSize: 12, - }, -}); +import { useTheme } from '../../contexts'; +import { MessagePreview } from '../MessagePreview/MessagePreview'; export type ChannelPreviewMessageProps = { /** @@ -25,24 +18,19 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { const { theme: { - channelPreview: { message }, - colors: { grey }, + channelPreview: { + message: { container }, + }, }, } = useTheme(); return ( - - {latestMessagePreview?.previews?.map( - (preview, index) => - preview.text && ( - - {preview.text} - - ), - )} - + + + ); }; + +const styles = StyleSheet.create({ + container: {}, +}); diff --git a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx index 06e37d4748..8bcc63199c 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx @@ -47,6 +47,23 @@ const ChannelPreviewUIComponent = (props: ChannelPreviewUIComponentProps) => ( ); +const initChannelFromData = async ( + chatClient: StreamChat, + overrides: Record = {}, +) => { + const mockedChannel = generateChannelResponse(overrides); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + channel.countUnread = jest.fn().mockReturnValue(0); + channel.initialized = true; + channel.lastMessage = jest.fn().mockReturnValue(generateMessage()); + channel.muteStatus = jest.fn().mockReturnValue({ muted: false }); + + return channel; +}; + describe('ChannelPreview', () => { const clientUser = generateUser(); let chatClient: StreamChat; @@ -98,12 +115,10 @@ describe('ChannelPreview', () => { it("should not update the unread count if the event's cid does not match the channel's cid", async () => { const channelOnMock = jest.fn().mockReturnValue({ unsubscribe: jest.fn() }); - const c = generateChannelWrapper({ - countUnread: jest.fn().mockReturnValue(10), - on: channelOnMock, - }); + channel = await initChannelFromData(chatClient); - channel = c as unknown as Channel; + channel.countUnread = jest.fn().mockReturnValue(10); + channel.on = channelOnMock; const { getByTestId } = render(); @@ -128,12 +143,10 @@ describe('ChannelPreview', () => { countUnreadMock.mockReturnValue(10); - const c = generateChannelWrapper({ - countUnread: countUnreadMock, - on: channelOnMock, - }); + channel = await initChannelFromData(chatClient); - channel = c as unknown as Channel; + channel.countUnread = countUnreadMock; + channel.on = channelOnMock; const { getByTestId } = render(); @@ -159,11 +172,9 @@ describe('ChannelPreview', () => { it("should not update the unread count if the event's cid is undefined", async () => { const channelOnMock = jest.fn().mockReturnValue({ unsubscribe: jest.fn() }); - const c = generateChannelWrapper({ - on: channelOnMock, - }); + channel = await initChannelFromData(chatClient); - channel = c as unknown as Channel; + channel.on = channelOnMock; const { getByTestId } = render(); @@ -192,11 +203,9 @@ describe('ChannelPreview', () => { it("should not update the unread count if the event's cid does not match the channel's cid", async () => { const channelOnMock = jest.fn().mockReturnValue({ unsubscribe: jest.fn() }); - const c = generateChannelWrapper({ - on: channelOnMock, - }); + channel = await initChannelFromData(chatClient); - channel = c as unknown as Channel; + channel.on = channelOnMock; const { getByTestId } = render(); @@ -225,11 +234,9 @@ describe('ChannelPreview', () => { it("should not update the unread count if the event's user id does not match the client's user id", async () => { const channelOnMock = jest.fn().mockReturnValue({ unsubscribe: jest.fn() }); - const c = generateChannelWrapper({ - on: channelOnMock, - }); + channel = await initChannelFromData(chatClient); - channel = c as unknown as Channel; + channel.on = channelOnMock; const { getByTestId } = render(); diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useLatestMessagePreview.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useLatestMessagePreview.test.tsx index b1a95c2a63..e9835dbd24 100644 --- a/package/src/components/ChannelPreview/hooks/__tests__/useLatestMessagePreview.test.tsx +++ b/package/src/components/ChannelPreview/hooks/__tests__/useLatestMessagePreview.test.tsx @@ -2,7 +2,7 @@ import React, { FC } from 'react'; import { renderHook, waitFor } from '@testing-library/react-native'; -import type { MessageResponse, StreamChat } from 'stream-chat'; +import { ChannelResponse, MessageResponse, StreamChat } from 'stream-chat'; import { ChatContext, ChatContextValue } from '../../../../contexts/chatContext/ChatContext'; import { @@ -16,11 +16,22 @@ import { LATEST_MESSAGE, } from '../../../../mock-builders/api/channelMocks'; +import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../../mock-builders/generator/channel'; import { generateUser } from '../../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../../mock-builders/mock'; - import { useLatestMessagePreview } from '../useLatestMessagePreview'; +const initChannelFromData = async (chatClient: StreamChat, channelData: ChannelResponse) => { + const mockedChannel = generateChannelResponse(channelData); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + return channel; +}; + describe('useLatestMessagePreview', () => { const FORCE_UPDATE = 15; const clientUser = generateUser(); @@ -44,10 +55,15 @@ describe('useLatestMessagePreview', () => { ); it('should return a deleted message preview if the latest message is deleted', async () => { + const channel = await initChannelFromData( + chatClient, + CHANNEL_WITH_DELETED_MESSAGES as unknown as ChannelResponse, + ); + const latestMessage = { cid: 'test', type: 'deleted' } as unknown as MessageResponse; const { result } = renderHook( - () => useLatestMessagePreview(CHANNEL_WITH_DELETED_MESSAGES, FORCE_UPDATE, latestMessage), + () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), { wrapper: ChatProvider }, ); await waitFor(() => { @@ -56,10 +72,14 @@ describe('useLatestMessagePreview', () => { }); it('should return an "Nothing yet..." message preview if channel has no messages', async () => { + const channel = await initChannelFromData( + chatClient, + CHANNEL_WITH_NO_MESSAGES as unknown as ChannelResponse, + ); const latestMessage = undefined; const { result } = renderHook( - () => useLatestMessagePreview(CHANNEL_WITH_NO_MESSAGES, FORCE_UPDATE, latestMessage), + () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), { wrapper: ChatProvider }, ); await waitFor(() => { @@ -68,8 +88,12 @@ describe('useLatestMessagePreview', () => { }); it('should use latestMessage if provided', async () => { + const channel = await initChannelFromData( + chatClient, + CHANNEL_WITH_MESSAGES_TEXT as unknown as ChannelResponse, + ); const { result } = renderHook( - () => useLatestMessagePreview(CHANNEL_WITH_MESSAGES_TEXT, FORCE_UPDATE, LATEST_MESSAGE), + () => useLatestMessagePreview(channel, FORCE_UPDATE, LATEST_MESSAGE), { wrapper: ChatProvider }, ); @@ -82,10 +106,14 @@ describe('useLatestMessagePreview', () => { }); it('should return a channel with an empty message preview', async () => { + const channel = await initChannelFromData( + chatClient, + CHANNEL_WITH_EMPTY_MESSAGE as unknown as ChannelResponse, + ); const latestMessage = {} as unknown as MessageResponse; const { result } = renderHook( - () => useLatestMessagePreview(CHANNEL_WITH_EMPTY_MESSAGE, FORCE_UPDATE, latestMessage), + () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), { wrapper: ChatProvider }, ); @@ -98,6 +126,10 @@ describe('useLatestMessagePreview', () => { }); it('should return a mentioned user (@Max) message preview', async () => { + const channel = await initChannelFromData( + chatClient, + CHANNEL_WITH_MENTIONED_USERS as unknown as ChannelResponse, + ); const latestMessage = { mentioned_users: [{ id: 'Max', name: 'Max' }], text: 'Max', @@ -107,7 +139,7 @@ describe('useLatestMessagePreview', () => { } as unknown as MessageResponse; const { result } = renderHook( - () => useLatestMessagePreview(CHANNEL_WITH_MENTIONED_USERS, FORCE_UPDATE, latestMessage), + () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), { wrapper: ChatProvider }, ); await waitFor(() => { @@ -119,6 +151,11 @@ describe('useLatestMessagePreview', () => { }); it('should return the latest command preview', async () => { + const channel = await initChannelFromData( + chatClient, + CHANNEL_WITH_MESSAGES_COMMAND as unknown as ChannelResponse, + ); + const latestMessage = { command: 'giphy', user: { @@ -127,7 +164,7 @@ describe('useLatestMessagePreview', () => { } as unknown as MessageResponse; const { result } = renderHook( - () => useLatestMessagePreview(CHANNEL_WITH_MESSAGES_COMMAND, FORCE_UPDATE, latestMessage), + () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), { wrapper: ChatProvider }, ); await waitFor(() => { @@ -139,6 +176,10 @@ describe('useLatestMessagePreview', () => { }); it('should return an attachment preview', async () => { + const channel = await initChannelFromData( + chatClient, + CHANNEL_WITH_MESSAGES_ATTACHMENTS as unknown as ChannelResponse, + ); const latestMessage = { attachments: ['arbitrary value'], user: { @@ -147,7 +188,7 @@ describe('useLatestMessagePreview', () => { } as unknown as MessageResponse; const { result } = renderHook( - () => useLatestMessagePreview(CHANNEL_WITH_MESSAGES_ATTACHMENTS, FORCE_UPDATE, latestMessage), + () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), { wrapper: ChatProvider }, ); @@ -160,10 +201,14 @@ describe('useLatestMessagePreview', () => { }); it('should default to messages from the channel state if latestMessage is undefined', async () => { + const channel = await initChannelFromData( + chatClient, + CHANNEL_WITH_MESSAGES_TEXT as unknown as ChannelResponse, + ); const latestMessage = undefined; const { result } = renderHook( - () => useLatestMessagePreview(CHANNEL_WITH_MESSAGES_TEXT, FORCE_UPDATE, latestMessage), + () => useLatestMessagePreview(channel, FORCE_UPDATE, latestMessage), { wrapper: ChatProvider }, ); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index 9df1c86c80..8fccb6d522 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -26,6 +26,11 @@ export const useChannelPreviewData = ( const channelLastMessage = channel.lastMessage(); const channelLastMessageString = `${channelLastMessage?.id}${channelLastMessage?.updated_at}`; + useEffect(() => { + const unsubscribe = channel.messageComposer.registerDraftEventSubscriptions(); + return () => unsubscribe(); + }, [channel.messageComposer]); + useEffect(() => { const { unsubscribe } = client.on('notification.mark_read', () => { setUnread(channel.countUnread()); diff --git a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts index dba79b5c39..aa522bcc3e 100644 --- a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts +++ b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts @@ -2,12 +2,15 @@ import { useMemo } from 'react'; import { TFunction } from 'i18next'; import type { + AttachmentManagerState, Channel, ChannelState, + DraftMessage, MessageResponse, PollState, PollVote, StreamChat, + TextComposerState, UserResponse, } from 'stream-chat'; @@ -26,6 +29,7 @@ export type LatestMessagePreview = { previews: { bold: boolean; text: string; + draft?: boolean; }[]; status: number; created_at?: string | Date; @@ -82,6 +86,7 @@ const getMentionUsers = (mentionedUser: UserResponse[] | undefined) => { const getLatestMessageDisplayText = ( channel: Channel, client: StreamChat, + draftMessage: DraftMessage | undefined, message: LatestMessage | undefined, t: (key: string) => string, pollState: LatestMessagePreviewSelectorReturnType | undefined, @@ -101,6 +106,21 @@ const getLatestMessageDisplayText = ( ? `${messageSender === t('You') ? '' : '@'}${messageSender}: ` : ''; const boldOwner = messageSenderText.includes('@'); + if (draftMessage) { + if (draftMessage.attachments?.length) { + return [ + { bold: true, draft: true, text: 'Draft:' }, + { bold: false, text: t('🏙 Attachment...') }, + ]; + } + if (draftMessage.text) { + return [ + { bold: true, draft: true, text: 'Draft:' }, + { bold: false, text: draftMessage.text }, + ]; + } + } + if (message.text) { // rough guess optimization to limit string preview to max 100 characters const shortenedText = message.text.substring(0, 100).replace(/\n/g, ' '); @@ -201,12 +221,13 @@ const getLatestMessageReadStatus = ( const getLatestMessagePreview = (params: { channel: Channel; client: StreamChat; + draftMessage?: DraftMessage; pollState: LatestMessagePreviewSelectorReturnType | undefined; readEvents: boolean; t: TFunction; lastMessage?: ReturnType | MessageResponse; }) => { - const { channel, client, lastMessage, pollState, readEvents, t } = params; + const { channel, client, draftMessage, lastMessage, pollState, readEvents, t } = params; const messages = channel.state.messages; @@ -231,11 +252,19 @@ const getLatestMessagePreview = (params: { return { created_at: message?.created_at, messageObject: message, - previews: getLatestMessageDisplayText(channel, client, message, t, pollState), + previews: getLatestMessageDisplayText(channel, client, draftMessage, message, t, pollState), status: getLatestMessageReadStatus(channel, client, message, readEvents), }; }; +const textComposerStateSelector = (state: TextComposerState) => ({ + text: state.text, +}); + +const stateSelector = (state: AttachmentManagerState) => ({ + attachments: state.attachments, +}); + /** * Hook to set the display preview for latest message on channel. * @@ -251,12 +280,34 @@ export const useLatestMessagePreview = ( const { client } = useChatContext(); const { t } = useTranslationContext(); + const { text: draftText } = useStateStore( + channel.messageComposer.textComposer.state, + textComposerStateSelector, + ); + + const { attachments } = useStateStore( + channel.messageComposer.attachmentManager.state, + stateSelector, + ); + + const draftMessage: DraftMessage | undefined = useMemo( + () => + !channel.messageComposer.compositionIsEmpty + ? { + attachments, + id: channel.messageComposer.id, + text: draftText, + } + : undefined, + [channel.messageComposer, attachments, draftText], + ); + const channelConfigExists = typeof channel?.getConfig === 'function'; const translatedLastMessage = useTranslatedMessage(lastMessage); const channelLastMessageString = translatedLastMessage - ? stringifyMessage(translatedLastMessage) + ? stringifyMessage({ message: translatedLastMessage }) : ''; const readEvents = useMemo(() => { @@ -282,6 +333,7 @@ export const useLatestMessagePreview = ( return getLatestMessagePreview({ channel, client, + draftMessage, lastMessage: translatedLastMessage, pollState, readEvents, @@ -290,6 +342,7 @@ export const useLatestMessagePreview = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [ channelLastMessageString, + draftMessage, forceUpdate, readEvents, readStatus, diff --git a/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx b/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx index 8a055d8fdd..139f90ec76 100644 --- a/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx +++ b/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx @@ -2,9 +2,7 @@ import React from 'react'; import type { SharedValue } from 'react-native-reanimated'; -import { act } from 'react-test-renderer'; - -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; diff --git a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx index eb9b6f7601..e72cd70e3b 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx @@ -3,6 +3,8 @@ import React from 'react'; import type { SharedValue } from 'react-native-reanimated'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; import { LocalMessage } from 'stream-chat'; @@ -22,6 +24,8 @@ import { generateMessage } from '../../../mock-builders/generator/message'; import { ImageGallery } from '../ImageGallery'; +dayjs.extend(duration); + jest.mock('../../../native.ts', () => { const { View } = require('react-native'); return { diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx index 29aa2f0c8a..ea553a0eaf 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx @@ -3,8 +3,6 @@ import React from 'react'; import { Text, View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; -import { ReactTestInstance } from 'react-test-renderer'; - import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; import { LocalMessage } from 'stream-chat'; @@ -188,8 +186,10 @@ describe('ImageGalleryFooter', () => { , ); + const { getByLabelText } = screen; + await waitFor(() => { - user.press(screen.queryByLabelText('Share Button') as ReactTestInstance); + user.press(getByLabelText('Share Button')); }); await waitFor(() => { @@ -231,8 +231,10 @@ describe('ImageGalleryFooter', () => { , ); + const { getByLabelText } = screen; + await waitFor(() => { - user.press(screen.queryByLabelText('Share Button') as ReactTestInstance); + user.press(getByLabelText('Share Button')); }); await waitFor(() => { @@ -280,8 +282,10 @@ describe('ImageGalleryFooter', () => { , ); + const { getByLabelText } = screen; + await waitFor(() => { - user.press(screen.queryByLabelText('Share Button') as ReactTestInstance); + user.press(getByLabelText('Share Button')); }); await waitFor(() => { diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx index 494f256ffc..1ea5816c2a 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { Text, View } from 'react-native'; -import { act } from 'react-test-renderer'; -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx index bb68354ff5..f39ffaa28f 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx @@ -3,9 +3,7 @@ import React from 'react'; import { Text, View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; -import { act } from 'react-test-renderer'; - -import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; +import { act, render, screen, userEvent, waitFor } from '@testing-library/react-native'; import { LocalMessage } from 'stream-chat'; diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryOverlay.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryOverlay.test.tsx index 53da8b2eab..04b5e8d272 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryOverlay.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryOverlay.test.tsx @@ -3,9 +3,7 @@ import React from 'react'; import { State } from 'react-native-gesture-handler'; import type { SharedValue } from 'react-native-reanimated'; -import { act } from 'react-test-renderer'; - -import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; import { LocalMessage } from 'stream-chat'; diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx index 8abf44d5b3..4788260271 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { act, ReactTestInstance } from 'react-test-renderer'; +import { ReactTestInstance } from 'react-test-renderer'; -import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; +import { act, render, screen, userEvent, waitFor } from '@testing-library/react-native'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; diff --git a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx index b67846f619..305a9f1a0b 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx @@ -204,7 +204,7 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWith ) : ( - {t('{{ index }} of {{ photoLength }}', { + {t('{{ index }} of {{ photoLength }}', { index: photoLength - selectedIndex, photoLength, })} diff --git a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx index 1c831681ff..972e08b4d1 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx @@ -108,7 +108,7 @@ export const ImageGalleryHeader = (props: Props) => { ) : ( - {photo?.user?.name || photo?.user?.id || t('Unknown User')} + {photo?.user?.name || photo?.user?.id || t('Unknown User')} {date && {date}} diff --git a/package/src/components/ImageGallery/components/ImageGridHandle.tsx b/package/src/components/ImageGallery/components/ImageGridHandle.tsx index 4c76e51d22..6f91e93098 100644 --- a/package/src/components/ImageGallery/components/ImageGridHandle.tsx +++ b/package/src/components/ImageGallery/components/ImageGridHandle.tsx @@ -67,9 +67,7 @@ export const ImageGridHandle = (props: Props) => { {centerComponent ? ( centerComponent({ closeGridView }) ) : ( - - {t('Photos and Videos')} - + {t('Photos and Videos')} )} {rightComponent ? ( rightComponent({ closeGridView }) diff --git a/package/src/components/Indicators/EmptyStateIndicator.tsx b/package/src/components/Indicators/EmptyStateIndicator.tsx index 90eaf2b6b2..9e585446a8 100644 --- a/package/src/components/Indicators/EmptyStateIndicator.tsx +++ b/package/src/components/Indicators/EmptyStateIndicator.tsx @@ -36,13 +36,13 @@ export const EmptyStateIndicator = ({ listType }: EmptyStateProps) => { style={[styles.channelTitle, { color: black }, channelTitle]} testID='empty-channel-state-title' > - {t("Let's start chatting!")} + {t("Let's start chatting!")} - {t('How about sending your first message to a friend?')} + {t('How about sending your first message to a friend?')} ); @@ -51,7 +51,7 @@ export const EmptyStateIndicator = ({ listType }: EmptyStateProps) => { - {t('No chats here yet…')} + {t('No chats here yet…')} ); @@ -59,7 +59,7 @@ export const EmptyStateIndicator = ({ listType }: EmptyStateProps) => { return ( - {t('No threads here yet')}... + {t('No threads here yet')}... ); default: diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 0e10894ccf..b3f42cd86d 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -18,6 +18,10 @@ import { KeyboardContextValue, useKeyboardContext, } from '../../contexts/keyboardContext/KeyboardContext'; +import { + MessageComposerAPIContextValue, + useMessageComposerAPIContext, +} from '../../contexts/messageComposerContext/MessageComposerAPIContext'; import { MessageContextValue, MessageProvider } from '../../contexts/messageContext/MessageContext'; import { MessagesContextValue, @@ -34,6 +38,7 @@ import { import { isVideoPlayerAvailable, NativeHandlers } from '../../native'; import { FileTypes } from '../../types/types'; import { + checkMessageEquality, hasOnlyEmojis, isBlockedMessage, isBouncedMessage, @@ -172,8 +177,6 @@ export type MessagePropsWithContext = Pick< | 'deleteReaction' | 'retrySendMessage' | 'selectReaction' - | 'setEditingState' - | 'setQuotedMessageState' | 'supportedReactions' | 'updateMessage' | 'PollContent' @@ -196,7 +199,7 @@ export type MessagePropsWithContext = Pick< onThreadSelect?: (message: LocalMessage) => void; showUnreadUnderlay?: boolean; style?: StyleProp; - }; + } & Pick; /** * Since this component doesn't consume `messages` from `MessagesContext`, @@ -256,7 +259,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { selectReaction, sendReaction, setEditingState, - setQuotedMessageState, showAvatar, showMessageStatus, showUnreadUnderlay, @@ -266,6 +268,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { threadList = false, updateMessage, readBy, + setQuotedMessage, } = props; const isMessageAIGenerated = messagesContext.isMessageAIGenerated; const isAIGenerated = useMemo( @@ -501,7 +504,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { retrySendMessage, sendReaction, setEditingState, - setQuotedMessageState, + setQuotedMessage, supportedReactions, }); @@ -546,7 +549,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { selectReaction, sendReaction, setEditingState, - setQuotedMessageState, + setQuotedMessage, supportedReactions, t, updateMessage, @@ -695,6 +698,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { reactions, readBy, setIsEditedMessageOpen, + setQuotedMessage, showAvatar, showMessageOverlay, showMessageStatus: typeof showMessageStatus === 'boolean' ? showMessageStatus : isMyMessage, @@ -817,28 +821,16 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit return false; } - const isPrevMessageTypeDeleted = prevMessage.type === 'deleted'; - const isNextMessageTypeDeleted = nextMessage.type === 'deleted'; - - const messageEqual = - isPrevMessageTypeDeleted === isNextMessageTypeDeleted && - prevMessage.status === nextMessage.status && - prevMessage.type === nextMessage.type && - prevMessage.text === nextMessage.text && - prevMessage.pinned === nextMessage.pinned && - `${prevMessage?.updated_at}` === `${nextMessage?.updated_at}` && - prevMessage.i18n === nextMessage.i18n; + const messageEqual = checkMessageEquality(prevMessage, nextMessage); if (!messageEqual) { return false; } - const isPrevQuotedMessageTypeDeleted = prevMessage.quoted_message?.type === 'deleted'; - const isNextQuotedMessageTypeDeleted = nextMessage.quoted_message?.type === 'deleted'; - - const quotedMessageEqual = - prevMessage.quoted_message?.id === nextMessage.quoted_message?.id && - isPrevQuotedMessageTypeDeleted === isNextQuotedMessageTypeDeleted; + const quotedMessageEqual = checkMessageEquality( + prevMessage.quoted_message, + nextMessage.quoted_message, + ); if (!quotedMessageEqual) { return false; @@ -875,6 +867,14 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit return false; } + const quotedMessageAttachmentsEqual = + prevMessage.quoted_message?.attachments?.length === + nextMessage.quoted_message?.attachments?.length; + + if (!quotedMessageAttachmentsEqual) { + return false; + } + const latestReactionsEqual = Array.isArray(prevMessage.latest_reactions) && Array.isArray(nextMessage.latest_reactions) ? prevMessage.latest_reactions.length === nextMessage.latest_reactions.length && @@ -942,6 +942,7 @@ export const Message = (props: MessageProps) => { const { openThread } = useThreadContext(); const { t } = useTranslationContext(); const readBy = useMemo(() => getReadState(message, read), [message, read]); + const { setQuotedMessage, setEditingState } = useMessageComposerAPIContext(); return ( { messagesContext, openThread, readBy, + setEditingState, + setQuotedMessage, t, }} {...props} diff --git a/package/src/components/Message/MessageSimple/MessageBounce.tsx b/package/src/components/Message/MessageSimple/MessageBounce.tsx index 093f99d3ca..eecf289a98 100644 --- a/package/src/components/Message/MessageSimple/MessageBounce.tsx +++ b/package/src/components/Message/MessageSimple/MessageBounce.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { Alert } from 'react-native'; +import { + MessageComposerAPIContextValue, + useMessageComposerAPIContext, +} from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; import { MessageContextValue, useMessageContext, @@ -13,11 +17,11 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran export type MessageBouncePropsWithContext = Pick< MessagesContextValue, - 'setEditingState' | 'removeMessage' | 'retrySendMessage' + 'removeMessage' | 'retrySendMessage' > & Pick & { setIsBounceDialogOpen: React.Dispatch>; - }; + } & Pick; export const MessageBounceWithContext = (props: MessageBouncePropsWithContext) => { const { t } = useTranslationContext(); @@ -91,7 +95,8 @@ export type MessageBounceProps = Partial & { export const MessageBounce = (props: MessageBounceProps) => { const { message } = useMessageContext(); - const { removeMessage, retrySendMessage, setEditingState } = useMessagesContext(); + const { removeMessage, retrySendMessage } = useMessagesContext(); + const { setEditingState } = useMessageComposerAPIContext(); return ( { return ( - {t('Edited') + ' '} + {t('Edited') + ' '} {MessageTimestamp && ( { ]} testID='only-visible-to-you' > - {t('Only visible to you')} + {t('Only visible to you')} ); @@ -157,7 +157,7 @@ const MessageFooterWithContext = (props: MessageFooterPropsWithContext) => { ⦁ - {t('Edited')} + {t('Edited')} ) : null} diff --git a/package/src/components/Message/MessageSimple/MessagePinnedHeader.tsx b/package/src/components/Message/MessageSimple/MessagePinnedHeader.tsx index 6ead399a5e..4b7e5d8521 100644 --- a/package/src/components/Message/MessageSimple/MessagePinnedHeader.tsx +++ b/package/src/components/Message/MessageSimple/MessagePinnedHeader.tsx @@ -33,8 +33,8 @@ export const MessagePinnedHeader = (props: MessagePinnedHeaderProps) => { > - {t('Pinned by')}{' '} - {message?.pinned_by?.id === client?.user?.id ? t('You') : message?.pinned_by?.name} + {t('Pinned by')}{' '} + {message?.pinned_by?.id === client?.user?.id ? t('You') : message?.pinned_by?.name} ); diff --git a/package/src/components/Message/MessageSimple/MessageReplies.tsx b/package/src/components/Message/MessageSimple/MessageReplies.tsx index d4e15bff5f..12bc59e4e8 100644 --- a/package/src/components/Message/MessageSimple/MessageReplies.tsx +++ b/package/src/components/Message/MessageSimple/MessageReplies.tsx @@ -142,8 +142,8 @@ const MessageRepliesWithContext = (props: MessageRepliesPropsWithContext) => { > {message.reply_count === 1 - ? t('1 Thread Reply') - : t('{{ replyCount }} Thread Replies', { + ? t('1 Thread Reply') + : t('{{ replyCount }} Thread Replies', { replyCount: message.reply_count, })} diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 52a416b5d6..717c15e5d0 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -26,6 +26,7 @@ import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { NativeHandlers } from '../../../native'; +import { checkMessageEquality, checkQuotedMessageEquality } from '../../../utils/utils'; import { useMessageData } from '../hooks/useMessageData'; const styles = StyleSheet.create({ @@ -69,10 +70,10 @@ export type MessageSimplePropsWithContext = Pick< | 'onlyEmojis' | 'otherAttachments' | 'showMessageStatus' + | 'setQuotedMessage' > & Pick< MessagesContextValue, - | 'clearQuotedMessageState' | 'enableMessageGroupingByUser' | 'enableSwipeToReply' | 'myMessageTheme' @@ -89,7 +90,6 @@ export type MessageSimplePropsWithContext = Pick< | 'ReactionListBottom' | 'reactionListPosition' | 'ReactionListTop' - | 'setQuotedMessageState' > & { /** * Will determine whether the swipeable wrapper is always rendered for each @@ -106,7 +106,6 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { const { width } = Dimensions.get('screen'); const { alignment, - clearQuotedMessageState, enableMessageGroupingByUser, enableSwipeToReply, groupStyles, @@ -130,9 +129,9 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { ReactionListBottom, reactionListPosition, ReactionListTop, - setQuotedMessageState, showMessageStatus, shouldRenderSwipeableWrapper, + setQuotedMessage, } = props; const { @@ -217,9 +216,8 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { ); const onSwipeToReply = useCallback(() => { - clearQuotedMessageState(); - setQuotedMessageState(message); - }, [clearQuotedMessageState, message, setQuotedMessageState]); + setQuotedMessage(message); + }, [setQuotedMessage, message]); const THRESHOLD = 25; @@ -479,11 +477,6 @@ const areEqual = ( otherAttachments: nextOtherAttachments, } = nextProps; - const repliesEqual = prevMessage.reply_count === nextMessage.reply_count; - if (!repliesEqual) { - return false; - } - const hasReactionsEqual = prevHasReactions === nextHasReactions; if (!hasReactionsEqual) { return false; @@ -494,9 +487,6 @@ const areEqual = ( return false; } - const isPrevMessageTypeDeleted = prevMessage.type === 'deleted'; - const isNextMessageTypeDeleted = nextMessage.type === 'deleted'; - const lastGroupMessageEqual = prevLastGroupMessage === nextLastGroupMessage; if (!lastGroupMessageEqual) { return false; @@ -507,24 +497,15 @@ const areEqual = ( return false; } - const messageEqual = - isPrevMessageTypeDeleted === isNextMessageTypeDeleted && - prevMessage.reply_count === nextMessage.reply_count && - prevMessage.status === nextMessage.status && - prevMessage.type === nextMessage.type && - prevMessage.text === nextMessage.text && - prevMessage.i18n === nextMessage.i18n && - prevMessage.pinned === nextMessage.pinned; + const messageEqual = checkMessageEquality(prevMessage, nextMessage); if (!messageEqual) { return false; } - const isPrevQuotedMessageTypeDeleted = prevMessage.quoted_message?.type === 'deleted'; - const isNextQuotedMessageTypeDeleted = nextMessage.quoted_message?.type === 'deleted'; - - const quotedMessageEqual = - prevMessage.quoted_message?.id === nextMessage.quoted_message?.id && - isPrevQuotedMessageTypeDeleted === isNextQuotedMessageTypeDeleted; + const quotedMessageEqual = checkQuotedMessageEquality( + prevMessage.quoted_message, + nextMessage.quoted_message, + ); if (!quotedMessageEqual) { return false; @@ -554,6 +535,14 @@ const areEqual = ( return false; } + const quotedMessageAttachmentsEqual = + prevMessage.quoted_message?.attachments?.length === + nextMessage.quoted_message?.attachments?.length; + + if (!quotedMessageAttachmentsEqual) { + return false; + } + const latestReactionsEqual = Array.isArray(prevMessage.latest_reactions) && Array.isArray(nextMessage.latest_reactions) ? prevMessage.latest_reactions.length === nextMessage.latest_reactions.length && @@ -611,9 +600,9 @@ export const MessageSimple = (props: MessageSimpleProps) => { otherAttachments, showMessageStatus, isMessageAIGenerated, + setQuotedMessage, } = useMessageContext(); const { - clearQuotedMessageState, enableMessageGroupingByUser, enableSwipeToReply, MessageAvatar, @@ -630,7 +619,6 @@ export const MessageSimple = (props: MessageSimpleProps) => { ReactionListBottom, reactionListPosition, ReactionListTop, - setQuotedMessageState, } = useMessagesContext(); const isAIGenerated = useMemo( () => isMessageAIGenerated(message), @@ -643,7 +631,6 @@ export const MessageSimple = (props: MessageSimpleProps) => { {...{ alignment, channel, - clearQuotedMessageState, enableMessageGroupingByUser, enableSwipeToReply, groupStyles, @@ -668,7 +655,7 @@ export const MessageSimple = (props: MessageSimpleProps) => { ReactionListBottom, reactionListPosition, ReactionListTop, - setQuotedMessageState, + setQuotedMessage, shouldRenderSwipeableWrapper, showMessageStatus, }} diff --git a/package/src/components/Message/MessageSimple/__tests__/MessageContent.test.js b/package/src/components/Message/MessageSimple/__tests__/MessageContent.test.js index acf6eb4912..736f1c4613 100644 --- a/package/src/components/Message/MessageSimple/__tests__/MessageContent.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/MessageContent.test.js @@ -16,7 +16,7 @@ import { getTestClientWithUser } from '../../../../mock-builders/mock'; import { Channel } from '../../../Channel/Channel'; import { Chat } from '../../../Chat/Chat'; import { Message } from '../../Message'; -import { MessageContent } from '../../MessageSimple/MessageContent'; +import { MessageContent } from '../MessageContent'; describe('MessageContent', () => { let channel; @@ -39,13 +39,11 @@ describe('MessageContent', () => { renderMessage = (options) => render( - - - - - - - , + + + + + , ); }); diff --git a/package/src/components/Message/MessageSimple/__tests__/MessageSimple.test.js b/package/src/components/Message/MessageSimple/__tests__/MessageSimple.test.js index e6c5d4bfb1..c2079bdac8 100644 --- a/package/src/components/Message/MessageSimple/__tests__/MessageSimple.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/MessageSimple.test.js @@ -160,7 +160,6 @@ describe('MessageSimple', () => { renderMessage({ groupStyles: ['top'], message }); await waitFor(() => { - console.log(screen.getByTestId('message-simple-wrapper').props.style[1]); expect(screen.getByTestId('message-simple-wrapper').props.style[1]).toMatchObject({}); }); }); @@ -185,7 +184,6 @@ describe('MessageSimple', () => { renderMessage({ message }); await waitFor(() => { - console.log(screen.getByTestId('message-content-wrapper').props.style); expect(screen.getByTestId('message-content-wrapper').props.style[2]).toMatchObject({ borderWidth: 0, }); @@ -199,7 +197,6 @@ describe('MessageSimple', () => { renderMessage({ message }); await waitFor(() => { - console.log(screen.getByTestId('message-content-wrapper').props.style); expect(screen.getByTestId('message-content-wrapper').props.style[2]).toMatchObject({ borderWidth: 0, }); diff --git a/package/src/components/Message/MessageSimple/__tests__/MessageTextContainer.test.tsx b/package/src/components/Message/MessageSimple/__tests__/MessageTextContainer.test.tsx index dd063bca25..ab18a2a654 100644 --- a/package/src/components/Message/MessageSimple/__tests__/MessageTextContainer.test.tsx +++ b/package/src/components/Message/MessageSimple/__tests__/MessageTextContainer.test.tsx @@ -22,9 +22,9 @@ import { Chat } from '../../../Chat/Chat'; import { MessageList } from '../../../MessageList/MessageList'; import { MessageTextContainer } from '../MessageTextContainer'; -afterEach(cleanup); - describe('MessageTextContainer', () => { + afterEach(cleanup); + it('should render message text container', async () => { const staticUser = generateStaticUser(1); const message = generateMessage({ diff --git a/package/src/components/Message/MessageSimple/__tests__/ReactionListTop.test.js b/package/src/components/Message/MessageSimple/__tests__/ReactionListTop.test.js index c0fe78f9a7..03e60c7320 100644 --- a/package/src/components/Message/MessageSimple/__tests__/ReactionListTop.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/ReactionListTop.test.js @@ -24,6 +24,8 @@ describe('ReactionListTop', () => { const messages = [generateMessage({ user })]; beforeEach(async () => { + jest.clearAllMocks(); + cleanup(); const members = [generateMember({ user })]; const mockedChannel = generateChannelResponse({ members, @@ -46,11 +48,6 @@ describe('ReactionListTop', () => { ); }); - afterEach(() => { - jest.clearAllMocks(); - cleanup(); - }); - it('renders the ReactionListTop component', async () => { renderMessage({ hasReactions: true, diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap index 6224aeaefb..164ee4ead7 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap @@ -121,5 +121,55 @@ exports[`MessageStatus should render message status with read by container 1`] = + + + + + `; diff --git a/package/src/components/Message/MessageSimple/utils/parseLinks.ts b/package/src/components/Message/MessageSimple/utils/parseLinks.ts index 1438ed2f69..0d09077660 100644 --- a/package/src/components/Message/MessageSimple/utils/parseLinks.ts +++ b/package/src/components/Message/MessageSimple/utils/parseLinks.ts @@ -17,7 +17,10 @@ const removeMarkdownLinksFromText = (input: string) => input.replace(/\[.*\]\(.* const removeUserNamesWithEmailFromText = (input: string) => input.replace(/@(\w+(\.\w+)?)(@\w+\.\w+)/g, ''); -export const parseLinksFromText = (input: string): LinkInfo[] => { +export const parseLinksFromText = (input?: string): LinkInfo[] => { + if (!input) { + return []; + } const strippedInput = [removeMarkdownLinksFromText, removeUserNamesWithEmailFromText].reduce( (acc, fn) => fn(acc), input, diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 37a594c6f9..84a1da424a 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -41,15 +41,18 @@ export const useCreateMessageContext = ({ showMessageStatus, threadList, videos, + setQuotedMessage, }: MessageContextValue) => { const groupStylesLength = groupStyles.length; const reactionsValue = reactions.map(({ count, own, type }) => `${own}${type}${count}`).join(); - const stringifiedMessage = stringifyMessage(message); + const stringifiedMessage = stringifyMessage({ message }); const membersValue = JSON.stringify(members); const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); - const quotedMessageDeletedValue = message.quoted_message?.deleted_at; + const stringifiedQuotedMessage = message.quoted_message + ? stringifyMessage({ includeReactions: false, message: message.quoted_message }) + : ''; const messageContext: MessageContextValue = useMemo( () => ({ @@ -84,6 +87,7 @@ export const useCreateMessageContext = ({ reactions, readBy, setIsEditedMessageOpen, + setQuotedMessage, showAvatar, showMessageOverlay, showMessageStatus, @@ -93,7 +97,6 @@ export const useCreateMessageContext = ({ // eslint-disable-next-line react-hooks/exhaustive-deps [ actionsEnabled, - quotedMessageDeletedValue, alignment, goToMessage, groupStylesLength, @@ -102,9 +105,10 @@ export const useCreateMessageContext = ({ lastGroupMessage, lastReceivedId, membersValue, - stringifiedMessage, myMessageThemeString, reactionsValue, + stringifiedMessage, + stringifiedQuotedMessage, readBy, showAvatar, showMessageStatus, diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 7432e37597..3f835dca3c 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -2,6 +2,7 @@ import { Alert } from 'react-native'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; +import { MessageComposerAPIContextValue } from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; @@ -17,25 +18,20 @@ export const useMessageActionHandlers = ({ retrySendMessage, sendReaction, setEditingState, - setQuotedMessageState, + setQuotedMessage, }: Pick< MessagesContextValue, - | 'sendReaction' - | 'deleteMessage' - | 'deleteReaction' - | 'retrySendMessage' - | 'setEditingState' - | 'setQuotedMessageState' - | 'supportedReactions' + 'sendReaction' | 'deleteMessage' | 'deleteReaction' | 'retrySendMessage' | 'supportedReactions' > & Pick & Pick & - Pick) => { + Pick & + Pick) => { const { t } = useTranslationContext(); const handleResendMessage = () => retrySendMessage(message); const handleQuotedReplyMessage = () => { - setQuotedMessageState(message); + setQuotedMessage(message); }; const isMuted = (client.mutedUsers || []).some( diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx index 5b859148f9..7f45b451ec 100644 --- a/package/src/components/Message/hooks/useMessageActions.tsx +++ b/package/src/components/Message/hooks/useMessageActions.tsx @@ -6,6 +6,7 @@ import { useMessageActionHandlers } from './useMessageActionHandlers'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; +import { MessageComposerAPIContextValue } from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; @@ -50,8 +51,6 @@ export type MessageActionsHookProps = Pick< | 'removeMessage' | 'deleteReaction' | 'retrySendMessage' - | 'setEditingState' - | 'setQuotedMessageState' | 'selectReaction' | 'supportedReactions' | 'updateMessage' @@ -62,7 +61,7 @@ export type MessageActionsHookProps = Pick< Pick & Pick & { onThreadSelect?: (message: LocalMessage) => void; - }; + } & Pick; export const useMessageActions = ({ channel, @@ -90,9 +89,9 @@ export const useMessageActions = ({ selectReaction, sendReaction, setEditingState, - setQuotedMessageState, supportedReactions, t, + setQuotedMessage, }: MessageActionsHookProps) => { const { theme: { @@ -121,7 +120,7 @@ export const useMessageActions = ({ retrySendMessage, sendReaction, setEditingState, - setQuotedMessageState, + setQuotedMessage, supportedReactions, }); diff --git a/package/src/components/MessageInput/AttachButton.tsx b/package/src/components/MessageInput/AttachButton.tsx index 281387c4b4..a6e63e0f5c 100644 --- a/package/src/components/MessageInput/AttachButton.tsx +++ b/package/src/components/MessageInput/AttachButton.tsx @@ -5,14 +5,20 @@ import { Pressable } from 'react-native'; import { NativeAttachmentPicker } from './components/NativeAttachmentPicker'; import { useAttachmentPickerContext } from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; -import { ChannelContextValue } from '../../contexts/channelContext/ChannelContext'; -import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { Attach } from '../../icons/Attach'; import { isImageMediaLibraryAvailable } from '../../native'; -type AttachButtonPropsWithContext = Pick & { +type AttachButtonPropsWithContext = Pick< + MessageInputContextValue, + 'handleAttachButtonPress' | 'toggleAttachmentPicker' +> & { + disabled?: boolean; /** Function that opens attachment options bottom sheet */ handleOnPress?: ((event: GestureResponderEvent) => void) & (() => void); selectedPicker?: 'images'; @@ -21,14 +27,19 @@ type AttachButtonPropsWithContext = Pick & { const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { const [showAttachButtonPicker, setShowAttachButtonPicker] = useState(false); const [attachButtonLayoutRectangle, setAttachButtonLayoutRectangle] = useState(); - const { disabled, handleOnPress, selectedPicker } = props; + const { + disabled = false, + handleAttachButtonPress, + handleOnPress, + selectedPicker, + toggleAttachmentPicker, + } = props; const { theme: { colors: { accent_blue, grey }, messageInput: { attachButton }, }, } = useTheme(); - const { handleAttachButtonPress, toggleAttachmentPicker } = useMessageInputContext(); const onAttachButtonLayout = (event: LayoutChangeEvent) => { const layout = event.nativeEvent.layout; @@ -51,6 +62,9 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { }; const onPressHandler = () => { + if (disabled) { + return; + } if (handleOnPress) { handleOnPress(); return; @@ -71,7 +85,7 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { null : onPressHandler} + onPress={onPressHandler} style={[attachButton]} testID='attach-button' > @@ -119,8 +133,14 @@ export type AttachButtonProps = Partial; */ export const AttachButton = (props: AttachButtonProps) => { const { selectedPicker } = useAttachmentPickerContext(); + const { handleAttachButtonPress, toggleAttachmentPicker } = useMessageInputContext(); - return ; + return ( + + ); }; AttachButton.displayName = 'AttachButton{messageInput}'; diff --git a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx new file mode 100644 index 0000000000..9fc1f2e6e3 --- /dev/null +++ b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx @@ -0,0 +1,274 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FlatList, LayoutChangeEvent, StyleSheet, View } from 'react-native'; + +import { + isLocalAudioAttachment, + isLocalFileAttachment, + isLocalImageAttachment, + isLocalVoiceRecordingAttachment, + isVideoAttachment, + LocalAttachment, + LocalImageAttachment, +} from 'stream-chat'; + +import { useAudioPreviewManager } from './hooks/useAudioPreviewManager'; + +import { useMessageComposer } from '../../contexts'; +import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { isSoundPackageAvailable } from '../../native'; + +const IMAGE_PREVIEW_SIZE = 100; +const FILE_PREVIEW_HEIGHT = 60; + +export type AttachmentUploadPreviewListPropsWithContext = Pick< + MessageInputContextValue, + | 'AudioAttachmentUploadPreview' + | 'FileAttachmentUploadPreview' + | 'ImageAttachmentUploadPreview' + | 'VideoAttachmentUploadPreview' +>; + +/** + * AttachmentUploadPreviewList + * UI Component to preview the files set for upload + */ +const UnMemoizedAttachmentUploadListPreview = ( + props: AttachmentUploadPreviewListPropsWithContext, +) => { + const [flatListWidth, setFlatListWidth] = useState(0); + const flatListRef = useRef | null>(null); + const { + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, + VideoAttachmentUploadPreview, + } = props; + const { attachmentManager } = useMessageComposer(); + const { attachments } = useAttachmentManagerState(); + const { + theme: { + colors: { grey_whisper }, + messageInput: { + attachmentSeparator, + attachmentUploadPreviewList: { filesFlatList, imagesFlatList, wrapper }, + }, + }, + } = useTheme(); + + const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); + const fileUploads = useMemo(() => { + return attachments.filter((attachment) => !isLocalImageAttachment(attachment)); + }, [attachments]); + const audioUploads = useMemo(() => { + return fileUploads.filter( + (attachment) => + isLocalAudioAttachment(attachment) || isLocalVoiceRecordingAttachment(attachment), + ); + }, [fileUploads]); + + const { audioAttachmentsStateMap, onLoad, onProgress, onPlayPause } = + useAudioPreviewManager(audioUploads); + + const renderImageItem = useCallback( + ({ item }: { item: LocalImageAttachment }) => { + return ( + + ); + }, + [ + ImageAttachmentUploadPreview, + attachmentManager.removeAttachments, + attachmentManager.uploadAttachment, + ], + ); + + const renderFileItem = useCallback( + ({ item }: { item: LocalAttachment }) => { + if (isLocalImageAttachment(item)) { + // This is already handled in the `renderImageItem` above, so we return null here to avoid duplication. + return null; + } else if (isLocalVoiceRecordingAttachment(item)) { + return ( + + ); + } else if (isLocalAudioAttachment(item)) { + if (isSoundPackageAvailable()) { + return ( + + ); + } else { + return ( + + ); + } + } else if (isVideoAttachment(item)) { + return ( + + ); + } else if (isLocalFileAttachment(item)) { + return ( + + ); + } else return null; + }, + [ + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + VideoAttachmentUploadPreview, + attachmentManager.removeAttachments, + attachmentManager.uploadAttachment, + audioAttachmentsStateMap, + flatListWidth, + onLoad, + onPlayPause, + onProgress, + ], + ); + + useEffect(() => { + if (fileUploads.length && flatListRef.current) { + setTimeout(() => flatListRef.current?.scrollToEnd(), 1); + } + }, [fileUploads.length]); + + const onLayout = useCallback( + (event: LayoutChangeEvent) => { + if (flatListRef.current) { + setFlatListWidth(event.nativeEvent.layout.width); + } + }, + [flatListRef], + ); + + if (!attachments.length) { + return null; + } + + return ( + + {imageUploads.length ? ( + ({ + index, + length: IMAGE_PREVIEW_SIZE + 8, + offset: (IMAGE_PREVIEW_SIZE + 8) * index, + })} + horizontal + keyExtractor={(item) => item.localMetadata.id} + renderItem={renderImageItem} + style={[styles.imagesFlatList, imagesFlatList]} + /> + ) : null} + {imageUploads.length && fileUploads.length ? ( + + ) : null} + {fileUploads.length ? ( + ({ + index, + length: FILE_PREVIEW_HEIGHT + 8, + offset: (FILE_PREVIEW_HEIGHT + 8) * index, + })} + keyExtractor={(item) => item.localMetadata.id} + onLayout={onLayout} + ref={flatListRef} + renderItem={renderFileItem} + style={[styles.filesFlatList, filesFlatList]} + testID={'file-upload-preview'} + /> + ) : null} + + ); +}; + +export type AttachmentUploadPreviewListProps = Partial; + +const MemoizedAttachmentUploadPreviewListWithContext = React.memo( + UnMemoizedAttachmentUploadListPreview, +); + +/** + * AttachmentUploadPreviewList + * UI Component to preview the files set for upload + */ +export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListProps) => { + const { + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, + VideoAttachmentUploadPreview, + } = useMessageInputContext(); + return ( + + ); +}; + +const styles = StyleSheet.create({ + attachmentSeparator: { + borderBottomWidth: 1, + marginBottom: 10, + }, + filesFlatList: { marginBottom: 12, maxHeight: FILE_PREVIEW_HEIGHT * 2.5 + 16 }, + imagesFlatList: { paddingBottom: 12 }, +}); + +AttachmentUploadPreviewList.displayName = + 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}'; diff --git a/package/src/components/MessageInput/CommandsButton.tsx b/package/src/components/MessageInput/CommandsButton.tsx index 8cba55d77c..7ca093dece 100644 --- a/package/src/components/MessageInput/CommandsButton.tsx +++ b/package/src/components/MessageInput/CommandsButton.tsx @@ -1,73 +1,51 @@ -import React from 'react'; -import type { GestureResponderEvent } from 'react-native'; +import React, { useCallback } from 'react'; +import type { GestureResponderEvent, PressableProps } from 'react-native'; import { Pressable } from 'react-native'; -import { - isSuggestionCommand, - SuggestionsContextValue, - useSuggestionsContext, -} from '../../contexts/suggestionsContext/SuggestionsContext'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { Lightning } from '../../icons/Lightning'; -type CommandsButtonPropsWithContext = Pick & { - /** Function that opens commands selector */ - handleOnPress?: ((event: GestureResponderEvent) => void) & (() => void); +export type CommandsButtonProps = { + /** Function that opens commands selector. */ + handleOnPress?: PressableProps['onPress']; }; -const CommandsButtonWithContext = (props: CommandsButtonPropsWithContext) => { - const { handleOnPress, suggestions } = props; +export const CommandsButton = (props: CommandsButtonProps) => { + const { handleOnPress } = props; + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + + const onPressHandler = useCallback( + async (event: GestureResponderEvent) => { + if (handleOnPress) { + handleOnPress(event); + return; + } + + await textComposer.handleChange({ + selection: { + end: 1, + start: 1, + }, + text: '/', + }); + }, + [handleOnPress, textComposer], + ); const { theme: { - colors: { accent_blue, grey }, + colors: { grey }, messageInput: { commandsButton }, }, } = useTheme(); return ( - - isSuggestionCommand(suggestion)) - ? accent_blue - : grey - } - size={32} - /> + + ); }; -const areEqual = ( - prevProps: CommandsButtonPropsWithContext, - nextProps: CommandsButtonPropsWithContext, -) => { - const { suggestions: prevSuggestions } = prevProps; - const { suggestions: nextSuggestions } = nextProps; - - const suggestionsEqual = !!prevSuggestions === !!nextSuggestions; - if (!suggestionsEqual) { - return false; - } - - return true; -}; - -const MemoizedCommandsButton = React.memo( - CommandsButtonWithContext, - areEqual, -) as typeof CommandsButtonWithContext; - -export type CommandsButtonProps = Partial; - -/** - * UI Component for attach button in MessageInput component. - */ -export const CommandsButton = (props: CommandsButtonProps) => { - const { suggestions } = useSuggestionsContext(); - - return ; -}; - CommandsButton.displayName = 'CommandsButton{messageInput}'; diff --git a/package/src/components/MessageInput/FileUploadPreview.tsx b/package/src/components/MessageInput/FileUploadPreview.tsx deleted file mode 100644 index accf612708..0000000000 --- a/package/src/components/MessageInput/FileUploadPreview.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { FlatList, I18nManager, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; - -import { UploadProgressIndicator } from './UploadProgressIndicator'; - -import { ChatContextValue, useChatContext } from '../../contexts'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../contexts/messageInputContext/MessageInputContext'; -import { - MessagesContextValue, - useMessagesContext, -} from '../../contexts/messagesContext/MessagesContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; -import { Close } from '../../icons/Close'; -import { Warning } from '../../icons/Warning'; -import { isSoundPackageAvailable } from '../../native'; -import type { AudioUpload, FileUpload } from '../../types/types'; -import { getTrimmedAttachmentTitle } from '../../utils/getTrimmedAttachmentTitle'; -import { - getDurationLabelFromDuration, - getIndicatorTypeForFileState, - ProgressIndicatorTypes, -} from '../../utils/utils'; -import { getFileSizeDisplayText } from '../Attachment/FileAttachment'; -import { WritingDirectionAwareText } from '../RTLComponents/WritingDirectionAwareText'; - -const FILE_PREVIEW_HEIGHT = 60; -const WARNING_ICON_SIZE = 16; - -const styles = StyleSheet.create({ - dismiss: { - borderRadius: 24, - height: 24, - marginRight: 4, - position: 'absolute', - right: 8, - top: 8, - width: 24, - }, - fileContainer: { - borderRadius: 12, - borderWidth: 1, - flexDirection: 'row', - paddingHorizontal: 8, - }, - fileIcon: { - alignItems: 'center', - alignSelf: 'center', - justifyContent: 'center', - }, - filenameText: { - fontSize: 14, - fontWeight: 'bold', - }, - fileSizeText: { - fontSize: 12, - marginTop: 10, - }, - fileTextContainer: { - justifyContent: 'space-around', - marginVertical: 10, - paddingHorizontal: 10, - }, - flatList: { marginBottom: 12, maxHeight: FILE_PREVIEW_HEIGHT * 2.5 + 16 }, - overlay: { - borderRadius: 12, - marginHorizontal: 8, - marginTop: 2, - }, - unsupportedFile: { - flexDirection: 'row', - paddingTop: 10, - }, - unsupportedFileText: { - fontSize: 12, - marginHorizontal: 4, - }, - warningIconStyle: { - borderRadius: 24, - marginTop: 2, - }, -}); - -const UnsupportedFileTypeOrFileSizeIndicator = ({ - indicatorType, - item, -}: { - indicatorType: (typeof ProgressIndicatorTypes)[keyof typeof ProgressIndicatorTypes]; - item: FileUpload; -}) => { - const { - theme: { - colors: { accent_red, grey, grey_dark }, - messageInput: { - fileUploadPreview: { fileSizeText }, - }, - }, - } = useTheme(); - - const { t } = useTranslationContext(); - - return indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( - - - - {t('File type not supported')} - - - ) : ( - - {item.file.duration - ? getDurationLabelFromDuration(item.file.duration) - : getFileSizeDisplayText(item.file.size)} - - ); -}; - -export type FileUploadPreviewProps = Partial< - Pick< - MessageInputContextValue, - 'fileUploads' | 'removeFile' | 'uploadFile' | 'setFileUploads' | 'AudioAttachmentUploadPreview' - > -> & - Partial> & - Partial>; - -/** - * FileUploadPreview - * UI Component to preview the files set for upload - */ -export const FileUploadPreview = (props: FileUploadPreviewProps) => { - const { - AudioAttachmentUploadPreview: propAudioAttachmentUploadPreview, - enableOfflineSupport: propEnableOfflineSupport, - FileAttachmentIcon: propFileAttachmentIcon, - fileUploads: propFileUploads, - removeFile: propRemoveFile, - uploadFile: propUploadFile, - } = props; - - const { enableOfflineSupport: contextEnableOfflineSupport } = useChatContext(); - const { - AudioAttachmentUploadPreview: contextAudioAttachmentUploadPreview, - fileUploads: contextFileUploads, - removeFile: contextRemoveFile, - uploadFile: contextUploadFile, - } = useMessageInputContext(); - const { FileAttachmentIcon: contextFileAttachmentIcon } = useMessagesContext(); - - const enableOfflineSupport = propEnableOfflineSupport ?? contextEnableOfflineSupport; - const AudioAttachmentUploadPreview = - propAudioAttachmentUploadPreview ?? contextAudioAttachmentUploadPreview; - const fileUploads = propFileUploads ?? contextFileUploads; - const removeFile = propRemoveFile ?? contextRemoveFile; - const uploadFile = propUploadFile ?? contextUploadFile; - const FileAttachmentIcon = propFileAttachmentIcon ?? contextFileAttachmentIcon; - - const [filesToDisplay, setFilesToDisplay] = useState([]); - - const flatListRef = useRef | null>(null); - const [flatListWidth, setFlatListWidth] = useState(0); - - useEffect(() => { - setFilesToDisplay( - fileUploads.map((file) => ({ - ...file, - duration: file.duration || filesToDisplay.find((f) => f.id === file.id)?.duration || 0, - paused: true, - progress: 0, - })), - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileUploads]); - - // Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here and the duration is set. - const onLoad = (index: string, duration: number) => { - setFilesToDisplay((prevFilesUploads) => - prevFilesUploads.map((fileUpload) => ({ - ...fileUpload, - duration: fileUpload.id === index ? duration : fileUpload.duration, - })), - ); - }; - - // The handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The progressed duration is set here. - const onProgress = (index: string, progress: number) => { - setFilesToDisplay((prevFilesUploads) => - prevFilesUploads.map((fileUpload) => ({ - ...fileUpload, - progress: fileUpload.id === index ? progress : fileUpload.progress, - })), - ); - }; - - // The handler which controls or sets the paused/played state of the audio. - const onPlayPause = (index: string, pausedStatus?: boolean) => { - if (pausedStatus === false) { - // If the status is false we set the audio with the index as playing and the others as paused. - setFilesToDisplay((prevFileUploads) => - prevFileUploads.map((fileUpload) => ({ - ...fileUpload, - paused: fileUpload.id !== index, - })), - ); - } else { - // If the status is true we simply set all the audio's paused state as true. - setFilesToDisplay((prevFileUploads) => - prevFileUploads.map((fileUpload) => ({ - ...fileUpload, - paused: true, - })), - ); - } - }; - - const { - theme: { - colors: { black, grey_dark, grey_gainsboro, grey_whisper }, - messageInput: { - fileUploadPreview: { dismiss, fileContainer, filenameText, fileTextContainer, flatList }, - }, - }, - } = useTheme(); - - const renderItem = ({ item }: { item: AudioUpload }) => { - const indicatorType = getIndicatorTypeForFileState(item.state, enableOfflineSupport); - const isAudio = item.file.type?.startsWith('audio/'); - - return ( - <> - { - uploadFile({ newFile: item }); - }} - style={styles.overlay} - type={indicatorType} - > - {isAudio && isSoundPackageAvailable() ? ( - - ) : ( - - - - - - - {getTrimmedAttachmentTitle(item.file.name)} - - {indicatorType !== null && ( - - )} - - - )} - - { - removeFile(item.id); - }} - style={[styles.dismiss, { backgroundColor: grey_gainsboro }, dismiss]} - testID='remove-file-upload-preview' - > - - - - ); - }; - - const fileUploadsLength = fileUploads.length; - - useEffect(() => { - if (fileUploadsLength && flatListRef.current) { - setTimeout(() => flatListRef.current?.scrollToEnd(), 1); - } - }, [fileUploadsLength]); - - return fileUploadsLength ? ( - ({ - index, - length: FILE_PREVIEW_HEIGHT + 8, - offset: (FILE_PREVIEW_HEIGHT + 8) * index, - })} - keyExtractor={(item) => `${item.id}`} - onLayout={({ - nativeEvent: { - layout: { width }, - }, - }) => { - setFlatListWidth(width); - }} - ref={flatListRef} - renderItem={renderItem} - style={[styles.flatList, flatList]} - /> - ) : null; -}; - -FileUploadPreview.displayName = 'FileUploadPreview{messageInput{fileUploadPreview}}'; diff --git a/package/src/components/MessageInput/ImageUploadPreview.tsx b/package/src/components/MessageInput/ImageUploadPreview.tsx deleted file mode 100644 index e1046f7ba6..0000000000 --- a/package/src/components/MessageInput/ImageUploadPreview.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React from 'react'; -import { - FlatList, - Image, - StyleSheet, - Text, - TouchableOpacity, - TouchableOpacityProps, - View, -} from 'react-native'; - -import { UploadProgressIndicator } from './UploadProgressIndicator'; - -import { ChatContextValue, useChatContext } from '../../contexts'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; -import { Close } from '../../icons/Close'; -import { Warning } from '../../icons/Warning'; -import type { FileUpload } from '../../types/types'; -import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../utils/utils'; - -const IMAGE_PREVIEW_SIZE = 100; -const WARNING_ICON_SIZE = 16; - -const styles = StyleSheet.create({ - dismiss: { - borderRadius: 24, - position: 'absolute', - right: 8, - top: 8, - }, - fileSizeText: { - fontSize: 12, - paddingHorizontal: 10, - }, - flatList: { paddingBottom: 12 }, - iconContainer: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'center', - }, - itemContainer: { - flexDirection: 'row', - height: IMAGE_PREVIEW_SIZE, - marginLeft: 8, - }, - unsupportedImage: { - borderRadius: 20, - bottom: 8, - flexDirection: 'row', - marginHorizontal: 3, - position: 'absolute', - }, - upload: { - borderRadius: 10, - height: IMAGE_PREVIEW_SIZE, - width: IMAGE_PREVIEW_SIZE, - }, - warningIconStyle: { - borderRadius: 24, - marginTop: 6, - }, - warningText: { - alignItems: 'center', - color: 'black', - fontSize: 10, - justifyContent: 'center', - marginHorizontal: 4, - }, -}); - -type ImageUploadPreviewPropsWithContext = Pick< - MessageInputContextValue, - 'imageUploads' | 'removeImage' | 'uploadImage' -> & - Pick; - -export type ImageUploadPreviewProps = Partial; - -type ImageUploadPreviewItem = { index: number; item: FileUpload }; - -export const UnsupportedImageTypeIndicator = ({ - indicatorType, -}: { - indicatorType: (typeof ProgressIndicatorTypes)[keyof typeof ProgressIndicatorTypes] | null; -}) => { - const { - theme: { - colors: { accent_red, overlay, white }, - }, - } = useTheme(); - - const { t } = useTranslationContext(); - return indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( - - - - {t('Not supported')} - - - ) : null; -}; - -const ImageUploadPreviewWithContext = (props: ImageUploadPreviewPropsWithContext) => { - const { enableOfflineSupport, imageUploads, removeImage, uploadImage } = props; - - const { - theme: { - messageInput: { - imageUploadPreview: { flatList, itemContainer, upload }, - }, - }, - } = useTheme(); - - const renderItem = ({ index, item }: ImageUploadPreviewItem) => { - const indicatorType = getIndicatorTypeForFileState(item.state, enableOfflineSupport); - const itemMarginForIndex = index === imageUploads.length - 1 ? { marginRight: 8 } : {}; - - return ( - - { - uploadImage({ newImage: item }); - }} - style={styles.upload} - type={indicatorType} - > - - - { - removeImage(item.id); - }} - /> - - - ); - }; - - return imageUploads.length > 0 ? ( - ({ - index, - length: IMAGE_PREVIEW_SIZE + 8, - offset: (IMAGE_PREVIEW_SIZE + 8) * index, - })} - horizontal - keyExtractor={(item) => item.id} - renderItem={renderItem} - style={[styles.flatList, flatList]} - /> - ) : null; -}; - -type DismissUploadProps = Pick; - -const DismissUpload = ({ onPress }: DismissUploadProps) => { - const { - theme: { - colors: { overlay, white }, - messageInput: { - imageUploadPreview: { dismiss, dismissIconColor }, - }, - }, - } = useTheme(); - - return ( - - - - ); -}; - -const areEqual = ( - prevProps: ImageUploadPreviewPropsWithContext, - nextProps: ImageUploadPreviewPropsWithContext, -) => { - const { imageUploads: prevImageUploads } = prevProps; - const { imageUploads: nextImageUploads } = nextProps; - - return ( - prevImageUploads.length === nextImageUploads.length && - prevImageUploads.every( - (prevImageUpload, index) => prevImageUpload.state === nextImageUploads[index].state, - ) - ); -}; - -const MemoizedImageUploadPreviewWithContext = React.memo( - ImageUploadPreviewWithContext, - areEqual, -) as typeof ImageUploadPreviewWithContext; - -/** - * UI Component to preview the images set for upload - */ -export const ImageUploadPreview = (props: ImageUploadPreviewProps) => { - const { enableOfflineSupport } = useChatContext(); - const { imageUploads, removeImage, uploadImage } = useMessageInputContext(); - - return ( - - ); -}; - -ImageUploadPreview.displayName = 'ImageUploadPreview{messageInput{imageUploadPreview}}'; diff --git a/package/src/components/MessageInput/InputButtons.tsx b/package/src/components/MessageInput/InputButtons.tsx index c1b138a71e..0d8e8af2a6 100644 --- a/package/src/components/MessageInput/InputButtons.tsx +++ b/package/src/components/MessageInput/InputButtons.tsx @@ -1,16 +1,22 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; +import { TextComposerState } from 'stream-chat'; + +import { + AttachmentPickerContextValue, + OwnCapabilitiesContextValue, + useAttachmentPickerContext, +} from '../../contexts'; +import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { MessageInputContextValue, useMessageInputContext, } from '../../contexts/messageInputContext/MessageInputContext'; import { useOwnCapabilitiesContext } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; - -const styles = StyleSheet.create({ - attachButtonContainer: { paddingRight: 5 }, -}); +import { useStateStore } from '../../hooks/useStateStore'; export type InputButtonsProps = Partial; @@ -18,39 +24,47 @@ export type InputButtonsWithContextProps = Pick< MessageInputContextValue, | 'AttachButton' | 'CommandsButton' - | 'giphyActive' | 'hasCameraPicker' | 'hasCommands' | 'hasFilePicker' | 'hasImagePicker' - | 'hasText' | 'MoreOptionsButton' - | 'openCommandsPicker' - | 'selectedPicker' - | 'setShowMoreOptions' - | 'showMoreOptions' | 'toggleAttachmentPicker' ->; +> & + Pick & + Pick; + +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, + hasText: !!state.text, +}); export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => { const { AttachButton, CommandsButton, - giphyActive, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - hasText, MoreOptionsButton, - openCommandsPicker, - setShowMoreOptions, - showMoreOptions, + uploadFile: ownCapabilitiesUploadFile, } = props; + const { textComposer } = useMessageComposer(); + const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); + + const [showMoreOptions, setShowMoreOptions] = useState(true); + const { attachments } = useAttachmentManagerState(); + + const shouldShowMoreOptions = hasText || attachments.length; + + useEffect(() => { + setShowMoreOptions(!shouldShowMoreOptions); + }, [shouldShowMoreOptions]); const { theme: { - messageInput: { attachButtonContainer, commandsButtonContainer }, + messageInput: { attachButtonContainer }, }, } = useTheme(); @@ -58,28 +72,30 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => setShowMoreOptions(true); }, [setShowMoreOptions]); - const ownCapabilities = useOwnCapabilitiesContext(); + const hasAttachmentUploadCapabilities = + (hasCameraPicker || hasFilePicker || hasImagePicker) && ownCapabilitiesUploadFile; + const showCommandsButton = hasCommands && !hasText; + + if (command) { + return null; + } - if (giphyActive) { + if (!hasAttachmentUploadCapabilities && !hasCommands) { return null; } - return !showMoreOptions && (hasCameraPicker || hasImagePicker || hasFilePicker) && hasCommands ? ( + return !showMoreOptions ? ( ) : ( <> - {(hasCameraPicker || hasImagePicker || hasFilePicker) && ownCapabilities.uploadFile && ( + {hasAttachmentUploadCapabilities ? ( - )} - {hasCommands && !hasText && ( - - - - )} + ) : null} + {showCommandsButton ? : null} ); }; @@ -89,25 +105,19 @@ const areEqual = ( nextProps: InputButtonsWithContextProps, ) => { const { - giphyActive: prevGiphyActive, hasCameraPicker: prevHasCameraPicker, hasCommands: prevHasCommands, hasFilePicker: prevHasFilePicker, hasImagePicker: prevHasImagePicker, - hasText: prevHasText, selectedPicker: prevSelectedPicker, - showMoreOptions: prevShowMoreOptions, } = prevProps; const { - giphyActive: nextGiphyActive, hasCameraPicker: nextHasCameraPicker, hasCommands: nextHasCommands, hasFilePicker: nextHasFilePicker, hasImagePicker: nextHasImagePicker, - hasText: nextHasText, selectedPicker: nextSelectedPicker, - showMoreOptions: nextShowMoreOptions, } = nextProps; if (prevHasCameraPicker !== nextHasCameraPicker) { @@ -130,18 +140,6 @@ const areEqual = ( return false; } - if (prevShowMoreOptions !== nextShowMoreOptions) { - return false; - } - - if (prevHasText !== nextHasText) { - return false; - } - - if (prevGiphyActive !== nextGiphyActive) { - return false; - } - return true; }; @@ -154,39 +152,35 @@ export const InputButtons = (props: InputButtonsProps) => { const { AttachButton, CommandsButton, - giphyActive, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - hasText, MoreOptionsButton, - openCommandsPicker, - selectedPicker, - setShowMoreOptions, - showMoreOptions, toggleAttachmentPicker, } = useMessageInputContext(); + const { selectedPicker } = useAttachmentPickerContext(); + const { uploadFile } = useOwnCapabilitiesContext(); return ( ); }; + +const styles = StyleSheet.create({ + attachButtonContainer: { paddingRight: 5 }, +}); diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 3f5761fa66..75969e081f 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -1,12 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Modal, - NativeSyntheticEvent, - SafeAreaView, - StyleSheet, - TextInputFocusEventData, - View, -} from 'react-native'; +import { Modal, SafeAreaView, StyleSheet, View } from 'react-native'; import { Gesture, @@ -23,7 +16,7 @@ import Animated, { withSpring, } from 'react-native-reanimated'; -import type { UserResponse } from 'stream-chat'; +import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; import { useAudioController } from './hooks/useAudioController'; import { useCountdown } from './hooks/useCountdown'; @@ -37,6 +30,13 @@ import { ChannelContextValue, useChannelContext, } from '../../contexts/channelContext/ChannelContext'; +import { + MessageComposerAPIContextValue, + useMessageComposerAPIContext, +} from '../../contexts/messageComposerContext/MessageComposerAPIContext'; +import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useMessageComposerHasSendableData } from '../../contexts/messageInputContext/hooks/useMessageComposerHasSendableData'; import { MessageInputContextValue, useMessageInputContext, @@ -45,23 +45,19 @@ import { MessagesContextValue, useMessagesContext, } from '../../contexts/messagesContext/MessagesContext'; -import { - SuggestionsContextValue, - useSuggestionsContext, -} from '../../contexts/suggestionsContext/SuggestionsContext'; + import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../hooks/useStateStore'; import { isAudioRecorderAvailable, isImageMediaLibraryAvailable, NativeHandlers, } from '../../native'; -import { compressedImageURI } from '../../utils/compressImage'; import { AIStates, useAIState } from '../AITypingIndicatorView'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; @@ -106,61 +102,46 @@ const styles = StyleSheet.create({ type MessageInputPropsWithContext = Pick< AttachmentPickerContextValue, - 'AttachmentPickerSelectionBar' + 'bottomInset' | 'selectedPicker' > & Pick & Pick & Pick< MessageInputContextValue, | 'additionalTextInputProps' - | 'asyncIds' | 'audioRecordingEnabled' | 'asyncMessagesLockDistance' | 'asyncMessagesMinimumPressDuration' | 'asyncMessagesSlideToCancelDistance' | 'asyncMessagesMultiSendEnabled' - | 'asyncUploads' + | 'attachmentPickerBottomSheetHeight' + | 'AttachmentPickerSelectionBar' + | 'attachmentSelectionBarHeight' + | 'AttachmentUploadPreviewList' | 'AudioRecorder' | 'AudioRecordingInProgress' | 'AudioRecordingLockIndicator' | 'AudioRecordingPreview' + | 'AutoCompleteSuggestionList' | 'cooldownEndsAt' | 'CooldownTimer' - | 'clearEditingState' - | 'clearQuotedMessageState' | 'closeAttachmentPicker' | 'compressImageQuality' - | 'editing' - | 'FileUploadPreview' - | 'fileUploads' - | 'giphyActive' - | 'ImageUploadPreview' - | 'imageUploads' | 'Input' | 'inputBoxRef' | 'InputButtons' | 'InputEditingStateHeader' - | 'InputGiphySearch' + | 'CameraSelectorIcon' + | 'CreatePollIcon' + | 'FileSelectorIcon' + | 'ImageSelectorIcon' + | 'VideoRecorderSelectorIcon' + | 'CommandInput' | 'InputReplyStateHeader' - | 'isValidMessage' - | 'maxNumberOfFiles' - | 'mentionedUsers' - | 'numberOfUploads' - | 'quotedMessage' - | 'resetInput' | 'SendButton' - | 'sending' - | 'sendMessageAsync' - | 'setShowMoreOptions' - | 'setGiphyActive' - | 'showMoreOptions' | 'ShowThreadMessageInChannelButton' | 'StartAudioRecordingButton' - | 'removeFile' - | 'removeImage' - | 'text' | 'uploadNewFile' - | 'uploadNewImage' | 'openPollCreationDialog' | 'closePollCreationDialog' | 'showPollCreationDialog' @@ -169,27 +150,34 @@ type MessageInputPropsWithContext = Pick< | 'StopMessageStreamingButton' > & Pick & - Pick< - SuggestionsContextValue, - | 'AutoCompleteSuggestionHeader' - | 'AutoCompleteSuggestionItem' - | 'AutoCompleteSuggestionList' - | 'suggestions' - | 'triggerType' - > & - Pick & - Pick; + Pick & + Pick & { editing: boolean }; + +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, + hasText: !!state.text, + mentionedUsers: state.mentionedUsers, + suggestions: state.suggestions, +}); + +const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ + quotedMessage: state.quotedMessage, +}); const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { + AttachmentPickerSelectionBar, + attachmentPickerBottomSheetHeight, + attachmentSelectionBarHeight, + bottomInset, + selectedPicker, + additionalTextInputProps, - asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, - asyncUploads, - AttachmentPickerSelectionBar, + AttachmentUploadPreviewList, AudioRecorder, audioRecordingEnabled, AudioRecordingInProgress, @@ -199,52 +187,36 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { channel, closeAttachmentPicker, closePollCreationDialog, - compressImageQuality, cooldownEndsAt, CooldownTimer, CreatePollContent, editing, - FileUploadPreview, - fileUploads, - giphyActive, - ImageUploadPreview, - imageUploads, Input, inputBoxRef, InputButtons, InputEditingStateHeader, - InputGiphySearch, + CommandInput, InputReplyStateHeader, isOnline, - isValidMessage, - maxNumberOfFiles, members, - mentionedUsers, - numberOfUploads, - quotedMessage, - removeFile, - removeImage, Reply, - resetInput, + threadList, SendButton, - sending, sendMessage, - sendMessageAsync, - setShowMoreOptions, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - suggestions, - text, - thread, - threadList, - triggerType, - uploadNewFile, - uploadNewImage, watchers, } = props; + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); + const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); + const { attachments } = useAttachmentManagerState(); + const hasSendableData = useMessageComposerHasSendableData(); + const [height, setHeight] = useState(0); const { @@ -252,7 +224,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { colors: { border, grey_whisper, white, white_smoke }, messageInput: { attachmentSelectionBar, - attachmentSeparator, autoCompleteInputContainer, composerContainer, container, @@ -267,278 +238,32 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { }, } = useTheme(); - const { - attachmentPickerBottomSheetHeight, - attachmentSelectionBarHeight, - bottomInset, - selectedFiles, - selectedImages, - selectedPicker, - setMaxNumberOfFiles, - setSelectedFiles, - setSelectedImages, - } = useAttachmentPickerContext(); - const { seconds: cooldownRemainingSeconds } = useCountdown(cooldownEndsAt); - /** - * Mounting and un-mounting logic are un-related in following useEffect. - * While mounting we want to pass maxNumberOfFiles (which is prop on Channel component) - * to AttachmentPicker (on OverlayProvider) - * - * While un-mounting, we want to close the picker e.g., while navigating away. - */ - useEffect(() => { - setMaxNumberOfFiles(maxNumberOfFiles ?? 10); - - return closeAttachmentPicker; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [hasResetImages, setHasResetImages] = useState(false); - const [hasResetFiles, setHasResetFiles] = useState(false); - const [focused, setFocused] = useState(false); - const selectedImagesLength = hasResetImages ? selectedImages.length : 0; - const imageUploadsLength = hasResetImages ? imageUploads.length : 0; - const selectedFilesLength = hasResetFiles ? selectedFiles.length : 0; - const fileUploadsLength = hasResetFiles ? fileUploads.length : 0; - const imagesForInput = (!!thread && !!threadList) || (!thread && !threadList); - - /** - * Reset the selected images when the component is unmounted. - */ - useEffect(() => { - setSelectedImages([]); - if (imageUploads.length) { - imageUploads.forEach((image) => removeImage(image.id)); - } - return () => setSelectedImages([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - /** - * Reset the selected files when the component is unmounted. - */ - useEffect(() => { - setSelectedFiles([]); - if (fileUploads.length) { - fileUploads.forEach((file) => removeFile(file.id)); - } - - return () => setSelectedFiles([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (hasResetImages === false && imageUploadsLength === 0 && selectedImagesLength === 0) { - setHasResetImages(true); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageUploadsLength, selectedImagesLength]); - - useEffect(() => { - if (hasResetFiles === false && fileUploadsLength === 0 && selectedFilesLength === 0) { - setHasResetFiles(true); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileUploadsLength, selectedFilesLength]); - - useEffect(() => { - if (imagesForInput === false && imageUploadsLength) { - imageUploads.forEach((image) => removeImage(image.id)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imagesForInput, imageUploadsLength]); - - const uploadImagesHandler = async () => { - const imageToUpload = selectedImages.find((selectedImage) => { - const uploadedImage = imageUploads.find( - (imageUpload) => - imageUpload.file.uri === selectedImage.uri || imageUpload.url === selectedImage.uri, - ); - return !uploadedImage; - }); - - if (imageToUpload) { - const compressedImage = await compressedImageURI(imageToUpload, compressImageQuality); - uploadNewImage({ - ...imageToUpload, - uri: compressedImage, - }); - } - }; - - const removeImagesHandler = () => { - const imagesToRemove = imageUploads.filter( - (imageUpload) => - !selectedImages.find( - (selectedImage) => - selectedImage.uri === imageUpload.file.uri || selectedImage.uri === imageUpload.url, - ), - ); - imagesToRemove.forEach((image) => removeImage(image.id)); - }; - - const uploadFilesHandler = async () => { - const fileToUpload = selectedFiles.find((selectedFile) => { - const uploadedFile = fileUploads.find( - (fileUpload) => - fileUpload.file.uri === selectedFile.uri || fileUpload.url === selectedFile.uri, - ); - return !uploadedFile; - }); - if (fileToUpload) { - await uploadNewFile(fileToUpload); - } - }; - - const removeFilesHandler = () => { - const filesToRemove = fileUploads.filter( - (fileUpload) => - !selectedFiles.find( - (selectedFile) => - selectedFile.uri === fileUpload.file.uri || selectedFile.uri === fileUpload.url, - ), - ); - filesToRemove.forEach((file) => removeFile(file.id)); - }; - - /** - * When a user selects or deselects an image in the image picker using media library. - */ - useEffect(() => { - const uploadOrRemoveImage = async () => { - if (imagesForInput) { - if (selectedImagesLength > imageUploadsLength) { - /** User selected an image in bottom sheet attachment picker */ - await uploadImagesHandler(); - } else { - /** User de-selected an image in bottom sheet attachment picker */ - removeImagesHandler(); - } - } - }; - // If image picker is not available, don't do anything - if (!isImageMediaLibraryAvailable()) { - return; - } - uploadOrRemoveImage(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedImagesLength]); - - /** - * When a user selects or deselects a video in the image picker using media library. - */ - useEffect(() => { - const uploadOrRemoveFile = async () => { - if (selectedFilesLength > fileUploadsLength) { - /** User selected a video in bottom sheet attachment picker */ - await uploadFilesHandler(); - } else { - /** User de-selected a video in bottom sheet attachment picker */ - removeFilesHandler(); - } - }; - uploadOrRemoveFile(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedFilesLength]); + // Close the attachment picker state when the component unmounts + useEffect( + () => () => { + closeAttachmentPicker(); + }, + [closeAttachmentPicker], + ); - /** - * This is for image attachments selected from attachment picker. - */ useEffect(() => { - if (imagesForInput && isImageMediaLibraryAvailable()) { - if (imageUploadsLength < selectedImagesLength) { - // /** User removed some image from seleted images within ImageUploadPreview. */ - const updatedSelectedImages = selectedImages.filter((selectedImage) => { - const uploadedImage = imageUploads.find( - (imageUpload) => - imageUpload.file.uri === selectedImage.uri || imageUpload.url === selectedImage.uri, - ); - return uploadedImage; - }); - setSelectedImages(updatedSelectedImages); - } else if (imageUploadsLength > selectedImagesLength) { - /** - * User is editing some message which contains image attachments. - **/ - setSelectedImages(imageUploads.map((imageUpload) => imageUpload.file)); - } + if (editing && inputBoxRef.current) { + inputBoxRef.current.focus(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageUploadsLength]); + }, [editing, inputBoxRef]); /** - * This is for video attachments selected from attachment picker. + * Effect to get the draft data for legacy thread composer and set it to message composer. + * TODO: This can be removed once we remove legacy thread composer. */ useEffect(() => { - if (isImageMediaLibraryAvailable()) { - if (fileUploadsLength < selectedFilesLength) { - /** User removed some video from seleted files within ImageUploadPreview. */ - const updatedSelectedFiles = selectedFiles.filter((selectedFile) => { - const uploadedFile = fileUploads.find( - (fileUpload) => - fileUpload.file.uri === selectedFile.uri || fileUpload.url === selectedFile.uri, - ); - return uploadedFile; - }); - setSelectedFiles(updatedSelectedFiles); - } else if (fileUploadsLength > selectedFilesLength) { - /** - * User is editing some message which contains video attachments. - **/ - setSelectedFiles(fileUploads.map((fileUpload) => fileUpload.file)); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileUploadsLength]); - - const editingExists = !!editing; - - useEffect(() => { - if (editing && inputBoxRef.current) { - inputBoxRef.current.focus(); - } - - /** - * Make sure to test `initialValue` functionality, if you are modifying following condition. - * - * We have the following condition, to make sure - when user comes out of "editing message" state, - * we wipe out all the state around message input such as text, mentioned users, image uploads etc. - * But it also means, this condition will be fired up on first render, which may result in clearing - * the initial value set on input box, through the prop - `initialValue`. - * This prop generally gets used for the case of draft message functionality. - */ - if ( - !editing && - (giphyActive || - fileUploads.length > 0 || - mentionedUsers.length > 0 || - imageUploads.length > 0 || - numberOfUploads > 0) && - resetInput - ) { - resetInput(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editingExists]); + const threadId = messageComposer.threadId; + if (!threadId) return; - const asyncIdsString = asyncIds.join(); - const asyncUploadsString = Object.values(asyncUploads) - .map(({ state, url }) => `${state}${url}`) - .join(); - useEffect(() => { - if (Object.keys(asyncUploads).length) { - /** - * When successful image upload response occurs after hitting send, - * send a follow up message with the image - */ - sending.current = true; - asyncIds.forEach((id) => sendMessageAsync(id)); - sending.current = false; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [asyncIdsString, asyncUploadsString, sendMessageAsync]); + messageComposer.getDraft(); + }, [messageComposer]); const getMembers = () => { const result: UserResponse[] = []; @@ -577,34 +302,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { return result; }; - const additionalTextInputContainerProps = { - ...additionalTextInputProps, - }; - - const memoizedAdditionalTextInputProps = useMemo( - () => ({ - ...additionalTextInputProps, - onBlur: (event: NativeSyntheticEvent) => { - if (additionalTextInputProps?.onBlur) { - additionalTextInputProps?.onBlur(event); - } - if (setFocused) { - setFocused(false); - } - setShowMoreOptions(true); - }, - onFocus: (event: NativeSyntheticEvent) => { - if (additionalTextInputProps?.onFocus) { - additionalTextInputProps.onFocus(event); - } - if (setFocused) { - setFocused(true); - } - }, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [additionalTextInputProps], - ); + const isFocused = inputBoxRef.current?.isFocused(); const { deleteVoiceRecording, @@ -624,23 +322,12 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { waveformData, } = useAudioController(); - const isSendingButtonVisible = () => { - if (audioRecordingEnabled && isAudioRecorderAvailable()) { - if (recording) { - return false; - } - if (text && text.trim()) { - return true; - } - - const imagesAndFiles = [...imageUploads, ...fileUploads]; - if (imagesAndFiles.length === 0) { - return false; - } - } + const asyncAudioEnabled = audioRecordingEnabled && isAudioRecorderAvailable(); + const showSendingButton = hasText || attachments.length || command; - return true; - }; + const isSendingButtonVisible = useMemo(() => { + return asyncAudioEnabled ? showSendingButton && !recording : true; + }, [asyncAudioEnabled, recording, showSendingButton]); const micPositionX = useSharedValue(0); const micPositionY = useSharedValue(0); @@ -701,37 +388,35 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { runOnJS(setMicLocked)(false); }); - const animatedStyles = { - lockIndicator: useAnimatedStyle(() => ({ - transform: [ - { - translateY: interpolate( - micPositionY.value, - [0, Y_AXIS_POSITION], - [0, Y_AXIS_POSITION], - Extrapolation.CLAMP, - ), - }, - ], - })), - micButton: useAnimatedStyle(() => ({ - opacity: interpolate(micPositionX.value, [0, X_AXIS_POSITION], [1, 0], Extrapolation.CLAMP), - transform: [{ translateX: micPositionX.value }, { translateY: micPositionY.value }], - })), - slideToCancel: useAnimatedStyle(() => ({ - opacity: interpolate(micPositionX.value, [0, X_AXIS_POSITION], [1, 0], Extrapolation.CLAMP), - transform: [ - { - translateX: interpolate( - micPositionX.value, - [0, X_AXIS_POSITION], - [0, X_AXIS_POSITION / 2], - Extrapolation.CLAMP, - ), - }, - ], - })), - }; + const lockIndicatorAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate( + micPositionY.value, + [0, Y_AXIS_POSITION], + [0, Y_AXIS_POSITION], + Extrapolation.CLAMP, + ), + }, + ], + })); + const micButttonAnimatedStyle = useAnimatedStyle(() => ({ + opacity: interpolate(micPositionX.value, [0, X_AXIS_POSITION], [1, 0], Extrapolation.CLAMP), + transform: [{ translateX: micPositionX.value }, { translateY: micPositionY.value }], + })); + const slideToCancelAnimatedStyle = useAnimatedStyle(() => ({ + opacity: interpolate(micPositionX.value, [0, X_AXIS_POSITION], [1, 0], Extrapolation.CLAMP), + transform: [ + { + translateX: interpolate( + micPositionX.value, + [0, X_AXIS_POSITION], + [0, X_AXIS_POSITION / 2], + Extrapolation.CLAMP, + ), + }, + ], + })); const { aiState } = useAIState(channel); @@ -750,13 +435,13 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { style={[styles.container, { backgroundColor: white, borderColor: border }, container]} > {editing && } - {quotedMessage && } + {quotedMessage && !editing && } {recording && ( <> {recordingStatus === 'stopped' ? ( { {Input ? ( - + ) : ( <> {recording ? ( @@ -790,7 +472,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { recording={recording} recordingDuration={recordingDuration} recordingStopped={recordingStatus === 'stopped'} - slideToCancelStyle={animatedStyles.slideToCancel} + slideToCancelStyle={slideToCancelAnimatedStyle} stopVoiceRecording={stopVoiceRecording} uploadVoiceRecording={uploadVoiceRecording} /> @@ -804,39 +486,25 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { styles.inputBoxContainer, { borderColor: grey_whisper, - paddingVertical: giphyActive ? 8 : 12, + paddingVertical: command ? 8 : 12, }, inputBoxContainer, - focused ? focusedInputBoxContainer : null, + isFocused ? focusedInputBoxContainer : null, ]} > - {((typeof editing !== 'boolean' && editing?.quoted_message) || - quotedMessage) && ( + {quotedMessage && ( )} - {imageUploads.length ? : null} - {imageUploads.length && fileUploads.length ? ( - - ) : null} - {fileUploads.length ? : null} - {giphyActive ? ( - + + {command ? ( + ) : ( )} @@ -846,25 +514,19 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { {shouldDisplayStopAIGeneration ? ( - ) : isSendingButtonVisible() ? ( + ) : isSendingButtonVisible ? ( cooldownRemainingSeconds ? ( ) : ( - + ) ) : null} {audioRecordingEnabled && isAudioRecorderAvailable() && !micLocked && ( { - {triggerType && suggestions ? ( - - - - ) : null} - - {selectedPicker && ( + + + + {isImageMediaLibraryAvailable() && selectedPicker ? ( { > - )} + ) : null} + {showPollCreationDialog ? ( - prevAsyncUploads[key].state === nextAsyncUploads[key].state && - prevAsyncUploads[key].url === nextAsyncUploads[key].url, - ); - if (!asyncUploadsEqual) { - return false; - } - - const fileUploadsEqual = prevFileUploads.length === nextFileUploads.length; - if (!fileUploadsEqual) { - return false; - } - - const mentionedUsersEqual = prevMentionedUsers.length === nextMentionedUsers.length; - if (!mentionedUsersEqual) { - return false; - } - - const suggestionsEqual = - !!prevSuggestions?.data && !!nextSuggestions?.data - ? prevSuggestions.data.length === nextSuggestions.data.length && - prevSuggestions.data.every(({ name }, index) => name === nextSuggestions.data[index].name) - : !!prevSuggestions === !!nextSuggestions; - if (!suggestionsEqual) { + const threadListEqual = prevThreadList === nextThreadList; + if (!threadListEqual) { return false; } - const threadEqual = - prevThread?.id === nextThread?.id && - prevThread?.text === nextThread?.text && - prevThread?.reply_count === nextThread?.reply_count; - if (!threadEqual) { - return false; - } - - const threadListEqual = prevThreadList === nextThreadList; - if (!threadListEqual) { + const selectedPickerEqual = prevSelectedPicker === nextSelectedPicker; + if (!selectedPickerEqual) { return false; } @@ -1142,7 +714,6 @@ export type MessageInputProps = Partial; * [Translation Context](https://getstream.io/chat/docs/sdk/reactnative/contexts/translation-context/) */ export const MessageInput = (props: MessageInputProps) => { - const { AttachmentPickerSelectionBar } = useAttachmentPickerContext(); const { isOnline } = useChatContext(); const ownCapabilities = useOwnCapabilitiesContext(); @@ -1150,76 +721,57 @@ export const MessageInput = (props: MessageInputProps) => { const { additionalTextInputProps, - asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, - asyncUploads, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadPreviewList, AudioRecorder, audioRecordingEnabled, AudioRecordingInProgress, AudioRecordingLockIndicator, AudioRecordingPreview, AudioRecordingWaveform, - clearEditingState, - clearQuotedMessageState, + AutoCompleteSuggestionList, + CameraSelectorIcon, closeAttachmentPicker, closePollCreationDialog, compressImageQuality, cooldownEndsAt, CooldownTimer, CreatePollContent, - editing, - FileUploadPreview, - fileUploads, - giphyActive, - ImageUploadPreview, - imageUploads, + CreatePollIcon, + FileSelectorIcon, + ImageSelectorIcon, Input, inputBoxRef, InputButtons, InputEditingStateHeader, - InputGiphySearch, + CommandInput, InputReplyStateHeader, - isValidMessage, - maxNumberOfFiles, - mentionedUsers, - numberOfUploads, openPollCreationDialog, - quotedMessage, - removeFile, - removeImage, - resetInput, SendButton, - sending, sendMessage, - sendMessageAsync, SendMessageDisallowedIndicator, - setGiphyActive, - setShowMoreOptions, - showMoreOptions, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - text, uploadNewFile, - uploadNewImage, + VideoRecorderSelectorIcon, } = useMessageInputContext(); + const { bottomInset, bottomSheetRef, selectedPicker } = useAttachmentPickerContext(); + const messageComposer = useMessageComposer(); + const editing = !!messageComposer.editedMessage; + const { clearEditingState } = useMessageComposerAPIContext(); const { Reply } = useMessagesContext(); - const { - AutoCompleteSuggestionHeader, - AutoCompleteSuggestionItem, - AutoCompleteSuggestionList, - suggestions, - triggerType, - } = useSuggestionsContext(); - - const { thread } = useThreadContext(); - const { t } = useTranslationContext(); /** @@ -1234,75 +786,60 @@ export const MessageInput = (props: MessageInputProps) => { void) & (() => void); + handleOnPress?: () => void; }; export const MoreOptionsButton = (props: MoreOptionsButtonProps) => { @@ -21,14 +20,14 @@ export const MoreOptionsButton = (props: MoreOptionsButtonProps) => { } = useTheme(); return ( - [moreOptionsButton, { opacity: pressed ? 0.8 : 1 }]} testID='more-options-button' > - + ); }; diff --git a/package/src/components/MessageInput/SendButton.tsx b/package/src/components/MessageInput/SendButton.tsx index f2bcec8afd..b5b9959cf4 100644 --- a/package/src/components/MessageInput/SendButton.tsx +++ b/package/src/components/MessageInput/SendButton.tsx @@ -1,22 +1,37 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Pressable } from 'react-native'; +import { TextComposerState } from 'stream-chat'; + +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { MessageInputContextValue, useMessageInputContext, } from '../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; import { Search } from '../../icons/Search'; import { SendRight } from '../../icons/SendRight'; import { SendUp } from '../../icons/SendUp'; -type SendButtonPropsWithContext = Pick & { - /** Disables the button */ disabled: boolean; +export type SendButtonProps = Partial> & { + /** Disables the button */ + disabled: boolean; }; -const SendButtonWithContext = (props: SendButtonPropsWithContext) => { - const { disabled = false, giphyActive, sendMessage } = props; +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, +}); + +export const SendButton = (props: SendButtonProps) => { + const { disabled = false, sendMessage: propsSendMessage } = props; + const { sendMessage: sendMessageFromContext } = useMessageInputContext(); + const sendMessage = propsSendMessage || sendMessageFromContext; + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + const { theme: { colors: { accent_blue, grey_gainsboro }, @@ -24,14 +39,21 @@ const SendButtonWithContext = (props: SendButtonPropsWithContext) => { }, } = useTheme(); + const onPressHandler = useCallback(() => { + if (disabled) { + return; + } + sendMessage(); + }, [disabled, sendMessage]); + return ( null : () => sendMessage()} + onPress={onPressHandler} style={[sendButton]} testID='send-button' > - {giphyActive ? ( + {command ? ( ) : disabled ? ( @@ -42,56 +64,4 @@ const SendButtonWithContext = (props: SendButtonPropsWithContext) => { ); }; -const areEqual = (prevProps: SendButtonPropsWithContext, nextProps: SendButtonPropsWithContext) => { - const { - disabled: prevDisabled, - giphyActive: prevGiphyActive, - sendMessage: prevSendMessage, - } = prevProps; - const { - disabled: nextDisabled, - giphyActive: nextGiphyActive, - sendMessage: nextSendMessage, - } = nextProps; - - const disabledEqual = prevDisabled === nextDisabled; - if (!disabledEqual) { - return false; - } - - const giphyActiveEqual = prevGiphyActive === nextGiphyActive; - if (!giphyActiveEqual) { - return false; - } - - const sendMessageEqual = prevSendMessage === nextSendMessage; - if (!sendMessageEqual) { - return false; - } - - return true; -}; - -const MemoizedSendButton = React.memo( - SendButtonWithContext, - areEqual, -) as typeof SendButtonWithContext; - -export type SendButtonProps = Partial; - -/** - * UI Component for send button in MessageInput component. - */ -export const SendButton = (props: SendButtonProps) => { - const { giphyActive, sendMessage } = useMessageInputContext(); - - return ( - - ); -}; - SendButton.displayName = 'SendButton{messageInput}'; diff --git a/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx b/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx index 3a552c461b..f3c59bc582 100644 --- a/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx +++ b/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx @@ -42,7 +42,7 @@ export const SendMessageDisallowedIndicator = () => { testID='send-message-disallowed-indicator' > - {t("You can't send messages in this channel")} + {t("You can't send messages in this channel")} ); diff --git a/package/src/components/MessageInput/ShowThreadMessageInChannelButton.tsx b/package/src/components/MessageInput/ShowThreadMessageInChannelButton.tsx index 8479e5db83..e40d70ebe1 100644 --- a/package/src/components/MessageInput/ShowThreadMessageInChannelButton.tsx +++ b/package/src/components/MessageInput/ShowThreadMessageInChannelButton.tsx @@ -1,60 +1,35 @@ -import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import React, { useCallback } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../contexts/messageInputContext/MessageInputContext'; +import { MessageComposerState } from 'stream-chat'; + +import { ChannelContextValue } from '../../contexts/channelContext/ChannelContext'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../hooks/useStateStore'; import { Check } from '../../icons'; -const styles = StyleSheet.create({ - checkBox: { - alignItems: 'center', - borderRadius: 3, - borderWidth: 2, - height: 16, - justifyContent: 'center', - width: 16, - }, - container: { - flexDirection: 'row', - marginHorizontal: 2, - marginTop: 8, - }, - innerContainer: { - flexDirection: 'row', - }, - text: { - fontSize: 13, - marginLeft: 12, - }, +const stateSelector = (state: MessageComposerState) => ({ + showReplyInChannel: state.showReplyInChannel, }); export type ShowThreadMessageInChannelButtonWithContextProps = Pick< - MessageInputContextValue, - 'sendThreadMessageInChannel' | 'setSendThreadMessageInChannel' + ThreadContextValue, + 'allowThreadMessagesInChannel' > & - Pick & - Pick & { - threadList?: boolean; - }; + Pick & { threadList?: ChannelContextValue['threadList'] }; export const ShowThreadMessageInChannelButtonWithContext = ( props: ShowThreadMessageInChannelButtonWithContextProps, ) => { - const { - allowThreadMessagesInChannel, - sendThreadMessageInChannel, - setSendThreadMessageInChannel, - t, - threadList, - } = props; + const { allowThreadMessagesInChannel, t, threadList } = props; + const messageComposer = useMessageComposer(); + const { showReplyInChannel } = useStateStore(messageComposer.state, stateSelector); const { theme: { @@ -72,20 +47,22 @@ export const ShowThreadMessageInChannelButtonWithContext = ( }, } = useTheme(); + const onPressHandler = useCallback(() => { + messageComposer.toggleShowReplyInChannel(); + }, [messageComposer]); + if (!threadList || !allowThreadMessagesInChannel) { return null; } return ( - setSendThreadMessageInChannel((prevSendInChannel) => !prevSendInChannel)} - > + ({ opacity: pressed ? 0.8 : 1 })}> - {sendThreadMessageInChannel && ( - - )} + {showReplyInChannel && } - - {t('Also send to channel')} - + {t('Also send to channel')} - + ); }; @@ -113,13 +86,11 @@ const areEqual = ( ) => { const { allowThreadMessagesInChannel: prevAllowThreadMessagesInChannel, - sendThreadMessageInChannel: prevSendThreadMessageInChannel, t: prevT, threadList: prevThreadList, } = prevProps; const { allowThreadMessagesInChannel: nextAllowThreadMessagesInChannel, - sendThreadMessageInChannel: nexSendThreadMessageInChannel, t: nextT, threadList: nextThreadList, } = nextProps; @@ -129,12 +100,6 @@ const areEqual = ( return false; } - const sendThreadMessageInChannelEqual = - prevSendThreadMessageInChannel === nexSendThreadMessageInChannel; - if (!sendThreadMessageInChannelEqual) { - return false; - } - const threadListEqual = prevThreadList === nextThreadList; if (!threadListEqual) { return false; @@ -160,14 +125,11 @@ export type ShowThreadMessageInChannelButtonProps = export const ShowThreadMessageInChannelButton = (props: ShowThreadMessageInChannelButtonProps) => { const { t } = useTranslationContext(); const { allowThreadMessagesInChannel } = useThreadContext(); - const { sendThreadMessageInChannel, setSendThreadMessageInChannel } = useMessageInputContext(); return ( { - const getComponent = (props = {}) => ( - - - +const renderComponent = ({ channelProps, client, props }) => { + return render( + + + + + + + , ); +}; + +describe('AttachButton', () => { + let client; + let channel; + + beforeEach(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); - it('should render an enabled AttachButton', async () => { + it('should render an disabled AttachButton', async () => { const handleOnPress = jest.fn(); - const user = userEvent.setup(); + const channelProps = { channel }; + const props = { disabled: true, handleOnPress }; - render(getComponent({ handleOnPress })); + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; await waitFor(() => { - expect(screen.queryByTestId('attach-button')).toBeTruthy(); + expect(queryByTestId('attach-button')).toBeTruthy(); expect(handleOnPress).toHaveBeenCalledTimes(0); }); - user.press(screen.getByTestId('attach-button')); + act(() => { + fireEvent.press(screen.getByTestId('attach-button')); + }); - await waitFor(() => expect(handleOnPress).toHaveBeenCalledTimes(1)); + await waitFor(() => { + expect(handleOnPress).toHaveBeenCalledTimes(0); + }); const snapshot = screen.toJSON(); @@ -34,20 +66,27 @@ describe('AttachButton', () => { }); }); - it('should render a disabled AttachButton', async () => { + it('should render a enabled AttachButton', async () => { const handleOnPress = jest.fn(); - const user = userEvent.setup(); + const channelProps = { channel }; + const props = { disabled: false, handleOnPress }; + + renderComponent({ channelProps, client, props }); - render(getComponent({ disabled: true, handleOnPress })); + const { queryByTestId } = screen; await waitFor(() => { - expect(screen.queryByTestId('attach-button')).toBeTruthy(); + expect(queryByTestId('attach-button')).toBeTruthy(); expect(handleOnPress).toHaveBeenCalledTimes(0); }); - user.press(screen.getByTestId('attach-button')); + act(() => { + fireEvent.press(screen.getByTestId('attach-button')); + }); - await waitFor(() => expect(handleOnPress).toHaveBeenCalledTimes(0)); + await waitFor(() => { + expect(handleOnPress).toHaveBeenCalledTimes(1); + }); const snapshot = screen.toJSON(); @@ -55,4 +94,79 @@ describe('AttachButton', () => { expect(snapshot).toMatchSnapshot(); }); }); + + it('should call handleAttachButtonPress when the button is clicked if passed', async () => { + const handleAttachButtonPress = jest.fn(); + const channelProps = { channel, handleAttachButtonPress }; + const props = { disabled: false }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('attach-button')).toBeTruthy(); + expect(handleAttachButtonPress).toHaveBeenCalledTimes(0); + }); + + act(() => { + fireEvent.press(screen.getByTestId('attach-button')); + }); + + await waitFor(() => { + expect(handleAttachButtonPress).toHaveBeenCalledTimes(1); + }); + + const snapshot = screen.toJSON(); + + await waitFor(() => { + expect(snapshot).toMatchSnapshot(); + }); + }); + + it("should open native attachment picker when the media library isn't present", async () => { + jest.spyOn(NativeHandler, 'isImageMediaLibraryAvailable').mockImplementation(() => false); + + const channelProps = { channel }; + const props = {}; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('attach-button')).toBeTruthy(); + }); + + act(() => { + fireEvent.press(screen.getByTestId('attach-button')); + }); + + await waitFor(() => { + expect(queryByTestId('native-attachment-picker')).toBeTruthy(); + }); + }); + + it('should open stream attachment picker when the media library is present', async () => { + jest.spyOn(NativeHandler, 'isImageMediaLibraryAvailable').mockImplementation(() => true); + + const channelProps = { channel }; + const props = {}; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('attach-button')).toBeTruthy(); + }); + + act(() => { + fireEvent.press(screen.getByTestId('attach-button')); + }); + + await waitFor(() => { + expect(queryByTestId('attachment-picker-list')).toBeTruthy(); + }); + }); }); diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js new file mode 100644 index 0000000000..5532989480 --- /dev/null +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js @@ -0,0 +1,492 @@ +import React from 'react'; + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { OverlayProvider } from '../../../contexts'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { + generateAudioAttachment, + generateFileAttachment, + generateImageAttachment, + generateVideoAttachment, +} from '../../../mock-builders/attachments'; + +import { FileState } from '../../../utils/utils'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList'; + +jest.mock('../../../native.ts', () => { + const { View } = require('react-native'); + + return { + isAudioRecorderAvailable: jest.fn(() => true), + isDocumentPickerAvailable: jest.fn(() => true), + isImageMediaLibraryAvailable: jest.fn(() => true), + isImagePickerAvailable: jest.fn(() => true), + isSoundPackageAvailable: jest.fn(() => false), + NativeHandlers: { + Sound: { + Player: View, + }, + }, + }; +}); + +const renderComponent = ({ client, channel, props }) => { + return render( + + + + + + + , + ); +}; + +describe('AttachmentUploadPreviewList', () => { + let client; + let channel; + + beforeEach(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + act(() => { + channel.messageComposer.attachmentManager.initState(); + }); + }); + + it('should return null when no files are uploaded', async () => { + const props = {}; + + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-upload-preview')).toHaveLength(0); + }); + }); + + it('should return null when the file is an image', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FINISHED, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(0); + }); + }); + + it('should render FileAttachmentUploadPreview when the sound package is unavailable', async () => { + const attachments = [ + generateAudioAttachment({ + localMetadata: { + id: 'audio-attachment', + uploadState: FileState.UPLOADING, + }, + }), + ]; + + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + }); + }); + + describe('FileAttachmentUploadPreview', () => { + it('should render FileAttachmentUploadPreview with all uploading files', async () => { + const attachments = [ + generateFileAttachment({ + localMetadata: { + id: 'file-attachment', + uploadState: FileState.UPLOADING, + }, + }), + generateVideoAttachment({ + localMetadata: { + id: 'video-attachment', + uploadState: FileState.UPLOADING, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); + }); + + renderComponent({ channel, client, props }); + + const { getAllByTestId, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength(2); + expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(2); + }); + + act(() => { + fireEvent.press(getAllByTestId('remove-upload-preview')[0]); + }); + + await waitFor(() => { + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(1); + }); + + act(() => { + fireEvent.press(getAllByTestId('remove-upload-preview')[0]); + }); + + await waitFor(() => { + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(0); + }); + }); + + it('should render FileAttachmentUploadPreview with all uploaded files', async () => { + const attachments = [ + generateFileAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FINISHED, + }, + }), + generateVideoAttachment({ + localMetadata: { + id: 'video-attachment', + uploadState: FileState.FINISHED, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(2); + }); + }); + + it('should render FileAttachmentUploadPreview with all failed files', async () => { + const uploadAttachmentSpy = jest.fn(); + channel.messageComposer.attachmentManager.uploadAttachment = uploadAttachmentSpy; + const attachments = [ + generateFileAttachment({ + localMetadata: { + id: 'file-attachment', + uploadState: FileState.FAILED, + }, + }), + generateVideoAttachment({ + localMetadata: { + id: 'video-attachment', + uploadState: FileState.FAILED, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { getAllByTestId, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(2); + }); + + act(() => { + fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); + }); + + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(2); + expect(uploadAttachmentSpy).toHaveBeenCalled(); + }); + }); + + it('should render FileAttachmentUploadPreview with all unsupported', async () => { + const attachments = [ + generateFileAttachment({ + localMetadata: { + id: 'file-attachment', + uploadState: FileState.BLOCKED, + }, + }), + generateVideoAttachment({ + localMetadata: { + id: 'video-attachment', + uploadState: FileState.BLOCKED, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByText, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(queryAllByText('Not supported')).toHaveLength(2); + }); + }); + }); + + describe('ImageAttachmentUploadPreview', () => { + it('should render ImageAttachmentUploadPreview with all uploading images', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.UPLOADING, + }, + }), + ]; + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { getAllByTestId, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + }); + + await act(() => { + fireEvent.press(getAllByTestId('remove-upload-preview')[0]); + }); + + await waitFor(() => { + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(0); + }); + }); + + it('should return null when no images are uploaded', async () => { + const props = {}; + + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-upload-preview')).toHaveLength(0); + }); + }); + + it('should render ImageAttachmentUploadPreview with all uploaded images', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FINISHED, + }, + }), + ]; + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; + + await waitFor(() => { + const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); + for (const image of imageAttachments) { + fireEvent(image, 'loadEnd'); + } + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); + }); + }); + + it('should render ImageAttachmentUploadPreview with all failed images', async () => { + const uploadAttachmentSpy = jest.fn(); + channel.messageComposer.attachmentManager.uploadAttachment = uploadAttachmentSpy; + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FAILED, + }, + }), + ]; + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { getAllByTestId, queryAllByTestId } = screen; + + await waitFor(() => { + const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); + for (const image of imageAttachments) { + fireEvent(image, 'loadEnd'); + } + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); + }); + + await act(() => { + fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(1); + expect(uploadAttachmentSpy).toHaveBeenCalled(); + }); + }); + + it('should render ImageAttachmentUploadPreview with all unsupported', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.BLOCKED, + }, + }), + ]; + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByText, queryAllByTestId } = screen; + + await waitFor(() => { + const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); + for (const image of imageAttachments) { + fireEvent(image, 'loadEnd'); + } + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByText('Not supported')).toHaveLength(1); + }); + }); + + it('should render ImageAttachmentUploadPreview with 1 uploading, 1 uploaded, and 1 failed image, and 1 unsupported', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment-1', + uploadState: FileState.UPLOADING, + }, + }), + generateImageAttachment({ + localMetadata: { + id: 'image-attachment-2', + uploadState: FileState.FINISHED, + }, + }), + generateImageAttachment({ + localMetadata: { + id: 'image-attachment-3', + uploadState: FileState.FAILED, + }, + }), + generateImageAttachment({ + localMetadata: { + id: 'image-attachment-4', + uploadState: FileState.BLOCKED, + }, + }), + ]; + + const props = {}; + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByTestId, queryAllByText } = screen; + + await waitFor(() => { + const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); + for (const image of imageAttachments) { + fireEvent(image, 'loadEnd'); + } + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(4); + expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByText('Not supported')).toHaveLength(1); + }); + }); + }); +}); diff --git a/package/src/components/MessageInput/__tests__/UploadProgressIndicator.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadProgressIndicator.test.js similarity index 60% rename from package/src/components/MessageInput/__tests__/UploadProgressIndicator.test.js rename to package/src/components/MessageInput/__tests__/AttachmentUploadProgressIndicator.test.js index 22ddaa2a0e..f9774366cf 100644 --- a/package/src/components/MessageInput/__tests__/UploadProgressIndicator.test.js +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadProgressIndicator.test.js @@ -1,18 +1,27 @@ import React from 'react'; -import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { ProgressIndicatorTypes } from '../../../utils/utils'; -import { UploadProgressIndicator } from '../UploadProgressIndicator'; -describe('UploadProgressIndicator', () => { - it('should render an inactive UploadProgressIndicator', async () => { +import { AttachmentUploadProgressIndicator } from '../components/AttachmentPreview/AttachmentUploadProgressIndicator'; + +describe('AttachmentUploadProgressIndicator', () => { + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('should render an inactive AttachmentUploadProgressIndicator', async () => { const action = jest.fn(); render( - + , ); @@ -23,12 +32,15 @@ describe('UploadProgressIndicator', () => { }); }); - it('should render an active UploadProgressIndicator', async () => { + it('should render an active AttachmentUploadProgressIndicator', async () => { const action = jest.fn(); render( - + , ); @@ -39,12 +51,15 @@ describe('UploadProgressIndicator', () => { }); }); - it('should render an active UploadProgressIndicator and not-supported indicator', async () => { + it('should render an active AttachmentUploadProgressIndicator and not-supported indicator', async () => { const action = jest.fn(); render( - + , ); @@ -56,12 +71,15 @@ describe('UploadProgressIndicator', () => { }); }); - it('should render an active UploadProgressIndicator and in-progress indicator', async () => { + it('should render an active AttachmentUploadProgressIndicator and in-progress indicator', async () => { const action = jest.fn(); render( - + , ); @@ -73,13 +91,12 @@ describe('UploadProgressIndicator', () => { }); }); - it('should render an active UploadProgressIndicator and retry indicator', async () => { + it('should render an active AttachmentUploadProgressIndicator and retry indicator', async () => { const action = jest.fn(); - const user = userEvent.setup(); render( - + , ); @@ -91,7 +108,9 @@ describe('UploadProgressIndicator', () => { expect(action).toHaveBeenCalledTimes(0); }); - user.press(screen.getByTestId('retry-upload-progress-indicator')); + act(() => { + fireEvent.press(screen.getByTestId('retry-upload-progress-indicator')); + }); await waitFor(() => expect(action).toHaveBeenCalledTimes(1)); }); diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js new file mode 100644 index 0000000000..00a93050cb --- /dev/null +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js @@ -0,0 +1,198 @@ +import React from 'react'; + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { OverlayProvider } from '../../../contexts'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateAudioAttachment } from '../../../mock-builders/attachments'; + +import { FileState } from '../../../utils/utils'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList'; + +jest.mock('../../../native.ts', () => { + const View = require('react-native').View; + + return { + isAudioRecorderAvailable: jest.fn(() => true), + isDocumentPickerAvailable: jest.fn(() => true), + isImageMediaLibraryAvailable: jest.fn(() => true), + isImagePickerAvailable: jest.fn(() => true), + isSoundPackageAvailable: jest.fn(() => true), + NativeHandlers: { + Sound: { + Player: View, + }, + }, + }; +}); + +const renderComponent = ({ client, channel, props }) => { + return render( + + + + + + + , + ); +}; + +describe('AudioAttachmentUploadPreview render', () => { + let client; + let channel; + + beforeEach(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + act(() => { + channel.messageComposer.attachmentManager.initState(); + }); + }); + + it('should render AudioAttachmentUploadPreview with all uploading files', async () => { + const attachments = [ + generateAudioAttachment({ + localMetadata: { + file: { + uri: 'file://audio-attachment.mp3', + }, + id: 'audio-attachment', + uploadState: FileState.UPLOADING, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); + }); + + renderComponent({ channel, client, props }); + + const { getByLabelText, getAllByTestId, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('audio-attachment-upload-preview')).toHaveLength(1); + expect(getByLabelText('audio-attachment-preview')).toBeDefined(); + expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + }); + + act(() => { + fireEvent.press(getAllByTestId('remove-upload-preview')[0]); + }); + + await waitFor(() => { + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(0); + }); + }); + + it('should render AudioAttachmentUploadPreview with all uploaded files', async () => { + const attachments = [ + generateAudioAttachment({ + localMetadata: { + file: { + uri: 'file://audio-attachment.mp3', + }, + id: 'audio-attachment', + uploadState: FileState.FINISHED, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); + }); + + renderComponent({ channel, client, props }); + + const { getByLabelText, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('audio-attachment-upload-preview')).toHaveLength(1); + expect(getByLabelText('audio-attachment-preview')).toBeDefined(); + expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); + }); + }); + + it('should render AudioAttachmentUploadPreview with all failed files', async () => { + const uploadAttachmentSpy = jest.fn(); + channel.messageComposer.attachmentManager.uploadAttachment = uploadAttachmentSpy; + const attachments = [ + generateAudioAttachment({ + localMetadata: { + file: { + uri: 'file://audio-attachment.mp3', + }, + id: 'audio-attachment', + uploadState: FileState.FAILED, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); + }); + + renderComponent({ channel, client, props }); + + const { getAllByTestId, getByLabelText, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('audio-attachment-upload-preview')).toHaveLength(1); + expect(getByLabelText('audio-attachment-preview')).toBeDefined(); + expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); + }); + + act(() => { + fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); + }); + + await waitFor(() => { + expect(queryAllByTestId('audio-attachment-upload-preview')).toHaveLength(1); + expect(getByLabelText('audio-attachment-preview')).toBeDefined(); + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(1); + expect(uploadAttachmentSpy).toHaveBeenCalled(); + }); + }); + + it('should render AudioAttachmentUploadPreview with all unsupported', async () => { + const attachments = [ + generateAudioAttachment({ + localMetadata: { + file: { + uri: 'file://audio-attachment.mp3', + }, + id: 'audio-attachment', + uploadState: FileState.BLOCKED, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); + }); + + renderComponent({ channel, client, props }); + + const { getByLabelText, queryAllByTestId, queryAllByText } = screen; + + await waitFor(() => { + expect(queryAllByTestId('audio-attachment-upload-preview')).toHaveLength(1); + expect(getByLabelText('audio-attachment-preview')).toBeDefined(); + expect(queryAllByText('Not supported')).toHaveLength(1); + }); + }); +}); diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreviewExpo.test.tsx b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreviewExpo.test.tsx index 14aec5fc23..3d77d120fc 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreviewExpo.test.tsx +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreviewExpo.test.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { act } from 'react-test-renderer'; - -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { MessageInputContext, diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreviewNative.test.tsx b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreviewNative.test.tsx index cee89ceb9e..629c5fa663 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreviewNative.test.tsx +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreviewNative.test.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { act } from 'react-test-renderer'; - -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { MessageInputContext, @@ -16,7 +14,7 @@ import type { FileUpload } from '../../../types/types'; import { AudioAttachment, AudioAttachmentProps } from '../../Attachment/AudioAttachment'; jest.mock('../../../native.ts', () => { - const View = require('react-native/Libraries/Components/View/View'); + const View = require('react-native').View; return { isSoundPackageAvailable: jest.fn(() => true), diff --git a/package/src/components/MessageInput/__tests__/CommandsButton.test.js b/package/src/components/MessageInput/__tests__/CommandsButton.test.js new file mode 100644 index 0000000000..cd226cb652 --- /dev/null +++ b/package/src/components/MessageInput/__tests__/CommandsButton.test.js @@ -0,0 +1,70 @@ +import React from 'react'; + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { OverlayProvider } from '../../../contexts'; + +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { CommandsButton } from '../CommandsButton'; + +const renderComponent = ({ client, channel, props }) => { + return render( + + + + + + + , + ); +}; + +describe('CommandsButton', () => { + let client; + let channel; + + beforeEach(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('should render component', async () => { + const props = {}; + renderComponent({ channel, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('commands-button')).toBeTruthy(); + }); + }); + + it('should call handleOnPress callback when the button is clicked if passed', async () => { + const handleOnPress = jest.fn(); + const props = { handleOnPress }; + + renderComponent({ channel, client, props }); + + const { getByTestId, queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('commands-button')).toBeTruthy(); + }); + + act(() => { + fireEvent.press(getByTestId('commands-button')); + }); + + await waitFor(() => { + expect(handleOnPress).toHaveBeenCalled(); + }); + }); +}); diff --git a/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js b/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js deleted file mode 100644 index f5a3288da2..0000000000 --- a/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js +++ /dev/null @@ -1,379 +0,0 @@ -import React from 'react'; -import { View } from 'react-native'; - -import { fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native'; - -import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; -import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; -import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; -import { generateFileUploadPreview } from '../../../mock-builders/generator/attachment'; -import { generateChannelResponse } from '../../../mock-builders/generator/channel'; -import { generateMember } from '../../../mock-builders/generator/member'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { FileState } from '../../../utils/utils'; -import { Channel } from '../../Channel/Channel'; -import { Chat } from '../../Chat/Chat'; -import { FileUploadPreview } from '../FileUploadPreview'; - -function MockedFlatList(props) { - const items = props.data.map((item, index) => { - const key = props.keyExtractor(item, index); - return {props.renderItem({ index, item })}; - }); - return {items}; -} - -jest.mock('../../../native.ts', () => { - const { View } = require('react-native'); - - return { - isAudioRecorderAvailable: jest.fn(() => true), - isDocumentPickerAvailable: jest.fn(() => true), - isImageMediaLibraryAvailable: jest.fn(() => true), - isImagePickerAvailable: jest.fn(() => true), - isSoundPackageAvailable: jest.fn(() => true), - NativeHandlers: { - Sound: { - Player: View, - }, - }, - }; -}); - -describe('FileUploadPreview', () => { - it('should render FileUploadPreview with all uploading files', async () => { - const fileUploads = [ - generateFileUploadPreview({ id: 'file-upload-id-1', state: FileState.UPLOADING }), - generateFileUploadPreview({ id: 'file-upload-id-2', state: FileState.UPLOADING }), - generateFileUploadPreview({ id: 'file-upload-id-3', state: FileState.UPLOADING }), - generateFileUploadPreview({ id: 'file-upload-id-4', state: FileState.UPLOADING }), - ]; - const removeFile = jest.fn(); - const uploadFile = jest.fn(); - const user = userEvent.setup(); - - const user1 = generateUser(); - - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], - }); - - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); - - await waitFor(() => { - expect(screen.queryAllByTestId('active-upload-progress-indicator')).toHaveLength( - fileUploads.length, - ); - expect(screen.queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByTestId('upload-progress-indicator')).toHaveLength(fileUploads.length); - expect(screen.queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByText('File type not supported')).toHaveLength(0); - expect(removeFile).toHaveBeenCalledTimes(0); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - - user.press(screen.getAllByTestId('remove-file-upload-preview')[0]); - - await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - }); - - it('should render FileUploadPreview with all uploaded files', async () => { - const fileUploads = [ - generateFileUploadPreview({ id: 'file-upload-id-1', state: FileState.UPLOADED }), - generateFileUploadPreview({ id: 'file-upload-id-2', state: FileState.UPLOADED }), - generateFileUploadPreview({ id: 'file-upload-id-3', state: FileState.UPLOADED }), - generateFileUploadPreview({ id: 'file-upload-id-4', state: FileState.UPLOADED }), - ]; - const removeFile = jest.fn(); - const uploadFile = jest.fn(); - const user = userEvent.setup(); - - const user1 = generateUser(); - - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], - }); - - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); - - await waitFor(() => { - expect(screen.queryAllByTestId('active-upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength( - fileUploads.length, - ); - expect(screen.queryAllByTestId('upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByText('File type not supported')).toHaveLength(0); - expect(removeFile).toHaveBeenCalledTimes(0); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - - user.press(screen.getAllByTestId('remove-file-upload-preview')[0]); - - await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - }); - - it('should render FileUploadPreview with all failed files', async () => { - const fileUploads = [ - generateFileUploadPreview({ id: 'file-upload-id-1', state: FileState.UPLOAD_FAILED }), - generateFileUploadPreview({ id: 'file-upload-id-2', state: FileState.UPLOAD_FAILED }), - generateFileUploadPreview({ id: 'file-upload-id-3', state: FileState.UPLOAD_FAILED }), - generateFileUploadPreview({ id: 'file-upload-id-4', state: FileState.UPLOAD_FAILED }), - ]; - const removeFile = jest.fn(); - const uploadFile = jest.fn(); - - const user1 = generateUser(); - const user = userEvent.setup(); - - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], - }); - - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); - - await waitFor(() => { - expect(screen.queryAllByTestId('active-upload-progress-indicator')).toHaveLength( - fileUploads.length, - ); - expect(screen.queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByTestId('upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByTestId('retry-upload-progress-indicator')).toHaveLength( - fileUploads.length, - ); - expect(screen.queryAllByText('File type not supported')).toHaveLength(0); - expect(removeFile).toHaveBeenCalledTimes(0); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - - user.press(screen.getAllByTestId('remove-file-upload-preview')[0]); - - await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - - user.press(screen.getAllByTestId('retry-upload-progress-indicator')[0]); - - await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(1); - }); - }); - - it('should render FileUploadPreview with all unsupported files', async () => { - const fileUploads = [ - generateFileUploadPreview({ id: 'file-upload-id-1', state: FileState.NOT_SUPPORTED }), - generateFileUploadPreview({ id: 'file-upload-id-2', state: FileState.NOT_SUPPORTED }), - generateFileUploadPreview({ id: 'file-upload-id-3', state: FileState.NOT_SUPPORTED }), - generateFileUploadPreview({ id: 'file-upload-id-4', state: FileState.NOT_SUPPORTED }), - ]; - const removeFile = jest.fn(); - const uploadFile = jest.fn(); - - const user1 = generateUser(); - const user = userEvent.setup(); - - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], - }); - - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); - - await waitFor(() => { - expect(screen.queryAllByTestId('active-upload-progress-indicator')).toHaveLength( - fileUploads.length, - ); - expect(screen.queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByTestId('upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(0); - expect(screen.queryAllByText('File type not supported')).toHaveLength(fileUploads.length); - expect(removeFile).toHaveBeenCalledTimes(0); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - - user.press(screen.getAllByTestId('remove-file-upload-preview')[0]); - - await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - }); - - it('should render FileUploadPreview with 1 uploading, 1 uploaded, and 1 failed file', async () => { - const fileUploads = [ - generateFileUploadPreview({ id: 'file-upload-id-1', state: FileState.UPLOADING }), - generateFileUploadPreview({ id: 'file-upload-id-2', state: FileState.UPLOADED }), - generateFileUploadPreview({ id: 'file-upload-id-3', state: FileState.UPLOAD_FAILED }), - generateFileUploadPreview({ id: 'file-upload-id-4', state: FileState.NOT_SUPPORTED }), - ]; - const removeFile = jest.fn(); - const uploadFile = jest.fn(); - - const user1 = generateUser(); - - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], - }); - - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); - await waitFor(() => { - expect(screen.queryAllByTestId('active-upload-progress-indicator')).toHaveLength( - fileUploads.length - 1, - ); - expect(screen.queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); - expect(screen.queryAllByTestId('upload-progress-indicator')).toHaveLength(1); - expect(screen.queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); - expect(screen.queryAllByText('File type not supported')).toHaveLength(1); - expect(removeFile).toHaveBeenCalledTimes(0); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); - }); - - it('should render FileUploadPreview with all uploaded audios', async () => { - const fileUploads = [ - generateFileUploadPreview({ - id: 'file-upload-id-1', - state: FileState.UPLOADED, - type: 'audio/mp3', - }), - ]; - const removeFile = jest.fn(); - const uploadFile = jest.fn(); - - const user1 = generateUser(); - - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], - }); - - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); - - const AudioAttachmentComponent = screen.getByTestId('audio-attachment-upload-preview'); - - await waitFor(() => { - fireEvent(AudioAttachmentComponent, 'onLoad'); - fireEvent(AudioAttachmentComponent, 'onProgress'); - fireEvent(AudioAttachmentComponent, 'onPlayPause'); - fireEvent(AudioAttachmentComponent, 'onPlayPause', { - status: false, - }); - }); - }); -}); diff --git a/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js b/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js deleted file mode 100644 index 28ec435c4a..0000000000 --- a/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js +++ /dev/null @@ -1,214 +0,0 @@ -import React from 'react'; - -import { fireEvent, render, waitFor } from '@testing-library/react-native'; - -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { generateImageUploadPreview } from '../../../mock-builders/generator/attachment'; -import { FileState } from '../../../utils/utils'; - -import { ImageUploadPreview } from '../ImageUploadPreview'; - -describe('ImageUploadPreview', () => { - it('should render ImageUploadPreview with all uploading images', async () => { - const imageUploads = [ - generateImageUploadPreview({ id: 'image-upload-preview-1', state: FileState.UPLOADING }), - generateImageUploadPreview({ id: 'image-upload-preview-2', state: FileState.UPLOADING }), - generateImageUploadPreview({ id: 'image-upload-preview-3', state: FileState.UPLOADING }), - generateImageUploadPreview({ id: 'image-upload-preview-4', state: FileState.UPLOADING }), - ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { getAllByTestId, queryAllByTestId, queryAllByText } = render( - - - , - ); - - await waitFor(() => { - expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength( - imageUploads.length, - ); - expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(0); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(imageUploads.length); - expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(0); - expect(queryAllByText('Not supported')).toHaveLength(0); - expect(removeImage).toHaveBeenCalledTimes(0); - expect(uploadImage).toHaveBeenCalledTimes(0); - }); - - fireEvent.press(getAllByTestId('remove-image-upload-preview')[0]); - - await waitFor(() => { - expect(removeImage).toHaveBeenCalledTimes(1); - expect(uploadImage).toHaveBeenCalledTimes(0); - }); - }); - - it('should render ImageUploadPreview with all uploaded images', async () => { - const imageUploads = [ - generateImageUploadPreview({ id: 'image-upload-preview-1', state: FileState.UPLOADED }), - generateImageUploadPreview({ id: 'image-upload-preview-2', state: FileState.UPLOADED }), - generateImageUploadPreview({ id: 'image-upload-preview-3', state: FileState.UPLOADED }), - generateImageUploadPreview({ id: 'image-upload-preview-4', state: FileState.UPLOADED }), - ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { getAllByTestId, queryAllByTestId, queryAllByText } = render( - - - , - ); - - await waitFor(() => { - expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength(0); - expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength( - imageUploads.length, - ); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(0); - expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(0); - expect(queryAllByText('Not supported')).toHaveLength(0); - expect(removeImage).toHaveBeenCalledTimes(0); - expect(uploadImage).toHaveBeenCalledTimes(0); - }); - - fireEvent.press(getAllByTestId('remove-image-upload-preview')[0]); - - await waitFor(() => { - expect(removeImage).toHaveBeenCalledTimes(1); - expect(uploadImage).toHaveBeenCalledTimes(0); - }); - }); - - it('should render ImageUploadPreview with all failed images', async () => { - const imageUploads = [ - generateImageUploadPreview({ id: 'image-upload-preview-1', state: FileState.UPLOAD_FAILED }), - generateImageUploadPreview({ id: 'image-upload-preview-2', state: FileState.UPLOAD_FAILED }), - generateImageUploadPreview({ id: 'image-upload-preview-3', state: FileState.UPLOAD_FAILED }), - generateImageUploadPreview({ id: 'image-upload-preview-4', state: FileState.UPLOAD_FAILED }), - ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { getAllByTestId, queryAllByTestId, queryAllByText } = render( - - - , - ); - - await waitFor(() => { - expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength( - imageUploads.length, - ); - expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(0); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(0); - expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(imageUploads.length); - expect(queryAllByText('Not supported')).toHaveLength(0); - expect(removeImage).toHaveBeenCalledTimes(0); - expect(uploadImage).toHaveBeenCalledTimes(0); - }); - - fireEvent.press(getAllByTestId('remove-image-upload-preview')[0]); - - await waitFor(() => { - expect(removeImage).toHaveBeenCalledTimes(1); - expect(uploadImage).toHaveBeenCalledTimes(0); - }); - - fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); - - await waitFor(() => { - expect(removeImage).toHaveBeenCalledTimes(1); - expect(uploadImage).toHaveBeenCalledTimes(1); - }); - }); - - it('should render ImageUploadPreview with all unsupported', async () => { - const imageUploads = [ - generateImageUploadPreview({ - id: 'image-upload-preview-1', - state: FileState.NOT_SUPPORTED, - }), - generateImageUploadPreview({ - id: 'image-upload-preview-2', - state: FileState.NOT_SUPPORTED, - }), - generateImageUploadPreview({ - id: 'image-upload-preview-3', - state: FileState.NOT_SUPPORTED, - }), - generateImageUploadPreview({ - id: 'image-upload-preview-4', - state: FileState.NOT_SUPPORTED, - }), - ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { queryAllByTestId, queryAllByText } = render( - - - , - ); - - await waitFor(() => { - expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength( - imageUploads.length, - ); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(0); - expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(0); - expect(queryAllByText('Not supported')).toHaveLength(imageUploads.length); - expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(0); - - expect(removeImage).toHaveBeenCalledTimes(0); - expect(uploadImage).toHaveBeenCalledTimes(0); - }); - }); - - it('should render ImageUploadPreview with 1 uploading, 1 uploaded, and 1 failed image, and 1 unsupported', async () => { - const imageUploads = [ - generateImageUploadPreview({ id: 'image-upload-preview-1', state: FileState.UPLOADING }), - generateImageUploadPreview({ id: 'image-upload-preview-2', state: FileState.UPLOADED }), - generateImageUploadPreview({ id: 'image-upload-preview-3', state: FileState.UPLOAD_FAILED }), - generateImageUploadPreview({ id: 'image-upload-preview-4', state: FileState.NOT_SUPPORTED }), - ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { queryAllByTestId, queryAllByText } = render( - - - , - ); - - await waitFor(() => { - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); - expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); - expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); - expect(queryAllByText('Not supported')).toHaveLength(1); - expect(removeImage).toHaveBeenCalledTimes(0); - expect(uploadImage).toHaveBeenCalledTimes(0); - }); - }); -}); diff --git a/package/src/components/MessageInput/__tests__/InputButtons.test.js b/package/src/components/MessageInput/__tests__/InputButtons.test.js new file mode 100644 index 0000000000..501fb72dba --- /dev/null +++ b/package/src/components/MessageInput/__tests__/InputButtons.test.js @@ -0,0 +1,207 @@ +import React from 'react'; + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { OverlayProvider } from '../../../contexts'; + +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateImageAttachment } from '../../../mock-builders/attachments'; +import { FileState } from '../../../utils/utils'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { InputButtons } from '../InputButtons'; + +const renderComponent = ({ channelProps, client, props }) => { + return render( + + + + + + + , + ); +}; + +describe('InputButtons', () => { + let client; + let channel; + + beforeEach(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + + act(() => { + channel.messageComposer.clear(); + }); + }); + + // TODO: Add it back once the command inject PR is merged + it.skip('should return null if the commands are set on the textComposer', async () => { + const props = {}; + const channelProps = { channel }; + + channel.messageComposer.textComposer.setCommand({ description: 'Ban a user', name: 'ban' }); + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('more-options-button')).toBeFalsy(); + expect(queryByTestId('commands-button')).toBeFalsy(); + expect(queryByTestId('attach-button')).toBeFalsy(); + }); + }); + + it('should return null if hasCommands is false and hasAttachmentUploadCapabilities is false', async () => { + const props = {}; + const channelProps = { + channel, + hasCommands: false, + overrideOwnCapabilities: { + uploadFile: false, + }, + }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('more-options-button')).toBeFalsy(); + expect(queryByTestId('commands-button')).toBeFalsy(); + expect(queryByTestId('attach-button')).toBeFalsy(); + }); + }); + + it('should show more options when the hasCommand is true and the hasAttachmentUploadCapabilities is true', async () => { + const props = {}; + const channelProps = { + channel, + }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('commands-button')).toBeTruthy(); + expect(queryByTestId('attach-button')).toBeTruthy(); + }); + }); + + it('should show only attach button when the hasCommand is false and the hasAttachmentUploadCapabilities is true', async () => { + const props = {}; + const channelProps = { + channel, + hasCommands: false, + }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('commands-button')).toBeFalsy(); + expect(queryByTestId('attach-button')).toBeTruthy(); + }); + }); + + it('should show only commands button when the hasCommand is true and the hasAttachmentUploadCapabilities is false', async () => { + const props = {}; + const channelProps = { + channel, + overrideOwnCapabilities: { + uploadFile: false, + }, + }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('commands-button')).toBeTruthy(); + expect(queryByTestId('attach-button')).toBeFalsy(); + }); + }); + + it('should not show commands buttons when there is text in the textComposer', async () => { + const props = {}; + const channelProps = { + channel, + }; + channel.messageComposer.textComposer.setText('hello'); + renderComponent({ channelProps, client, props }); + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('commands-button')).toBeFalsy(); + }); + }); + + it('should show more options button when there is text in the textComposer', async () => { + const props = {}; + const channelProps = { + channel, + }; + channel.messageComposer.textComposer.setText('hello'); + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('more-options-button')).toBeTruthy(); + }); + + act(() => { + fireEvent.press(queryByTestId('more-options-button')); + }); + + await waitFor(() => { + // Falsy, because the textComposer has text. This is a good test. + expect(queryByTestId('commands-button')).toBeFalsy(); + expect(queryByTestId('attach-button')).toBeTruthy(); + }); + }); + + it('should show more options button when there is attachments', async () => { + const props = {}; + const channelProps = { + channel, + }; + channel.messageComposer.attachmentManager.upsertAttachments([ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.UPLOADING, + }, + }), + ]); + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('more-options-button')).toBeTruthy(); + }); + + act(() => { + fireEvent.press(queryByTestId('more-options-button')); + }); + + await waitFor(() => { + expect(queryByTestId('commands-button')).toBeTruthy(); + expect(queryByTestId('attach-button')).toBeTruthy(); + }); + }); +}); diff --git a/package/src/components/MessageInput/__tests__/MessageInput.test.js b/package/src/components/MessageInput/__tests__/MessageInput.test.js index dc3749ead0..4243d7eba6 100644 --- a/package/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/package/src/components/MessageInput/__tests__/MessageInput.test.js @@ -1,22 +1,14 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Alert } from 'react-native'; -import { act, cleanup, fireEvent, render, userEvent, waitFor } from '@testing-library/react-native'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; -import { useMessagesContext } from '../../../contexts'; import * as AttachmentPickerUtils from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; -import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; -import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; -import { - generateFileAttachment, - generateImageAttachment, -} from '../../../mock-builders/generator/attachment'; -import { generateChannelResponse } from '../../../mock-builders/generator/channel'; -import { generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; + import { NativeHandlers } from '../../../native'; import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar'; import { CameraSelectorIcon } from '../../AttachmentPicker/components/CameraSelectorIcon'; @@ -27,84 +19,67 @@ import { Chat } from '../../Chat/Chat'; import { CreatePollIcon } from '../../Poll'; import { MessageInput } from '../MessageInput'; -describe('MessageInput', () => { - jest.spyOn(Alert, 'alert'); - jest.spyOn(AttachmentPickerUtils, 'useAttachmentPickerContext').mockImplementation( - jest.fn(() => ({ - AttachmentPickerSelectionBar, - CameraSelectorIcon, - closePicker: jest.fn(), - CreatePollIcon, - FileSelectorIcon, - ImageSelectorIcon, - openPicker: jest.fn(), - selectedFiles: [ - generateFileAttachment({ name: 'Dummy.pdf', size: 500000000 }), - generateFileAttachment({ name: 'Dummy.pdf', size: 600000000 }), - ], - selectedImages: [ - generateImageAttachment({ - file: { height: 100, uri: 'https://picsum.photos/200/300', width: 100 }, - size: 500000000, - uri: 'https://picsum.photos/200/300', - }), - generateImageAttachment({ - file: { height: 100, uri: 'https://picsum.photos/200/300', width: 100 }, - size: 600000000, - uri: 'https://picsum.photos/200/300', - }), - ], - selectedPicker: 'images', - setBottomInset: jest.fn(), - setMaxNumberOfFiles: jest.fn(), - setSelectedFiles: jest.fn(), - setSelectedImages: jest.fn(), - setSelectedPicker: jest.fn(), - setTopInset: jest.fn(), - })), - ); - - const clientUser = generateUser(); - let chatClient; - let channel; - - const getComponent = () => ( +jest.spyOn(Alert, 'alert'); +jest.spyOn(AttachmentPickerUtils, 'useAttachmentPickerContext').mockImplementation( + jest.fn(() => ({ + AttachmentPickerSelectionBar, + CameraSelectorIcon, + closePicker: jest.fn(), + CreatePollIcon, + FileSelectorIcon, + ImageSelectorIcon, + openPicker: jest.fn(), + selectedPicker: 'images', + setBottomInset: jest.fn(), + setSelectedPicker: jest.fn(), + setTopInset: jest.fn(), + })), +); + +const renderComponent = ({ channelProps, client, props }) => { + return render( - - - + + + - + , ); +}; - const initializeChannel = async (c) => { - useMockedApis(chatClient, [getOrCreateChannelApi(c)]); - - channel = chatClient.channel('messaging'); - - await channel.watch(); - }; +describe('MessageInput', () => { + let client; + let channel; beforeEach(async () => { - chatClient = await getTestClientWithUser(clientUser); + jest.clearAllMocks(); + cleanup(); + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; }); afterEach(() => { - channel = null; - cleanup(); + act(() => { + channel.messageComposer.clear(); + }); }); it('should render MessageInput', async () => { - await initializeChannel(generateChannelResponse()); + const channelProps = { channel }; - const openPicker = jest.fn(); - const closePicker = jest.fn(); - const attachmentValue = { closePicker, openPicker }; + renderComponent({ + channelProps, + client, + props: {}, + }); - const { getByTestId, queryByTestId, queryByText } = render(getComponent({ attachmentValue })); + const { getByTestId, queryByTestId, queryByText } = screen; - fireEvent.press(getByTestId('attach-button')); + act(() => { + fireEvent.press(getByTestId('attach-button')); + }); await waitFor(() => { expect(queryByTestId('upload-photo-touchable')).toBeTruthy(); @@ -115,40 +90,20 @@ describe('MessageInput', () => { }); }); - it('trigger file size threshold limit alert when images size above the limit', async () => { - await initializeChannel(generateChannelResponse()); - - render( - - - - - - - , - ); - - // Both for files and for images triggered in one test itself. - await waitFor(() => { - expect(Alert.alert).toHaveBeenCalledTimes(4); - }); - }); - it('should start the audio recorder on long press and cleanup on unmount', async () => { - jest.clearAllMocks(); + renderComponent({ + channelProps: { audioRecordingEnabled: true, channel }, + client, + props: {}, + }); - await initializeChannel(generateChannelResponse()); - const userBot = userEvent.setup(); + const { queryByTestId, unmount } = screen; - const { queryByTestId, unmount } = render( - - - - - , - ); + const audioButton = queryByTestId('audio-button'); - await userBot.longPress(queryByTestId('audio-button'), { duration: 1000 }); + act(() => { + fireEvent(audioButton, 'longPress'); + }); await waitFor(() => { expect(NativeHandlers.Audio.startRecording).toHaveBeenCalledTimes(1); @@ -157,7 +112,9 @@ describe('MessageInput', () => { expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.'); }); - unmount(); + await act(() => { + unmount(); + }); await waitFor(() => { expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1); @@ -167,20 +124,19 @@ describe('MessageInput', () => { }); it('should trigger an alert if a normal press happened on audio recording', async () => { - jest.clearAllMocks(); + renderComponent({ + channelProps: { audioRecordingEnabled: true, channel }, + client, + props: {}, + }); - await initializeChannel(generateChannelResponse()); - const userBot = userEvent.setup(); + const { queryByTestId } = screen; - const { queryByTestId } = render( - - - - - , - ); + const audioButton = queryByTestId('audio-button'); - await userBot.press(queryByTestId('audio-button')); + act(() => { + fireEvent.press(audioButton); + }); await waitFor(() => { expect(NativeHandlers.Audio.startRecording).not.toHaveBeenCalled(); @@ -191,117 +147,4 @@ describe('MessageInput', () => { expect(Alert.alert).toHaveBeenCalledWith('Hold to start recording.'); }); }); - - it('should render the SendMessageDisallowedIndicator if the send-message capability is not present', async () => { - await initializeChannel(generateChannelResponse()); - - const { queryByTestId } = render( - - - - - , - ); - - await waitFor(() => { - expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); - }); - - act(() => { - chatClient.dispatchEvent({ - cid: channel.data.cid, - own_capabilities: channel.data.own_capabilities.filter( - (capability) => capability !== 'send-message', - ), - type: 'capabilities.changed', - }); - }); - - await waitFor(() => { - expect(queryByTestId('send-message-disallowed-indicator')).toBeTruthy(); - }); - }); - - it('should not render the SendMessageDisallowedIndicator if the channel is frozen and the send-message capability is present', async () => { - await initializeChannel(generateChannelResponse({ channel: { frozen: true } })); - - const { queryByTestId } = render( - - - - - , - ); - - await waitFor(() => { - expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); - }); - }); - - it('should render the SendMessageDisallowedIndicator in a frozen channel only if the send-message capability is not present', async () => { - await initializeChannel(generateChannelResponse({ channel: { frozen: true } })); - - const { queryByTestId } = render( - - - - - , - ); - - act(() => { - chatClient.dispatchEvent({ - channel: { - ...channel.data, - own_capabilities: channel.data.own_capabilities.filter( - (capability) => capability !== 'send-message', - ), - }, - cid: channel.data.cid, - type: 'channel.updated', - }); - }); - - await waitFor(() => { - expect(queryByTestId('send-message-disallowed-indicator')).toBeTruthy(); - }); - }); - - const EditingStateMessageInput = () => { - const { setEditingState } = useMessagesContext(); - useEffect(() => { - setEditingState({ id: 'some-message-id' }); - }, [setEditingState]); - return ; - }; - - it('should not render the SendMessageDisallowedIndicator if we are editing a message, regardless of capabilities', async () => { - await initializeChannel(generateChannelResponse()); - - const { queryByTestId } = render( - - - - - , - ); - - await waitFor(() => { - expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); - }); - - act(() => { - chatClient.dispatchEvent({ - cid: channel.data.cid, - own_capabilities: channel.data.own_capabilities.filter( - (capability) => capability !== 'send-message', - ), - type: 'capabilities.changed', - }); - }); - - await waitFor(() => { - expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); - }); - }); }); diff --git a/package/src/components/MessageInput/__tests__/SendButton.test.js b/package/src/components/MessageInput/__tests__/SendButton.test.js index 561d126d7e..e501588eb6 100644 --- a/package/src/components/MessageInput/__tests__/SendButton.test.js +++ b/package/src/components/MessageInput/__tests__/SendButton.test.js @@ -1,58 +1,67 @@ import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; -import { MessagesProvider } from '../../../contexts/messagesContext/MessagesContext'; -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { OverlayProvider } from '../../../contexts'; + +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; import { SendButton } from '../SendButton'; -describe('SendButton', () => { - const getComponent = ({ editing, ...rest } = {}) => ( - - - - - +const renderComponent = ({ client, channel, props }) => { + return render( + + + + + + + , ); +}; - it('should render a non-editing enabled SendButton', async () => { - const sendMessage = jest.fn(); - - const { getByTestId, queryByTestId, toJSON } = render( - getComponent({ editing: false, sendMessage }), - ); - - await waitFor(() => { - expect(queryByTestId('send-button')).toBeTruthy(); - expect(sendMessage).toHaveBeenCalledTimes(0); - }); - - fireEvent.press(getByTestId('send-button')); +describe('SendButton', () => { + let client; + let channel; - await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); + beforeEach(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); - const snapshot = toJSON(); + afterEach(() => { + jest.clearAllMocks(); + cleanup(); - await waitFor(() => { - expect(snapshot).toMatchSnapshot(); + act(() => { + channel.messageComposer.clear(); }); }); - it('should render a non-editing disabled SendButton', async () => { + it('should render a SendButton', async () => { const sendMessage = jest.fn(); - const { getByTestId, queryByTestId, toJSON } = render( - getComponent({ disabled: true, editing: false, sendMessage }), - ); + const props = { sendMessage }; + + renderComponent({ channel, client, props }); + + const { getByTestId, queryByTestId, toJSON } = screen; await waitFor(() => { expect(queryByTestId('send-button')).toBeTruthy(); expect(sendMessage).toHaveBeenCalledTimes(0); }); - fireEvent.press(getByTestId('send-button')); + act(() => { + fireEvent.press(getByTestId('send-button')); + }); - await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(0)); + await waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(getByTestId('send-up')).toBeDefined(); + }); const snapshot = toJSON(); @@ -61,21 +70,28 @@ describe('SendButton', () => { }); }); - it('should render an editing enabled SendButton', async () => { + it('should render a disabled SendButton', async () => { const sendMessage = jest.fn(); - const { getByTestId, queryByTestId, toJSON } = render( - getComponent({ editing: true, sendMessage }), - ); + const props = { disabled: true, sendMessage }; + + renderComponent({ channel, client, props }); + + const { getByTestId, queryByTestId, toJSON } = screen; await waitFor(() => { expect(queryByTestId('send-button')).toBeTruthy(); expect(sendMessage).toHaveBeenCalledTimes(0); }); - fireEvent.press(getByTestId('send-button')); + act(() => { + fireEvent.press(getByTestId('send-button')); + }); - await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); + await waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(0); + expect(getByTestId('send-right')).toBeDefined(); + }); const snapshot = toJSON(); @@ -84,26 +100,20 @@ describe('SendButton', () => { }); }); - it('should render an editing disabled SendButton', async () => { + // TODO: Add it back once the command inject PR is merged + it.skip('should show search button if the command is enabled', async () => { const sendMessage = jest.fn(); - const { getByTestId, queryByTestId, toJSON } = render( - getComponent({ disabled: true, editing: true, sendMessage }), - ); - - await waitFor(() => { - expect(queryByTestId('send-button')).toBeTruthy(); - expect(sendMessage).toHaveBeenCalledTimes(0); - }); + const props = { sendMessage }; - fireEvent.press(getByTestId('send-button')); + channel.messageComposer.textComposer.setCommand({ description: 'Ban a user', name: 'ban' }); - await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(0)); + renderComponent({ channel, client, props }); - const snapshot = toJSON(); + const { queryByTestId } = screen; await waitFor(() => { - expect(snapshot).toMatchSnapshot(); + expect(queryByTestId('search-icon')).toBeTruthy(); }); }); }); diff --git a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js new file mode 100644 index 0000000000..c7613cd31e --- /dev/null +++ b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js @@ -0,0 +1,187 @@ +import React from 'react'; + +import { Alert } from 'react-native'; + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; + +import { MessageComposer } from 'stream-chat'; + +import * as AttachmentPickerUtils from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; +import * as UseMessageComposerHooks from '../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; + +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; + +import { generateLocalFileUploadAttachmentData } from '../../../mock-builders/attachments'; + +import { generateMessage } from '../../../mock-builders/generator/message'; + +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { MessageInput } from '../MessageInput'; + +jest.spyOn(Alert, 'alert'); +jest.spyOn(AttachmentPickerUtils, 'useAttachmentPickerContext').mockImplementation( + jest.fn(() => ({ + closePicker: jest.fn(), + openPicker: jest.fn(), + selectedPicker: 'images', + setBottomInset: jest.fn(), + setSelectedPicker: jest.fn(), + setTopInset: jest.fn(), + })), +); + +const renderComponent = ({ channelProps, client, props }) => { + return render( + + + + + + + , + ); +}; + +const editedMessageSetup = async ({ composerConfig, composition } = {}) => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + const channel = channels[0]; + + const messageComposer = new MessageComposer({ + client: chatClient, + composition, + compositionContext: composition, + config: composerConfig, + }); + + messageComposer.registerSubscriptions(); + jest.spyOn(UseMessageComposerHooks, 'useMessageComposer').mockReturnValue(messageComposer); + + return { channel, chatClient, messageComposer }; +}; + +describe('SendMessageDisallowedIndicator', () => { + let client; + let channel; + + beforeEach(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + act(() => { + channel.messageComposer.clear(); + }); + }); + + it('should render the SendMessageDisallowedIndicator if the send-message capability is not present', async () => { + const props = {}; + const channelProps = { audioRecordingEnabled: true, channel }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); + }); + + act(() => { + client.dispatchEvent({ + cid: channel.data.cid, + own_capabilities: channel.data.own_capabilities.filter( + (capability) => capability !== 'send-message', + ), + type: 'capabilities.changed', + }); + }); + + await waitFor(() => { + expect(queryByTestId('send-message-disallowed-indicator')).toBeTruthy(); + }); + }); + + it('should not render the SendMessageDisallowedIndicator if the channel is frozen and the send-message capability is present', async () => { + const props = {}; + const channelProps = { channel }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); + }); + }); + + it('should render the SendMessageDisallowedIndicator in a frozen channel only if the send-message capability is not present', async () => { + const props = {}; + const channelProps = { channel }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; + + act(() => { + client.dispatchEvent({ + channel: { + ...channel.data, + own_capabilities: channel.data.own_capabilities.filter( + (capability) => capability !== 'send-message', + ), + }, + cid: channel.data.cid, + type: 'channel.updated', + }); + }); + + await waitFor(() => { + expect(queryByTestId('send-message-disallowed-indicator')).toBeTruthy(); + }); + }); +}); + +describe("SendMessageDisallowedIndicator's edited state", () => { + it('should not render the SendMessageDisallowedIndicator if we are editing a message, regardless of capabilities', async () => { + const message = generateMessage({ + attachments: [generateLocalFileUploadAttachmentData()], + cid: 'messaging:channel-id', + text: 'test', + }); + + const { channel: customChannel, chatClient } = await editedMessageSetup({ + composition: message, + }); + + await renderComponent({ + channelProps: { channel: customChannel }, + client: chatClient, + props: {}, + }); + + const { queryByTestId } = screen; + + await waitFor(() => { + expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); + }); + + act(() => { + chatClient.dispatchEvent({ + cid: customChannel.data.cid, + own_capabilities: customChannel.data.own_capabilities.filter( + (capability) => capability !== 'send-message', + ), + type: 'capabilities.changed', + }); + }); + + await waitFor(() => { + expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); + }); + }); +}); diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap index e5908a1df0..01394e1138 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -1,95 +1,88 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AttachButton should render a disabled AttachButton 1`] = ` +exports[`AttachButton should call handleAttachButtonPress when the button is clicked if passed 1`] = ` - - - - - - - - - - - - + > + + + + + + + + + + + + + + + + `; -exports[`AttachButton should render an enabled AttachButton 1`] = ` +exports[`AttachButton should render a enabled AttachButton 1`] = ` - - - - - - - + + + + + + + + + + + + + + + + + + +`; + +exports[`AttachButton should render an disabled AttachButton 1`] = ` + + + + + - - - - - + > + + + + + + + + + + + + + + + + `; diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap index 8f5786422b..b46c4162bf 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap @@ -1,465 +1,371 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SendButton should render a non-editing disabled SendButton 1`] = ` +exports[`SendButton should render a SendButton 1`] = ` - - - - + - - - -`; - -exports[`SendButton should render a non-editing enabled SendButton 1`] = ` - - + + + + + + + - - - - - + + + `; -exports[`SendButton should render an editing disabled SendButton 1`] = ` +exports[`SendButton should render a disabled SendButton 1`] = ` - - - - + - - - -`; - -exports[`SendButton should render an editing enabled SendButton 1`] = ` - - + + + + + + + - - - - - + + + `; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx new file mode 100644 index 0000000000..6ff419c2f3 --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { Warning } from '../../../../icons/Warning'; +import { Progress, ProgressIndicatorTypes } from '../../../../utils/utils'; + +const WARNING_ICON_SIZE = 16; + +export type AttachmentUnsupportedIndicatorProps = { + /** Type of active indicator */ + indicatorType?: Progress; + /** Boolean to determine whether the attachment is an image */ + isImage?: boolean; +}; + +export const AttachmentUnsupportedIndicator = ({ + indicatorType, + isImage = false, +}: AttachmentUnsupportedIndicatorProps) => { + const { + theme: { + colors: { accent_red, grey_dark, overlay, white }, + messageInput: { + attachmentUnsupportedIndicator: { container, text, warningIcon }, + }, + }, + } = useTheme(); + + const { t } = useTranslationContext(); + + if (indicatorType !== ProgressIndicatorTypes.NOT_SUPPORTED) { + return null; + } + + return ( + + + + {t('Not supported')} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + marginTop: 4, + paddingHorizontal: 2, + }, + imageStyle: { + borderRadius: 16, + bottom: 8, + position: 'absolute', + }, + warningIconStyle: { + borderRadius: 24, + marginTop: 6, + }, + warningText: { + alignItems: 'center', + color: 'black', + fontSize: 10, + justifyContent: 'center', + marginHorizontal: 4, + }, +}); diff --git a/package/src/components/MessageInput/UploadProgressIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx similarity index 74% rename from package/src/components/MessageInput/UploadProgressIndicator.tsx rename to package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx index dc7194d095..23660a2bfb 100644 --- a/package/src/components/MessageInput/UploadProgressIndicator.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator.tsx @@ -1,31 +1,33 @@ import React, { PropsWithChildren } from 'react'; import { ActivityIndicator, - GestureResponderEvent, + Pressable, + PressableProps, StyleProp, StyleSheet, - TouchableOpacity, View, ViewStyle, } from 'react-native'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { Refresh } from '../../icons'; -import { ProgressIndicatorTypes } from '../../utils/utils'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { Refresh } from '../../../../icons'; +import { Progress, ProgressIndicatorTypes } from '../../../../utils/utils'; const REFRESH_ICON_SIZE = 18; -export type UploadProgressIndicatorProps = { +export type AttachmentUploadProgressIndicatorProps = { /** Action triggered when clicked indicator */ - action?: (event: GestureResponderEvent) => void; + onPress?: PressableProps['onPress']; /** style */ style?: StyleProp; /** Type of active indicator */ - type?: 'in_progress' | 'retry' | 'not_supported' | 'inactive' | null; + type?: Progress; }; -export const UploadProgressIndicator = (props: PropsWithChildren) => { - const { action, children, style, type } = props; +export const AttachmentUploadProgressIndicator = ( + props: PropsWithChildren, +) => { + const { onPress, children, style, type } = props; const { theme: { @@ -52,7 +54,7 @@ export const UploadProgressIndicator = (props: PropsWithChildren {type === ProgressIndicatorTypes.IN_PROGRESS && } - {type === ProgressIndicatorTypes.RETRY && } + {type === ProgressIndicatorTypes.RETRY && } ); @@ -75,7 +77,7 @@ const InProgressIndicator = () => { ); }; -const RetryIndicator = ({ action }: Pick) => { +const RetryIndicator = ({ onPress }: Pick) => { const { theme: { colors: { white_smoke }, @@ -86,14 +88,17 @@ const RetryIndicator = ({ action }: Pick } = useTheme(); return ( - + [styles.retryButtonContainer, { opacity: pressed ? 0.8 : 1 }]} + > - + ); }; @@ -136,5 +141,5 @@ const styles = StyleSheet.create({ }, }); -UploadProgressIndicator.displayName = - 'UploadProgressIndicator{messageInput{uploadProgressIndicator}}'; +AttachmentUploadProgressIndicator.displayName = + 'AttachmentUploadProgressIndicator{messageInput{uploadProgressIndicator}}'; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx new file mode 100644 index 0000000000..c80dc565df --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -0,0 +1,97 @@ +import React, { useCallback, useMemo } from 'react'; + +import { StyleSheet, View } from 'react-native'; + +import { FileReference, LocalAudioAttachment, LocalVoiceRecordingAttachment } from 'stream-chat'; + +import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator'; +import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; +import { DismissAttachmentUpload } from './DismissAttachmentUpload'; + +import { AudioAttachment } from '../../../../components/Attachment/AudioAttachment'; +import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; +import { AudioConfig, UploadAttachmentPreviewProps } from '../../../../types/types'; +import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; + +export type AudioAttachmentUploadPreviewProps> = + UploadAttachmentPreviewProps< + LocalAudioAttachment | LocalVoiceRecordingAttachment + > & { + audioAttachmentConfig: AudioConfig; + onLoad: (index: string, duration: number) => void; + onPlayPause: (index: string, pausedStatus?: boolean) => void; + onProgress: (index: string, progress: number) => void; + }; + +export const AudioAttachmentUploadPreview = ({ + attachment, + audioAttachmentConfig, + handleRetry, + removeAttachments, + onLoad, + onPlayPause, + onProgress, +}: AudioAttachmentUploadPreviewProps) => { + const { enableOfflineSupport } = useChatContext(); + const indicatorType = getIndicatorTypeForFileState( + attachment.localMetadata.uploadState, + enableOfflineSupport, + ); + + const finalAttachment = useMemo( + () => ({ + ...attachment, + asset_url: (attachment.localMetadata.file as FileReference).uri, + id: attachment.localMetadata.id, + ...audioAttachmentConfig, + }), + [attachment, audioAttachmentConfig], + ); + + const onRetryHandler = useCallback(() => { + handleRetry(attachment); + }, [attachment, handleRetry]); + + const onDismissHandler = useCallback(() => { + removeAttachments([attachment.localMetadata.id]); + }, [attachment, removeAttachments]); + + return ( + + + + + + + + {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + dismissWrapper: { + position: 'absolute', + right: 8, + top: 0, + }, + overlay: { + borderRadius: 12, + marginHorizontal: 8, + marginTop: 2, + }, +}); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx b/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx new file mode 100644 index 0000000000..43158d4b92 --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { Pressable, PressableProps, StyleSheet } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { Close } from '../../../../icons'; + +type DismissAttachmentUploadProps = PressableProps; + +export const DismissAttachmentUpload = ({ onPress }: DismissAttachmentUploadProps) => { + const { + theme: { + colors: { overlay, white }, + messageInput: { + dismissAttachmentUpload: { dismiss, dismissIcon, dismissIconColor }, + }, + }, + } = useTheme(); + + return ( + [ + styles.dismiss, + { backgroundColor: overlay, opacity: pressed ? 0.8 : 1 }, + dismiss, + ]} + testID='remove-upload-preview' + > + + + ); +}; + +const styles = StyleSheet.create({ + dismiss: { + borderRadius: 24, + position: 'absolute', + right: 8, + top: 8, + }, +}); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx new file mode 100644 index 0000000000..856bc31e87 --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -0,0 +1,161 @@ +import React, { useCallback } from 'react'; + +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import { LocalAudioAttachment, LocalFileAttachment, LocalVideoAttachment } from 'stream-chat'; + +import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator'; +import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; +import { DismissAttachmentUpload } from './DismissAttachmentUpload'; + +import { getFileSizeDisplayText } from '../../../../components/Attachment/FileAttachment'; +import { WritingDirectionAwareText } from '../../../../components/RTLComponents/WritingDirectionAwareText'; +import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; +import { useMessagesContext } from '../../../../contexts/messagesContext/MessagesContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { UploadAttachmentPreviewProps } from '../../../../types/types'; +import { getTrimmedAttachmentTitle } from '../../../../utils/getTrimmedAttachmentTitle'; +import { + getDurationLabelFromDuration, + getIndicatorTypeForFileState, + ProgressIndicatorTypes, +} from '../../../../utils/utils'; + +export type FileAttachmentUploadPreviewProps> = + UploadAttachmentPreviewProps< + | LocalFileAttachment + | LocalVideoAttachment + | LocalAudioAttachment + > & { + flatListWidth: number; + }; + +export const FileAttachmentUploadPreview = ({ + attachment, + flatListWidth, + handleRetry, + removeAttachments, +}: FileAttachmentUploadPreviewProps) => { + const { enableOfflineSupport } = useChatContext(); + const { FileAttachmentIcon } = useMessagesContext(); + const indicatorType = getIndicatorTypeForFileState( + attachment.localMetadata.uploadState, + enableOfflineSupport, + ); + + const { + theme: { + colors: { black, grey, grey_whisper }, + messageInput: { + fileAttachmentUploadPreview: { + fileContainer, + filenameText, + fileSizeText, + fileTextContainer, + uploadProgressOverlay, + wrapper, + }, + }, + }, + } = useTheme(); + + const onRetryHandler = useCallback(() => { + handleRetry(attachment); + }, [attachment, handleRetry]); + + const onDismissHandler = useCallback(() => { + removeAttachments([attachment.localMetadata.id]); + }, [attachment, removeAttachments]); + + return ( + + + + + + + + + {getTrimmedAttachmentTitle(attachment.title)} + + {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( + + ) : ( + + {attachment.duration + ? getDurationLabelFromDuration(attachment.duration) + : getFileSizeDisplayText(attachment.file_size)} + + )} + + + + + + ); +}; + +const styles = StyleSheet.create({ + fileContainer: { + borderRadius: 12, + borderWidth: 1, + flexDirection: 'row', + paddingHorizontal: 8, + }, + fileIcon: { + alignItems: 'center', + alignSelf: 'center', + justifyContent: 'center', + }, + filenameText: { + fontSize: 14, + fontWeight: 'bold', + }, + fileSizeText: { + fontSize: 12, + marginTop: 10, + }, + fileTextContainer: { + justifyContent: 'space-around', + marginVertical: 10, + paddingHorizontal: 10, + }, + overlay: { + borderRadius: 12, + marginTop: 2, + }, + wrapper: { + flexDirection: 'row', + marginHorizontal: 8, + }, +}); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx new file mode 100644 index 0000000000..e058876ee2 --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useState } from 'react'; + +import { Image, StyleSheet, View } from 'react-native'; + +import { LocalImageAttachment } from 'stream-chat'; + +import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator'; +import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; +import { DismissAttachmentUpload } from './DismissAttachmentUpload'; + +import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { UploadAttachmentPreviewProps } from '../../../../types/types'; +import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; + +const IMAGE_PREVIEW_SIZE = 100; + +export type ImageAttachmentUploadPreviewProps> = + UploadAttachmentPreviewProps>; + +export const ImageAttachmentUploadPreview = ({ + attachment, + handleRetry, + removeAttachments, +}: ImageAttachmentUploadPreviewProps) => { + const [loading, setLoading] = useState(true); + const { enableOfflineSupport } = useChatContext(); + const indicatorType = loading + ? ProgressIndicatorTypes.IN_PROGRESS + : getIndicatorTypeForFileState(attachment.localMetadata.uploadState, enableOfflineSupport); + + const { + theme: { + messageInput: { + imageAttachmentUploadPreview: { itemContainer, upload }, + }, + }, + } = useTheme(); + + const onRetryHandler = useCallback(() => { + handleRetry(attachment); + }, [attachment, handleRetry]); + + const onDismissHandler = useCallback(() => { + removeAttachments([attachment.localMetadata.id]); + }, [attachment, removeAttachments]); + + const onLoadEndHandler = useCallback(() => { + setLoading(false); + }, []); + + return ( + + + + + + + {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + fileSizeText: { + fontSize: 12, + paddingHorizontal: 10, + }, + flatList: { paddingBottom: 12 }, + itemContainer: { + flexDirection: 'row', + height: IMAGE_PREVIEW_SIZE, + marginLeft: 8, + }, + upload: { + borderRadius: 10, + height: IMAGE_PREVIEW_SIZE, + width: IMAGE_PREVIEW_SIZE, + }, +}); diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx index fed76d05fc..fd24d0b86f 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx @@ -187,9 +187,7 @@ const AudioRecorderWithContext = (props: AudioRecorderPropsWithContext) => { - - {t('Slide to Cancel')} - + {t('Slide to Cancel')} diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 52adab1668..0186f0d571 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -10,39 +10,41 @@ import { useTranslationContext } from '../../../../contexts/translationContext/T import { Mic } from '../../../../icons/Mic'; import { AudioRecordingReturnType, NativeHandlers } from '../../../../native'; -type AudioRecordingButtonPropsWithContext = Pick< - MessageInputContextValue, - 'asyncMessagesMinimumPressDuration' -> & { - /** - * The current voice recording that is in progress. - */ - recording: AudioRecordingReturnType; - /** - * Size of the mic button. - */ - buttonSize?: number; - /** - * Handler to determine what should happen on long press of the mic button. - */ - handleLongPress?: () => void; - /** - * Handler to determine what should happen on press of the mic button. - */ - handlePress?: () => void; - /** - * Boolean to determine if the audio recording permissions are granted. - */ - permissionsGranted?: boolean; - /** - * Function to start the voice recording. - */ - startVoiceRecording?: () => Promise; -}; +export type AudioRecordingButtonProps = Partial< + Pick & { + /** + * The current voice recording that is in progress. + */ + recording: AudioRecordingReturnType; + /** + * Size of the mic button. + */ + buttonSize?: number; + /** + * Handler to determine what should happen on long press of the mic button. + */ + handleLongPress?: () => void; + /** + * Handler to determine what should happen on press of the mic button. + */ + handlePress?: () => void; + /** + * Boolean to determine if the audio recording permissions are granted. + */ + permissionsGranted?: boolean; + /** + * Function to start the voice recording. + */ + startVoiceRecording?: () => Promise; + } +>; -const AudioRecordingButtonWithContext = (props: AudioRecordingButtonPropsWithContext) => { +/** + * Component to display the mic button on the Message Input. + */ +export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { const { - asyncMessagesMinimumPressDuration, + asyncMessagesMinimumPressDuration: propAsyncMessagesMinimumPressDuration, buttonSize, handleLongPress, handlePress, @@ -50,6 +52,11 @@ const AudioRecordingButtonWithContext = (props: AudioRecordingButtonPropsWithCon recording, startVoiceRecording, } = props; + const { asyncMessagesMinimumPressDuration: contextAsyncMessagesMinimumPressDuration } = + useMessageInputContext(); + + const asyncMessagesMinimumPressDuration = + propAsyncMessagesMinimumPressDuration || contextAsyncMessagesMinimumPressDuration; const { theme: { @@ -116,51 +123,6 @@ const AudioRecordingButtonWithContext = (props: AudioRecordingButtonPropsWithCon ); }; -const areEqual = ( - prevProps: AudioRecordingButtonPropsWithContext, - nextProps: AudioRecordingButtonPropsWithContext, -) => { - const { - asyncMessagesMinimumPressDuration: prevAsyncMessagesMinimumPressDuration, - recording: prevRecording, - } = prevProps; - const { - asyncMessagesMinimumPressDuration: nextAsyncMessagesMinimumPressDuration, - recording: nextRecording, - } = nextProps; - - const asyncMessagesMinimumPressDurationEqual = - prevAsyncMessagesMinimumPressDuration === nextAsyncMessagesMinimumPressDuration; - if (!asyncMessagesMinimumPressDurationEqual) { - return false; - } - - const recordingEqual = prevRecording === nextRecording; - if (!recordingEqual) { - return false; - } - - return true; -}; - -const MemoizedAudioRecordingButton = React.memo( - AudioRecordingButtonWithContext, - areEqual, -) as typeof AudioRecordingButtonWithContext; - -export type AudioRecordingButtonProps = Partial & { - recording: AudioRecordingReturnType; -}; - -/** - * Component to display the mic button on the Message Input. - */ -export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { - const { asyncMessagesMinimumPressDuration } = useMessageInputContext(); - - return ; -}; - const styles = StyleSheet.create({ container: { alignItems: 'center', diff --git a/package/src/components/MessageInput/components/InputGiphySearch.tsx b/package/src/components/MessageInput/components/CommandInput.tsx similarity index 51% rename from package/src/components/MessageInput/components/InputGiphySearch.tsx rename to package/src/components/MessageInput/components/CommandInput.tsx index fc01b85650..c789f657f1 100644 --- a/package/src/components/MessageInput/components/InputGiphySearch.tsx +++ b/package/src/components/MessageInput/components/CommandInput.tsx @@ -1,65 +1,41 @@ import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { TextComposerState } from 'stream-chat'; + +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; import { MessageInputContextValue, useMessageInputContext, } from '../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { CircleClose, GiphyLightning } from '../../../icons'; import { AutoCompleteInput } from '../../AutoCompleteInput/AutoCompleteInput'; import { useCountdown } from '../hooks/useCountdown'; -const styles = StyleSheet.create({ - autoCompleteInputContainer: { - alignItems: 'center', - flexDirection: 'row', - paddingLeft: 8, - paddingRight: 10, - }, - giphyContainer: { - alignItems: 'center', - borderRadius: 12, - flexDirection: 'row', - marginRight: 8, - paddingHorizontal: 8, - paddingVertical: 4, - }, - giphyText: { - fontSize: 12, - fontWeight: 'bold', - }, -}); - -export type InputGiphySearchProps = Partial< - Pick< - MessageInputContextValue, - 'additionalTextInputProps' | 'cooldownEndsAt' | 'setGiphyActive' | 'setShowMoreOptions' - > +export type CommandInputProps = Partial< + Pick > & { disabled: boolean; }; -export const InputGiphySearch = ({ - additionalTextInputProps: propAdditionalTextInputProps, +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, +}); + +export const CommandInput = ({ cooldownEndsAt: propCooldownEndsAt, disabled, - setGiphyActive: propSetGiphyActive, - setShowMoreOptions: propSetShowMoreOptions, -}: InputGiphySearchProps) => { - const { - additionalTextInputProps: contextAdditionalTextInputProps, - cooldownEndsAt: contextCooldownEndsAt, - setGiphyActive: contextSetGiphyActive, - setShowMoreOptions: contextSetShowMoreOptions, - } = useMessageInputContext(); +}: CommandInputProps) => { + const { cooldownEndsAt: contextCooldownEndsAt } = useMessageInputContext(); + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); - const additionalTextInputProps = propAdditionalTextInputProps || contextAdditionalTextInputProps; const cooldownEndsAt = propCooldownEndsAt || contextCooldownEndsAt; - const setGiphyActive = propSetGiphyActive || contextSetGiphyActive; - const setShowMoreOptions = propSetShowMoreOptions || contextSetShowMoreOptions; const { seconds: cooldownRemainingSeconds } = useCountdown(cooldownEndsAt); @@ -68,34 +44,68 @@ export const InputGiphySearch = ({ colors: { accent_blue, grey, white }, messageInput: { autoCompleteInputContainer, - giphyCommandInput: { giphyContainer, giphyText }, + commandInput: { closeButton, container, text }, }, }, } = useTheme(); + const onCloseHandler = () => { + textComposer.clearCommand(); + messageComposer?.restore(); + }; + + if (!command) { + return null; + } + + const commandName = (command.name ?? '').toUpperCase(); + return ( - + - GIPHY + {commandName} - - + { - setGiphyActive(false); - setShowMoreOptions(true); + onPress={onCloseHandler} + style={({ pressed }) => { + return [ + { + opacity: pressed ? 0.8 : 1, + }, + closeButton, + ]; }} testID='close-button' > - + ); }; -InputGiphySearch.displayName = 'InputGiphySearch{messageInput}'; +CommandInput.displayName = 'CommandInput{messageInput}'; + +const styles = StyleSheet.create({ + autoCompleteInputContainer: { + alignItems: 'center', + flexDirection: 'row', + paddingLeft: 8, + paddingRight: 10, + }, + giphyContainer: { + alignItems: 'center', + borderRadius: 12, + flexDirection: 'row', + marginRight: 8, + paddingHorizontal: 8, + paddingVertical: 4, + }, + giphyText: { + fontSize: 12, + fontWeight: 'bold', + }, +}); diff --git a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx b/package/src/components/MessageInput/components/InputEditingStateHeader.tsx index 5164971278..cb05599899 100644 --- a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx +++ b/package/src/components/MessageInput/components/InputEditingStateHeader.tsx @@ -1,42 +1,28 @@ -import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import React, { useCallback } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { - MessageInputContextValue, - useMessageInputContext, -} from '../../../contexts/messageInputContext/MessageInputContext'; + MessageComposerAPIContextValue, + useMessageComposerAPIContext, +} from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { CircleClose, Edit } from '../../../icons'; -const styles = StyleSheet.create({ - editingBoxHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingBottom: 10, - }, - editingBoxHeaderTitle: { - fontSize: 14, - fontWeight: 'bold', - }, -}); - export type InputEditingStateHeaderProps = Partial< - Pick + Pick >; export const InputEditingStateHeader = ({ clearEditingState: propClearEditingState, - resetInput: propResetInput, }: InputEditingStateHeaderProps) => { + const messageComposer = useMessageComposer(); const { t } = useTranslationContext(); - const { clearEditingState: contextClearEditingState, resetInput: contextResetInput } = - useMessageInputContext(); + const { clearEditingState: contextClearEditingState } = useMessageComposerAPIContext(); const clearEditingState = propClearEditingState || contextClearEditingState; - const resetInput = propResetInput || contextResetInput; const { theme: { @@ -47,27 +33,41 @@ export const InputEditingStateHeader = ({ }, } = useTheme(); + const onCloseHandler = useCallback(() => { + if (clearEditingState) { + clearEditingState(); + } + messageComposer.restore(); + }, [clearEditingState, messageComposer]); + return ( - {t('Editing Message')} + {t('Editing Message')} - { - if (resetInput) { - resetInput(); - } - if (clearEditingState) { - clearEditingState(); - } - }} + [{ opacity: pressed ? 0.8 : 1 }]} testID='close-button' > - + ); }; +const styles = StyleSheet.create({ + editingBoxHeader: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingBottom: 10, + }, + editingBoxHeaderTitle: { + fontSize: 14, + fontWeight: 'bold', + }, +}); + InputEditingStateHeader.displayName = 'EditingStateHeader{messageInput}'; diff --git a/package/src/components/MessageInput/components/InputReplyStateHeader.tsx b/package/src/components/MessageInput/components/InputReplyStateHeader.tsx index 8860217967..8fd09bf49d 100644 --- a/package/src/components/MessageInput/components/InputReplyStateHeader.tsx +++ b/package/src/components/MessageInput/components/InputReplyStateHeader.tsx @@ -1,39 +1,15 @@ import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../../contexts/messageInputContext/MessageInputContext'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { CircleClose, CurveLineLeftUp } from '../../../icons'; -const styles = StyleSheet.create({ - replyBoxHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingBottom: 10, - }, - replyBoxHeaderTitle: { - fontSize: 14, - fontWeight: 'bold', - }, -}); - -export type InputReplyStateHeaderProps = Partial< - Pick ->; - -export const InputReplyStateHeader = ({ - clearQuotedMessageState: propClearQuotedMessageState, - resetInput: propResetInput, -}: InputReplyStateHeaderProps) => { +export const InputReplyStateHeader = () => { const { t } = useTranslationContext(); - const { clearQuotedMessageState: contextClearQuotedMessageState, resetInput: contextResetInput } = - useMessageInputContext(); + const messageComposer = useMessageComposer(); const { theme: { colors: { black, grey, grey_gainsboro }, @@ -43,30 +19,38 @@ export const InputReplyStateHeader = ({ }, } = useTheme(); - const clearQuotedMessageState = propClearQuotedMessageState || contextClearQuotedMessageState; - const resetInput = propResetInput || contextResetInput; + const onCloseHandler = () => { + messageComposer.setQuotedMessage(null); + }; return ( - {t('Reply to Message')} + {t('Reply to Message')} - { - if (resetInput) { - resetInput(); - } - if (clearQuotedMessageState) { - clearQuotedMessageState(); - } - }} + [{ opacity: pressed ? 0.8 : 1 }]} testID='close-button' > - + ); }; +const styles = StyleSheet.create({ + replyBoxHeader: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingBottom: 8, + }, + replyBoxHeaderTitle: { + fontSize: 14, + fontWeight: 'bold', + }, +}); + InputReplyStateHeader.displayName = 'ReplyStateHeader{messageInput}'; diff --git a/package/src/components/MessageInput/components/NativeAttachmentPicker.tsx b/package/src/components/MessageInput/components/NativeAttachmentPicker.tsx index 650a48cc37..3eaab0aca3 100644 --- a/package/src/components/MessageInput/components/NativeAttachmentPicker.tsx +++ b/package/src/components/MessageInput/components/NativeAttachmentPicker.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useRef } from 'react'; import { Animated, Easing, LayoutRectangle, Platform, Pressable, StyleSheet } from 'react-native'; import { - useAttachmentPickerContext, useChannelContext, useMessagesContext, useOwnCapabilitiesContext, @@ -39,6 +38,10 @@ export const NativeAttachmentPicker = ({ }, } = useTheme(); const { + CameraSelectorIcon, + FileSelectorIcon, + ImageSelectorIcon, + VideoRecorderSelectorIcon, hasCameraPicker, hasFilePicker, hasImagePicker, @@ -51,8 +54,6 @@ export const NativeAttachmentPicker = ({ const { threadList } = useChannelContext(); const { hasCreatePoll } = useMessagesContext(); const ownCapabilities = useOwnCapabilitiesContext(); - const { CameraSelectorIcon, FileSelectorIcon, ImageSelectorIcon, VideoRecorderSelectorIcon } = - useAttachmentPickerContext(); const popupHeight = // the top padding @@ -168,6 +169,7 @@ export const NativeAttachmentPicker = ({ onClose({}); }} style={[styles.container, containerPopupStyle, container]} + testID={'native-attachment-picker'} > {/* all the attach buttons */} {buttons.map(({ icon, id, onPressHandler }) => ( diff --git a/package/src/components/MessageInput/hooks/useAudioController.tsx b/package/src/components/MessageInput/hooks/useAudioController.tsx index 7c5eb6269c..9cb19c0673 100644 --- a/package/src/components/MessageInput/hooks/useAudioController.tsx +++ b/package/src/components/MessageInput/hooks/useAudioController.tsx @@ -2,6 +2,10 @@ import { useEffect, useRef, useState } from 'react'; import { Alert, Platform } from 'react-native'; +import { LocalVoiceRecordingAttachment } from 'stream-chat'; + +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; + import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; import { AudioRecordingReturnType, @@ -12,6 +16,7 @@ import { } from '../../../native'; import type { File } from '../../../types/types'; import { FileTypes } from '../../../types/types'; +import { generateRandomId } from '../../../utils/utils'; import { resampleWaveformData } from '../utils/audioSampling'; import { normalizeAudioLevel } from '../utils/normalizeAudioLevel'; @@ -31,8 +36,9 @@ export const useAudioController = () => { const [recording, setRecording] = useState(undefined); const [recordingDuration, setRecordingDuration] = useState(0); const [recordingStatus, setRecordingStatus] = useState('idle'); + const { attachmentManager } = useMessageComposer(); - const { sendMessage, uploadNewFile } = useMessageInputContext(); + const { sendMessage } = useMessageInputContext(); // For playback support in Expo CLI apps const soundRef = useRef(null); @@ -276,11 +282,27 @@ export const useAudioController = () => { waveform_data: resampledWaveformData, }; + const audioFile: LocalVoiceRecordingAttachment = { + asset_url: + typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string), + duration: durationInSeconds, + file_size: 0, + localMetadata: { + file, + id: generateRandomId(), + uploadState: 'pending', + }, + mime_type: 'audio/aac', + title: `audio_recording_${date}.aac`, + type: FileTypes.VoiceRecording, + waveform_data: resampledWaveformData, + }; + if (multiSendEnabled) { - await uploadNewFile(file, FileTypes.VoiceRecording); + await attachmentManager.uploadAttachment(audioFile); } else { // FIXME: cannot call handleSubmit() directly as the function has stale reference to file uploads - await uploadNewFile(file, FileTypes.VoiceRecording); + await attachmentManager.uploadAttachment(audioFile); setIsScheduleForSubmit(true); } resetState(); diff --git a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx new file mode 100644 index 0000000000..b0c6936bdb --- /dev/null +++ b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { LocalAttachment } from 'stream-chat'; + +import { AudioConfig } from '../../../types/types'; + +/** + * Manages the state of audio attachments for preview and playback. + * @param files The audio files to manage. + * @returns An object containing the state and handlers for audio attachments. + */ +export const useAudioPreviewManager = (files: LocalAttachment[]) => { + const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState< + Record + >({}); + + useEffect(() => { + setAudioAttachmentsStateMap((prevState) => { + const updatedStateMap = Object.fromEntries( + files.map((attachment) => { + const id = attachment.localMetadata.id; + + const config: AudioConfig = { + duration: attachment.duration ?? prevState[id]?.duration ?? 0, + paused: prevState[id]?.paused ?? true, + progress: prevState[id]?.progress ?? 0, + }; + + return [id, config]; + }), + ); + + return updatedStateMap; + }); + }, [files]); + + // Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here + // and the duration is set. + const onLoad = useCallback((index: string, duration: number) => { + setAudioAttachmentsStateMap((prevState) => ({ + ...prevState, + [index]: { + ...prevState[index], + duration, + }, + })); + }, []); + + // The handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The + // progressed duration is set here. + const onProgress = useCallback((index: string, progress: number) => { + setAudioAttachmentsStateMap((prevState) => ({ + ...prevState, + [index]: { + ...prevState[index], + progress, + }, + })); + }, []); + + // The handler which controls or sets the paused/played state of the audio. + const onPlayPause = useCallback((index: string, pausedStatus?: boolean) => { + if (pausedStatus === false) { + // In this case, all others except the index are set to paused. + setAudioAttachmentsStateMap((prevState) => { + const newState = { ...prevState }; + Object.keys(newState).forEach((key) => { + if (key !== index) { + newState[key].paused = true; + } + }); + return { + ...newState, + [index]: { + ...newState[index], + paused: false, + }, + }; + }); + } else { + setAudioAttachmentsStateMap((prevState) => ({ + ...prevState, + [index]: { + ...prevState[index], + paused: true, + }, + })); + } + }, []); + + return { + audioAttachmentsStateMap, + onLoad, + onPlayPause, + onProgress, + setAudioAttachmentsStateMap, + }; +}; diff --git a/package/src/components/MessageList/InlineUnreadIndicator.tsx b/package/src/components/MessageList/InlineUnreadIndicator.tsx index fc93cdaa1b..eda88ba573 100644 --- a/package/src/components/MessageList/InlineUnreadIndicator.tsx +++ b/package/src/components/MessageList/InlineUnreadIndicator.tsx @@ -20,7 +20,7 @@ export const InlineUnreadIndicator = () => { accessibilityLabel='Inline unread indicator' style={[styles.container, { backgroundColor: light_gray }, container]} > - {t('Unread Messages')} + {t('Unread Messages')} ); }; diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index 03763f1870..bca539e3be 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -66,7 +66,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp container, ]} > - {t('Unread Messages')} + {t('Unread Messages')} [ diff --git a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js index e77cbad740..788c1e51ea 100644 --- a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js +++ b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js @@ -10,6 +10,11 @@ import { ScrollToBottomButton } from '../ScrollToBottomButton'; afterEach(cleanup); describe('ScrollToBottomButton', () => { + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + it('should render nothing if showNotification is false', async () => { const i18nInstance = new Streami18n(); const translators = await i18nInstance.getTranslators(); diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index 4132e1a608..e8d17fd485 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -147,7 +147,7 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { ); const renderHeader = () => ( - {t('Message Reactions')} + {t('Message Reactions')} ); const selectorReactions: ReactionSelectorItemType[] = messageReactions.map((reaction) => ({ diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx index 30d6f243dc..e93cefa59b 100644 --- a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx @@ -38,6 +38,26 @@ const defaultProps = { supportedReactions: mockSupportedReactions, }; +const renderComponent = (props = {}) => + render( + + + null, + MessageUserReactionsItem: (props: MessageUserReactionsItemProps) => ( + {props.reaction.id + ' ' + props.reaction.type} + ), + } as unknown as MessagesContextValue + } + > + + + + , + ); + describe('MessageUserReactions when the supportedReactions are defined', () => { beforeAll(() => { const mockLoadNextPage = jest.fn(); @@ -58,25 +78,6 @@ describe('MessageUserReactions when the supportedReactions are defined', () => { ], }); }); - const renderComponent = (props = {}) => - render( - - - null, - MessageUserReactionsItem: (props: MessageUserReactionsItemProps) => ( - {props.reaction.id + ' ' + props.reaction.type} - ), - } as unknown as MessagesContextValue - } - > - - - - , - ); it('renders correctly', () => { const { getByLabelText, getByText } = renderComponent(); @@ -109,9 +110,9 @@ describe('MessageUserReactions when the supportedReactions are defined', () => { expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-selected'); }); - it('renders reactions list', async () => { + it('renders reactions list', () => { const { getByText } = renderComponent(); - const reactionItems = await getByText('1 like'); + const reactionItems = getByText('1 like'); expect(reactionItems).toBeDefined(); }); @@ -132,27 +133,27 @@ describe('MessageUserReactions when the supportedReactions are defined', () => { }); }); -const renderComponent = (props = {}) => - render( - - - null, - MessageUserReactionsItem: (props: MessageUserReactionsItemProps) => ( - {props.reaction.id + ' ' + props.reaction.type} - ), - } as unknown as MessagesContextValue - } - > - - - - , - ); - describe("MessageUserReactions when the supportedReactions aren't defined", () => { + beforeAll(() => { + const mockLoadNextPage = jest.fn(); + + const mockUseFetchReactions = jest.spyOn(useFetchReactionsModule, 'useFetchReactions'); + mockUseFetchReactions.mockReturnValue({ + loading: false, + loadNextPage: mockLoadNextPage, + reactions: [ + { + type: 'like', + user: { id: '1', image: 'user1.jpg', name: 'User 1' }, + } as unknown as ReactionResponse, + { + type: 'love', + user: { id: '2', image: 'user2.jpg', name: 'User 2' }, + } as unknown as ReactionResponse, + ], + }); + }); + it("don't render reaction buttons that is of unsupported type", () => { const { queryAllByLabelText } = renderComponent({ message: { ...generateMessage(), reaction_groups: undefined }, diff --git a/package/src/components/MessagePreview/MessagePreview.tsx b/package/src/components/MessagePreview/MessagePreview.tsx new file mode 100644 index 0000000000..742cd8bdbb --- /dev/null +++ b/package/src/components/MessagePreview/MessagePreview.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../contexts'; + +export type MessagePreviewSkeletonProps = { + /** + * Whether the message text should be bold. + */ + bold: boolean; + /** + * The text of the message preview. + */ + text: string; + /** + * Whether the message is a draft. + */ + draft?: boolean; +}; + +export type MessagePreviewProps = { + previews: MessagePreviewSkeletonProps[]; +}; + +export const MessagePreview = ({ previews }: MessagePreviewProps) => { + const { + theme: { + messagePreview: { message }, + colors: { accent_blue, grey }, + }, + } = useTheme(); + + return ( + + {previews?.map((preview, index) => + preview.text ? ( + + {preview.text} + + ) : null, + )} + + ); +}; + +const styles = StyleSheet.create({ + bold: { fontWeight: '500' }, + container: { + flexDirection: 'row', + flexShrink: 1, + }, + message: { + flexShrink: 1, + marginRight: 2, + }, +}); diff --git a/package/src/components/Poll/CreatePollContent.tsx b/package/src/components/Poll/CreatePollContent.tsx index 2ac11cb481..421060d92d 100644 --- a/package/src/components/Poll/CreatePollContent.tsx +++ b/package/src/components/Poll/CreatePollContent.tsx @@ -1,234 +1,151 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Pressable, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { StyleSheet, Switch, Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useSharedValue } from 'react-native-reanimated'; -import { CreatePollData, PollOptionData, VotingVisibility } from 'stream-chat'; +import { PollComposerState, VotingVisibility } from 'stream-chat'; -import { CreatePollOptions, CurrentOptionPositionsCache, PollModalHeader } from './components'; +import { CreatePollOptions, CurrentOptionPositionsCache } from './components'; + +import { CreatePollHeader } from './components/CreatePollHeader'; +import { MultipleAnswersField } from './components/MultipleAnswersField'; +import { NameField } from './components/NameField'; import { CreatePollContentContextValue, CreatePollContentProvider, InputMessageInputContextValue, - useChatContext, useCreatePollContentContext, useTheme, useTranslationContext, } from '../../contexts'; -import { SendPoll } from '../../icons'; - -export const isMaxNumberOfVotesValid = (maxNumberOfVotes: string) => { - const parsedMaxNumberOfVotes = Number(maxNumberOfVotes); +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useStateStore } from '../../hooks/useStateStore'; +import { POLL_OPTION_HEIGHT } from '../../utils/constants'; - return ( - !isNaN(parsedMaxNumberOfVotes) && parsedMaxNumberOfVotes > 1 && parsedMaxNumberOfVotes <= 10 - ); -}; +const pollComposerStateSelector = (state: PollComposerState) => ({ + options: state.data.options, +}); export const CreatePollContent = () => { + const [isAnonymousPoll, setIsAnonymousPoll] = useState(false); + const [allowUserSuggestedOptions, setAllowUserSuggestedOptions] = useState(false); + const [allowAnswers, setAllowAnswers] = useState(false); + const { t } = useTranslationContext(); - const [pollTitle, setPollTitle] = useState(''); - const [pollOptions, setPollOptions] = useState([{ text: '' }]); - const [multipleAnswersAllowed, setMultipleAnswersAllowed] = useState(false); - const [maxVotesPerPerson, setMaxVotesPerPerson] = useState(''); - const [maxVotesPerPersonEnabled, setMaxVotesPerPersonEnabled] = useState(false); - const [isAnonymous, setIsAnonymous] = useState(false); - const [optionSuggestionsAllowed, setOptionSuggestionsAllowed] = useState(false); - const [commentsAllowed, setCommentsAllowed] = useState(false); - const [duplicates, setDuplicates] = useState([]); - const { closePollCreationDialog, createAndSendPoll } = useCreatePollContentContext(); + const messageComposer = useMessageComposer(); + const { pollComposer } = messageComposer; + const { options } = useStateStore(pollComposer.state, pollComposerStateSelector); + + const { createPollOptionHeight, closePollCreationDialog, createAndSendPoll } = + useCreatePollContentContext(); // positions and index lookup map // TODO: Please rethink the structure of this, bidirectional data flow is not great const currentOptionPositions = useSharedValue({ - inverseIndexCache: { 0: 0 }, - positionCache: { 0: { updatedIndex: 0, updatedTop: 0 } }, + inverseIndexCache: {}, + positionCache: {}, }); const { theme: { - colors: { accent_error, bg_user, black, white }, + colors: { bg_user, black, white }, poll: { - createContent: { - addComment, - anonymousPoll, - headerContainer, - maxVotes, - multipleAnswers, - name, - scrollView, - sendButton, - suggestOption, - }, + createContent: { addComment, anonymousPoll, scrollView, suggestOption }, }, }, } = useTheme(); useEffect(() => { - const seenTexts = new Set(); - const duplicateTexts = new Set(); - for (const option of pollOptions) { - const { text } = option; - if (seenTexts.has(text)) { - duplicateTexts.add(text); - } - if (text.length > 0) { - seenTexts.add(text); - } - } + if (!createPollOptionHeight) return; + const newCurrentOptionPositions: CurrentOptionPositionsCache = { + inverseIndexCache: {}, + positionCache: {}, + }; + options.forEach((option, index) => { + newCurrentOptionPositions.inverseIndexCache[index] = option.id; + newCurrentOptionPositions.positionCache[option.id] = { + updatedIndex: index, + updatedTop: index * createPollOptionHeight, + }; + }); + currentOptionPositions.value = newCurrentOptionPositions; + }, [createPollOptionHeight, currentOptionPositions, options]); + + const onBackPressHandler = useCallback(() => { + pollComposer.initState(); + closePollCreationDialog?.(); + }, [pollComposer, closePollCreationDialog]); + + const onCreatePollPressHandler = useCallback(async () => { + await createAndSendPoll(); + }, [createAndSendPoll]); + + const onAnonymousPollChangeHandler = useCallback( + async (value: boolean) => { + setIsAnonymousPoll(value); + await pollComposer.updateFields({ + voting_visibility: value ? VotingVisibility.anonymous : VotingVisibility.public, + }); + }, + [pollComposer], + ); - setDuplicates(Array.from(duplicateTexts)); - }, [pollOptions]); + const onAllowUserSuggestedOptionsChangeHandler = useCallback( + async (value: boolean) => { + setAllowUserSuggestedOptions(value); + await pollComposer.updateFields({ allow_user_suggested_options: value }); + }, + [pollComposer], + ); - const isPollValid = - pollTitle && - pollTitle?.length > 0 && - duplicates.length === 0 && - ((maxVotesPerPersonEnabled && isMaxNumberOfVotesValid(maxVotesPerPerson)) || - !maxVotesPerPersonEnabled); + const onAllowAnswersChangeHandler = useCallback( + async (value: boolean) => { + setAllowAnswers(value); + await pollComposer.updateFields({ allow_answers: value }); + }, + [pollComposer], + ); return ( <> - - closePollCreationDialog?.()} title={t('Create Poll')} /> - { - const currentPollOptions = Object.assign({}, pollOptions); - const reorderedPollOptions = []; - - for (let i = 0; i < pollOptions.length; i++) { - const currentOption = - currentPollOptions[currentOptionPositions.value.inverseIndexCache[i]]; - if (currentOption.text.length > 0) { - reorderedPollOptions.push(currentOption); - } - } - - createAndSendPoll({ - allow_answers: commentsAllowed, - allow_user_suggested_options: optionSuggestionsAllowed, - enforce_unique_vote: !multipleAnswersAllowed, - name: pollTitle, - options: reorderedPollOptions, - voting_visibility: isAnonymous ? VotingVisibility.anonymous : VotingVisibility.public, - ...(isMaxNumberOfVotesValid(maxVotesPerPerson) && maxVotesPerPersonEnabled - ? { max_votes_allowed: Number(maxVotesPerPerson) } - : {}), - }); - }} - style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }, styles.sendButton, sendButton]} - > - - - + - {t('Questions')} - - - - - - {t('Multiple answers')} - - { - if (multipleAnswersAllowed) { - setMaxVotesPerPersonEnabled(false); - } - setMultipleAnswersAllowed(!multipleAnswersAllowed); - }} - value={multipleAnswersAllowed} - /> - - {multipleAnswersAllowed ? ( - - {maxVotesPerPersonEnabled && !isMaxNumberOfVotesValid(maxVotesPerPerson) ? ( - - {t('Type a number from 2 to 10')} - - ) : null} - - - setMaxVotesPerPersonEnabled(!maxVotesPerPersonEnabled)} - value={maxVotesPerPersonEnabled} - /> - - - ) : null} - + + + - {t('Anonymous poll')} + {t('Anonymous poll')} - setIsAnonymous(!isAnonymous)} value={isAnonymous} /> + - {t('Suggest an option')} + {t('Suggest an option')} setOptionSuggestionsAllowed(!optionSuggestionsAllowed)} - value={optionSuggestionsAllowed} + onValueChange={onAllowUserSuggestedOptionsChangeHandler} + value={allowUserSuggestedOptions} /> - {t('Add a comment')} + {t('Add a comment')} - setCommentsAllowed(!commentsAllowed)} - value={commentsAllowed} - /> + @@ -238,23 +155,28 @@ export const CreatePollContent = () => { export const CreatePoll = ({ closePollCreationDialog, CreatePollContent: CreatePollContentOverride, - createPollOptionHeight, + createPollOptionHeight = POLL_OPTION_HEIGHT, sendMessage, }: Pick< CreatePollContentContextValue, 'createPollOptionHeight' | 'closePollCreationDialog' | 'sendMessage' > & Pick) => { - const { client } = useChatContext(); + const messageComposer = useMessageComposer(); - const createAndSendPoll = useCallback( - async (pollData: CreatePollData) => { - const poll = await client.polls.createPoll(pollData); - await sendMessage({ customMessageData: { poll_id: poll?.id } }); + const createAndSendPoll = useCallback(async () => { + try { + await messageComposer.createPoll(); + await sendMessage(); closePollCreationDialog?.(); - }, - [client, sendMessage, closePollCreationDialog], - ); + // it's important that the reset of the pollComposer state happens + // after we've already closed the modal; as otherwise we'd get weird + // UI behaviour. + messageComposer.pollComposer.initState(); + } catch (error) { + console.log('Error creating a poll and sending a message:', error); + } + }, [messageComposer, sendMessage, closePollCreationDialog]); return ( void; + /** + * Handler for create poll button press + * @returns void + */ + onCreatePollPressHandler: () => void; +}; + +export const CreatePollHeader = ({ + onBackPressHandler, + onCreatePollPressHandler, +}: CreatePollHeaderProps) => { + const { t } = useTranslationContext(); + + const canCreatePoll = useCanCreatePoll(); + + const { + theme: { + colors: { white }, + poll: { + createContent: { headerContainer, sendButton }, + }, + }, + } = useTheme(); + + return ( + + + [{ opacity: pressed ? 0.5 : 1 }, styles.sendButton, sendButton]} + > + + + + ); +}; + +const styles = StyleSheet.create({ + headerContainer: { flexDirection: 'row', justifyContent: 'space-between' }, + sendButton: { paddingHorizontal: 16, paddingVertical: 18 }, +}); diff --git a/package/src/components/Poll/components/CreatePollOptions.tsx b/package/src/components/Poll/components/CreatePollOptions.tsx index 474f0442d7..e220ba459f 100644 --- a/package/src/components/Poll/components/CreatePollOptions.tsx +++ b/package/src/components/Poll/components/CreatePollOptions.tsx @@ -1,8 +1,9 @@ -import React, { Dispatch, SetStateAction, useCallback, useMemo } from 'react'; -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { StyleSheet, Text, TextInput, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { interpolate, + runOnJS, SharedValue, useAnimatedReaction, useAnimatedStyle, @@ -10,55 +11,68 @@ import Animated, { useSharedValue, withDelay, withSpring, + withTiming, } from 'react-native-reanimated'; -import { PollOptionData } from 'stream-chat'; +import { PollComposerOption, PollComposerState } from 'stream-chat'; import { useCreatePollContentContext, useTheme, useTranslationContext } from '../../../contexts'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useStateStore } from '../../../hooks/useStateStore'; import { DragHandle } from '../../../icons'; +import { POLL_OPTION_HEIGHT } from '../../../utils/constants'; export type CurrentOptionPositionsCache = { inverseIndexCache: { - [key: number]: number; + [key: number]: string; }; positionCache: { - [key: number]: { + [key: string]: { updatedIndex: number; updatedTop: number; }; }; }; -const OPTION_HEIGHT = 71; +export type CreatePollOptionType = { + boundaries: { maxBound: number; minBound: number }; + currentOptionPositions: SharedValue; + draggedItemId: SharedValue; + error?: string; + handleChangeText: (newText: string, index: number) => void; + handleBlur: () => void; + index: number; + isDragging: SharedValue<1 | 0>; + option: PollComposerOption; + /** + * + * @param newOrder The inverse index object of the new options position after re-ordering. + * @returns + */ + onNewOrder: (newOrder: CurrentOptionPositionsCache['inverseIndexCache']) => void; +}; export const CreatePollOption = ({ boundaries, currentOptionPositions, draggedItemId, + error, + handleBlur, handleChangeText, - hasDuplicate, index, isDragging, option, -}: { - boundaries: { maxBound: number; minBound: number }; - currentOptionPositions: SharedValue; - draggedItemId: SharedValue; - handleChangeText: (newText: string, index: number) => void; - hasDuplicate: boolean; - index: number; - isDragging: SharedValue<1 | 0>; - option: PollOptionData; -}) => { + onNewOrder, +}: CreatePollOptionType) => { const { t } = useTranslationContext(); - const { createPollOptionHeight = OPTION_HEIGHT } = useCreatePollContentContext(); + const { createPollOptionHeight = POLL_OPTION_HEIGHT } = useCreatePollContentContext(); const top = useSharedValue(index * createPollOptionHeight); const isDraggingDerived = useDerivedValue(() => isDragging.value); const draggedItemIdDerived = useDerivedValue(() => draggedItemId.value); const isCurrentDraggingItem = useDerivedValue( - () => isDraggingDerived.value && draggedItemIdDerived.value === index, + () => isDraggingDerived.value && draggedItemIdDerived.value === option.id, ); const animatedStyles = useAnimatedStyle(() => ({ @@ -81,14 +95,15 @@ export const CreatePollOption = ({ // used for swapping with newIndex const currentIndex = useSharedValue(null); + // The sanity check for position cache updated index, is added because after a poll is sent its been reset + // by the composer and it throws an undefined error. This can be removed in future. useAnimatedReaction( - () => currentOptionPositionsDerived.value.positionCache[index].updatedIndex, + () => currentOptionPositionsDerived.value.positionCache[option.id]?.updatedIndex ?? 0, (currentValue, previousValue) => { if (currentValue !== previousValue) { - top.value = withSpring( - currentOptionPositionsDerived.value.positionCache[index].updatedIndex * - createPollOptionHeight, - ); + const updatedIndex = + currentOptionPositionsDerived.value.positionCache[option.id]?.updatedIndex ?? 0; + top.value = withSpring(updatedIndex * createPollOptionHeight); } }, ); @@ -99,10 +114,11 @@ export const CreatePollOption = ({ isDragging.value = withSpring(1); // keep track of dragged item - draggedItemId.value = index; + draggedItemId.value = option.id; // store dragged item id for future swap - currentIndex.value = currentOptionPositionsDerived.value.positionCache[index].updatedIndex; + currentIndex.value = + currentOptionPositionsDerived.value.positionCache[option.id].updatedIndex; }) .onUpdate((e) => { const { inverseIndexCache, positionCache } = currentOptionPositionsDerived.value; @@ -182,6 +198,7 @@ export const CreatePollOption = ({ } // stop dragging isDragging.value = withDelay(200, withSpring(0)); + runOnJS(onNewOrder)(currentOptionPositionsDerived.value.inverseIndexCache); }); const { @@ -195,6 +212,13 @@ export const CreatePollOption = ({ }, } = useTheme(); + const onChangeTextHandler = useCallback( + (newText: string) => { + handleChangeText(newText, index); + }, + [handleChangeText, index], + ); + return ( - {hasDuplicate ? ( + {error ? ( - {t('This is already an option')} + {t(error)} ) : null} handleChangeText(newText, index)} - placeholder={t('Option')} + onBlur={handleBlur} + onChangeText={onChangeTextHandler} + placeholder={t('Add an option')} style={[styles.optionInput, { color: black }, optionStyle.input]} - value={option.text} /> @@ -237,92 +261,108 @@ export const CreatePollOption = ({ const MemoizedCreatePollOption = React.memo(CreatePollOption); -export const CreatePollOptions = (props: { +const pollComposerStateSelector = (state: PollComposerState) => ({ + errors: state.errors.options, + options: state.data.options, +}); + +export type CreatePollOptionsProps = { currentOptionPositions: SharedValue; - duplicates: string[]; - pollOptions: PollOptionData[]; - setPollOptions: Dispatch>; -}) => { +}; + +export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsProps) => { const { t } = useTranslationContext(); - const { createPollOptionHeight = OPTION_HEIGHT } = useCreatePollContentContext(); - const { currentOptionPositions, duplicates = [], pollOptions, setPollOptions } = props; + const messageComposer = useMessageComposer(); + const { pollComposer } = messageComposer; + const { errors, options } = useStateStore(pollComposer.state, pollComposerStateSelector); + const { createPollOptionHeight = POLL_OPTION_HEIGHT } = useCreatePollContentContext(); + const updateOption = useCallback( (newText: string, index: number) => { - setPollOptions((prevOptions) => - prevOptions.map((option, idx) => (idx === index ? { ...option, text: newText } : option)), - ); + pollComposer.updateFields({ + options: { index, text: newText }, + }); }, - [setPollOptions], + [pollComposer], ); + const handleBlur = useCallback(() => { + pollComposer.handleFieldBlur('options'); + }, [pollComposer]); + // used to know if drag is happening or not const isDragging = useSharedValue<0 | 1>(0); + // this will hold id for item which user started dragging - const draggedItemId = useSharedValue(null); + const draggedItemId = useSharedValue(null); + + // holds the animated height of the option container + const animatedOptionsContainerHeight = useSharedValue(createPollOptionHeight * options.length); + + useEffect(() => { + animatedOptionsContainerHeight.value = withTiming(createPollOptionHeight * options.length, { + duration: 200, + }); + }, [animatedOptionsContainerHeight, createPollOptionHeight, options.length]); + + const animatedOptionsContainerStyle = useAnimatedStyle(() => ({ + height: animatedOptionsContainerHeight.value, + })); const boundaries = useMemo( - () => ({ maxBound: (pollOptions.length - 1) * createPollOptionHeight, minBound: 0 }), - [createPollOptionHeight, pollOptions.length], + () => ({ maxBound: (options.length - 1) * createPollOptionHeight, minBound: 0 }), + [createPollOptionHeight, options.length], ); const { theme: { - colors: { bg_user, black }, + colors: { black }, poll: { createContent: { - pollOptions: { addOption, container, title }, + pollOptions: { container, title }, }, }, }, } = useTheme(); + const onNewOrderChange = useCallback( + async (newOrder: CurrentOptionPositionsCache['inverseIndexCache']) => { + const reorderedPollOptions = []; + + for (let i = 0; i < options.length; i++) { + const currentOption = options.find((option) => option.id === newOrder[i]); + if (currentOption) { + reorderedPollOptions.push(currentOption); + } + } + + await pollComposer.updateFields({ + options: reorderedPollOptions, + }); + }, + [options, pollComposer], + ); + return ( - {t('Options')} - - {pollOptions.map((option, index) => ( + {t('Options')} + + {options.map((option, index) => ( ))} - - { - const newIndex = pollOptions.length; - currentOptionPositions.value = { - inverseIndexCache: { - ...currentOptionPositions.value.inverseIndexCache, - [newIndex]: newIndex, - }, - positionCache: { - ...currentOptionPositions.value.positionCache, - [newIndex]: { - updatedIndex: newIndex, - updatedTop: newIndex * createPollOptionHeight, - }, - }, - }; - setPollOptions([...pollOptions, { text: '' }]); - }} - style={({ pressed }) => [ - { opacity: pressed ? 0.5 : 1 }, - styles.addOptionWrapper, - { backgroundColor: bg_user }, - addOption.wrapper, - ]} - > - - {t('Add an option')} - - + ); }; diff --git a/package/src/components/Poll/components/MultipleAnswersField.tsx b/package/src/components/Poll/components/MultipleAnswersField.tsx new file mode 100644 index 0000000000..bf4257af64 --- /dev/null +++ b/package/src/components/Poll/components/MultipleAnswersField.tsx @@ -0,0 +1,122 @@ +import React, { useCallback, useState } from 'react'; +import { StyleSheet, Switch, Text, TextInput, View } from 'react-native'; + +import { PollComposerState } from 'stream-chat'; + +import { useTheme, useTranslationContext } from '../../../contexts'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useStateStore } from '../../../hooks/useStateStore'; + +const pollComposerStateSelector = (state: PollComposerState) => ({ + error: state.errors.max_votes_allowed, + max_votes_allowed: state.data.max_votes_allowed, +}); + +export const MultipleAnswersField = () => { + const [allowMultipleVotes, setAllowMultipleVotes] = useState(false); + const { t } = useTranslationContext(); + const messageComposer = useMessageComposer(); + const { pollComposer } = messageComposer; + const { handleFieldBlur, updateFields } = pollComposer; + const { error, max_votes_allowed } = useStateStore(pollComposer.state, pollComposerStateSelector); + + const { + theme: { + colors: { accent_error, bg_user, black }, + poll: { + createContent: { maxVotes, multipleAnswers }, + }, + }, + } = useTheme(); + + const onEnforceUniqueVoteHandler = useCallback( + async (value: boolean) => { + setAllowMultipleVotes(value); + await updateFields({ enforce_unique_vote: !value }); + }, + [updateFields], + ); + + const onChangeTextHandler = useCallback( + async (newText: string) => { + await updateFields({ max_votes_allowed: newText }); + }, + [updateFields], + ); + + const onBlurHandler = useCallback(async () => { + await handleFieldBlur('max_votes_allowed'); + }, [handleFieldBlur]); + + return ( + + + + {t('Multiple answers')} + + + + {allowMultipleVotes ? ( + + {max_votes_allowed && error ? ( + + {t(error)} + + ) : null} + + + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + maxVotesInput: { flex: 1, fontSize: 16 }, + maxVotesValidationText: { + fontSize: 12, + left: 16, + position: 'absolute', + top: 0, + }, + maxVotesWrapper: { + alignItems: 'flex-start', + flexDirection: 'column', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 18, + }, + multipleAnswersRow: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 18, + }, + multipleAnswersWrapper: { borderRadius: 12, marginTop: 16 }, + text: { fontSize: 16 }, + textInputWrapper: { + alignItems: 'center', + borderRadius: 12, + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 16, + paddingHorizontal: 16, + paddingVertical: 18, + }, +}); diff --git a/package/src/components/Poll/components/NameField.tsx b/package/src/components/Poll/components/NameField.tsx new file mode 100644 index 0000000000..0e7993140c --- /dev/null +++ b/package/src/components/Poll/components/NameField.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react'; +import { StyleSheet, Text, TextInput, View } from 'react-native'; + +import { useTheme, useTranslationContext } from '../../../contexts'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; + +export const NameField = () => { + const { t } = useTranslationContext(); + const messageComposer = useMessageComposer(); + const { pollComposer } = messageComposer; + const { handleFieldBlur, updateFields } = pollComposer; + + const { + theme: { + colors: { bg_user, black }, + poll: { + createContent: { name }, + }, + }, + } = useTheme(); + + const onChangeText = useCallback( + async (newText: string) => { + await updateFields({ name: newText }); + }, + [updateFields], + ); + + const onBlur = useCallback(async () => { + await handleFieldBlur('name'); + }, [handleFieldBlur]); + + return ( + + {t('Questions')} + + + ); +}; + +const styles = StyleSheet.create({ + text: { fontSize: 16 }, + textInputWrapper: { + alignItems: 'center', + borderRadius: 12, + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 16, + paddingHorizontal: 16, + paddingVertical: 18, + }, +}); diff --git a/package/src/components/Poll/components/PollAnswersList.tsx b/package/src/components/Poll/components/PollAnswersList.tsx index a4949602a9..37dc7f1235 100644 --- a/package/src/components/Poll/components/PollAnswersList.tsx +++ b/package/src/components/Poll/components/PollAnswersList.tsx @@ -56,7 +56,7 @@ export const AnswerListAddCommentButton = (props: PollButtonProps) => { ]} > - {ownAnswer ? t('Update your comment') : t('Add a comment')} + {ownAnswer ? t('Update your comment') : t('Add a comment')} {showAddCommentDialog ? ( @@ -64,7 +64,7 @@ export const AnswerListAddCommentButton = (props: PollButtonProps) => { closeDialog={() => setShowAddCommentDialog(false)} initialValue={ownAnswer?.answer_text ?? ''} onSubmit={addComment} - title={t('Add a comment')} + title={t('Add a comment')} visible={showAddCommentDialog} /> ) : null} @@ -117,7 +117,7 @@ export const PollAnswerListItem = ({ answer }: { answer: PollAnswer }) => { ) : null} - {isAnonymous ? t('Anonymous') : answer.user?.name} + {isAnonymous ? t('Anonymous') : answer.user?.name} {dateString} diff --git a/package/src/components/Poll/components/PollButtons.tsx b/package/src/components/Poll/components/PollButtons.tsx index cbce131b86..b88beada1a 100644 --- a/package/src/components/Poll/components/PollButtons.tsx +++ b/package/src/components/Poll/components/PollButtons.tsx @@ -34,7 +34,7 @@ export const ViewResultsButton = (props: PollButtonProps) => { return ( <> - ('View Results')} /> + {showResults ? ( { visible={showResults} > - setShowResults(false)} - title={t('Poll Results')} - /> + setShowResults(false)} title={t('Poll Results')} /> @@ -81,7 +78,7 @@ export const ShowAllOptionsButton = (props: PollButtonProps) => { {options && options.length > 10 ? ( ('See all {{count}} options', { count: options.length })} + title={t('See all {{count}} options', { count: options.length })} /> ) : null} {showAllOptions ? ( @@ -91,10 +88,7 @@ export const ShowAllOptionsButton = (props: PollButtonProps) => { visible={showAllOptions} > - setShowAllOptions(false)} - title={t('Poll Options')} - /> + setShowAllOptions(false)} title={t('Poll Options')} /> @@ -130,7 +124,7 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => { {answersCount && answersCount > 0 ? ( ('View {{count}} comments', { count: answersCount })} + title={t('View {{count}} comments', { count: answersCount })} /> ) : null} {showAnswers ? ( @@ -140,10 +134,7 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => { visible={showAnswers} > - setShowAnswers(false)} - title={t('Poll Comments')} - /> + setShowAnswers(false)} title={t('Poll Comments')} /> @@ -171,13 +162,13 @@ export const SuggestOptionButton = (props: PollButtonProps) => { return ( <> {!isClosed && allowUserSuggestedOptions ? ( - ('Suggest an option')} /> + ) : null} {showAddOptionDialog ? ( setShowAddOptionDialog(false)} onSubmit={addOption} - title={t('Suggest an option')} + title={t('Suggest an option')} visible={showAddOptionDialog} /> ) : null} @@ -204,14 +195,14 @@ export const AddCommentButton = (props: PollButtonProps) => { return ( <> {!isClosed && allowAnswers ? ( - ('Add a comment')} /> + ) : null} {showAddCommentDialog ? ( setShowAddCommentDialog(false)} initialValue={ownAnswer?.answer_text ?? ''} onSubmit={addComment} - title={t('Add a comment')} + title={t('Add a comment')} visible={showAddCommentDialog} /> ) : null} @@ -225,7 +216,7 @@ export const EndVoteButton = () => { const { client } = useChatContext(); return !isClosed && createdBy?.id === client.userID ? ( - ('End Vote')} /> + ) : null; }; diff --git a/package/src/components/Poll/components/PollInputDialog.tsx b/package/src/components/Poll/components/PollInputDialog.tsx index e08632dc9b..602e782542 100644 --- a/package/src/components/Poll/components/PollInputDialog.tsx +++ b/package/src/components/Poll/components/PollInputDialog.tsx @@ -57,7 +57,7 @@ export const PollInputDialog = ({ ('Ask a question')} + placeholder={t('Ask a question')} style={[styles.input, { color: black }, input]} value={dialogInput} /> @@ -67,7 +67,7 @@ export const PollInputDialog = ({ style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })} > - {t('Cancel')} + {t('Cancel')} ({ marginLeft: 32, opacity: pressed ? 0.5 : 1 })} > - - {t('SEND')} - + {t('SEND')} diff --git a/package/src/components/Poll/components/PollResults/PollOptionFullResults.tsx b/package/src/components/Poll/components/PollResults/PollOptionFullResults.tsx index 03a8f4ec82..7c2d111c75 100644 --- a/package/src/components/Poll/components/PollResults/PollOptionFullResults.tsx +++ b/package/src/components/Poll/components/PollResults/PollOptionFullResults.tsx @@ -46,7 +46,7 @@ export const PollOptionFullResultsContent = ({ () => ( - {t('{{count}} votes', { count: voteCountsByOption[option.id] ?? 0 })} + {t('{{count}} votes', { count: voteCountsByOption[option.id] ?? 0 })} ), diff --git a/package/src/components/Poll/components/PollResults/PollResultItem.tsx b/package/src/components/Poll/components/PollResults/PollResultItem.tsx index 7dad29fcca..558680a77c 100644 --- a/package/src/components/Poll/components/PollResults/PollResultItem.tsx +++ b/package/src/components/Poll/components/PollResults/PollResultItem.tsx @@ -58,7 +58,7 @@ export const ShowAllVotesButton = (props: ShowAllVotesButtonProps) => { {ownCapabilities.queryPollVotes && voteCountsByOption && voteCountsByOption?.[option.id] > 5 ? ( - ('Show All')} /> + ) : null} {showAllVotes ? ( { {option.text} - {t('{{count}} votes', { count: voteCountsByOption[option.id] ?? 0 })} + {t('{{count}} votes', { count: voteCountsByOption[option.id] ?? 0 })} {latestVotesByOption?.[option.id]?.length > 0 ? ( diff --git a/package/src/components/Poll/components/PollResults/PollVote.tsx b/package/src/components/Poll/components/PollResults/PollVote.tsx index 58340ff30c..cb63511b38 100644 --- a/package/src/components/Poll/components/PollResults/PollVote.tsx +++ b/package/src/components/Poll/components/PollResults/PollVote.tsx @@ -47,7 +47,7 @@ export const PollVote = ({ vote }: { vote: PollVoteClass }) => { ) : null} - {isAnonymous ? t('Anonymous') : (vote.user?.name ?? vote.user?.id)} + {isAnonymous ? t('Anonymous') : (vote.user?.name ?? vote.user?.id)} {dateString} diff --git a/package/src/components/Poll/hooks/useCanCreatePoll.ts b/package/src/components/Poll/hooks/useCanCreatePoll.ts new file mode 100644 index 0000000000..d0136c0efc --- /dev/null +++ b/package/src/components/Poll/hooks/useCanCreatePoll.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; + +export const useCanCreatePoll = () => { + const { pollComposer } = useMessageComposer(); + const [canCreatePoll, setCanCreatePoll] = useState(pollComposer.canCreatePoll); + useEffect( + () => + pollComposer.state.subscribe(() => { + setCanCreatePoll(pollComposer.canCreatePoll); + }), + [pollComposer], + ); + return canCreatePoll; +}; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 35ff5acceb..26b4cd6be3 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Image, ImageStyle, StyleSheet, Text, View, ViewStyle } from 'react-native'; @@ -6,15 +6,11 @@ import dayjs from 'dayjs'; import merge from 'lodash/merge'; -import type { Attachment, PollState } from 'stream-chat'; +import type { Attachment, MessageComposerState, PollState } from 'stream-chat'; -import { useChatContext } from '../../contexts'; +import { useChatContext, useMessageComposer } from '../../contexts'; import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext'; import { useMessageContext } from '../../contexts/messageContext/MessageContext'; -import { - MessageInputContext, - MessageInputContextValue, -} from '../../contexts/messageInputContext/MessageInputContext'; import { MessagesContextValue, useMessagesContext, @@ -28,7 +24,7 @@ import { useStateStore } from '../../hooks'; import { FileTypes } from '../../types/types'; import { getResizedImageUrl } from '../../utils/getResizedImageUrl'; import { getTrimmedAttachmentTitle } from '../../utils/getTrimmedAttachmentTitle'; -import { hasOnlyEmojis } from '../../utils/utils'; +import { checkQuotedMessageEquality, hasOnlyEmojis } from '../../utils/utils'; import { FileIcon as FileIconDefault } from '../Attachment/FileIcon'; import { VideoThumbnail } from '../Attachment/VideoThumbnail'; @@ -83,8 +79,14 @@ const selector = (nextValue: PollState): ReplySelectorReturnType => ({ name: nextValue.name, }); -type ReplyPropsWithContext = Pick & - Pick & +const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ + quotedMessage: state.quotedMessage, +}); + +type ReplyPropsWithContext = Pick< + MessagesContextValue, + 'FileAttachmentIcon' | 'MessageAvatar' | 'quotedMessage' +> & Pick & { attachmentSize?: number; styles?: Partial<{ @@ -150,6 +152,7 @@ const ReplyWithContext = (props: ReplyPropsWithContext) => { styles: stylesProp = {}, t, } = props; + const { resizableCDNHosts } = useChatConfigContext(); const [error, setError] = useState(false); @@ -341,18 +344,6 @@ const ReplyWithContext = (props: ReplyPropsWithContext) => { ); }; -/** - * When a reply is rendered in a MessageSimple, it does - * not have a MessageInputContext. As this is deliberate, - * this function exists to avoid the error thrown when - * using a context outside of its provider. - * */ -const useMessageInputContextIfAvailable = () => { - const contextValue = useContext(MessageInputContext) as unknown as MessageInputContextValue; - - return contextValue; -}; - const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithContext) => { const { quotedMessage: prevQuotedMessage } = prevProps; const { quotedMessage: nextQuotedMessage } = nextProps; @@ -360,12 +351,14 @@ const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithCon const quotedMessageEqual = !!prevQuotedMessage && !!nextQuotedMessage && - typeof prevQuotedMessage !== 'boolean' && - typeof nextQuotedMessage !== 'boolean' - ? prevQuotedMessage.id === nextQuotedMessage.id && - prevQuotedMessage.deleted_at === nextQuotedMessage.deleted_at && - prevQuotedMessage.type === nextQuotedMessage.type - : !!prevQuotedMessage === !!nextQuotedMessage; + checkQuotedMessageEquality(prevQuotedMessage, nextQuotedMessage); + + const quotedMessageAttachmentsEqual = + prevQuotedMessage?.attachments?.length === nextQuotedMessage?.attachments?.length; + + if (!quotedMessageAttachmentsEqual) { + return false; + } if (!quotedMessageEqual) { return false; @@ -387,11 +380,8 @@ export const Reply = (props: ReplyProps) => { const { FileAttachmentIcon = FileIconDefault, MessageAvatar = MessageAvatarDefault } = useMessagesContext(); - const { editing, quotedMessage } = useMessageInputContextIfAvailable(); - - const quotedEditingMessage = ( - typeof editing !== 'boolean' ? editing?.quoted_message || false : false - ) as MessageInputContextValue['quotedMessage']; + const messageComposer = useMessageComposer(); + const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); const { t } = useTranslationContext(); @@ -401,8 +391,8 @@ export const Reply = (props: ReplyProps) => { FileAttachmentIcon, MessageAvatar, quotedMessage: message - ? (message.quoted_message as MessageInputContextValue['quotedMessage']) - : quotedMessage || quotedEditingMessage, + ? (message.quoted_message as MessagesContextValue['quotedMessage']) + : quotedMessage, t, }} {...props} diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 791b2d6000..e040f8812e 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -114,7 +114,10 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { {...additionalMessageListProps} /> diff --git a/package/src/components/Thread/__tests__/Thread.test.js b/package/src/components/Thread/__tests__/Thread.test.js index edb8fedefe..185e3eeaa4 100644 --- a/package/src/components/Thread/__tests__/Thread.test.js +++ b/package/src/components/Thread/__tests__/Thread.test.js @@ -1,6 +1,6 @@ import React from 'react'; -import { act, cleanup, render, waitFor } from '@testing-library/react-native'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; import { v5 as uuidv5 } from 'uuid'; import { AttachmentPickerProvider } from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; @@ -9,6 +9,7 @@ import { ChannelsStateProvider } from '../../../contexts/channelsStateContext/Ch import { ImageGalleryProvider } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateMember } from '../../../mock-builders/generator/member'; @@ -22,38 +23,52 @@ import { Thread } from '../Thread'; const StreamReactNativeNamespace = '9b244ee4-7d69-4d7b-ae23-cf89e9f7b035'; -afterEach(cleanup); +const renderComponent = ({ chatClient, channel, props, thread }) => { + return render( + + + + + + + , + ); +}; describe('Thread', () => { + let chatClient; + let channel; + + beforeEach(async () => { + const { client: client, channels } = await initiateClientWithChannels(); + chatClient = client; + channel = channels[0]; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + it('should render a new thread', async () => { - const thread = generateMessage({ text: 'Thread Message Text' }); - const thread2 = generateMessage({ text: 'Thread2 Message Text' }); + const cid = 'messaging:test-channel'; + const thread = generateMessage({ cid, text: 'Thread Message Text' }); const parent_id = thread.id; + const props = { + thread, + }; + const threadResponses = [ - generateMessage({ parent_id, text: 'Response Message Text' }), - generateMessage({ parent_id }), - generateMessage({ parent_id }), + generateMessage({ cid, parent_id, text: 'Response Message Text' }), + generateMessage({ cid, parent_id }), + generateMessage({ cid, parent_id }), ]; - const mockedChannel = generateChannelResponse({ - messages: [thread, thread2], - }); - - const chatClient = await getTestClientWithUser({ id: 'Neil' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.watch(); channel.state.addMessagesSorted(threadResponses); - const { getAllByText, getByText, queryByText } = render( - - - - - - - , - ); + renderComponent({ channel, chatClient, props, thread }); + + const { getAllByText, getByText, queryByText } = screen; await waitFor(() => { expect(getByText('Also send to channel')).toBeTruthy(); @@ -63,16 +78,33 @@ describe('Thread', () => { }, 10000); it('should match thread snapshot', async () => { + const cid = 'messaging:test-channel'; const i18nInstance = new Streami18n(); const user1 = generateStaticUser(1); const user2 = generateStaticUser(3); - const thread = generateStaticMessage('Message3', { user: user2 }, '2020-05-05T14:50:00.000Z'); + const thread = generateStaticMessage( + 'Message3', + { cid, user: user2 }, + '2020-05-05T14:50:00.000Z', + ); const parent_id = thread.id; const threadResponses = [ - generateStaticMessage('Message4', { parent_id, user: user1 }, '2020-05-05T14:50:00.000Z'), - generateStaticMessage('Message5', { parent_id, user: user2 }, '2020-05-05T14:50:00.000Z'), - generateStaticMessage('Message6', { parent_id, user: user1 }, '2020-05-05T14:50:00.000Z'), + generateStaticMessage( + 'Message4', + { cid, parent_id, user: user1 }, + '2020-05-05T14:50:00.000Z', + ), + generateStaticMessage( + 'Message5', + { cid, parent_id, user: user2 }, + '2020-05-05T14:50:00.000Z', + ), + generateStaticMessage( + 'Message6', + { cid, parent_id, user: user1 }, + '2020-05-05T14:50:00.000Z', + ), ]; const mockedChannel = generateChannelResponse({ @@ -81,8 +113,8 @@ describe('Thread', () => { }, members: [generateMember({ user: user1 }), generateMember({ user: user1 })], messages: [ - generateStaticMessage('Message1', { user: user1 }, '2020-05-05T14:48:00.000Z'), - generateStaticMessage('Message2', { user: user2 }, '2020-05-05T14:49:00.000Z'), + generateStaticMessage('Message1', { cid, user: user1 }, '2020-05-05T14:48:00.000Z'), + generateStaticMessage('Message2', { cid, user: user2 }, '2020-05-05T14:49:00.000Z'), thread, ...threadResponses, ], diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index e607860800..1c8a611958 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -50,6 +50,7 @@ exports[`Thread should match thread snapshot 1`] = ` [ { "attachments": [], + "cid": "messaging:test-channel", "created_at": 2020-05-05T14:50:00.000Z, "deleted_at": null, "error": null, @@ -77,6 +78,7 @@ exports[`Thread should match thread snapshot 1`] = ` }, { "attachments": [], + "cid": "messaging:test-channel", "created_at": 2020-05-05T14:50:00.000Z, "deleted_at": null, "error": null, @@ -104,6 +106,7 @@ exports[`Thread should match thread snapshot 1`] = ` }, { "attachments": [], + "cid": "messaging:test-channel", "created_at": 2020-05-05T14:50:00.000Z, "deleted_at": null, "error": null, @@ -1926,7 +1929,7 @@ exports[`Thread should match thread snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -2062,73 +2065,84 @@ exports[`Thread should match thread snapshot 1`] = ` - + - - - - - - + /> + + + + + + + + `; diff --git a/package/src/components/Thread/components/ThreadFooterComponent.tsx b/package/src/components/Thread/components/ThreadFooterComponent.tsx index 7b1df5c268..e7f9bf584a 100644 --- a/package/src/components/Thread/components/ThreadFooterComponent.tsx +++ b/package/src/components/Thread/components/ThreadFooterComponent.tsx @@ -132,8 +132,8 @@ const ThreadFooterComponentWithContext = (props: ThreadFooterComponentPropsWithC {replyCount === 1 - ? t('1 Reply') - : t('{{ replyCount }} Replies', { + ? t('1 Reply') + : t('{{ replyCount }} Replies', { replyCount, })} diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index e2cce5b64d..f4da483bc3 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -1,7 +1,16 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { LocalMessage, Thread, ThreadState } from 'stream-chat'; +import { + AttachmentManagerState, + ChannelState, + DraftMessage, + LocalMessage, + MessageResponse, + TextComposerState, + Thread, + ThreadState, +} from 'stream-chat'; import { TranslationContextValue, @@ -16,9 +25,11 @@ import { import { useThreadsContext } from '../../contexts/threadsContext/ThreadsContext'; import { useStateStore } from '../../hooks'; import { MessageBubble } from '../../icons'; +import { FileTypes } from '../../types/types'; import { getDateString } from '../../utils/i18n/getDateString'; import { Avatar } from '../Avatar/Avatar'; import { useChannelPreviewDisplayName } from '../ChannelPreview/hooks/useChannelPreviewDisplayName'; +import { MessagePreview } from '../MessagePreview/MessagePreview'; export type ThreadListItemProps = { thread: Thread; @@ -43,8 +54,14 @@ const styles = StyleSheet.create({ alignItems: 'center', flexDirection: 'row', }, - lastReplyText: { flex: 1, fontSize: 14, marginTop: 4 }, - parentMessageText: { flex: 1, fontSize: 12 }, + parentMessagePreviewContainer: { + flex: 1, + marginVertical: 2, + }, + previewMessageContainer: { + flex: 1, + marginTop: 4, + }, touchableWrapper: { flex: 1, paddingHorizontal: 8, @@ -68,44 +85,169 @@ export const attachmentTypeIconMap = { voiceRecording: '🎙️', } as const; -const getTitleFromMessage = ({ +type LatestMessage = ReturnType | MessageResponse; + +const getMessageSenderName = ( + message: LatestMessage | undefined, + currentUserId: string | undefined, + t: (key: string) => string, +) => { + if (message?.user?.id === currentUserId) { + return t('You'); + } + + return message?.user?.name || message?.user?.username || message?.user?.id || ''; +}; + +const getPreviewFromMessage = ({ + t, currentUserId, + draftMessage, + parentMessage = false, message, - t, }: { t: TranslationContextValue['t']; currentUserId?: string; + draftMessage?: DraftMessage; + parentMessage?: boolean; message?: LocalMessage; }) => { - const attachment = message?.attachments?.at(0); + if (draftMessage) { + if (draftMessage.attachments?.length) { + const attachment = draftMessage?.attachments?.at(0); - const attachmentIcon = attachment - ? `${ - attachmentTypeIconMap[(attachment.type as keyof typeof attachmentTypeIconMap) ?? 'file'] ?? - attachmentTypeIconMap.file - } ` - : ''; + const attachmentIcon = attachment + ? `${ + attachmentTypeIconMap[ + (attachment.type as keyof typeof attachmentTypeIconMap) ?? 'file' + ] ?? attachmentTypeIconMap.file + } ` + : ''; - const messageBelongsToCurrentUserPrefix = - message?.user?.id === currentUserId ? `${t('You')}: ` : ''; + if (attachment?.type === FileTypes.VoiceRecording) { + return [ + { bold: true, draft: true, text: 'Draft: ' }, + { bold: false, text: attachmentIcon }, + { + bold: false, + text: t('Voice message'), + }, + ]; + } + return [ + { bold: true, draft: true, text: 'Draft: ' }, + { bold: false, text: attachmentIcon }, + { + bold: false, + text: + attachment?.type === FileTypes.Image + ? attachment?.fallback + ? attachment?.fallback + : 'N/A' + : attachment?.title + ? attachment?.title + : 'N/A', + }, + ]; + } - if (message?.deleted_at && message.parent_id) { - return `${messageBelongsToCurrentUserPrefix}${t('This reply was deleted')}.`; + if (draftMessage.text) { + return [ + { bold: true, draft: true, text: 'Draft: ' }, + { + bold: false, + text: draftMessage.text, + }, + ]; + } } - if (message?.deleted_at && !message.parent_id) { - return `${messageBelongsToCurrentUserPrefix}${t('The source message was deleted')}.`; - } + if (message) { + const messageSender = getMessageSenderName(message, currentUserId, t); + const messageSenderText = !parentMessage ? (messageSender ? `${messageSender}: ` : '') : ''; + const isNotOwner = message.user?.id !== currentUserId; + + if (message.text) { + return [ + { bold: isNotOwner, text: messageSenderText }, + { bold: false, text: message.text || 'N/A' }, + ]; + } + if (message.command) { + return [ + { bold: isNotOwner, text: messageSenderText }, + { bold: false, text: `/${message.command}` }, + ]; + } + + if (message?.deleted_at && message.parent_id) { + return [ + { bold: isNotOwner, text: messageSenderText }, + { bold: false, text: `${t('This reply was deleted')}.` }, + ]; + } + + if (message?.deleted_at && !message.parent_id) { + return [ + { bold: isNotOwner, text: messageSenderText }, + { bold: false, text: `${t('The source message was deleted')}.` }, + ]; + } + + if (message?.attachments?.length) { + const attachment = message?.attachments?.at(0); - if (attachment?.type === 'voiceRecording') { - return `${attachmentIcon}${messageBelongsToCurrentUserPrefix}${t('Voice message')}.`; + const attachmentIcon = attachment + ? `${ + attachmentTypeIconMap[ + (attachment.type as keyof typeof attachmentTypeIconMap) ?? 'file' + ] ?? attachmentTypeIconMap.file + } ` + : ''; + + if (attachment?.type === FileTypes.VoiceRecording) { + return [ + { bold: false, text: attachmentIcon }, + { + bold: isNotOwner, + text: messageSenderText, + }, + { + bold: false, + text: t('Voice message'), + }, + ]; + } + + return [ + { bold: false, text: attachmentIcon }, + { bold: isNotOwner, text: messageSenderText }, + { + bold: false, + text: + attachment?.type === FileTypes.Image + ? attachment?.fallback + ? attachment?.fallback + : 'N/A' + : attachment?.title + ? attachment?.title + : 'N/A', + }, + ]; + } } - return `${attachmentIcon}${messageBelongsToCurrentUserPrefix}${ - message?.text || attachment?.fallback || 'N/A' - }`; + return [{ bold: false, text: t('Empty message...') }]; }; +const textComposerStateSelector = (state: TextComposerState) => ({ + text: state.text, +}); + +const stateSelector = (state: AttachmentManagerState) => ({ + attachments: state.attachments, +}); + export const ThreadListItemComponent = () => { const { channel, @@ -126,6 +268,56 @@ export const ThreadListItemComponent = () => { threadListItem, }, } = useTheme(); + const { text: draftText } = useStateStore( + thread.messageComposer.textComposer.state, + textComposerStateSelector, + ); + + const { attachments } = useStateStore( + thread.messageComposer.attachmentManager.state, + stateSelector, + ); + + useEffect(() => { + const unsubscribe = thread.messageComposer.registerDraftEventSubscriptions(); + return () => unsubscribe(); + }, [thread.messageComposer]); + + const draftMessage: DraftMessage | undefined = useMemo( + () => + !thread.messageComposer.compositionIsEmpty + ? { + attachments, + id: thread.messageComposer.id, + text: draftText, + } + : undefined, + [thread.messageComposer, attachments, draftText], + ); + + const previews = useMemo(() => { + return getPreviewFromMessage({ + currentUserId: client.userID, + draftMessage, + message: lastReply as LocalMessage, + t, + }); + }, [client.userID, draftMessage, lastReply, t]); + + const parentMessagePreview = useMemo(() => { + return [ + { + bold: true, + text: `${t('replied to')}: `, + }, + ...getPreviewFromMessage({ + currentUserId: client.userID, + message: parentMessage as LocalMessage, + parentMessage: true, + t, + }), + ]; + }, [client.userID, parentMessage, t]); return ( { > - + {displayName || 'N/A'} - - {t('replied to')}: {getTitleFromMessage({ message: parentMessage, t })} - + + {ownUnreadMessageCount > 0 && !deletedAtDateString ? ( { {lastReply?.user?.name} - - {deletedAtDateString - ? 'This thread was deleted.' - : getTitleFromMessage({ - currentUserId: client.userID, - message: lastReply, - t, - })} - + + + + {deletedAtDateString ?? dateString} diff --git a/package/src/components/index.ts b/package/src/components/index.ts index f0740576cf..30cc31bbdd 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -122,16 +122,20 @@ export * from './MessageInput/components/AudioRecorder/AudioRecordingInProgress' export * from './MessageInput/components/AudioRecorder/AudioRecordingLockIndicator'; export * from './MessageInput/components/AudioRecorder/AudioRecordingPreview'; export * from './MessageInput/components/AudioRecorder/AudioRecordingWaveform'; +export * from './MessageInput/components/CommandInput'; export * from './MessageInput/CooldownTimer'; -export * from './MessageInput/FileUploadPreview'; -export * from './MessageInput/ImageUploadPreview'; +export * from './MessageInput/AttachmentUploadPreviewList'; export * from './MessageInput/InputButtons'; export * from './MessageInput/MessageInput'; export * from './MessageInput/MoreOptionsButton'; export * from './MessageInput/SendButton'; export * from './MessageInput/StopMessageStreamingButton'; export * from './MessageInput/ShowThreadMessageInChannelButton'; -export * from './MessageInput/UploadProgressIndicator'; +export * from './MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; +export * from './MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator'; +export * from './MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; +export * from './MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; +export * from './MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview'; export * from './MessageList/DateHeader'; export * from './MessageList/hooks/useMessageList'; @@ -159,6 +163,8 @@ export * from './MessageMenu/MessageUserReactionsAvatar'; export * from './MessageMenu/MessageReactionPicker'; export * from './MessageMenu/hooks/useFetchReactions'; +export * from './MessagePreview/MessagePreview'; + export * from './ProgressControl/ProgressControl'; export * from './Poll'; diff --git a/package/src/contexts/__tests__/index.test.tsx b/package/src/contexts/__tests__/index.test.tsx index 67b57c57c9..4113e96bf6 100644 --- a/package/src/contexts/__tests__/index.test.tsx +++ b/package/src/contexts/__tests__/index.test.tsx @@ -13,7 +13,6 @@ import { useOverlayContext, useOwnCapabilitiesContext, usePaginatedMessageListContext, - useSuggestionsContext, useTheme, useThreadContext, useTypingContext, @@ -45,10 +44,6 @@ describe('contexts hooks in a component throws an error with message when not wr useOwnCapabilitiesContext, 'The useOwnCapabilitiesContext hook was called outside the Channel Component. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', ], - [ - useSuggestionsContext, - 'The useSuggestionsContext hook was called outside of the SuggestionsContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ], [ useTypingContext, 'The useTypingContext hook was called outside of the TypingContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', diff --git a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx index 189d6a80c7..3758949d5f 100644 --- a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx +++ b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx @@ -1,8 +1,6 @@ import React, { PropsWithChildren, useContext, useEffect, useState } from 'react'; -import { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; - -import type { File } from '../../types/types'; +import BottomSheet from '@gorhom/bottom-sheet'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -14,36 +12,6 @@ export type AttachmentPickerIconProps = { }; export type AttachmentPickerContextValue = { - /** - * Custom UI component to render [draggable handle](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) of attachment picker. - * - * **Default** [AttachmentPickerBottomSheetHandle](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerBottomSheetHandle.tsx) - */ - AttachmentPickerBottomSheetHandle: React.FC; - /** - * Height of the image picker bottom sheet handle. - * @type number - * @default 20 - */ - attachmentPickerBottomSheetHandleHeight: number; - /** - * Height of the image picker bottom sheet when opened. - * @type number - * @default 40% of window height - */ - attachmentPickerBottomSheetHeight: number; - /** - * Custom UI component for AttachmentPickerSelectionBar - * - * **Default: ** [AttachmentPickerSelectionBar](https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx) - */ - AttachmentPickerSelectionBar: React.ComponentType; - /** - * Height of the attachment selection bar displayed on the attachment picker. - * @type number - * @default 52 - */ - attachmentSelectionBarHeight: number; /** * `bottomInset` determine the height of the `AttachmentPicker` and the underlying shift to the `MessageList` when it is opened. * This can also be set via the `setBottomInset` function provided by the `useAttachmentPickerContext` hook. @@ -52,51 +20,14 @@ export type AttachmentPickerContextValue = { * for more details. */ bottomInset: number; - /** - * Custom UI component for [camera selector icon](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) - * - * **Default: ** [CameraSelectorIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/CameraSelectorIcon.tsx) - */ - CameraSelectorIcon: React.ComponentType; + bottomSheetRef: React.RefObject; closePicker: () => void; - /** - * Custom UI component for the poll creation icon. - * - * **Default: ** [CreatePollIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/CreatePollIcon.tsx) - */ - CreatePollIcon: React.ComponentType; - /** - * Custom UI component for [file selector icon](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) - * - * **Default: ** [FileSelectorIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/FileSelectorIcon.tsx) - */ - FileSelectorIcon: React.ComponentType; - /** - * Custom UI component for [image selector icon](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) - * - * **Default: ** [ImageSelectorIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/ImageSelectorIcon.tsx) - */ - ImageSelectorIcon: React.ComponentType; - /** - * Limit for maximum files that can be attached per message. - */ - maxNumberOfFiles: number; openPicker: () => void; - selectedFiles: File[]; - selectedImages: File[]; setBottomInset: React.Dispatch>; - setMaxNumberOfFiles: React.Dispatch>; - setSelectedFiles: React.Dispatch>; - setSelectedImages: React.Dispatch>; setSelectedPicker: React.Dispatch>; setTopInset: React.Dispatch>; topInset: number; - /** - * Custom UI component for Android's video recorder selector icon. - * - * **Default: ** [VideoRecorderSelectorIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/VideoRecorderSelectorIcon.tsx) - */ - VideoRecorderSelectorIcon: React.ComponentType; + selectedPicker?: 'images'; }; @@ -108,25 +39,13 @@ export const AttachmentPickerProvider = ({ children, value, }: PropsWithChildren<{ - value?: Pick< - AttachmentPickerContextValue, - | 'CameraSelectorIcon' - | 'closePicker' - | 'CreatePollIcon' - | 'FileSelectorIcon' - | 'ImageSelectorIcon' - | 'openPicker' - | 'VideoRecorderSelectorIcon' - > & + value?: Pick & Partial>; }>) => { const bottomInsetValue = value?.bottomInset; const topInsetValue = value?.topInset; const [bottomInset, setBottomInset] = useState(bottomInsetValue ?? 0); - const [maxNumberOfFiles, setMaxNumberOfFiles] = useState(10); - const [selectedImages, setSelectedImages] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); const [selectedPicker, setSelectedPicker] = useState<'images'>(); const [topInset, setTopInset] = useState(topInsetValue ?? 0); @@ -139,14 +58,8 @@ export const AttachmentPickerProvider = ({ }, [topInsetValue]); const combinedValue = { - maxNumberOfFiles, - selectedFiles, - selectedImages, selectedPicker, setBottomInset, - setMaxNumberOfFiles, - setSelectedFiles, - setSelectedImages, setSelectedPicker, setTopInset, ...value, diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index c7a7f59894..62542bdedc 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -49,10 +49,6 @@ export type ChannelContextValue = { */ enforceUniqueReaction: boolean; error: boolean | Error; - /** - * When set to false, it will disable giphy command on MessageInput component. - */ - giphyEnabled: boolean; /** * Hide inline date separators on channel */ diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index c34f5b8ee7..baf3287315 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -8,20 +8,21 @@ export * from './imageGalleryContext/ImageGalleryContext'; export * from './keyboardContext/KeyboardContext'; export * from './messageContext/MessageContext'; export * from './messageInputContext/hooks/useCreateMessageInputContext'; -export * from './messageInputContext/hooks/useMessageDetailsForState'; export * from './messageInputContext/MessageInputContext'; +export * from './messageInputContext/hooks/useMessageComposer'; +export * from './messageInputContext/hooks/useAttachmentManagerState'; +export * from './messageInputContext/hooks/useMessageComposerHasSendableData'; export * from './messagesContext/MessagesContext'; export * from './paginatedMessageListContext/PaginatedMessageListContext'; export * from './overlayContext/OverlayContext'; export * from './overlayContext/OverlayProvider'; export * from './ownCapabilitiesContext/OwnCapabilitiesContext'; -export * from './suggestionsContext/SuggestionsContext'; export * from './themeContext/ThemeContext'; export * from './themeContext/utils/theme'; export * from './threadContext/ThreadContext'; export * from './threadsContext/ThreadsContext'; export * from './threadsContext/ThreadListItemContext'; -export * from './translationContext/TranslationContext'; +export * from './translationContext'; export * from './typingContext/TypingContext'; export * from './utils/getDisplayName'; export * from './pollContext'; diff --git a/package/src/contexts/messageComposerContext/MessageComposerAPIContext.tsx b/package/src/contexts/messageComposerContext/MessageComposerAPIContext.tsx new file mode 100644 index 0000000000..4435d4db1d --- /dev/null +++ b/package/src/contexts/messageComposerContext/MessageComposerAPIContext.tsx @@ -0,0 +1,42 @@ +import React, { useContext } from 'react'; + +import { LocalMessage, type MessageComposer } from 'stream-chat'; + +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +export type MessageComposerAPIContextValue = { + setQuotedMessage: MessageComposer['setQuotedMessage']; + setEditingState: (message?: LocalMessage) => void; + clearEditingState: () => void; +}; + +export const MessageComposerAPIContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as MessageComposerAPIContextValue, +); + +type Props = React.PropsWithChildren<{ + value: MessageComposerAPIContextValue; +}>; + +export const MessageComposerAPIProvider = ({ children, value }: Props) => { + return ( + + {children} + + ); +}; + +export const useMessageComposerAPIContext = () => { + const contextValue = useContext( + MessageComposerAPIContext, + ) as unknown as MessageComposerAPIContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useMessageComposerAPIContext hook was called outside of the MessageComposerAPIContext provider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx new file mode 100644 index 0000000000..49e058bdb1 --- /dev/null +++ b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx @@ -0,0 +1,81 @@ +import React, { useContext, useMemo, useState } from 'react'; + +import { LocalMessage } from 'stream-chat'; + +import { + MessageComposerAPIContextValue, + MessageComposerAPIProvider, +} from './MessageComposerAPIContext'; + +import { ChannelProps } from '../../components'; +import { useStableCallback } from '../../hooks'; +import { useCreateMessageComposer } from '../messageInputContext/hooks/useCreateMessageComposer'; +import { ThreadContextValue } from '../threadContext/ThreadContext'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +export type MessageComposerContextValue = { + channel: ChannelProps['channel']; + thread: ThreadContextValue['thread']; + threadInstance: ThreadContextValue['threadInstance']; + /** + * Variable that tracks the editing state. + * It is defined with message type if the editing state is true, else its undefined. + */ + editing?: LocalMessage; +}; + +export const MessageComposerContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as MessageComposerContextValue, +); + +type Props = React.PropsWithChildren<{ + value: Pick; +}>; + +export const MessageComposerProvider = ({ children, value }: Props) => { + const [editing, setEditing] = useState(undefined); + + const setEditingState: MessageComposerAPIContextValue['setEditingState'] = useStableCallback( + (message) => { + setEditing(message); + }, + ); + + const clearEditingState: MessageComposerAPIContextValue['clearEditingState'] = useStableCallback( + () => setEditing(undefined), + ); + + const messageComposerContextValue = useMemo(() => ({ editing, ...value }), [editing, value]); + + const messageComposer = useCreateMessageComposer(messageComposerContextValue); + + const setQuotedMessage = useStableCallback((message: LocalMessage | null) => + messageComposer.setQuotedMessage(message), + ); + + const messageComposerAPIContextValue = useMemo( + () => ({ clearEditingState, setEditingState, setQuotedMessage }), + [clearEditingState, setEditingState, setQuotedMessage], + ); + + return ( + + + {children} + + + ); +}; + +export const useMessageComposerContext = () => { + const contextValue = useContext(MessageComposerContext) as unknown as MessageComposerContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useMessageComposerContext hook was called outside of the MessageComposerContext provider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 8d4280478b..824e217a40 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -14,6 +14,7 @@ import type { MessageContentType } from '../../contexts/messagesContext/Messages import type { DeepPartial } from '../../contexts/themeContext/ThemeContext'; import type { Theme } from '../../contexts/themeContext/utils/theme'; +import { MessageComposerAPIContextValue } from '../messageComposerContext/MessageComposerAPIContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; export type Alignment = 'right' | 'left'; @@ -119,7 +120,8 @@ export type MessageContextValue = { preventPress?: boolean; /** Whether or not the avatar show show next to Message */ showAvatar?: boolean; -} & Pick; +} & Pick & + Pick; export const MessageContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as MessageContextValue, diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 3e1a6eb549..17b2cfb425 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -1,85 +1,75 @@ import React, { - LegacyRef, PropsWithChildren, + Ref, useCallback, useContext, useEffect, - useMemo, useRef, useState, } from 'react'; import { Alert, Keyboard, Linking, TextInput, TextInputProps } from 'react-native'; -import uniq from 'lodash/uniq'; +import { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; import { - Attachment, LocalMessage, - logChatPromiseExecution, - Message, - SendFileAPIResponse, + MessageComposer, + SendMessageOptions, StreamChat, Message as StreamMessage, - UserFilters, - UserOptions, + UpdateMessageOptions, + UploadRequestFn, UserResponse, - UserSort, } from 'stream-chat'; +import { useAttachmentManagerState } from './hooks/useAttachmentManagerState'; import { useCreateMessageInputContext } from './hooks/useCreateMessageInputContext'; -import { useMessageDetailsForState } from './hooks/useMessageDetailsForState'; +import { useMessageComposer } from './hooks/useMessageComposer'; -import { isUploadAllowed, MAX_FILE_SIZE_TO_UPLOAD, prettifyFileSize } from './utils/utils'; - -import { PollContentProps, StopMessageStreamingButtonProps } from '../../components'; -import { AudioAttachmentProps } from '../../components/Attachment/AudioAttachment'; +import { + AutoCompleteSuggestionHeaderProps, + AutoCompleteSuggestionItemProps, + AutoCompleteSuggestionListProps, + PollContentProps, + StopMessageStreamingButtonProps, +} from '../../components'; import { parseLinksFromText } from '../../components/Message/MessageSimple/utils/parseLinks'; import type { AttachButtonProps } from '../../components/MessageInput/AttachButton'; +import { AttachmentUploadPreviewListProps } from '../../components/MessageInput/AttachmentUploadPreviewList'; import type { CommandsButtonProps } from '../../components/MessageInput/CommandsButton'; +import type { AttachmentUploadProgressIndicatorProps } from '../../components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; +import { AudioAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; +import { FileAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; +import { ImageAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview'; import type { AudioRecorderProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecorder'; import type { AudioRecordingButtonProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingButton'; import type { AudioRecordingInProgressProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingInProgress'; import type { AudioRecordingLockIndicatorProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator'; import type { AudioRecordingPreviewProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingPreview'; import type { AudioRecordingWaveformProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingWaveform'; +import type { CommandInputProps } from '../../components/MessageInput/components/CommandInput'; import type { InputEditingStateHeaderProps } from '../../components/MessageInput/components/InputEditingStateHeader'; -import type { InputGiphySearchProps } from '../../components/MessageInput/components/InputGiphySearch'; -import type { InputReplyStateHeaderProps } from '../../components/MessageInput/components/InputReplyStateHeader'; import type { CooldownTimerProps } from '../../components/MessageInput/CooldownTimer'; -import type { FileUploadPreviewProps } from '../../components/MessageInput/FileUploadPreview'; import { useCooldown } from '../../components/MessageInput/hooks/useCooldown'; -import type { ImageUploadPreviewProps } from '../../components/MessageInput/ImageUploadPreview'; import type { InputButtonsProps } from '../../components/MessageInput/InputButtons'; import type { MessageInputProps } from '../../components/MessageInput/MessageInput'; import type { MoreOptionsButtonProps } from '../../components/MessageInput/MoreOptionsButton'; import type { SendButtonProps } from '../../components/MessageInput/SendButton'; -import type { UploadProgressIndicatorProps } from '../../components/MessageInput/UploadProgressIndicator'; -import type { Emoji } from '../../emoji-data'; -import { - isDocumentPickerAvailable, - isImageMediaLibraryAvailable, - MediaTypes, - NativeHandlers, -} from '../../native'; -import { File, FileTypes, FileUpload } from '../../types/types'; +import { useStableCallback } from '../../hooks/useStableCallback'; import { - ACITriggerSettings, - ACITriggerSettingsParams, - TriggerSettings, -} from '../../utils/ACITriggerSettings'; + createAttachmentsCompositionMiddleware, + createDraftAttachmentsCompositionMiddleware, +} from '../../middlewares/attachments'; + +import { isDocumentPickerAvailable, MediaTypes, NativeHandlers } from '../../native'; +import { File } from '../../types/types'; import { compressedImageURI } from '../../utils/compressImage'; -import { removeReservedFields } from '../../utils/removeReservedFields'; import { - FileState, - FileStateValue, - generateRandomId, - getFileNameFromPath, - getFileTypeFromMimeType, - isBouncedMessage, -} from '../../utils/utils'; -import { useAttachmentPickerContext } from '../attachmentPickerContext/AttachmentPickerContext'; -import { ChannelContextValue, useChannelContext } from '../channelContext/ChannelContext'; + AttachmentPickerIconProps, + useAttachmentPickerContext, +} from '../attachmentPickerContext/AttachmentPickerContext'; +import { useChannelContext } from '../channelContext/ChannelContext'; import { useChatContext } from '../chatContext/ChatContext'; -import { useMessagesContext } from '../messagesContext/MessagesContext'; +import { useMessageComposerAPIContext } from '../messageComposerContext/MessageComposerAPIContext'; import { useOwnCapabilitiesContext } from '../ownCapabilitiesContext/OwnCapabilitiesContext'; import { useThreadContext } from '../threadContext/ThreadContext'; import { useTranslationContext } from '../translationContext/TranslationContext'; @@ -87,189 +77,65 @@ import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; -/** - * Function to escape special characters except . in a string and replace with '_' - * @param text - * @returns string - */ -function escapeRegExp(text: string) { - return text.replace(/[[\]{}()*+?,\\^$|#\s]/g, '_'); -} - -export type EmojiSearchIndex = { - search: (query: string) => PromiseLike> | Array | null; -}; - -export type MentionAllAppUsersQuery = { - filters?: UserFilters; - options?: UserOptions; - sort?: UserSort; -}; - export type LocalMessageInputContext = { - appendText: (newText: string) => void; - asyncIds: string[]; - asyncUploads: { - [key: string]: { - state: string; - url: string; - }; - }; - giphyEnabled: boolean; closeAttachmentPicker: () => void; /** The time at which the active cooldown will end */ cooldownEndsAt: Date; - /** - * An array of file objects which are set for upload. It has the following structure: - * - * ```json - * [ - * { - * "file": // File object, - * "id": "randomly_generated_temp_id_1", - * "state": "uploading" // or "finished", - * "url": "https://url1.com", - * }, - * { - * "file": // File object, - * "id": "randomly_generated_temp_id_2", - * "state": "uploading" // or "finished", - * "url": "https://url1.com", - * }, - * ] - * ``` - * - */ - fileUploads: FileUpload[]; - giphyActive: boolean; - hasText: boolean; - /** - * An array of image objects which are set for upload. It has the following structure: - * - * ```json - * [ - * { - * "file": // File object, - * "id": "randomly_generated_temp_id_1", - * "state": "uploading" // or "finished", - * }, - * { - * "file": // File object, - * "id": "randomly_generated_temp_id_2", - * "state": "uploading" // or "finished", - * }, - * ] - * ``` - * - */ - imageUploads: FileUpload[]; - inputBoxRef: React.MutableRefObject; - isValidMessage: () => boolean; - mentionedUsers: string[]; - numberOfUploads: number; - onChange: (newText: string) => void; - onSelectItem: (item: UserResponse) => void; + + inputBoxRef: React.RefObject; openAttachmentPicker: () => void; - openCommandsPicker: () => void; - openFilePicker: () => void; - openMentionsPicker: () => void; /** * Function for picking a photo from native image picker and uploading it. */ pickAndUploadImageFromNativePicker: () => Promise; pickFile: () => Promise; - /** - * Function for removing a file from the upload preview - * - * @param id string ID of file in `fileUploads` object in state of MessageInput - */ - removeFile: (id: string) => void; - /** - * Function for removing an image from the upload preview - * - * @param id string ID of image in `imageUploads` object in state of MessageInput - */ - removeImage: (id: string) => void; - resetInput: (pendingAttachments?: Attachment[]) => void; - selectedPicker: string | undefined; - sending: React.MutableRefObject; - sendMessage: (params?: { customMessageData?: Partial }) => Promise; - sendMessageAsync: (id: string) => void; - sendThreadMessageInChannel: boolean; - setAsyncIds: React.Dispatch>; - setAsyncUploads: React.Dispatch< - React.SetStateAction<{ - [key: string]: { - state: string; - url: string; - }; - }> - >; - setFileUploads: React.Dispatch>; - setGiphyActive: React.Dispatch>; - setImageUploads: React.Dispatch>; + selectedPicker?: 'images'; + sendMessage: () => Promise; /** * Ref callback to set reference on input box */ - setInputBoxRef: LegacyRef | undefined; - setMentionedUsers: React.Dispatch>; - setNumberOfUploads: React.Dispatch>; - setSendThreadMessageInChannel: React.Dispatch>; - setShowMoreOptions: React.Dispatch>; - setText: React.Dispatch>; - showMoreOptions: boolean; + setInputBoxRef: Ref | undefined; /** * Function for taking a photo and uploading it */ takeAndUploadImage: (mediaType?: MediaTypes) => Promise; - text: string; toggleAttachmentPicker: () => void; - /** - * Mapping of input triggers to the outputs to be displayed by the AutoCompleteInput - */ - triggerSettings: TriggerSettings; - updateMessage: () => Promise; - /** Function for attempting to upload a file */ - uploadFile: ({ newFile }: { newFile: FileUpload }) => Promise; - /** Function for attempting to upload an image */ - uploadImage: ({ newImage }: { newImage: FileUpload }) => Promise; - uploadNewFile: (file: File, fileType?: FileTypes) => Promise; - uploadNewImage: (image: File) => Promise; + uploadNewFile: (file: File) => Promise; }; export type InputMessageInputContextValue = { /** - * Controls how many pixels to the top side the user has to scroll in order to lock the recording view and allow the user to lift their finger from the screen without stopping the recording. + * Controls how many pixels to the top side the user has to scroll in order to lock the recording view and allow the + * user to lift their finger from the screen without stopping the recording. */ asyncMessagesLockDistance: number; /** - * Controls the minimum duration that the user has to press on the record button in the composer, in order to start recording a new voice message. + * Controls the minimum duration that the user has to press on the record button in the composer, in order to start + * recording a new voice message. */ asyncMessagesMinimumPressDuration: number; /** - * When it’s enabled, recorded messages won’t be sent immediately. Instead they will “stack up” in the composer allowing the user to send multiple voice recording as part of the same message. + * When it’s enabled, recorded messages won’t be sent immediately. Instead they will “stack up” in the composer + * allowing the user to send multiple voice recording as part of the same message. */ asyncMessagesMultiSendEnabled: boolean; /** - * Controls how many pixels to the leading side the user has to scroll in order to cancel the recording of a voice message. + * Controls how many pixels to the leading side the user has to scroll in order to cancel the recording of a voice + * message. */ asyncMessagesSlideToCancelDistance: number; /** * Custom UI component for attach button. * - * Defaults to and accepts same props as: [AttachButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/attach-button/) + * Defaults to and accepts same props as: + * [AttachButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/attach-button/) */ AttachButton: React.ComponentType; - /** - * Custom UI component for audio attachment upload preview. - * - * Defaults to and accepts same props as: [AudioAttachmentUploadPreview](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/Attachment/AudioAttachment.tsx) - */ - AudioAttachmentUploadPreview: React.ComponentType; /** * Custom UI component for audio recorder UI. * - * Defaults to and accepts same props as: [AudioRecorder](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/AudioRecorder.tsx) + * Defaults to and accepts same props as: + * [AudioRecorder](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/AudioRecorder.tsx) */ AudioRecorder: React.ComponentType; /** @@ -279,34 +145,114 @@ export type InputMessageInputContextValue = { /** * Custom UI component to render audio recording in progress. * - * **Default** [AudioRecordingInProgress](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx) + * **Default** + * [AudioRecordingInProgress](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx) */ AudioRecordingInProgress: React.ComponentType; /** * Custom UI component for audio recording lock indicator. * - * Defaults to and accepts same props as: [AudioRecordingLockIndicator](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx) + * Defaults to and accepts same props as: + * [AudioRecordingLockIndicator](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx) */ AudioRecordingLockIndicator: React.ComponentType; /** * Custom UI component to render audio recording preview. * - * **Default** [AudioRecordingPreview](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx) + * **Default** + * [AudioRecordingPreview](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx) */ AudioRecordingPreview: React.ComponentType; /** * Custom UI component to render audio recording waveform. * - * **Default** [AudioRecordingWaveform](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx) + * **Default** + * [AudioRecordingWaveform](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx) */ AudioRecordingWaveform: React.ComponentType; - clearEditingState: () => void; - clearQuotedMessageState: () => void; + AutoCompleteSuggestionHeader: React.ComponentType; + AutoCompleteSuggestionItem: React.ComponentType; + AutoCompleteSuggestionList: React.ComponentType; + + /** + * Custom UI component to render [draggable handle](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) of attachmentpicker. + * + * **Default** + * [AttachmentPickerBottomSheetHandle](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerBottomSheetHandle.tsx) + */ + AttachmentPickerBottomSheetHandle: React.FC; + /** + * Height of the image picker bottom sheet handle. + * @type number + * @default 20 + */ + attachmentPickerBottomSheetHandleHeight: number; + /** + * Height of the image picker bottom sheet when opened. + * @type number + * @default 40% of window height + */ + attachmentPickerBottomSheetHeight: number; + /** + * Custom UI component for AttachmentPickerSelectionBar + * + * **Default: ** + * [AttachmentPickerSelectionBar](https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx) + */ + AttachmentPickerSelectionBar: React.ComponentType; + /** + * Height of the attachment selection bar displayed on the attachment picker. + * @type number + * @default 52 + */ + attachmentSelectionBarHeight: number; + + AttachmentUploadPreviewList: React.ComponentType; + /** + * Custom UI component for [camera selector icon](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) + * + * **Default: ** + * [CameraSelectorIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/CameraSelectorIcon.tsx) + */ + CameraSelectorIcon: React.ComponentType; + /** + * Custom UI component for the poll creation icon. + * + * **Default: ** + * [CreatePollIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/CreatePollIcon.tsx) + */ + CreatePollIcon: React.ComponentType; + /** + * Custom UI component for [file selector icon](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) + * + * **Default: ** + * [FileSelectorIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/FileSelectorIcon.tsx) + */ + FileSelectorIcon: React.ComponentType; + /** + * Custom UI component for [image selector icon](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) + * + * **Default: ** + * [ImageSelectorIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/ImageSelectorIcon.tsx) + */ + ImageSelectorIcon: React.ComponentType; + /** + * Custom UI component for Android's video recorder selector icon. + * + * **Default: ** + * [VideoRecorderSelectorIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/VideoRecorderSelectorIcon.tsx) + */ + VideoRecorderSelectorIcon: React.ComponentType; + AudioAttachmentUploadPreview: React.ComponentType; + ImageAttachmentUploadPreview: React.ComponentType; + FileAttachmentUploadPreview: React.ComponentType; + VideoAttachmentUploadPreview: React.ComponentType; /** * Custom UI component for commands button. * - * Defaults to and accepts same props as: [CommandsButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/commands-button/) + * Defaults to and accepts same props as: + * [CommandsButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/commands-button/) */ CommandsButton: React.ComponentType; /** @@ -314,15 +260,14 @@ export type InputMessageInputContextValue = { * being allowed to send another message. This component is displayed in place of the * send button for the MessageInput component. * - * **default** [CooldownTimer](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/CooldownTimer.tsx) + * **default** + * [CooldownTimer](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/CooldownTimer.tsx) */ CooldownTimer: React.ComponentType; - editMessage: StreamChat['updateMessage']; - /** - * Custom UI component for FileUploadPreview. - * Defaults to and accepts same props as: https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/FileUploadPreview.tsx - */ - FileUploadPreview: React.ComponentType; + editMessage: (params: { + localMessage: LocalMessage; + options?: UpdateMessageOptions; + }) => ReturnType; /** When false, CameraSelectorIcon will be hidden */ hasCameraPicker: boolean; @@ -333,34 +278,30 @@ export type InputMessageInputContextValue = { hasFilePicker: boolean; /** When false, ImageSelectorIcon will be hidden */ hasImagePicker: boolean; - /** - * Custom UI component for ImageUploadPreview. - * Defaults to and accepts same props as: https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/ImageUploadPreview.tsx - */ - ImageUploadPreview: React.ComponentType; + InputEditingStateHeader: React.ComponentType; - InputGiphySearch: React.ComponentType; - InputReplyStateHeader: React.ComponentType; - /** Limit on allowed number of files to attach at a time. */ - maxNumberOfFiles: number; + CommandInput: React.ComponentType; + InputReplyStateHeader: React.ComponentType; /** * Custom UI component for more options button. * - * Defaults to and accepts same props as: [MoreOptionsButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/more-options-button/) + * Defaults to and accepts same props as: + * [MoreOptionsButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/more-options-button/) */ MoreOptionsButton: React.ComponentType; - /** Limit on the number of lines in the text input before scrolling */ - numberOfLines: number; /** * Custom UI component for send button. * - * Defaults to and accepts same props as: [SendButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/send-button/) + * Defaults to and accepts same props as: + * [SendButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/send-button/) */ SendButton: React.ComponentType; - sendImageAsync: boolean; - sendMessage: (message: Partial) => Promise; - setQuotedMessageState: (message: LocalMessage) => void; + sendMessage: (params: { + localMessage: LocalMessage; + message: StreamMessage; + options?: SendMessageOptions; + }) => Promise; /** * Custom UI component to render checkbox with text ("Also send to channel") in Thread's input box. * When ticked, message will also be sent in parent channel. @@ -372,28 +313,21 @@ export type InputMessageInputContextValue = { /** * Custom UI component for audio recording mic button. * - * Defaults to and accepts same props as: [AudioRecordingButton](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx) + * Defaults to and accepts same props as: + * [AudioRecordingButton](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx) */ StartAudioRecordingButton: React.ComponentType; StopMessageStreamingButton: React.ComponentType | null; /** * Custom UI component to render upload progress indicator on attachment preview. - * - * **Default** [UploadProgressIndicator](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/UploadProgressIndicator.tsx) */ - UploadProgressIndicator: React.ComponentType; + AttachmentUploadProgressIndicator: React.ComponentType; /** * Additional props for underlying TextInput component. These props will be forwarded as it is to TextInput component. * * @see See https://reactnative.dev/docs/textinput#reference */ additionalTextInputProps?: TextInputProps; - /** Max number of suggestions to display in autocomplete list. Defaults to 10. */ - autoCompleteSuggestionsLimit?: number; - /** - * Mapping of input triggers to the outputs to be displayed by the AutoCompleteInput - */ - autoCompleteTriggerSettings?: (settings: ACITriggerSettingsParams) => TriggerSettings; closePollCreationDialog?: () => void; /** * Compress image with quality (from 0 to 1, where 1 is best quality). @@ -404,52 +338,24 @@ export type InputMessageInputContextValue = { compressImageQuality?: number; /** - * Override the entire content of the CreatePoll component. The component has full access to the - * useCreatePollContext() hook. + * Override the entire content of the CreatePoll component. The component has full access to the useCreatePollContext() hook. * */ CreatePollContent?: React.ComponentType; /** * Override file upload request * - * @param file File object - { uri: '', name: '' } - * @param channel Current channel object + * @param file File object * * @overrideType Function */ - doDocUploadRequest?: ( - file: File, - channel: ChannelContextValue['channel'], - ) => Promise; + doFileUploadRequest?: UploadRequestFn; - /** - * Override image upload request - * - * @param file File object - { uri: '' } - * @param channel Current channel object - * - * @overrideType Function - */ - doImageUploadRequest?: ( - file: File, - channel: ChannelContextValue['channel'], - ) => Promise; - - /** - * Variable that tracks the editing state. - * It is defined with message type if the editing state is true, else its undefined. - */ - editing?: LocalMessage; - /** - * Prop to override the default emoji search index in auto complete suggestion list. - */ - emojiSearchIndex?: EmojiSearchIndex; /** * Handler for when the attach button is pressed. */ handleAttachButtonPress?: () => void; - /** Initial value to set on input */ - initialValue?: string; + /** * Custom UI component for AutoCompleteInput. * Has access to all of [MessageInputContext](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/contexts/messageInputContext/MessageInputContext.tsx) @@ -462,7 +368,8 @@ export type InputMessageInputContextValue = { >; /** * Custom UI component to override buttons on left side of input box - * Defaults to [InputButtons](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/InputButtons.tsx), + * Defaults to + * [InputButtons](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/InputButtons.tsx), * which contain following components/buttons: * * - AttachButton @@ -476,16 +383,7 @@ export type InputMessageInputContextValue = { * - toggleAttachmentPicker */ InputButtons?: React.ComponentType; - maxMessageLength?: number; - /** Object containing filters/sort/options overrides for an @mention user query */ - mentionAllAppUsersEnabled?: boolean; - mentionAllAppUsersQuery?: MentionAllAppUsersQuery; - /** - * Callback that is called when the text input's text changes. Changed text is passed as a single string argument to the callback handler. - */ - onChangeText?: (newText: string) => void; openPollCreationDialog?: ({ sendMessage }: Pick) => void; - quotedMessage?: LocalMessage; SendMessageDisallowedIndicator?: React.ComponentType; /** * ref for input setter function @@ -511,170 +409,65 @@ export const MessageInputProvider = ({ }: PropsWithChildren<{ value: InputMessageInputContextValue; }>) => { - const { - closePicker, - openPicker, - selectedFiles, - selectedImages, - selectedPicker, - setSelectedFiles, - setSelectedImages, - setSelectedPicker, - } = useAttachmentPickerContext(); - const { appSettings, client, enableOfflineSupport, isOnline } = useChatContext(); - const { removeMessage } = useMessagesContext(); + const { closePicker, openPicker, selectedPicker, setSelectedPicker } = + useAttachmentPickerContext(); + const { client, enableOfflineSupport } = useChatContext(); + const channelCapabilities = useOwnCapabilitiesContext(); - const getFileUploadConfig = () => { - const fileConfig = appSettings?.app?.file_upload_config; - if (fileConfig !== undefined) { - return fileConfig; - } else { - return {}; - } - }; - - const getImageUploadConfig = () => { - const imageConfig = appSettings?.app?.image_upload_config; - if (imageConfig !== undefined) { - return imageConfig; - } - return {}; - }; - - const channelCapabities = useOwnCapabilitiesContext(); - - const { channel, giphyEnabled, uploadAbortControllerRef } = useChannelContext(); + const { uploadAbortControllerRef } = useChannelContext(); + const { clearEditingState } = useMessageComposerAPIContext(); const { thread } = useThreadContext(); const { t } = useTranslationContext(); const inputBoxRef = useRef(null); - const sending = useRef(false); - const [asyncIds, setAsyncIds] = useState([]); - const [asyncUploads, setAsyncUploads] = useState<{ - [key: string]: { - state: string; - url: string; - }; - }>({}); - const [giphyActive, setGiphyActive] = useState(false); - const [sendThreadMessageInChannel, setSendThreadMessageInChannel] = useState(false); const [showPollCreationDialog, setShowPollCreationDialog] = useState(false); const defaultOpenPollCreationDialog = useCallback(() => setShowPollCreationDialog(true), []); const closePollCreationDialog = useCallback(() => setShowPollCreationDialog(false), []); - const { - editing, - initialValue, - openPollCreationDialog: openPollCreationDialogFromContext, - StopMessageStreamingButton, - } = value; + const { openPollCreationDialog: openPollCreationDialogFromContext } = value; - const { - fileUploads, - imageUploads, - mentionedUsers, - numberOfUploads, - setFileUploads, - setImageUploads, - setMentionedUsers, - setNumberOfUploads, - setShowMoreOptions, - setText, - showMoreOptions, - text, - } = useMessageDetailsForState(editing, initialValue); const { endsAt: cooldownEndsAt, start: startCooldown } = useCooldown(); - const { onChangeText, emojiSearchIndex, autoCompleteTriggerSettings } = value; - const threadId = thread?.id; - useEffect(() => { - setSendThreadMessageInChannel(false); - }, [threadId]); + const messageComposer = useMessageComposer(); + const { attachmentManager, editedMessage } = messageComposer; + const { availableUploadSlots } = useAttachmentManagerState(); - const appendText = useStableCallback((newText: string) => { - setText((prevText) => `${prevText}${newText}`); - }); - - /** Checks if the message is valid or not. Accordingly we can enable/disable send button */ - const isValidMessage = useStableCallback(() => { - if (text && text.trim()) { - return true; - } - - const imagesAndFiles = [...imageUploads, ...fileUploads]; - if (imagesAndFiles.length === 0) { - return false; + /** + * These are the RN SDK specific middlewares that are added to the message composer to provide the default behaviour. + * TODO: Discuss and decide if we provide them by default in the SDK or leave it to the user to add them if they want + * the feature. + */ + useEffect(() => { + if (value.doFileUploadRequest) { + attachmentManager.setCustomUploadFn(value.doFileUploadRequest); } if (enableOfflineSupport) { - // Allow only if none of the attachments have unsupported status - for (const file of imagesAndFiles) { - if (file.state === FileState.NOT_SUPPORTED) { - return false; - } - } - - return true; - } - - for (const file of imagesAndFiles) { - if (!file || file.state === FileState.UPLOAD_FAILED) { - continue; - } - if (file.state === FileState.UPLOADING) { - // TODO: show error to user that they should wait until image is uploaded - return false; - } - - return true; - } - - return false; - }); - - const onChange = useCallback( - (newText: string) => { - if (sending.current) { - return; - } - setText(newText); - - if (newText && channel && channelCapabities.sendTypingEvents && isOnline) { - logChatPromiseExecution(channel.keystroke(thread?.id), 'start typing event'); - } - - if (onChangeText) { - onChangeText(newText); - } - }, - [channel, channelCapabities.sendTypingEvents, isOnline, setText, thread?.id, onChangeText], - ); - - const openCommandsPicker = useStableCallback(() => { - appendText('/'); - if (inputBoxRef.current) { - inputBoxRef.current.focus(); - } - }); + messageComposer.compositionMiddlewareExecutor.replace([ + createAttachmentsCompositionMiddleware(messageComposer), + ]); - const openMentionsPicker = useStableCallback(() => { - appendText('@'); - if (inputBoxRef.current) { - inputBoxRef.current.focus(); + messageComposer.draftCompositionMiddlewareExecutor.replace([ + createDraftAttachmentsCompositionMiddleware(messageComposer), + ]); } - }); + }, [value.doFileUploadRequest, enableOfflineSupport, messageComposer, attachmentManager]); /** * Function for capturing a photo and uploading it */ const takeAndUploadImage = useStableCallback(async (mediaType?: MediaTypes) => { - setSelectedPicker(undefined); - closePicker(); + if (!availableUploadSlots) { + Alert.alert(t('Maximum number of files reached')); + return; + } + const file = await NativeHandlers.takePhoto({ compressImageQuality: value.compressImageQuality, mediaType, }); + if (file.askToOpenSettings) { Alert.alert( t('Allow camera access in device settings'), @@ -685,20 +478,23 @@ export const MessageInputProvider = ({ ], ); } - if (!file.cancelled) { - if (file.type.includes('image')) { - // We already compressed the image in the native handler, so we can upload it directly. - await uploadNewImage(file); - } else { - await uploadNewFile(file); - } + + if (file.cancelled) { + return; } + + await uploadNewFile(file); }); /** * Function for picking a photo from native image picker and uploading it */ const pickAndUploadImageFromNativePicker = useStableCallback(async () => { + if (!availableUploadSlots) { + Alert.alert(t('Maximum number of files reached')); + return; + } + const result = await NativeHandlers.pickImage(); if (result.askToOpenSettings) { Alert.alert( @@ -711,30 +507,39 @@ export const MessageInputProvider = ({ ); } - // RN CLI - if (numberOfUploads >= value.maxNumberOfFiles) { + if (result.cancelled || !result.assets?.length) { + return; + } + + result.assets.forEach(async (asset) => { + await uploadNewFile(asset); + }); + }); + + const pickFile = useStableCallback(async () => { + if (!isDocumentPickerAvailable()) { + console.log( + 'The file picker is not installed. Check our Getting Started documentation to install it.', + ); + return; + } + + if (!availableUploadSlots) { Alert.alert(t('Maximum number of files reached')); return; } - if (result.assets && result.assets.length > 0) { - // Expo - if (result.assets.length > value.maxNumberOfFiles) { - Alert.alert(t('Maximum number of files reached')); - return; - } - result.assets.forEach(async (asset) => { - if (asset.type.includes('image')) { - const compressedURI = await compressedImageURI(asset, value.compressImageQuality); - await uploadNewImage({ - ...asset, - uri: compressedURI, - }); - } else { - await uploadNewFile(asset); - } - }); + const result = await NativeHandlers.pickDocument({ + maxNumberOfFiles: availableUploadSlots, + }); + + if (result.cancelled || !result.assets?.length) { + return; } + + result.assets.forEach(async (asset) => { + await uploadNewFile(asset); + }); }); /** @@ -765,165 +570,25 @@ export const MessageInputProvider = ({ } }, [closeAttachmentPicker, openAttachmentPicker, selectedPicker]); - const onSelectItem = useStableCallback((item: UserResponse) => { - setMentionedUsers((prevMentionedUsers) => [...prevMentionedUsers, item.id]); - }); - - const pickFile = useStableCallback(async () => { - if (!isDocumentPickerAvailable()) { - console.log( - 'The file picker is not installed. Check our Getting Started documentation to install it.', - ); - return; - } - - if (numberOfUploads >= value.maxNumberOfFiles) { - Alert.alert(t('Maximum number of files reached')); - return; - } + const sendMessage = useStableCallback(async () => { + startCooldown(); - const result = await NativeHandlers.pickDocument({ - maxNumberOfFiles: value.maxNumberOfFiles - numberOfUploads, - }); - - if (!result.cancelled && result.assets) { - result.assets.forEach(async (asset) => { - if (asset.type.includes('image')) { - const compressedURI = await compressedImageURI(asset, value.compressImageQuality); - await uploadNewImage({ - ...asset, - uri: compressedURI, - }); - } else { - await uploadNewFile(asset); - } - }); + if (inputBoxRef.current) { + inputBoxRef.current.clear(); } - }); - const removeFile = useCallback( - (id: string) => { - if (fileUploads.some((file) => file.id === id)) { - setFileUploads((prevFileUploads) => prevFileUploads.filter((file) => file.id !== id)); - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); - } - }, - [fileUploads, setFileUploads, setNumberOfUploads], - ); - - const removeImage = useCallback( - (id: string) => { - if (imageUploads.some((image) => image.id === id)) { - setImageUploads((prevImageUploads) => prevImageUploads.filter((image) => image.id !== id)); - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); + try { + const composition = await messageComposer.compose(); + // This is added to ensure the input box is cleared if there's no change and user presses on the send button. + if (!composition && editedMessage) { + clearEditingState(); } - }, - [imageUploads, setImageUploads, setNumberOfUploads], - ); - - const resetInput = useStableCallback((pendingAttachments: Attachment[] = []) => { - /** - * If the MediaLibrary is available, reset the selected files and images - */ - if (isImageMediaLibraryAvailable()) { - setSelectedFiles([]); - setSelectedImages([]); - } - - setFileUploads([]); - setGiphyActive(false); - setShowMoreOptions(true); - setImageUploads([]); - setMentionedUsers([]); - setNumberOfUploads( - (prevNumberOfUploads) => prevNumberOfUploads - (pendingAttachments?.length || 0), - ); - setText(''); - if (value.editing) { - value.clearEditingState(); - } - }); - - const mapImageUploadToAttachment = useStableCallback((image: FileUpload): Attachment => { - return { - fallback: image.file.name, - image_url: image.url, - mime_type: image.file.type, - original_height: image.file.height, - original_width: image.file.width, - originalImage: image.file, - type: FileTypes.Image, - }; - }); - - const mapFileUploadToAttachment = useStableCallback((file: FileUpload): Attachment => { - if (file.type === FileTypes.Image) { - return { - fallback: file.file.name, - image_url: file.url, - mime_type: file.file.type, - original_height: file.file.height, - original_width: file.file.width, - originalFile: file.file, - type: FileTypes.Image, - }; - } else if (file.type === FileTypes.Audio) { - return { - asset_url: file.url || file.file.uri, - duration: file.file.duration, - file_size: file.file.size, - mime_type: file.file.type, - originalFile: file.file, - title: file.file.name, - type: FileTypes.Audio, - }; - } else if (file.type === FileTypes.Video) { - return { - asset_url: file.url || file.file.uri, - duration: file.file.duration, - file_size: file.file.size, - mime_type: file.file.type, - originalFile: file.file, - thumb_url: file.thumb_url, - title: file.file.name, - type: FileTypes.Video, - }; - } else if (file.type === FileTypes.VoiceRecording) { - return { - asset_url: file.url || file.file.uri, - duration: file.file.duration, - file_size: file.file.size, - mime_type: file.file.type, - originalFile: file.file, - title: file.file.name, - type: FileTypes.VoiceRecording, - waveform_data: file.file.waveform_data, - }; - } else { - return { - asset_url: file.url || file.file.uri, - file_size: file.file.size, - mime_type: file.file.type, - originalFile: file.file, - title: file.file.name, - type: FileTypes.File, - }; - } - }); + if (!composition || !composition.message) return; - // TODO: Figure out why this is async, as it doesn't await any promise. - const sendMessage = useStableCallback( - async ({ - customMessageData, - }: { - customMessageData?: Partial; - } = {}) => { - if (sending.current) { - return; - } - const linkInfos = parseLinksFromText(text); + const { localMessage, message, sendOptions } = composition; + const linkInfos = parseLinksFromText(localMessage.text); - if (!channelCapabities.sendLinks && linkInfos.length > 0) { + if (!channelCapabilities.sendLinks && linkInfos.length > 0) { Alert.alert( t('Links are disabled'), t('Sending links is not allowed in this conversation'), @@ -932,174 +597,38 @@ export const MessageInputProvider = ({ return; } - sending.current = true; - - startCooldown(); - - const prevText = giphyEnabled && giphyActive ? `/giphy ${text}` : text; - setText(''); - - if (inputBoxRef.current) { - inputBoxRef.current.clear(); - } - - const attachments = [] as Attachment[]; - for (const image of imageUploads) { - if (enableOfflineSupport) { - if (image.state === FileState.NOT_SUPPORTED) { - return; - } - attachments.push(mapImageUploadToAttachment(image)); - continue; - } - - if ((!image || image.state === FileState.UPLOAD_FAILED) && !enableOfflineSupport) { - continue; - } - - if (image.state === FileState.UPLOADING) { - // TODO: show error to user that they should wait until image is uploaded - if (value.sendImageAsync) { - /** - * If user hit send before image uploaded, push ID into a queue to later - * be matched with the successful CDN response - */ - setAsyncIds((prevAsyncIds) => [...prevAsyncIds, image.id]); - } else { - sending.current = false; - return setText(prevText); - } - } - - // To get the mime type of the image from the file name and send it as an response for an image - if (image.state === FileState.UPLOADED || image.state === FileState.FINISHED) { - attachments.push(mapImageUploadToAttachment(image)); - } - } - - for (const file of fileUploads) { - if (enableOfflineSupport) { - if (file.state === FileState.NOT_SUPPORTED) { - return; - } - attachments.push(mapFileUploadToAttachment(file)); - continue; - } - - if (!file || file.state === FileState.UPLOAD_FAILED) { - continue; - } - - if (file.state === FileState.UPLOADING) { - // TODO: show error to user that they should wait until image is uploaded - sending.current = false; - return; - } - - if (file.state === FileState.UPLOADED || file.state === FileState.FINISHED) { - attachments.push(mapFileUploadToAttachment(file)); + if (editedMessage && editedMessage.type !== 'error') { + try { + clearEditingState(); + await value.editMessage({ localMessage, options: sendOptions }); + } catch (error) { + throw new Error('Error while editing message'); } - } - - // Disallow sending message if its empty. - if (!prevText && attachments.length === 0 && !customMessageData?.poll_id) { - sending.current = false; - return; - } - - const message = value.editing; - if (message && message.type !== 'error') { - const updatedMessage = { - ...message, - attachments, - mentioned_users: mentionedUsers.map((userId) => ({ id: userId })), - quoted_message: undefined, - text: prevText, - ...customMessageData, - } as Parameters[0]; - - // TODO: Remove this line and show an error when submit fails - value.clearEditingState(); - - const updateMessagePromise = value - .editMessage( - // @ts-ignore - removeReservedFields(updatedMessage), - ) - .then(value.clearEditingState); - logChatPromiseExecution(updateMessagePromise, 'update message'); - resetInput(attachments); - - sending.current = false; } else { try { - /** - * If the message is bounced by moderation, we firstly remove the message from message list and then send a new message. - */ - if (message && isBouncedMessage(message)) { - await removeMessage(message); - } - value.sendMessage({ - attachments, - mentioned_users: uniq(mentionedUsers), - /** Parent message id - in case of thread */ - parent_id: thread?.id, - quoted_message_id: value.quotedMessage ? value.quotedMessage.id : undefined, - show_in_channel: sendThreadMessageInChannel || undefined, - text: prevText, - ...customMessageData, - } as unknown as StreamMessage); - - value.clearQuotedMessageState(); - sending.current = false; - resetInput(attachments); - } catch (_error) { - sending.current = false; - if (value.quotedMessage && typeof value.quotedMessage !== 'boolean') { - value.setQuotedMessageState(value.quotedMessage); + // Since the message id does not get cleared, we have to handle this manually + // and let the poll creation dialog handle clearing the rest of the state. Once + // sending a message has been moved to the composer as an API, this will be + // redundant and can be removed. + if (localMessage.poll_id) { + messageComposer.state.partialNext({ + id: MessageComposer.generateId(), + pollId: null, + }); + } else { + messageComposer.clear(); } - setText(prevText.slice(giphyEnabled && giphyActive ? 7 : 0)); // 7 because of '/giphy ' length - console.log('Failed to send message'); + await value.sendMessage({ + localMessage, + message, + options: sendOptions, + }); + } catch (error) { + throw new Error('Error while sending message'); } } - }, - ); - - const sendMessageAsync = useStableCallback((id: string) => { - const image = asyncUploads[id]; - if (!image || image.state === FileState.UPLOAD_FAILED) { - return; - } - - if (image.state === FileState.UPLOADED || image.state === FileState.FINISHED) { - const attachments = [ - { - image_url: image.url, - type: FileTypes.Image, - }, - ] as StreamMessage['attachments']; - - startCooldown(); - try { - value.sendMessage({ - attachments, - mentioned_users: [], - parent_id: thread?.id, - quoted_message_id: value.quotedMessage ? value.quotedMessage.id : undefined, - show_in_channel: sendThreadMessageInChannel || undefined, - text: '', - } as unknown as Partial); - - setAsyncIds((prevAsyncIds) => prevAsyncIds.splice(prevAsyncIds.indexOf(id), 1)); - setAsyncUploads((prevAsyncUploads) => { - delete prevAsyncUploads[id]; - return prevAsyncUploads; - }); - - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); - } catch (_error) { - console.log('Failed'); - } + } catch (error) { + console.error('Error while sending message:', error); } }); @@ -1110,324 +639,23 @@ export const MessageInputProvider = ({ } }); - const triggerSettings = useMemo(() => { - try { - let triggerSettings: TriggerSettings = {}; - if (channel) { - if (autoCompleteTriggerSettings) { - triggerSettings = autoCompleteTriggerSettings({ - channel, - client, - emojiSearchIndex, - onMentionSelectItem: onSelectItem, - }); - } else { - triggerSettings = ACITriggerSettings({ - channel, - client, - emojiSearchIndex, - onMentionSelectItem: onSelectItem, - }); - } - } - return triggerSettings; - } catch (error) { - console.warn('Error in getting trigger settings', error); - throw error; - } - }, [channel, client, onSelectItem, autoCompleteTriggerSettings, emojiSearchIndex]); - - // const triggerSettings = getTriggerSettings(); - - const updateMessage = useStableCallback(async () => { - try { - if (value.editing) { - await client.updateMessage({ - ...value.editing, - quoted_message: undefined, - text: giphyEnabled && giphyActive ? `/giphy ${text}` : text, - } as Parameters[0]); - } - - value.clearEditingState(); - resetInput(); - } catch (error) { - console.log(error); - } - }); - - const regexCondition = /File (extension \.\w{2,4}|type \S+) is not supported/; - - const getUploadSetStateAction = useStableCallback( - ( - id: string, - fileState: FileStateValue, - extraData: Partial = {}, - ): React.SetStateAction => - (prevUploads: UploadType[]) => - prevUploads.map((prevUpload) => { - if (prevUpload.id === id) { - return { - ...prevUpload, - ...extraData, - state: fileState, - }; - } - return prevUpload; - }), - ); - - const handleFileOrImageUploadError = useStableCallback( - (error: unknown, isImageError: boolean, id: string) => { - if (isImageError) { - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); - if (error instanceof Error) { - if (regexCondition.test(error.message)) { - return setImageUploads(getUploadSetStateAction(id, FileState.NOT_SUPPORTED)); - } - - return setImageUploads(getUploadSetStateAction(id, FileState.UPLOAD_FAILED)); - } - } else { - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); - - if (error instanceof Error) { - if (regexCondition.test(error.message)) { - return setFileUploads(getUploadSetStateAction(id, FileState.NOT_SUPPORTED)); - } - return setFileUploads(getUploadSetStateAction(id, FileState.UPLOAD_FAILED)); - } - } - }, - ); - - const uploadFile = useStableCallback(async ({ newFile }: { newFile: FileUpload }) => { - const { file, id } = newFile; - - // The file name can have special characters, so we escape it. - const filename = escapeRegExp(file.name); - - setFileUploads(getUploadSetStateAction(id, FileState.UPLOADING)); - - let response: Partial = {}; - try { - if (value.doDocUploadRequest) { - response = await value.doDocUploadRequest(file, channel); - } else if (channel && file.uri) { - uploadAbortControllerRef.current.set( - filename, - client.createAbortControllerForNextRequest(), - ); - // Compress images selected through file picker when uploading them - if (file.type?.includes('image')) { - const compressedUri = await compressedImageURI(file, value.compressImageQuality); - response = await channel.sendFile(compressedUri, filename, file.type); - } else { - response = await channel.sendFile(file.uri, filename, file.type); - } - uploadAbortControllerRef.current.delete(filename); - } - - const extraData: Partial = { - thumb_url: response.thumb_url, - url: response.file, - }; - setFileUploads(getUploadSetStateAction(id, FileState.UPLOADED, extraData)); - } catch (error: unknown) { - if ( - error instanceof Error && - (error.name === 'AbortError' || error.name === 'CanceledError') - ) { - // nothing to do - uploadAbortControllerRef.current.delete(filename); - return; - } - handleFileOrImageUploadError(error, false, id); - } - }); - - const uploadImage = useStableCallback(async ({ newImage }: { newImage: FileUpload }) => { - const { file, id } = newImage || {}; - - if (!file) { - return; - } - - let response = {} as SendFileAPIResponse; - - const uri = file.uri || ''; - // The file name can have special characters, so we escape it. - const filename = escapeRegExp(file.name ?? getFileNameFromPath(uri)); - + const uploadNewFile = useStableCallback(async (file: File) => { try { - const contentType = file.type || 'multipart/form-data'; - if (value.doImageUploadRequest) { - response = await value.doImageUploadRequest(file, channel); - } else if (channel) { - if (value.sendImageAsync) { - uploadAbortControllerRef.current.set( - filename, - client.createAbortControllerForNextRequest(), - ); - channel.sendImage(file.uri, filename, contentType).then( - (res) => { - uploadAbortControllerRef.current.delete(filename); - if (asyncIds.includes(id)) { - // Evaluates to true if user hit send before image successfully uploaded - setAsyncUploads((prevAsyncUploads) => { - prevAsyncUploads[id] = { - ...prevAsyncUploads[id], - state: FileState.UPLOADED, - url: res.file, - }; - return prevAsyncUploads; - }); - } else { - const newImageUploads = getUploadSetStateAction( - id, - FileState.UPLOADED, - { - url: res.file, - }, - ); - setImageUploads(newImageUploads); - } - }, - () => { - uploadAbortControllerRef.current.delete(filename); - }, - ); - } else { - uploadAbortControllerRef.current.set( - filename, - client.createAbortControllerForNextRequest(), - ); - response = await channel.sendImage(file.uri, filename, contentType); - uploadAbortControllerRef.current.delete(filename); - } - } - - if (Object.keys(response).length) { - const newImageUploads = getUploadSetStateAction(id, FileState.UPLOADED, { - height: file.height, - url: response.file, - width: file.width, - }); - setImageUploads(newImageUploads); - } + uploadAbortControllerRef.current.set(file.name, client.createAbortControllerForNextRequest()); + const fileURI = file.type.includes('image') + ? await compressedImageURI(file, value.compressImageQuality) + : file.uri; + const updatedFile = { ...file, uri: fileURI }; + await attachmentManager.uploadFiles([updatedFile]); + uploadAbortControllerRef.current.delete(file.name); } catch (error) { if ( error instanceof Error && (error.name === 'AbortError' || error.name === 'CanceledError') ) { - // nothing to do - uploadAbortControllerRef.current.delete(filename); + uploadAbortControllerRef.current.delete(file.name); return; } - handleFileOrImageUploadError(error, true, id); - } - }); - - /** - * The fileType is optional and is used to override the file type detection. - * This is useful for voice recordings, where the file type is not always detected correctly. - * This will change if we unify the file uploads to attachments. - */ - const uploadNewFile = useStableCallback(async (file: File, fileType?: FileTypes) => { - try { - const id: string = generateRandomId(); - const fileConfig = getFileUploadConfig(); - const { size_limit } = fileConfig; - - const isAllowed = isUploadAllowed({ config: fileConfig, file }); - - const sizeLimit = size_limit || MAX_FILE_SIZE_TO_UPLOAD; - - if (file.size && file.size > sizeLimit) { - Alert.alert( - t('File is too large: {{ size }}, maximum upload size is {{ limit }}', { - limit: prettifyFileSize(sizeLimit), - size: prettifyFileSize(file.size), - }), - ); - setSelectedFiles(selectedFiles.filter((selectedFile) => selectedFile.uri !== file.uri)); - return; - } - - const fileState = isAllowed ? FileState.UPLOADING : FileState.NOT_SUPPORTED; - const derivedFileType = fileType ?? getFileTypeFromMimeType(file.type); - - const newFile: FileUpload = { - duration: file.duration || 0, - file, - id, - mime_type: file.type, - state: fileState, - thumb_url: file.thumb_url, - type: derivedFileType, - url: file.uri, - }; - - await Promise.all([ - setFileUploads((prevFileUploads) => prevFileUploads.concat([newFile])), - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads + 1), - ]); - - if (isAllowed) { - await uploadFile({ newFile }); - } - } catch (error) { - console.log('Error uploading file', error); - } - }); - - const uploadNewImage = useStableCallback(async (image: File) => { - try { - const id = generateRandomId(); - const imageUploadConfig = getImageUploadConfig(); - - const { size_limit } = imageUploadConfig; - - const isAllowed = isUploadAllowed({ config: imageUploadConfig, file: image }); - - const sizeLimit = size_limit || MAX_FILE_SIZE_TO_UPLOAD; - - if (image.size && image?.size > sizeLimit) { - Alert.alert( - t('File is too large: {{ size }}, maximum upload size is {{ limit }}', { - limit: prettifyFileSize(sizeLimit), - size: prettifyFileSize(image.size), - }), - ); - setSelectedImages( - selectedImages.filter((selectedImage) => selectedImage.uri !== image.uri), - ); - return; - } - - const imageState = isAllowed ? FileState.UPLOADING : FileState.NOT_SUPPORTED; - - const newImage: FileUpload = { - file: image, - height: image.height, - id, - mime_type: image.type, - state: imageState, - type: FileTypes.Image, - url: image.uri, - width: image.width, - }; - - await Promise.all([ - setImageUploads((prevImageUploads) => prevImageUploads.concat([newImage])), - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads + 1), - ]); - - if (isAllowed) { - await uploadImage({ newImage }); - } - } catch (error) { - console.log('Error uploading image', error); } }); @@ -1440,63 +668,23 @@ export const MessageInputProvider = ({ }); const messageInputContext = useCreateMessageInputContext({ - appendText, - asyncIds, - asyncUploads, closeAttachmentPicker, cooldownEndsAt, - fileUploads, - giphyActive, - giphyEnabled, - imageUploads, inputBoxRef, - isValidMessage, - mentionedUsers, - numberOfUploads, - onChange, - onSelectItem, openAttachmentPicker, - openCommandsPicker, - openFilePicker: pickFile, - openMentionsPicker, pickAndUploadImageFromNativePicker, pickFile, - removeFile, - removeImage, - resetInput, - selectedPicker, - sending, - sendMessageAsync, - sendThreadMessageInChannel, - setAsyncIds, - setAsyncUploads, - setFileUploads, - setGiphyActive, - setImageUploads, setInputBoxRef, - setMentionedUsers, - setNumberOfUploads, - setSendThreadMessageInChannel, - setShowMoreOptions, - setText, - showMoreOptions, takeAndUploadImage, - text, thread, toggleAttachmentPicker, - triggerSettings, - updateMessage, - uploadFile, - uploadImage, uploadNewFile, - uploadNewImage, ...value, closePollCreationDialog, - hasText: !!text, openPollCreationDialog, + selectedPicker, sendMessage, // overriding the originally passed in sendMessage showPollCreationDialog, - StopMessageStreamingButton, }); return ( { return contextValue; }; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const useStableCallback = (callback: T): T => { - const ref = useRef(callback); - ref.current = callback; - return useCallback(((...args: unknown[]) => ref.current(...args)) as unknown as T, []); -}; diff --git a/package/src/contexts/messageInputContext/__tests__/MessageInputContext.test.tsx b/package/src/contexts/messageInputContext/__tests__/MessageInputContext.test.tsx deleted file mode 100644 index c3aac6c485..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/MessageInputContext.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { PropsWithChildren } from 'react'; - -import type { TextInput } from 'react-native'; - -import { act, renderHook, waitFor } from '@testing-library/react-native'; -import type { AppSettingsAPIResponse, StreamChat } from 'stream-chat'; - -import { ChatContextValue, ChatProvider } from '../../../contexts/chatContext/ChatContext'; - -import { generateImageAttachment } from '../../../mock-builders/generator/attachment'; - -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; - -import { FileState } from '../../../utils/utils'; -import { - InputMessageInputContextValue, - MessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -type WrapperType = Partial; - -afterEach(jest.clearAllMocks); - -const user1 = generateUser(); -const message = generateMessage({ user: user1 }); -describe('MessageInputContext', () => { - const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - - {children} - - - ); - - it('appendText works', () => { - const initialProps = { - editing: message, - hasImagePicker: true, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - act(() => { - result.current.setFileUploads([]); - result.current.setImageUploads([]); - result.current.setMentionedUsers([]); - result.current.setText('dummy'); - result.current.appendText('text'); - }); - - expect(result.current.text).toBe('dummytext'); - }); - - it('uploadNewImage with blocked image extensions to be not supported', () => { - const initialProps = { - editing: message, - maxNumberOfFiles: 2, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - act(() => { - result.current.uploadNewImage( - generateImageAttachment({ - name: 'dummy.png', - uri: 'https://www.bastiaanmulder.nl/wp-content/uploads/2013/11/dummy-image-square.png', - }), - ); - }); - - expect(result.current.imageUploads[0].state).toBe(FileState.NOT_SUPPORTED); - - act(() => { - result.current.uploadNewFile({ - name: 'dummy.mp3', - uri: 'https://www.bastiaanmulder.nl/wp-content/uploads/2013/11/dummy.mp3', - }); - }); - expect(result.current.imageUploads[0].state).toBe(FileState.NOT_SUPPORTED); - }); - - it('onSelectItem works', () => { - const mentioned_user = generateUser(); - const initialUsers = [mentioned_user.id]; - - const initialProps = { - editing: message, - hasImagePicker: true, - }; - - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - act(() => { - result.current.setFileUploads([]); - result.current.setImageUploads([]); - result.current.setMentionedUsers(initialUsers); - result.current.onSelectItem(mentioned_user); - }); - - expect(result.current.mentionedUsers.length).toBe(2); - }); - - it('setInputBoxRef works', () => { - const setInputRefMock = jest.fn(); - const inputRef = React.createRef(); - const initialProps = { - editing: message, - setInputRef: setInputRefMock, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - act(() => { - if (result.current.setInputBoxRef) { - result.current.setInputBoxRef(inputRef.current); - } - }); - - expect(setInputRefMock).toHaveBeenCalled(); - }); - - it('openCommandsPicker works', () => { - const initialProps = { - editing: message, - hasImagePicker: true, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - act(() => { - result.current.openCommandsPicker(); - }); - - expect(result.current.text).toBe(`${initialProps.editing.text}/`); - }); - - it('openMentionPicker works', async () => { - const initialProps = { - editing: message, - hasImagePicker: true, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - act(() => { - result.current.openMentionsPicker(); - }); - - await waitFor(() => { - expect(result.current.text).toBe(`${initialProps.editing.text}@`); - }); - }); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/__snapshots__/sendMessage.test.tsx.snap b/package/src/contexts/messageInputContext/__tests__/__snapshots__/sendMessage.test.tsx.snap deleted file mode 100644 index a4dd5829ed..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/__snapshots__/sendMessage.test.tsx.snap +++ /dev/null @@ -1,141 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MessageInputContext's sendMessage exit sendMessage when edit message is not boolean image upload status is uploaded successfully 1`] = ` -{ - "attachments": [ - { - "fallback": undefined, - "image_url": undefined, - "mime_type": undefined, - "originalImage": { - "uri": "http://www.jackblack.com/tenac_iousd.bmp", - }, - "original_height": undefined, - "original_width": undefined, - "type": "image", - }, - ], - "html": "

regular

", - "id": "7a85f744-cc89-4f82-a1d4-5456432cc8bf", - "mentioned_users": [ - { - "id": "dummy1", - }, - { - "id": "dummy2", - }, - ], - "quoted_message": undefined, - "text": "", - "user": { - "banned": false, - "created_at": "2020-04-27T13:39:49.331742Z", - "id": "5d6f6322-567e-4e1e-af90-97ef1ed5cc23", - "image": "fc86ddcb-bac4-400c-9afd-b0c0a1c0cd33", - "name": "50cbdd0e-ca7e-4478-9e2c-be0f1ac6a995", - "online": false, - "role": "user", - "updated_at": "2020-04-27T13:39:49.332087Z", - }, -} -`; - -exports[`MessageInputContext's sendMessage exit sendMessage when file upload status is uploaded successfully 1`] = ` -{ - "attachments": [ - { - "asset_url": undefined, - "file_size": undefined, - "mime_type": "file", - "originalFile": { - "name": "dummy.pdf", - "state": "uploaded", - "type": "file", - }, - "title": "dummy.pdf", - "type": "file", - }, - { - "asset_url": undefined, - "file_size": undefined, - "mime_type": "video/mp4", - "originalFile": { - "name": "dummy.pdf", - "state": "finished", - "type": "video/mp4", - }, - "title": "dummy.pdf", - "type": "file", - }, - { - "asset_url": undefined, - "file_size": undefined, - "mime_type": "audio/mp3", - "originalFile": { - "name": "dummy.pdf", - "state": "uploaded", - "type": "audio/mp3", - }, - "title": "dummy.pdf", - "type": "file", - }, - { - "asset_url": undefined, - "file_size": undefined, - "mime_type": "image/jpeg", - "originalFile": { - "name": "dummy.pdf", - "state": "finished", - "type": "image/jpeg", - }, - "title": "dummy.pdf", - "type": "file", - }, - ], - "mentioned_users": [ - "dummy1", - "dummy2", - ], - "parent_id": undefined, - "quoted_message_id": undefined, - "show_in_channel": undefined, - "text": "", -} -`; - -exports[`MessageInputContext's sendMessage exit sendMessage when image upload status is uploaded successfully 1`] = ` -{ - "attachments": [ - { - "fallback": undefined, - "image_url": undefined, - "mime_type": undefined, - "originalImage": { - "uri": "http://www.jackblack.com/tenac_iousd.bmp", - }, - "original_height": undefined, - "original_width": undefined, - "type": "image", - }, - { - "fallback": undefined, - "image_url": undefined, - "mime_type": undefined, - "originalImage": { - "uri": "http://www.jackblack.com/tenac_iousd.bmp", - }, - "original_height": undefined, - "original_width": undefined, - "type": "image", - }, - ], - "mentioned_users": [ - "dummy1", - "dummy2", - ], - "parent_id": undefined, - "quoted_message_id": undefined, - "show_in_channel": undefined, - "text": "", -} -`; diff --git a/package/src/contexts/messageInputContext/__tests__/__snapshots__/sendMessageAsync.test.tsx.snap b/package/src/contexts/messageInputContext/__tests__/__snapshots__/sendMessageAsync.test.tsx.snap deleted file mode 100644 index f276725051..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/__snapshots__/sendMessageAsync.test.tsx.snap +++ /dev/null @@ -1,33 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MessageInputContext's sendMessageAsync sendImageAsync is been called with finished file upload state and checked for snapshot) 1`] = ` -{ - "attachments": [ - { - "image_url": "https://www.test.com", - "type": "image", - }, - ], - "mentioned_users": [], - "parent_id": undefined, - "quoted_message_id": undefined, - "show_in_channel": undefined, - "text": "", -} -`; - -exports[`MessageInputContext's sendMessageAsync sendImageAsync is been called with uploaded file upload state and checked for snapshot) 1`] = ` -{ - "attachments": [ - { - "image_url": "https://www.test.com", - "type": "image", - }, - ], - "mentioned_users": [], - "parent_id": undefined, - "quoted_message_id": undefined, - "show_in_channel": undefined, - "text": "", -} -`; diff --git a/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx b/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx new file mode 100644 index 0000000000..6585f39e1f --- /dev/null +++ b/package/src/contexts/messageInputContext/__tests__/filePickers.test.tsx @@ -0,0 +1,327 @@ +import React from 'react'; +import { Alert } from 'react-native'; + +import { cleanup, renderHook, waitFor } from '@testing-library/react-native'; + +import { Chat } from '../../../components'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateFileReference } from '../../../mock-builders/attachments'; + +import { NativeHandlers } from '../../../native'; + +import { ChannelContextValue, ChannelProvider } from '../../channelContext/ChannelContext'; +import { MessageComposerProvider } from '../../messageComposerContext/MessageComposerContext'; +import { + InputMessageInputContextValue, + MessageInputProvider, + useMessageInputContext, +} from '../MessageInputContext'; + +jest.spyOn(Alert, 'alert'); + +const Wrapper = ({ channel, client, props }) => { + return ( + + + + + {props.children} + + + + + ); +}; + +describe("MessageInputContext's pickFile", () => { + let channel; + let chatClient; + + beforeEach(async () => { + const { client, channels } = await initiateClientWithChannels(); + channel = channels[0]; + chatClient = client; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + const initialProps = {}; + + it('run pickFile when availableUploadSlots is 0 and alert is triggered 1 number of times', async () => { + jest + .spyOn(channel.messageComposer.attachmentManager, 'availableUploadSlots', 'get') + .mockReturnValue(0); + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + await waitFor(() => { + result.current.pickFile(); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + expect(Alert.alert).toHaveBeenCalledWith('Maximum number of files reached'); + }); + + it.each([ + [false, undefined, true], + [false, undefined, false], + [false, [generateFileReference(), generateFileReference()], true], + [false, [], false], + [true, [generateFileReference(), generateFileReference()], false], + ])( + 'allow uploads %p when pickDocument returns assets %p and cancelled %p', + async (allowed, assets, cancelled) => { + const { attachmentManager } = channel.messageComposer; + jest.spyOn(NativeHandlers, 'pickDocument').mockImplementation( + jest.fn().mockResolvedValue({ + assets, + cancelled, + }), + ); + + jest.spyOn(attachmentManager, 'availableUploadSlots', 'get').mockReturnValue(2); + + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + const uploadFilesSpy = jest.spyOn(attachmentManager, 'uploadFiles'); + + await waitFor(() => { + result.current.pickFile(); + }); + + await waitFor(() => { + expect(NativeHandlers.pickDocument).toHaveBeenCalledTimes(1); + expect(NativeHandlers.pickDocument).toHaveBeenCalledWith({ maxNumberOfFiles: 2 }); + if (allowed) { + expect(uploadFilesSpy).toHaveBeenCalledTimes(2); + } else { + expect(uploadFilesSpy).not.toHaveBeenCalled(); + } + }); + }, + ); +}); + +describe("MessageInputContext's pickAndUploadImageFromNativePicker", () => { + let channel; + let chatClient; + + beforeEach(async () => { + const { client, channels } = await initiateClientWithChannels(); + channel = channels[0]; + chatClient = client; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + const initialProps = {}; + + it('run pickAndUploadImageFromNativePicker when availableUploadSlots is 0 and alert is triggered 1 number of times', async () => { + jest + .spyOn(channel.messageComposer.attachmentManager, 'availableUploadSlots', 'get') + .mockReturnValue(0); + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + await waitFor(() => { + result.current.pickAndUploadImageFromNativePicker(); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + expect(Alert.alert).toHaveBeenCalledWith('Maximum number of files reached'); + }); + + it('should show permissions alert when askToOpenSettings is true', async () => { + jest + .spyOn(channel.messageComposer.attachmentManager, 'availableUploadSlots', 'get') + .mockReturnValue(2); + + jest.spyOn(NativeHandlers, 'pickImage').mockImplementation( + jest.fn().mockResolvedValue({ + askToOpenSettings: true, + }), + ); + + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + await waitFor(() => { + result.current.pickAndUploadImageFromNativePicker(); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + }); + + it.each([ + [false, undefined, true], + [false, undefined, false], + [false, [generateFileReference(), generateFileReference()], true], + [false, [], false], + [true, [generateFileReference(), generateFileReference()], false], + ])( + 'allow uploads %p when pickImage returns assets %p and cancelled %p', + async (allowed, assets, cancelled) => { + const { attachmentManager } = channel.messageComposer; + jest.spyOn(NativeHandlers, 'pickImage').mockImplementation( + jest.fn().mockResolvedValue({ + assets, + cancelled, + }), + ); + + jest.spyOn(attachmentManager, 'availableUploadSlots', 'get').mockReturnValue(2); + + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + const uploadFilesSpy = jest.spyOn(attachmentManager, 'uploadFiles'); + + await waitFor(() => { + result.current.pickAndUploadImageFromNativePicker(); + }); + + await waitFor(() => { + expect(NativeHandlers.pickImage).toHaveBeenCalledTimes(1); + if (allowed) { + expect(uploadFilesSpy).toHaveBeenCalledTimes(2); + } else { + expect(uploadFilesSpy).not.toHaveBeenCalled(); + } + }); + }, + ); +}); + +describe("MessageInputContext's takeAndUploadImage", () => { + let channel; + let chatClient; + + beforeEach(async () => { + const { client, channels } = await initiateClientWithChannels(); + channel = channels[0]; + chatClient = client; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + const initialProps = {}; + + it('run takeAndUploadImage when availableUploadSlots is 0 and alert is triggered 1 number of times', async () => { + jest + .spyOn(channel.messageComposer.attachmentManager, 'availableUploadSlots', 'get') + .mockReturnValue(0); + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + await waitFor(() => { + result.current.takeAndUploadImage(); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + expect(Alert.alert).toHaveBeenCalledWith('Maximum number of files reached'); + }); + + it('should show permissions alert when askToOpenSettings is true', async () => { + jest + .spyOn(channel.messageComposer.attachmentManager, 'availableUploadSlots', 'get') + .mockReturnValue(2); + + jest.spyOn(NativeHandlers, 'takePhoto').mockImplementation( + jest.fn().mockResolvedValue({ + askToOpenSettings: true, + }), + ); + + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + await waitFor(() => { + result.current.takeAndUploadImage(); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + }); + + it.each([ + [false, undefined, true], + [false, undefined, false], + [false, generateFileReference(), true], + [false, [], false], + [true, generateFileReference(), false], + ])( + 'allow uploads %p when pickImage returns assets %p and cancelled %p', + async (allowed, asset, cancelled) => { + const { attachmentManager } = channel.messageComposer; + jest.spyOn(NativeHandlers, 'takePhoto').mockImplementation( + jest.fn().mockResolvedValue({ + ...asset, + cancelled, + }), + ); + + jest.spyOn(attachmentManager, 'availableUploadSlots', 'get').mockReturnValue(2); + + const { result } = renderHook(() => useMessageInputContext(), { + initialProps, + wrapper: (props) => , + }); + + const uploadFilesSpy = jest.spyOn(attachmentManager, 'uploadFiles'); + + await waitFor(() => { + result.current.takeAndUploadImage(); + }); + + await waitFor(() => { + expect(NativeHandlers.takePhoto).toHaveBeenCalledTimes(1); + expect(NativeHandlers.takePhoto).toHaveBeenCalledWith({ + compressImageQuality: undefined, + mediaType: undefined, + }); + if (allowed) { + expect(uploadFilesSpy).toHaveBeenCalledTimes(1); + } else { + expect(uploadFilesSpy).not.toHaveBeenCalled(); + } + }); + }, + ); +}); diff --git a/package/src/contexts/messageInputContext/__tests__/isValidMessage.test.tsx b/package/src/contexts/messageInputContext/__tests__/isValidMessage.test.tsx deleted file mode 100644 index 9ad3fbda9f..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/isValidMessage.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { PropsWithChildren } from 'react'; -import { act } from 'react-test-renderer'; - -import { renderHook } from '@testing-library/react-native'; - -import { - generateFileUploadPreview, - generateImageUploadPreview, -} from '../../../mock-builders/generator/attachment'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; - -import { FileState } from '../../../utils/utils'; -import { - InputMessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -const user1 = generateUser(); -const message = generateMessage({ user: user1 }); - -type WrapperType = Partial; - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - {children} - -); - -describe("MessageInputContext's isValidMessage", () => { - const initialProps = { - editing: message, - hasImagePicker: true, - }; - - it.each([ - [[], [], [], 0, '', false], - [[], [], [], 0, 'Dummy Text', true], - [[generateFileUploadPreview()], [], [], 0, '', true], - [[], [generateImageUploadPreview()], [], 0, '', true], - [[], [generateImageUploadPreview({ state: FileState.UPLOAD_FAILED })], [], 0, '', false], - [[generateFileUploadPreview({ state: FileState.UPLOAD_FAILED })], [], [], 0, '', false], - [[generateFileUploadPreview({ state: FileState.UPLOADING })], [], [], 0, '', false], - [[], [generateImageUploadPreview({ state: FileState.UPLOADING })], [], 0, '', false], - ])( - 'isValidMessage with fileUploads %p, imageUploads %p, mentionedUsers %p, numberOfUploads %d, text %s gives %p', - (fileUploads, imageUploads, mentionedUsers, numberOfUploads, text, isValidMessageStatus) => { - const { result } = renderHook(() => useMessageInputContext(), { - wrapper: (props) => ( - - ), - }); - - act(() => { - result.current.setFileUploads(fileUploads); - result.current.setImageUploads(imageUploads); - result.current.setMentionedUsers(mentionedUsers); - result.current.setNumberOfUploads(numberOfUploads); - result.current.setText(text); - }); - - expect(result.current.isValidMessage()).toBe(isValidMessageStatus); - }, - ); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/pickFile.test.tsx b/package/src/contexts/messageInputContext/__tests__/pickFile.test.tsx deleted file mode 100644 index 7a2e8be634..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/pickFile.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { PropsWithChildren } from 'react'; -import { Alert } from 'react-native'; -import { act } from 'react-test-renderer'; - -import { renderHook, waitFor } from '@testing-library/react-native'; - -import { generateFileAttachment } from '../../../mock-builders/generator/attachment'; - -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; -import { NativeHandlers } from '../../../native'; - -import * as AttachmentPickerContext from '../../attachmentPickerContext/AttachmentPickerContext'; - -import { - InputMessageInputContextValue, - MessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -const user1 = generateUser(); -const message = generateMessage({ user: user1 }); -type WrapperType = Partial; - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - {children} - -); - -describe("MessageInputContext's pickFile", () => { - afterEach(jest.clearAllMocks); - jest.spyOn(Alert, 'alert'); - jest.spyOn(NativeHandlers, 'pickDocument').mockImplementation( - jest.fn().mockResolvedValue({ - assets: [ - generateFileAttachment({ size: 500000000 }), - generateFileAttachment({ size: 600000000 }), - ], - cancelled: false, - }), - ); - jest.spyOn(AttachmentPickerContext, 'useAttachmentPickerContext').mockImplementation(() => ({ - selectedFiles: [], - setSelectedFiles: jest.fn(), - })); - - const initialProps = { - editing: message, - maxNumberOfFiles: 2, - }; - - it.each([[3, 1]])( - 'run pickFile when numberOfUploads is %d and alert is triggered %d number of times', - async (numberOfUploads, numberOfTimesCalled) => { - const { rerender, result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - act(() => { - result.current.setNumberOfUploads(numberOfUploads); - }); - - rerender({ editing: message, maxNumberOfFiles: 2 }); - - await waitFor(() => { - result.current.pickFile(); - }); - - expect(Alert.alert).toHaveBeenCalledTimes(numberOfTimesCalled); - expect(Alert.alert).toHaveBeenCalledWith('Maximum number of files reached'); - }, - ); - - it('trigger file size threshold limit alert when file size above the limit', async () => { - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - // @ts-ignore - - ), - }); - - await waitFor(() => { - result.current.pickFile(); - }); - - expect(Alert.alert).toHaveBeenCalledTimes(2); - expect(Alert.alert).toHaveBeenCalledWith( - 'File is too large: {{ size }}, maximum upload size is {{ limit }}', - ); - }); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/removeFile.test.tsx b/package/src/contexts/messageInputContext/__tests__/removeFile.test.tsx deleted file mode 100644 index b07c10e402..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/removeFile.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, { PropsWithChildren } from 'react'; -import { act } from 'react-test-renderer'; - -import { renderHook, waitFor } from '@testing-library/react-native'; - -import { generateFileUploadPreview } from '../../../mock-builders/generator/attachment'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; - -import { - InputMessageInputContextValue, - MessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -type WrapperType = Partial; - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - {children} - -); - -const user1 = generateUser(); -const message = generateMessage({ user: user1 }); -const newMessage = generateMessage({ id: 'new-id' }); -describe("MessageInputContext's removeFile", () => { - const initialProps = { - editing: message, - }; - - const file = generateFileUploadPreview({ - file: { - id: 'test', - name: 'Test Image', - uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg', - }, - }); - - it.each([ - [file.id, 0, 0], - ['dummy', 1, 1], - ])( - 'removeFile is been called with %s and checked for expectedFileUploadsLength %i, and expectedNumberOfUploadsLength %i)', - async (fileId, expectedFileUploadsLength, expectedNumberOfUploadsLength) => { - const { rerender, result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: Wrapper, - }); - - act(() => { - result.current.setFileUploads([file]); - result.current.setNumberOfUploads(1); - }); - - rerender({ editing: newMessage }); - - await waitFor(() => { - expect(result.current.fileUploads.length).toBe(1); - }); - - act(() => { - result.current.removeFile(fileId); - }); - - rerender({ editing: newMessage }); - - await waitFor(() => { - expect(result.current.fileUploads.length).toBe(expectedFileUploadsLength); - expect(result.current.numberOfUploads).toBe(expectedNumberOfUploadsLength); - }); - }, - ); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/removeImage.test.tsx b/package/src/contexts/messageInputContext/__tests__/removeImage.test.tsx deleted file mode 100644 index a1a0cb6c34..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/removeImage.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { PropsWithChildren } from 'react'; -import { act } from 'react-test-renderer'; - -import { renderHook, waitFor } from '@testing-library/react-native'; - -import { generateImageUploadPreview } from '../../../mock-builders/generator/attachment'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; - -import { - InputMessageInputContextValue, - MessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -type WrapperType = Partial; - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - {children} - -); - -const user1 = generateUser(); -const message = generateMessage({ user: user1 }); -const newMessage = generateMessage({ id: 'new-id' }); -describe("MessageInputContext's removeImage", () => { - const initialProps = { - editing: message, - }; - const image = generateImageUploadPreview({ - file: { - id: 'test', - name: 'Test Image', - uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg', - }, - }); - it.each([ - [image.id, 0, 0], - ['dummy', 1, 1], - ])( - 'removeImage is been called with %s and checked for expectedImageUploadsLength %i, and expectedNumberOfUploadsLength %i)', - async (imageId, expectedImageUploadsLength, expectedNumberOfUploadsLength) => { - const { rerender, result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => , - }); - - act(() => { - result.current.setImageUploads([image]); - result.current.setNumberOfUploads(1); - }); - - rerender({ editing: newMessage }); - - await waitFor(() => { - expect(result.current.imageUploads.length).toBe(1); - }); - - act(() => { - result.current.removeImage(imageId); - }); - - rerender({ editing: newMessage }); - - await waitFor(() => { - expect(result.current.imageUploads.length).toBe(expectedImageUploadsLength); - expect(result.current.numberOfUploads).toBe(expectedNumberOfUploadsLength); - }); - }, - ); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx b/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx index 322656999c..77b7f7869b 100644 --- a/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx +++ b/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx @@ -1,360 +1,268 @@ -import React, { PropsWithChildren } from 'react'; -import { act } from 'react-test-renderer'; +import React from 'react'; -import { renderHook, waitFor } from '@testing-library/react-native'; +import { act, cleanup, renderHook, waitFor } from '@testing-library/react-native'; import { LocalMessage } from 'stream-chat'; -import { - generateFileUploadPreview, - generateImageUploadPreview, -} from '../../../mock-builders/generator/attachment'; +import { Chat } from '../../../components'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; + +import { generateLocalFileUploadAttachmentData } from '../../../mock-builders/attachments'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; +import * as UseMessageComposerAPIContext from '../../messageComposerContext/MessageComposerAPIContext'; -import { FileState } from '../../../utils/utils'; -import * as AttachmentPickerContext from '../../attachmentPickerContext/AttachmentPickerContext'; +import { MessageComposerAPIContextValue } from '../../messageComposerContext/MessageComposerAPIContext'; +import { MessageComposerProvider } from '../../messageComposerContext/MessageComposerContext'; +import { + OwnCapabilitiesContextValue, + OwnCapabilitiesProvider, +} from '../../ownCapabilitiesContext/OwnCapabilitiesContext'; import { InputMessageInputContextValue, - MessageInputContextValue, MessageInputProvider, useMessageInputContext, } from '../MessageInputContext'; -type WrapperType = Partial; - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - {children} - -); - -const newMessage = generateMessage({ id: 'new-id' }); -describe("MessageInputContext's sendMessage", () => { - jest.spyOn(AttachmentPickerContext, 'useAttachmentPickerContext').mockImplementation(() => ({ - setSelectedFiles: jest.fn(), - setSelectedImages: jest.fn(), - })); - const message: LocalMessage | undefined = generateMessage({ - created_at: 'Sat Jul 02 2022 23:55:13 GMT+0530 (India Standard Time)', - id: '7a85f744-cc89-4f82-a1d4-5456432cc8bf', - updated_at: 'Sat Jul 02 2022 23:55:13 GMT+0530 (India Standard Time)', - user: generateUser({ - id: '5d6f6322-567e-4e1e-af90-97ef1ed5cc23', - image: 'fc86ddcb-bac4-400c-9afd-b0c0a1c0cd33', - name: '50cbdd0e-ca7e-4478-9e2c-be0f1ac6a995', - }), - }) as unknown as LocalMessage; - - it('exit sendMessage when file upload status failed', async () => { - const initialProps = { - editing: undefined, - }; - const files = generateFileUploadPreview({ state: FileState.UPLOAD_FAILED }); - const images = generateImageUploadPreview({ state: FileState.UPLOAD_FAILED }); - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); +const Wrapper = ({ messageComposerContextValue, client, props }) => { + return ( + + + + + {props.children} + + + + + ); +}; - act(() => { - result.current.setFileUploads([files]); - result.current.setImageUploads([images]); - }); +describe("MessageInputContext's sendMessage", () => { + let channel; + let chatClient; - act(() => { - result.current.sendMessage(); - }); + beforeEach(async () => { + const { client, channels } = await initiateClientWithChannels(); + channel = channels[0]; + chatClient = client; + }); - await expect(result.current.sending.current).toBeFalsy(); + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + channel.messageComposer.clear(); }); - it('exit sendMessage when image upload status is uploading', async () => { - const images = generateImageUploadPreview({ state: FileState.UPLOADING }); + it('should get into the catch block if the message composer compose throws an error', async () => { + const sendMessageMock = jest.fn(); const initialProps = { - editing: message, - sendImageAsync: true, + sendMessage: sendMessageMock, }; + const consoleErrorMock = jest.spyOn(console, 'error'); - const { rerender, result } = renderHook(() => useMessageInputContext(), { + const { result } = renderHook(() => useMessageInputContext(), { initialProps, wrapper: (props) => ( ), }); - act(() => { - result.current.setImageUploads([images]); - result.current.setMentionedUsers([]); - result.current.setText(''); - }); + const composerComposeMock = jest.spyOn(channel.messageComposer, 'compose'); + composerComposeMock.mockRejectedValue(new Error('Error composing message')); await waitFor(() => { result.current.sendMessage(); }); - rerender({ editing: newMessage, sendImageAsync: true }); - await waitFor(() => { - expect(result.current.asyncIds).toHaveLength(1); + expect(sendMessageMock).not.toHaveBeenCalled(); + expect(consoleErrorMock).toHaveBeenCalled(); }); - await expect(result.current.sending.current).toBeFalsy(); }); - it('exit sendMessage when image upload status is uploading and sendImageAsync is available', async () => { - const images = generateImageUploadPreview({ state: FileState.UPLOADING }); + it('should get into the catch block if the sendMessage throws an error', async () => { + const sendMessageMock = jest.fn(); + sendMessageMock.mockRejectedValue(new Error('Error sending message')); + const initialProps = { - editing: message, + sendMessage: sendMessageMock, }; + const consoleErrorMock = jest.spyOn(console, 'error'); const { result } = renderHook(() => useMessageInputContext(), { initialProps, wrapper: (props) => ( ), }); - act(() => { - result.current.setImageUploads([images]); - result.current.setMentionedUsers([]); - result.current.setText(''); + await act(async () => { + const text = 'Hello there'; + await channel.messageComposer.textComposer.handleChange({ + selection: { + end: text.length, + start: text.length, + }, + text, + }); }); - act(() => { + await waitFor(() => { result.current.sendMessage(); }); - await expect(result.current.sending.current).toBeFalsy(); + await waitFor(() => { + expect(sendMessageMock).toHaveBeenCalled(); + expect(consoleErrorMock).toHaveBeenCalled(); + }); }); - it('exit sendMessage when file upload status is uploading', async () => { - const files = generateFileUploadPreview({ state: FileState.UPLOADING }); + it('should not call composer clear if composition has poll id in it', async () => { + const sendMessageMock = jest.fn(); + const clearSpy = jest.spyOn(channel.messageComposer, 'clear'); const initialProps = { - editing: message, + sendMessage: sendMessageMock, }; + const { pollComposer } = channel.messageComposer; + jest.spyOn(chatClient, 'createPoll').mockResolvedValue({ poll: { id: 'test-poll-id' } }); + const { result } = renderHook(() => useMessageInputContext(), { initialProps, wrapper: (props) => ( ), }); - act(() => { - result.current.setFileUploads([files]); - result.current.setMentionedUsers([]); - result.current.setText(''); + await act(async () => { + await pollComposer.updateFields({ + id: 'test-poll', + name: 'Test Poll', + options: [ + { id: 1, text: '1' }, + { id: 2, text: '2' }, + ], + }); + await channel.messageComposer.createPoll(); }); - act(() => { + await waitFor(() => { result.current.sendMessage(); }); - await expect(result.current.sending.current).toBeFalsy(); + await waitFor(() => { + expect(clearSpy).not.toHaveBeenCalled(); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + }); }); - it('exit sendMessage when image upload status is uploaded successfully', async () => { + it('should send message', async () => { const sendMessageMock = jest.fn(); - const clearQuotedMessageStateMock = jest.fn(); - const images = [ - generateImageUploadPreview({ state: FileState.UPLOADED }), - generateImageUploadPreview({ state: FileState.FINISHED }), - ]; + const clearSpy = jest.spyOn(channel.messageComposer, 'clear'); const initialProps = { - clearQuotedMessageState: clearQuotedMessageStateMock, - editing: undefined, - quotedMessage: false, sendMessage: sendMessageMock, }; + const { result } = renderHook(() => useMessageInputContext(), { initialProps, wrapper: (props) => ( ), }); - act(() => { - result.current.setImageUploads(images); - result.current.setMentionedUsers(['dummy1', 'dummy2']); - result.current.setText(''); + await act(async () => { + const text = 'Hello there'; + await channel.messageComposer.textComposer.handleChange({ + selection: { + end: text.length, + start: text.length, + }, + text, + }); }); await waitFor(() => { result.current.sendMessage(); }); - expect(sendMessageMock.mock.calls[0][0]).toMatchSnapshot(); - expect(clearQuotedMessageStateMock).toHaveBeenCalled(); - expect(result.current.sending.current).toBeFalsy(); - expect(result.current.fileUploads.length).toBe(0); - expect(result.current.imageUploads.length).toBe(0); - expect(result.current.mentionedUsers.length).toBe(0); - }); - - it('exit sendMessage when image upload has an error and catch block is executed', () => { - const setQuotedMessageStateMock = jest.fn(); - const clearQuotedMessageStateMock = jest.fn(); - const generatedQuotedMessage: boolean | LocalMessage = message; - const images = [ - generateImageUploadPreview({ state: FileState.UPLOADED }), - generateImageUploadPreview({ state: FileState.FINISHED }), - ]; - const initialProps = { - clearQuotedMessageState: clearQuotedMessageStateMock, - editing: undefined, - quotedMessage: generatedQuotedMessage, - setQuotedMessageState: setQuotedMessageStateMock, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), + await waitFor(() => { + expect(clearSpy).toHaveBeenCalled(); + expect(sendMessageMock).toHaveBeenCalledTimes(1); }); + }); +}); - act(() => { - result.current.setImageUploads(images); - result.current.setMentionedUsers(['dummy1', 'dummy2']); - result.current.setText(''); - }); +describe("MessageInputContext's editMessage", () => { + let channel; + let chatClient; - act(() => { - result.current.sendMessage(); - }); + beforeAll(async () => { + const { client, channels } = await initiateClientWithChannels(); + channel = channels[0]; + chatClient = client; + }); - expect(setQuotedMessageStateMock).toHaveBeenCalled(); - expect(result.current.sending.current).toBeFalsy(); + afterEach(() => { + jest.clearAllMocks(); + cleanup(); }); - it('exit sendMessage when edit message is not boolean image upload status is uploaded successfully', () => { - const sendMessageMock = jest.fn(); - const clearQuotedMessageStateMock = jest.fn(); - const clearEditingStateMock = jest.fn(); - const editMessageMock = jest.fn().mockResolvedValue({ data: {} }); - const images = generateImageUploadPreview({ state: FileState.UPLOADED }); - const generatedMessage: boolean | LocalMessage = message; + it('should clear the edited state when the composition is empty', async () => { + const editMessageMock = jest.fn(); const initialProps = { - clearEditingState: clearEditingStateMock, - clearQuotedMessageState: clearQuotedMessageStateMock, - editing: generatedMessage, editMessage: editMessageMock, - quotedMessage: false, - sendMessage: sendMessageMock, }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - act(() => { - result.current.setImageUploads([images]); - result.current.setMentionedUsers(['dummy1', 'dummy2']); - result.current.setText(''); - }); + const clearEditingStateMock = jest.fn(); - act(() => { - result.current.sendMessage(); - }); + jest.spyOn(UseMessageComposerAPIContext, 'useMessageComposerAPIContext').mockReturnValue({ + clearEditingState: clearEditingStateMock, + } as unknown as MessageComposerAPIContextValue); - expect(editMessageMock.mock.calls[0][0]).toMatchSnapshot(); - expect(clearEditingStateMock).toHaveBeenCalled(); - expect(result.current.sending.current).toBeFalsy(); - expect(result.current.fileUploads.length).toBe(0); - expect(result.current.imageUploads.length).toBe(0); - expect(result.current.mentionedUsers.length).toBe(0); - }); + const message = generateMessage({ + attachments: [generateLocalFileUploadAttachmentData()], + cid: 'messaging:channel-id', + text: 'test', + }) as LocalMessage; - it('exit sendMessage when file upload status is uploaded successfully', () => { - const files = [ - generateFileUploadPreview({ state: FileState.UPLOADED }), - generateFileUploadPreview({ state: FileState.FINISHED, type: 'video/mp4' }), - generateFileUploadPreview({ state: FileState.UPLOADED, type: 'audio/mp3' }), - generateFileUploadPreview({ state: FileState.FINISHED, type: 'image/jpeg' }), - ]; - const sendMessageMock = jest.fn(); - const clearQuotedMessageStateMock = jest.fn(); - const initialProps = { - clearQuotedMessageState: clearQuotedMessageStateMock, - editing: undefined, - quotedMessage: false, - sendMessage: sendMessageMock, - }; const { result } = renderHook(() => useMessageInputContext(), { initialProps, wrapper: (props) => ( ), }); - act(() => { - result.current.setFileUploads(files); - result.current.setMentionedUsers(['dummy1', 'dummy2']); - result.current.setText(''); - }); - - act(() => { + await waitFor(() => { result.current.sendMessage(); }); - expect(sendMessageMock.mock.calls[0][0]).toMatchSnapshot(); - expect(clearQuotedMessageStateMock).toHaveBeenCalled(); - expect(result.current.sending.current).toBeFalsy(); - expect(result.current.fileUploads.length).toBe(0); - expect(result.current.imageUploads.length).toBe(0); - expect(result.current.mentionedUsers.length).toBe(0); + await waitFor(() => { + expect(clearEditingStateMock).toHaveBeenCalled(); + }); }); }); diff --git a/package/src/contexts/messageInputContext/__tests__/sendMessageAsync.test.tsx b/package/src/contexts/messageInputContext/__tests__/sendMessageAsync.test.tsx deleted file mode 100644 index b552eff3f2..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/sendMessageAsync.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { PropsWithChildren } from 'react'; -import { act } from 'react-test-renderer'; - -import { renderHook, waitFor } from '@testing-library/react-native'; - -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; - -import { FileState } from '../../../utils/utils'; -import { - InputMessageInputContextValue, - MessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -type WrapperType = Partial; - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - {children} - -); - -const user1 = generateUser(); -const message = generateMessage({ user: user1 }); -const newMessage = generateMessage({ id: 'new-id' }); -describe("MessageInputContext's sendMessageAsync", () => { - it('sendMessageAsync returns undefined when image state is UPLOAD_FAILED', () => { - const asyncUploads = { - 'test-file': { - state: FileState.UPLOAD_FAILED, - url: 'https://www.test.com', - }, - }; - const initialProps = { - editing: message, - }; - const { rerender, result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => , - }); - - act(() => { - result.current.setAsyncUploads(asyncUploads); - }); - - rerender({ editing: newMessage }); - - let data; - act(() => { - data = result.current.sendMessageAsync('test-file'); - }); - - expect(data).toBeUndefined(); - }); - - it.each([[FileState.UPLOADED], [FileState.FINISHED]])( - 'sendImageAsync is been called with %s file upload state and checked for snapshot)', - async (fileState) => { - const sendMessageMock = jest.fn(); - const asyncUploads = { - 'test-file': { - state: fileState, - url: 'https://www.test.com', - }, - }; - const initialProps = { - editing: message, - quotedMessage: false, - sendMessage: sendMessageMock, - }; - const { rerender, result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - await waitFor(() => { - result.current.setAsyncUploads(asyncUploads); - }); - - rerender({ editing: newMessage, quotedMessage: false, sendMessage: sendMessageMock }); - - await waitFor(() => { - result.current.sendMessageAsync('test-file'); - }); - - expect(sendMessageMock.mock.calls[0][0]).toMatchSnapshot(); - }, - ); - - it('sendMessageAsync goes to catch block', async () => { - const sendMessageMock = jest.fn(); - const asyncUploads = { - 'test-file': { - state: FileState.FINISHED, - url: 'https://www.test.com', - }, - }; - const initialProps = { - editing: message, - quotedMessage: false, - }; - const { rerender, result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - act(() => { - result.current.setAsyncUploads(asyncUploads); - }); - - rerender({ editing: newMessage, quotedMessage: false }); - - await waitFor(() => { - result.current.sendMessageAsync('test-file'); - }); - - expect(sendMessageMock).not.toHaveBeenCalled(); - }); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/updateMessage.test.tsx b/package/src/contexts/messageInputContext/__tests__/updateMessage.test.tsx deleted file mode 100644 index 5e342b4488..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/updateMessage.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { PropsWithChildren } from 'react'; -import { act } from 'react-test-renderer'; - -import { renderHook } from '@testing-library/react-native'; - -import type { LocalMessage, StreamChat } from 'stream-chat'; - -import { ChatContextValue, ChatProvider } from '../../../contexts/chatContext/ChatContext'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; - -import * as AttachmentPickerContext from '../../attachmentPickerContext/AttachmentPickerContext'; -import { - InputMessageInputContextValue, - MessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -const message = generateMessage({}); - -type WrapperType = Partial; - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - - {children} - - -); - -describe("MessageInputContext's updateMessage", () => { - jest.spyOn(AttachmentPickerContext, 'useAttachmentPickerContext').mockImplementation(() => ({ - setSelectedFiles: jest.fn(), - setSelectedImages: jest.fn(), - })); - const clearEditingStateMock = jest.fn(); - const generatedMessage: boolean | LocalMessage = generateMessage({ - created_at: 'Sat Jul 02 2022 23:55:13 GMT+0530 (India Standard Time)', - id: '7a85f744-cc89-4f82-a1d4-5456432cc8bf', - text: 'hey', - updated_at: 'Sat Jul 02 2022 23:55:13 GMT+0530 (India Standard Time)', - user: generateUser({ - id: '5d6f6322-567e-4e1e-af90-97ef1ed5cc23', - image: 'fc86ddcb-bac4-400c-9afd-b0c0a1c0cd33', - name: '50cbdd0e-ca7e-4478-9e2c-be0f1ac6a995', - }), - }) as unknown as LocalMessage; - - it('updateMessage throws error as clearEditingState is not available', async () => { - const initialProps = { - editing: generatedMessage, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => , - }); - - await act(async () => { - await result.current.updateMessage(); - }); - - expect(clearEditingStateMock).toHaveBeenCalledTimes(0); - }); - - it('updateMessage throws error as client.updateMessage is available', async () => { - const initialProps = { - clearEditingState: clearEditingStateMock, - editing: generatedMessage, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - await act(async () => { - await result.current.updateMessage(); - }); - - expect(clearEditingStateMock).toHaveBeenCalledTimes(2); - }); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/uploadFile.test.tsx b/package/src/contexts/messageInputContext/__tests__/uploadFile.test.tsx deleted file mode 100644 index 2af2fdde78..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/uploadFile.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, { PropsWithChildren } from 'react'; -import { act } from 'react-test-renderer'; - -import { renderHook, waitFor } from '@testing-library/react-native'; - -import { generateFileUploadPreview } from '../../../mock-builders/generator/attachment'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; - -import { - InputMessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -type WrapperType = Partial; - -const user1 = generateUser(); -const message = generateMessage({ user: user1 }); - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - {children} - -); - -describe("MessageInputContext's uploadFile", () => { - it('uploadFile works', async () => { - const doDocUploadRequestMock = jest.fn().mockResolvedValue({ - file: { - url: '', - }, - thumb_url: '', - }); - const initialProps = { - doDocUploadRequest: doDocUploadRequestMock, - editing: message, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - await waitFor(() => { - expect(result.current.fileUploads).toHaveLength(0); - }); - - act(() => { - result.current.uploadFile({ newFile: generateFileUploadPreview({ state: '' }) }); - }); - - await waitFor(() => { - expect(doDocUploadRequestMock).toHaveBeenCalled(); - }); - }); - - it('uploadFile catch block gets executed', async () => { - const doDocUploadRequestMock = jest.fn().mockResolvedValue(new Error('This is an error')); - const initialProps = { - doDocUploadRequest: doDocUploadRequestMock, - editing: message, - }; - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - await waitFor(() => { - expect(result.current.fileUploads).toHaveLength(0); - }); - - act(() => { - result.current.uploadFile({ newFile: generateFileUploadPreview({ state: '' }) }); - }); - - await waitFor(() => { - expect(result.current.fileUploads.length).toBe(0); - }); - }); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/uploadImage.test.tsx b/package/src/contexts/messageInputContext/__tests__/uploadImage.test.tsx deleted file mode 100644 index 97d34b05bd..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/uploadImage.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { PropsWithChildren } from 'react'; - -import { renderHook, waitFor } from '@testing-library/react-native'; - -import { generateImageUploadPreview } from '../../../mock-builders/generator/attachment'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; - -import { - InputMessageInputContextValue, - MessageInputProvider, - useMessageInputContext, -} from '../MessageInputContext'; - -type WrapperType = Partial; - -const Wrapper = ({ children, ...rest }: PropsWithChildren) => ( - - {children} - -); - -const user1 = generateUser(); -const message = generateMessage({ user: user1 }); -describe("MessageInputContext's uploadImage", () => { - it('uploadImage works', async () => { - const doImageUploadRequestMock = jest - .fn() - .mockResolvedValue({ file: 'https://www.test.com/dummy.png' }); - - const initialProps = { - doImageUploadRequest: doImageUploadRequestMock, - editing: message, - }; - - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - await waitFor(() => { - result.current.uploadImage({ newImage: generateImageUploadPreview() }); - }); - - expect(doImageUploadRequestMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/package/src/contexts/messageInputContext/__tests__/useMessageDetailsForState.test.tsx b/package/src/contexts/messageInputContext/__tests__/useMessageDetailsForState.test.tsx deleted file mode 100644 index c39d95a712..0000000000 --- a/package/src/contexts/messageInputContext/__tests__/useMessageDetailsForState.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; - -import { LocalMessage } from 'stream-chat'; - -import { - generateFileAttachment, - generateImageAttachment, -} from '../../../mock-builders/generator/attachment'; -import { generateMessage } from '../../../mock-builders/generator/message'; - -import { generateUser } from '../../../mock-builders/generator/user'; - -import { useMessageDetailsForState } from '../hooks/useMessageDetailsForState'; - -describe('useMessageDetailsForState', () => { - const message = generateMessage({ text: 'Dummy text' }); - it.each([[{ message }, { initialValue: '', message }]])( - 'test state of useMessageDetailsForState when initialProps differ', - () => { - const { result } = renderHook( - ({ message }) => useMessageDetailsForState(message as unknown as LocalMessage), - { - initialProps: { message }, - }, - ); - - expect(result.current.text).toBe(message.text); - }, - ); - - it('showMoreOptions is true when initialValue and text is same', () => { - const { result } = renderHook( - ({ initialValue, message }) => - useMessageDetailsForState(message as unknown as LocalMessage, initialValue), - { - initialProps: { - initialValue: 'Dummy text', - message: generateMessage({ text: 'Dummy text' }), - }, - }, - ); - - expect(result.current.showMoreOptions).toBe(true); - }); - - it('fileUploads, imageUploads and mentionedUsers are not empty when attachments are present in message', () => { - const { result } = renderHook( - ({ initialValue, message }) => - useMessageDetailsForState(message as unknown as LocalMessage, initialValue), - { - initialProps: { - initialValue: '', - message: generateMessage({ - attachments: [ - generateFileAttachment(), - generateImageAttachment(), - generateFileAttachment({ type: 'video' }), - generateFileAttachment({ type: 'audio' }), - ], - mentioned_users: [generateUser()], - }), - }, - }, - ); - - expect(result.current.fileUploads.length).toBeGreaterThan(0); - expect(result.current.imageUploads.length).toBeGreaterThan(0); - expect(result.current.mentionedUsers.length).toBeGreaterThan(0); - }); -}); diff --git a/package/src/contexts/messageInputContext/hooks/useAttachmentManagerState.ts b/package/src/contexts/messageInputContext/hooks/useAttachmentManagerState.ts new file mode 100644 index 0000000000..dfb3185fe7 --- /dev/null +++ b/package/src/contexts/messageInputContext/hooks/useAttachmentManagerState.ts @@ -0,0 +1,24 @@ +import type { AttachmentManagerState } from 'stream-chat'; + +import { useMessageComposer } from './useMessageComposer'; + +import { useStateStore } from '../../../hooks/useStateStore'; + +const stateSelector = (state: AttachmentManagerState) => ({ + attachments: state.attachments, +}); + +export const useAttachmentManagerState = () => { + const { attachmentManager } = useMessageComposer(); + const { attachments } = useStateStore(attachmentManager.state, stateSelector); + return { + attachments, + availableUploadSlots: attachmentManager.availableUploadSlots, + blockedUploadsCount: attachmentManager.blockedUploadsCount, + failedUploadsCount: attachmentManager.failedUploadsCount, + isUploadEnabled: attachmentManager.isUploadEnabled, + pendingUploadsCount: attachmentManager.pendingUploadsCount, + successfulUploadsCount: attachmentManager.successfulUploadsCount, + uploadsInProgressCount: attachmentManager.uploadsInProgressCount, + }; +}; diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts new file mode 100644 index 0000000000..c44507566c --- /dev/null +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts @@ -0,0 +1,88 @@ +import { useEffect, useMemo } from 'react'; + +import { FixedSizeQueueCache, MessageComposer } from 'stream-chat'; + +import { useChatContext } from '../../chatContext/ChatContext'; +import { MessageComposerContextValue } from '../../messageComposerContext/MessageComposerContext'; + +const queueCache = new FixedSizeQueueCache(64); + +export const useCreateMessageComposer = ({ + editing: editedMessage, + thread: parentMessage, + threadInstance, + channel, +}: Pick) => { + const { client } = useChatContext(); + + const cachedEditedMessage = useMemo(() => { + if (!editedMessage) return undefined; + + return editedMessage; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editedMessage?.id]); + + const cachedParentMessage = useMemo(() => { + if (!parentMessage) return undefined; + + return parentMessage; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parentMessage?.id]); + + // composer hierarchy + // edited message (always new) -> thread instance (own) -> thread message (always new) -> channel (own) + // editedMessage ?? thread ?? parentMessage ?? channel; + + const messageComposer = useMemo(() => { + if (cachedEditedMessage) { + const tag = MessageComposer.constructTag(cachedEditedMessage); + + const cachedComposer = queueCache.get(tag); + if (cachedComposer) return cachedComposer; + + return new MessageComposer({ + client, + composition: cachedEditedMessage, + compositionContext: cachedEditedMessage, + }); + } else if (threadInstance) { + return threadInstance.messageComposer; + } else if (cachedParentMessage) { + const compositionContext = { + ...cachedParentMessage, + legacyThreadId: cachedParentMessage.id, + }; + + const tag = MessageComposer.constructTag(compositionContext); + + const cachedComposer = queueCache.get(tag); + if (cachedComposer) return cachedComposer; + + // legacy thread will receive new composer + return new MessageComposer({ + client, + compositionContext, + }); + } else { + return channel.messageComposer; + } + }, [cachedEditedMessage, cachedParentMessage, channel, client, threadInstance]); + + if ( + (['legacy_thread', 'message'] as MessageComposer['contextType'][]).includes( + messageComposer.contextType, + ) && + !queueCache.peek(messageComposer.tag) + ) { + queueCache.add(messageComposer.tag, messageComposer); + } + + useEffect(() => { + const unsubscribe = messageComposer.registerSubscriptions(); + return () => { + unsubscribe(); + }; + }, [messageComposer]); + + return messageComposer; +}; diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 6fda3788f7..7ad8d8f712 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -5,14 +5,18 @@ import type { MessageInputContextValue } from '../MessageInputContext'; export const useCreateMessageInputContext = ({ additionalTextInputProps, - appendText, - asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, - asyncUploads, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadPreviewList, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -20,121 +24,73 @@ export const useCreateMessageInputContext = ({ AudioRecordingLockIndicator, AudioRecordingPreview, AudioRecordingWaveform, - autoCompleteSuggestionsLimit, - clearEditingState, - clearQuotedMessageState, + AutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem, + AutoCompleteSuggestionList, + CameraSelectorIcon, closeAttachmentPicker, closePollCreationDialog, + CommandInput, CommandsButton, compressImageQuality, cooldownEndsAt, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, - editing, + CreatePollIcon, editMessage, - emojiSearchIndex, - FileUploadPreview, - fileUploads, - giphyActive, - giphyEnabled, + FileAttachmentUploadPreview, + FileSelectorIcon, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - hasText, - ImageUploadPreview, - imageUploads, - initialValue, + ImageAttachmentUploadPreview, + ImageSelectorIcon, Input, inputBoxRef, InputButtons, InputEditingStateHeader, - InputGiphySearch, InputReplyStateHeader, - isValidMessage, - maxMessageLength, - maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, - mentionedUsers, MoreOptionsButton, - numberOfLines, - numberOfUploads, - onChange, - onChangeText, - onSelectItem, openAttachmentPicker, - openCommandsPicker, - openFilePicker, - openMentionsPicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, pickFile, - quotedMessage, - removeFile, - removeImage, - resetInput, selectedPicker, SendButton, - sendImageAsync, - sending, sendMessage, - sendMessageAsync, SendMessageDisallowedIndicator, - sendThreadMessageInChannel, - setAsyncIds, - setAsyncUploads, - setFileUploads, - setGiphyActive, - setImageUploads, setInputBoxRef, setInputRef, - setMentionedUsers, - setNumberOfUploads, - setQuotedMessageState, - setSendThreadMessageInChannel, - setShowMoreOptions, - setText, - showMoreOptions, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, takeAndUploadImage, - text, thread, toggleAttachmentPicker, - triggerSettings, - updateMessage, - uploadFile, - uploadImage, uploadNewFile, - uploadNewImage, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }: MessageInputContextValue & Pick) => { - const editingdep = editing?.id; - const fileUploadsValue = fileUploads.map(({ state }) => state).join(); - const imageUploadsValue = imageUploads.map(({ state }) => state).join(); - const asyncUploadsValue = Object.keys(asyncUploads).join(); - const mentionedUsersLength = mentionedUsers.length; - const quotedMessageId = quotedMessage ? quotedMessage.id : ''; const threadId = thread?.id; - const asyncIdsLength = asyncIds.length; const messageInputContext: MessageInputContextValue = useMemo( () => ({ additionalTextInputProps, - appendText, - asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, - asyncUploads, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadPreviewList, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -142,122 +98,57 @@ export const useCreateMessageInputContext = ({ AudioRecordingLockIndicator, AudioRecordingPreview, AudioRecordingWaveform, - autoCompleteSuggestionsLimit, - clearEditingState, - clearQuotedMessageState, + AutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem, + AutoCompleteSuggestionList, + CameraSelectorIcon, closeAttachmentPicker, closePollCreationDialog, + CommandInput, CommandsButton, compressImageQuality, cooldownEndsAt, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, - editing, + CreatePollIcon, editMessage, - emojiSearchIndex, - FileUploadPreview, - fileUploads, - giphyActive, - giphyEnabled, + FileAttachmentUploadPreview, + FileSelectorIcon, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - hasText, - ImageUploadPreview, - imageUploads, - initialValue, + ImageAttachmentUploadPreview, + ImageSelectorIcon, Input, inputBoxRef, InputButtons, InputEditingStateHeader, - InputGiphySearch, InputReplyStateHeader, - isValidMessage, - maxMessageLength, - maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, - mentionedUsers, MoreOptionsButton, - numberOfLines, - numberOfUploads, - onChange, - onChangeText, - onSelectItem, openAttachmentPicker, - openCommandsPicker, - openFilePicker, - openMentionsPicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, pickFile, - quotedMessage, - removeFile, - removeImage, - resetInput, selectedPicker, SendButton, - sendImageAsync, - sending, sendMessage, - sendMessageAsync, SendMessageDisallowedIndicator, - sendThreadMessageInChannel, - setAsyncIds, - setAsyncUploads, - setFileUploads, - setGiphyActive, - setImageUploads, setInputBoxRef, setInputRef, - setMentionedUsers, - setNumberOfUploads, - setQuotedMessageState, - setSendThreadMessageInChannel, - setShowMoreOptions, - setText, - showMoreOptions, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, takeAndUploadImage, - text, toggleAttachmentPicker, - triggerSettings, - updateMessage, - uploadFile, - uploadImage, uploadNewFile, - uploadNewImage, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [ - asyncIdsLength, - asyncUploadsValue, - cooldownEndsAt, - editingdep, - fileUploadsValue, - giphyActive, - giphyEnabled, - hasText, - imageUploadsValue, - maxMessageLength, - mentionedUsersLength, - quotedMessageId, - selectedPicker, - sendThreadMessageInChannel, - showMoreOptions, - text, - threadId, - showPollCreationDialog, - onChange, - ], + [cooldownEndsAt, threadId, showPollCreationDialog, selectedPicker], ); return messageInputContext; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts new file mode 100644 index 0000000000..68acbc6ba7 --- /dev/null +++ b/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts @@ -0,0 +1,9 @@ +import { useCreateMessageComposer } from './useCreateMessageComposer'; + +import { useMessageComposerContext } from '../../messageComposerContext/MessageComposerContext'; + +export const useMessageComposer = () => { + const messageComposerContext = useMessageComposerContext(); + + return useCreateMessageComposer(messageComposerContext); +}; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts b/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts new file mode 100644 index 0000000000..e3017cc62b --- /dev/null +++ b/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts @@ -0,0 +1,13 @@ +import type { EditingAuditState } from 'stream-chat'; + +import { useMessageComposer } from './useMessageComposer'; + +import { useStateStore } from '../../../hooks/useStateStore'; + +const editingAuditStateStateSelector = (state: EditingAuditState) => state; + +export const useMessageComposerHasSendableData = () => { + const messageComposer = useMessageComposer(); + useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); + return messageComposer.hasSendableData; +}; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageDetailsForState.ts b/package/src/contexts/messageInputContext/hooks/useMessageDetailsForState.ts deleted file mode 100644 index 4900583c22..0000000000 --- a/package/src/contexts/messageInputContext/hooks/useMessageDetailsForState.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { Attachment } from 'stream-chat'; - -import { FileTypes, FileUpload } from '../../../types/types'; -import { generateRandomId, getFileTypeFromMimeType, stringifyMessage } from '../../../utils/utils'; - -import type { MessageInputContextValue } from '../MessageInputContext'; - -export const useMessageDetailsForState = ( - message: MessageInputContextValue['editing'], - initialValue?: string, -) => { - const [fileUploads, setFileUploads] = useState([]); - const [imageUploads, setImageUploads] = useState([]); - const [mentionedUsers, setMentionedUsers] = useState([]); - const [numberOfUploads, setNumberOfUploads] = useState(0); - - const initialTextValue = initialValue || ''; - const [text, setText] = useState(initialTextValue); - - const isEqualToInitialText = text === initialTextValue; - - const [showMoreOptions, setShowMoreOptions] = useState(true); - - useEffect(() => { - if (!isEqualToInitialText) { - setShowMoreOptions(false); - } - if (fileUploads.length || imageUploads.length) { - setShowMoreOptions(false); - } - }, [isEqualToInitialText, imageUploads.length, fileUploads.length]); - - const messageValue = message ? stringifyMessage(message) : ''; - - useEffect(() => { - if (message && Array.isArray(message?.mentioned_users)) { - const mentionedUsers = message.mentioned_users.map((user) => user.id); - setMentionedUsers(mentionedUsers); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messageValue]); - - const mapAttachmentToFileUpload = (attachment: Attachment): FileUpload => { - const id = generateRandomId(); - - if (attachment.type === FileTypes.Audio) { - return { - file: { - duration: attachment.duration || 0, - name: attachment.title || '', - size: attachment.file_size || 0, - type: attachment.mime_type || '', - uri: attachment.asset_url || '', - }, - id, - state: 'finished', - type: FileTypes.Audio, - url: attachment.asset_url, - }; - } else if (attachment.type === FileTypes.Video) { - return { - file: { - duration: attachment.duration || 0, - name: attachment.title || '', - size: attachment.file_size || 0, - thumb_url: attachment.thumb_url || '', - type: attachment.mime_type || '', - uri: attachment.asset_url || '', - }, - id, - state: 'finished', - thumb_url: attachment.thumb_url, - type: FileTypes.Video, - url: attachment.asset_url, - }; - } else if (attachment.type === FileTypes.VoiceRecording) { - return { - file: { - duration: attachment.duration || 0, - name: attachment.title || '', - size: attachment.file_size || 0, - type: attachment.mime_type || '', - uri: attachment.asset_url || '', - waveform_data: attachment.waveform_data, - }, - id, - state: 'finished', - type: FileTypes.VoiceRecording, - url: attachment.asset_url, - }; - } else { - return { - file: { - name: attachment.title || '', - size: attachment.file_size || 0, - type: attachment.mime_type || '', - uri: attachment.asset_url || '', - }, - id, - state: 'finished', - type: getFileTypeFromMimeType(attachment.mime_type || ''), - url: attachment.asset_url, - }; - } - }; - - useEffect(() => { - if (message) { - setText(message?.text || ''); - const newFileUploads: FileUpload[] = []; - const newImageUploads: FileUpload[] = []; - - const attachments = Array.isArray(message.attachments) ? message.attachments : []; - - for (const attachment of attachments) { - if (attachment.type === FileTypes.Image) { - const id = generateRandomId(); - newImageUploads.push({ - file: { - height: attachment.original_height || 0, - name: attachment.fallback || '', - size: attachment.file_size || 0, - type: attachment.type || '', - uri: attachment.image_url || '', - width: attachment.original_width || 0, - }, - id, - state: 'finished', - type: FileTypes.Image, - url: attachment.image_url || attachment.asset_url || attachment.thumb_url, - }); - } else { - const fileUpload = mapAttachmentToFileUpload(attachment); - if (fileUpload) { - newFileUploads.push(fileUpload); - } - } - } - if (newFileUploads.length) { - setFileUploads(newFileUploads); - } - if (newImageUploads.length) { - setImageUploads(newImageUploads); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messageValue]); - - return { - fileUploads, - imageUploads, - mentionedUsers, - numberOfUploads, - setFileUploads, - setImageUploads, - setMentionedUsers, - setNumberOfUploads, - setShowMoreOptions, - setText, - showMoreOptions, - text, - }; -}; diff --git a/package/src/contexts/messageInputContext/utils/utils.ts b/package/src/contexts/messageInputContext/utils/utils.ts deleted file mode 100644 index 9fbc3ead8a..0000000000 --- a/package/src/contexts/messageInputContext/utils/utils.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { lookup } from 'mime-types'; -import type { FileUploadConfig } from 'stream-chat'; - -import { File } from '../../../types/types'; - -export const MAX_FILE_SIZE_TO_UPLOAD = 100 * 1024 * 1024; // 100 MB - -type CheckUploadPermissionsParams = { - config: FileUploadConfig; - file: File; -}; - -/** - * This utility function checks if the file upload is allowed based on the file upload config. - * @param Object File upload config and file to check - * @returns - */ -export const isUploadAllowed = ({ config, file }: CheckUploadPermissionsParams) => { - const { - allowed_file_extensions, - allowed_mime_types, - blocked_file_extensions, - blocked_mime_types, - } = config; - - if (allowed_file_extensions?.length) { - const allowed = allowed_file_extensions.some((fileExtension: string) => - file.name?.toLowerCase().endsWith(fileExtension.toLowerCase()), - ); - - if (!allowed) { - return false; - } - } - - if (blocked_file_extensions?.length) { - const blocked = blocked_file_extensions.some((fileExtension: string) => - file.name?.toLowerCase().endsWith(fileExtension.toLowerCase()), - ); - - if (blocked) { - return false; - } - } - - if (allowed_mime_types?.length) { - if (file.type) { - const allowed = allowed_mime_types.some( - (mimeType: string) => file.type && file.type.toLowerCase() === mimeType.toLowerCase(), - ); - - if (!allowed) { - return false; - } - } else if (file.name) { - const fileMimeType = lookup(file.name) as string; - const allowed = allowed_mime_types.some( - (mimeType: string) => fileMimeType.toLowerCase() === mimeType.toLowerCase(), - ); - - if (!allowed) { - return false; - } - } - } - - if (blocked_mime_types?.length) { - if (file.type) { - const blocked = blocked_mime_types.some( - (mimeType: string) => file.type && file.type.toLowerCase() === mimeType.toLowerCase(), - ); - - if (blocked) { - return false; - } - } else if (file.name) { - const fileMimeType = lookup(file.name) as string; - const blocked = blocked_mime_types.some( - (mimeType: string) => fileMimeType.toLowerCase() === mimeType.toLowerCase(), - ); - - if (blocked) { - return false; - } - } - } - - return true; -}; - -/** - * This utility function prettifies the file size. - * @param bytes The bytes of the file - * @param precision The precision to which the file size should be rounded - * @returns - */ -export function prettifyFileSize(bytes: number, precision = 3) { - const units = ['B', 'kB', 'MB', 'GB']; - const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - const mantissa = bytes / 1024 ** exponent; - return `${mantissa.toPrecision(precision)} ${units[exponent]}`; -} diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 624e1c77ca..6910bf09a8 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -2,7 +2,13 @@ import React, { PropsWithChildren, useContext } from 'react'; import { PressableProps, ViewProps } from 'react-native'; -import type { Attachment, ChannelState, LocalMessage, MessageResponse } from 'stream-chat'; +import type { + Attachment, + ChannelState, + CommandSuggestion, + LocalMessage, + MessageResponse, +} from 'stream-chat'; import type { PollContentProps, StreamingMessageViewProps } from '../../components'; import type { AttachmentProps } from '../../components/Attachment/Attachment'; @@ -63,7 +69,6 @@ import { NativeHandlers } from '../../native'; import type { ReactionData } from '../../utils/utils'; import type { Alignment, MessageContextValue } from '../messageContext/MessageContext'; -import type { SuggestionCommand } from '../suggestionsContext/SuggestionsContext'; import type { DeepPartial } from '../themeContext/ThemeContext'; import type { Theme } from '../themeContext/utils/theme'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -98,10 +103,6 @@ export type MessagesContextValue = Pick; - /** - * Handler to clear the quoted state of the message. - */ - clearQuotedMessageState: () => void; /** * UI component for DateHeader * Defaults to: [DateHeader](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageList/DateHeader.tsx) @@ -301,8 +302,6 @@ export type MessagesContextValue = Pick; sendReaction: (type: string, messageId: string) => Promise; - setEditingState: (message?: LocalMessage) => void; - setQuotedMessageState: (message?: LocalMessage) => void; /** * UI component for StreamingMessageView. Displays the text of a message with a typewriter animation. */ @@ -321,7 +320,7 @@ export type MessagesContextValue = Pick; + quotedMessage?: LocalMessage | null; /** * UI component for ReactionListTop * Defaults to: [ReactionList](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/Reaction/ReactionList.tsx) diff --git a/package/src/contexts/overlayContext/OverlayContext.tsx b/package/src/contexts/overlayContext/OverlayContext.tsx index 6cc559388d..e094429cb8 100644 --- a/package/src/contexts/overlayContext/OverlayContext.tsx +++ b/package/src/contexts/overlayContext/OverlayContext.tsx @@ -1,13 +1,10 @@ import React, { useContext } from 'react'; -import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; import type { Attachment } from 'stream-chat'; -import type { AttachmentPickerProps } from '../../components/AttachmentPicker/AttachmentPicker'; import type { ImageGalleryCustomComponents } from '../../components/ImageGallery/ImageGallery'; import type { Streami18n } from '../../utils/i18n/Streami18n'; -import type { AttachmentPickerContextValue } from '../attachmentPickerContext/AttachmentPickerContext'; import type { DeepPartial } from '../themeContext/ThemeContext'; import type { Theme } from '../themeContext/utils/theme'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -26,39 +23,19 @@ export const OverlayContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as OverlayContextValue, ); -export type OverlayProviderProps = Partial & - Partial< - Pick< - AttachmentPickerContextValue, - | 'AttachmentPickerBottomSheetHandle' - | 'attachmentPickerBottomSheetHandleHeight' - | 'attachmentPickerBottomSheetHeight' - | 'AttachmentPickerSelectionBar' - | 'attachmentSelectionBarHeight' - | 'bottomInset' - | 'CameraSelectorIcon' - | 'CreatePollIcon' - | 'FileSelectorIcon' - | 'ImageSelectorIcon' - | 'topInset' - | 'VideoRecorderSelectorIcon' - > - > & - ImageGalleryCustomComponents & { - autoPlayVideo?: boolean; - /** - * The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default - * */ - closePicker?: (ref: React.RefObject) => void; - giphyVersion?: keyof NonNullable; - /** https://github.com/GetStream/stream-chat-react-native/wiki/Internationalization-(i18n) */ - i18nInstance?: Streami18n; - imageGalleryGridHandleHeight?: number; - imageGalleryGridSnapPoints?: [string | number, string | number]; - numberOfImageGalleryGridColumns?: number; - openPicker?: (ref: React.RefObject) => void; - value?: Partial; - }; +export type OverlayProviderProps = ImageGalleryCustomComponents & { + autoPlayVideo?: boolean; + /** + * The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default + * */ + giphyVersion?: keyof NonNullable; + /** https://github.com/GetStream/stream-chat-react-native/wiki/Internationalization-(i18n) */ + i18nInstance?: Streami18n; + imageGalleryGridHandleHeight?: number; + imageGalleryGridSnapPoints?: [string | number, string | number]; + numberOfImageGalleryGridColumns?: number; + value?: Partial; +}; export const useOverlayContext = () => { const contextValue = useContext(OverlayContext); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 63092dcdf8..e4397032fb 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,33 +1,15 @@ -import React, { PropsWithChildren, useEffect, useRef, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useState } from 'react'; import { BackHandler } from 'react-native'; import { cancelAnimation, useSharedValue, withTiming } from 'react-native-reanimated'; -import type BottomSheet from '@gorhom/bottom-sheet'; - import { OverlayContext, OverlayProviderProps } from './OverlayContext'; -import { AttachmentPicker } from '../../components/AttachmentPicker/AttachmentPicker'; - -import { AttachmentPickerBottomSheetHandle as DefaultAttachmentPickerBottomSheetHandle } from '../../components/AttachmentPicker/components/AttachmentPickerBottomSheetHandle'; -import { AttachmentPickerError as DefaultAttachmentPickerError } from '../../components/AttachmentPicker/components/AttachmentPickerError'; -import { AttachmentPickerErrorImage as DefaultAttachmentPickerErrorImage } from '../../components/AttachmentPicker/components/AttachmentPickerErrorImage'; -import { AttachmentPickerIOSSelectMorePhotos as DefaultAttachmentPickerIOSSelectMorePhotos } from '../../components/AttachmentPicker/components/AttachmentPickerIOSSelectMorePhotos'; -import { AttachmentPickerSelectionBar as DefaultAttachmentPickerSelectionBar } from '../../components/AttachmentPicker/components/AttachmentPickerSelectionBar'; -import { CameraSelectorIcon as DefaultCameraSelectorIcon } from '../../components/AttachmentPicker/components/CameraSelectorIcon'; -import { FileSelectorIcon as DefaultFileSelectorIcon } from '../../components/AttachmentPicker/components/FileSelectorIcon'; -import { ImageOverlaySelectedComponent as DefaultImageOverlaySelectedComponent } from '../../components/AttachmentPicker/components/ImageOverlaySelectedComponent'; -import { ImageSelectorIcon as DefaultImageSelectorIcon } from '../../components/AttachmentPicker/components/ImageSelectorIcon'; -import { VideoRecorderSelectorIcon as DefaultVideoRecorderSelectorIcon } from '../../components/AttachmentPicker/components/VideoRecorderSelectorIcon'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; -import { CreatePollIcon as DefaultCreatePollIcon } from '../../components/Poll/components/CreatePollIcon'; -import { useStreami18n } from '../../hooks/useStreami18n'; -import { useViewport } from '../../hooks/useViewport'; -import { isImageMediaLibraryAvailable } from '../../native'; +import { useStreami18n } from '../../hooks/useStreami18n'; -import { AttachmentPickerProvider } from '../attachmentPickerContext/AttachmentPickerContext'; import { ImageGalleryProvider } from '../imageGalleryContext/ImageGalleryContext'; import { ThemeProvider } from '../themeContext/ThemeContext'; import { @@ -56,83 +38,18 @@ import { * @example ./OverlayProvider.md */ export const OverlayProvider = (props: PropsWithChildren) => { - const { vh } = useViewport(); - const bottomSheetCloseTimeoutRef = useRef>(undefined); const { - AttachmentPickerBottomSheetHandle = DefaultAttachmentPickerBottomSheetHandle, - attachmentPickerBottomSheetHandleHeight = 20, - attachmentPickerBottomSheetHeight = vh(45), - AttachmentPickerError = DefaultAttachmentPickerError, - attachmentPickerErrorButtonText, - AttachmentPickerErrorImage = DefaultAttachmentPickerErrorImage, - attachmentPickerErrorText, - AttachmentPickerIOSSelectMorePhotos = DefaultAttachmentPickerIOSSelectMorePhotos, - AttachmentPickerSelectionBar = DefaultAttachmentPickerSelectionBar, - attachmentSelectionBarHeight = 52, autoPlayVideo, - bottomInset, - CameraSelectorIcon = DefaultCameraSelectorIcon, children, - closePicker = (ref) => { - if (ref.current?.close) { - if (bottomSheetCloseTimeoutRef.current) { - clearTimeout(bottomSheetCloseTimeoutRef.current); - } - ref.current.close(); - // Attempt to close the bottomsheet again to circumvent accidental opening on Android. - // Details: This to prevent a race condition where the close function is called during the point when a internal container layout happens within the bottomsheet due to keyboard affecting the layout - // If the container layout measures a shorter height than previous but if the close snapped to the previous height's position, the bottom sheet will show up - // this short delay ensures that close function is always called after a container layout due to keyboard change - // NOTE: this timeout has to be above 500 as the keyboardAnimationDuration is 500 in the bottomsheet library - see src/hooks/useKeyboard.ts there for more details - bottomSheetCloseTimeoutRef.current = setTimeout(() => { - ref.current?.close(); - }, 600); - } - }, - CreatePollIcon = DefaultCreatePollIcon, - FileSelectorIcon = DefaultFileSelectorIcon, giphyVersion, i18nInstance, imageGalleryCustomComponents, imageGalleryGridHandleHeight = 40, imageGalleryGridSnapPoints, - ImageOverlaySelectedComponent = DefaultImageOverlaySelectedComponent, - ImageSelectorIcon = DefaultImageSelectorIcon, - numberOfAttachmentImagesToLoadPerCall, - numberOfAttachmentPickerImageColumns, numberOfImageGalleryGridColumns, - openPicker = (ref) => { - if (bottomSheetCloseTimeoutRef.current) { - clearTimeout(bottomSheetCloseTimeoutRef.current); - } - if (ref.current?.snapToIndex) { - ref.current.snapToIndex(0); - } else { - console.warn('bottom and top insets must be set for the image picker to work correctly'); - } - }, - topInset, value, - VideoRecorderSelectorIcon = DefaultVideoRecorderSelectorIcon, } = props; - const attachmentPickerProps = { - AttachmentPickerBottomSheetHandle, - attachmentPickerBottomSheetHandleHeight, - attachmentPickerBottomSheetHeight, - AttachmentPickerError, - attachmentPickerErrorButtonText, - AttachmentPickerErrorImage, - attachmentPickerErrorText, - AttachmentPickerIOSSelectMorePhotos, - attachmentSelectionBarHeight, - ImageOverlaySelectedComponent, - numberOfAttachmentImagesToLoadPerCall, - numberOfAttachmentPickerImageColumns, - }; - - const bottomSheetRef = useRef(null); - const [overlay, setOverlay] = useState(value?.overlay || 'none'); const overlayOpacity = useSharedValue(0); @@ -155,19 +72,7 @@ export const OverlayProvider = (props: PropsWithChildren) return () => backHandler.remove(); }, [overlay]); - useEffect( - () => - // cleanup the timeout if the component unmounts - () => { - if (bottomSheetCloseTimeoutRef.current) { - clearTimeout(bottomSheetCloseTimeoutRef.current); - } - }, - [], - ); - useEffect(() => { - closePicker(bottomSheetRef); cancelAnimation(overlayOpacity); if (overlay !== 'none') { overlayOpacity.value = withTiming(1); @@ -177,22 +82,6 @@ export const OverlayProvider = (props: PropsWithChildren) // eslint-disable-next-line react-hooks/exhaustive-deps }, [overlay]); - const attachmentPickerContext = { - AttachmentPickerBottomSheetHandle, - attachmentPickerBottomSheetHeight, - AttachmentPickerSelectionBar, - attachmentSelectionBarHeight, - bottomInset, - CameraSelectorIcon, - closePicker: () => closePicker(bottomSheetRef), - CreatePollIcon, - FileSelectorIcon, - ImageSelectorIcon, - openPicker: () => openPicker(bottomSheetRef), - topInset, - VideoRecorderSelectorIcon, - }; - const overlayContext = { overlay, setOverlay, @@ -202,27 +91,22 @@ export const OverlayProvider = (props: PropsWithChildren) return ( - - - - {children} - {overlay === 'gallery' && ( - - )} - {isImageMediaLibraryAvailable() ? ( - - ) : null} - - - + + + {children} + {overlay === 'gallery' && ( + + )} + + ); diff --git a/package/src/contexts/pollContext/createPollContentContext.tsx b/package/src/contexts/pollContext/createPollContentContext.tsx index 61f2528460..7fd69fafe2 100644 --- a/package/src/contexts/pollContext/createPollContentContext.tsx +++ b/package/src/contexts/pollContext/createPollContentContext.tsx @@ -1,14 +1,12 @@ import React, { PropsWithChildren, useContext } from 'react'; -import { CreatePollData } from 'stream-chat'; - import { MessageInputContextValue } from '../messageInputContext/MessageInputContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; export type CreatePollContentContextValue = { - createAndSendPoll: (pollData: CreatePollData) => Promise; + createAndSendPoll: () => Promise; sendMessage: MessageInputContextValue['sendMessage']; /** * A property that defines the constant height of the options within the poll creation screen. diff --git a/package/src/contexts/suggestionsContext/SuggestionsContext.tsx b/package/src/contexts/suggestionsContext/SuggestionsContext.tsx deleted file mode 100644 index 685be195f1..0000000000 --- a/package/src/contexts/suggestionsContext/SuggestionsContext.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, { PropsWithChildren, useCallback, useContext, useMemo, useState } from 'react'; - -import type { CommandResponse, UserResponse } from 'stream-chat'; - -import type { AutoCompleteSuggestionHeaderProps } from '../../components/AutoCompleteInput/AutoCompleteSuggestionHeader'; -import type { AutoCompleteSuggestionItemProps } from '../../components/AutoCompleteInput/AutoCompleteSuggestionItem'; -import type { AutoCompleteSuggestionListProps } from '../../components/AutoCompleteInput/AutoCompleteSuggestionList'; -import type { Emoji } from '../../emoji-data'; - -import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; - -import { isTestEnvironment } from '../utils/isTestEnvironment'; - -export type SuggestionComponentType = 'command' | 'emoji' | 'mention'; - -export const isSuggestionCommand = (suggestion: Suggestion): suggestion is SuggestionCommand => - 'args' in suggestion; - -export const isSuggestionEmoji = (suggestion: Suggestion): suggestion is Emoji => - 'unicode' in suggestion; - -export const isSuggestionUser = (suggestion: Suggestion): suggestion is SuggestionUser => - 'id' in suggestion; - -export type Suggestion = Emoji | SuggestionCommand | SuggestionUser; - -export type SuggestionCommand = CommandResponse; -export type SuggestionUser = UserResponse; - -export type Suggestions = { - data: Suggestion[]; - onSelect: (item: Suggestion) => void; - queryText?: string; -}; - -export type SuggestionsContextValue = { - AutoCompleteSuggestionHeader: React.ComponentType; - AutoCompleteSuggestionItem: React.ComponentType; - AutoCompleteSuggestionList: React.ComponentType; - /** Override handler for closing suggestions (mentions, command autocomplete etc) */ - closeSuggestions: () => void; - /** - * Override handler for opening suggestions (mentions, command autocomplete etc) - * - * @param component {Component|element} UI Component for suggestion item. - * @overrideType Function - */ - openSuggestions: (component: SuggestionComponentType) => Promise; - suggestions: Suggestions; - triggerType: SuggestionComponentType; - /** - * Override handler for updating suggestions (mentions, command autocomplete etc) - * - * @param newSuggestions {Component|element} UI Component for suggestion item. - * @overrideType Function - */ - updateSuggestions: (newSuggestions: Suggestions) => void; - queryText?: string; - suggestionsViewActive?: boolean; -}; - -export const SuggestionsContext = React.createContext( - DEFAULT_BASE_CONTEXT_VALUE as SuggestionsContextValue, -); - -/** - * This provider component exposes the properties stored within the SuggestionsContext. - */ -export const SuggestionsProvider = ({ - children, - value, -}: PropsWithChildren<{ value?: Partial }>) => { - const { AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList } = - value ?? {}; - const [triggerType, setTriggerType] = useState(null); - const [suggestions, setSuggestions] = useState(); - const [suggestionsViewActive, setSuggestionsViewActive] = useState(false); - - const openSuggestions = useCallback((component: SuggestionComponentType) => { - setTriggerType(component); - setSuggestionsViewActive(true); - }, []); - - const updateSuggestions = useCallback( - (newSuggestions: Suggestions) => { - setSuggestions(newSuggestions); - setSuggestionsViewActive(!!triggerType); - }, - [triggerType], - ); - - const closeSuggestions = useCallback(() => { - setTriggerType(null); - setSuggestions(undefined); - setSuggestionsViewActive(false); - }, []); - - const suggestionsContext = useMemo(() => { - return { - AutoCompleteSuggestionHeader, - AutoCompleteSuggestionItem, - AutoCompleteSuggestionList, - closeSuggestions, - openSuggestions, - suggestions, - suggestionsViewActive, - triggerType, - updateSuggestions, - }; - }, [ - AutoCompleteSuggestionHeader, - AutoCompleteSuggestionItem, - AutoCompleteSuggestionList, - closeSuggestions, - openSuggestions, - suggestions, - suggestionsViewActive, - triggerType, - updateSuggestions, - ]); - - return ( - - {children} - - ); -}; - -export const useSuggestionsContext = () => { - const contextValue = useContext(SuggestionsContext) as unknown as SuggestionsContextValue; - - if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { - throw new Error( - 'The useSuggestionsContext hook was called outside of the SuggestionsContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ); - } - - return contextValue; -}; diff --git a/package/src/contexts/suggestionsContext/__tests__/Suggestions.test.js b/package/src/contexts/suggestionsContext/__tests__/Suggestions.test.js deleted file mode 100644 index 4fb7ac3e5a..0000000000 --- a/package/src/contexts/suggestionsContext/__tests__/Suggestions.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; - -import { View } from 'react-native'; - -import { cleanup, render, waitFor } from '@testing-library/react-native'; - -import { SuggestionsProvider, useSuggestionsContext } from '../SuggestionsContext'; - -const SuggestionsContextConsumer = ({ fn }) => { - const value = useSuggestionsContext(); - fn(value); - return ; -}; - -describe('SuggestionsProvider', () => { - afterEach(cleanup); - - it('renders children without crashing', async () => { - const { getByTestId } = render( - - - , - ); - - await waitFor(() => expect(getByTestId('children')).toBeTruthy()); - }); - - it('exposes the suggestions context', async () => { - let context; - - render( - - { - context = ctx; - }} - /> - , - ); - - await waitFor(() => { - expect(context).toBeInstanceOf(Object); - expect(context.closeSuggestions).toBeInstanceOf(Function); - expect(context.openSuggestions).toBeInstanceOf(Function); - expect(typeof context.suggestionsViewActive).toBe('boolean'); - expect(context.updateSuggestions).toBeInstanceOf(Function); - }); - }); -}); diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 363d3b4494..9df8cf26b3 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -169,14 +169,14 @@ export type Theme = { container: ViewStyle; contentContainer: ViewStyle; date: TextStyle; - message: TextStyle & { - fontWeight: TextStyle['fontWeight']; - }; mutedStatus: { height: number; iconStyle: ViewStyle; width: number; }; + message: { + container: ViewStyle; + }; row: ViewStyle; title: TextStyle; unreadContainer: ViewStyle; @@ -269,6 +269,16 @@ export type Theme = { attachButtonContainer: ViewStyle; attachmentSelectionBar: ViewStyle; attachmentSeparator: ViewStyle; + attachmentUnsupportedIndicator: { + container: ViewStyle; + warningIcon: IconProps; + text: TextStyle; + }; + attachmentUploadPreviewList: { + filesFlatList: ViewStyle; + imagesFlatList: ViewStyle; + wrapper: ViewStyle; + }; audioRecorder: { arrowLeftIcon: IconProps; checkContainer: ViewStyle; @@ -307,14 +317,23 @@ export type Theme = { waveform: ViewStyle; }; autoCompleteInputContainer: ViewStyle; + commandInput: { + closeButton: ViewStyle; + container: ViewStyle; + text: TextStyle; + }; commandsButton: ViewStyle; - commandsButtonContainer: ViewStyle; composerContainer: ViewStyle; container: ViewStyle; cooldownTimer: { container: ViewStyle; text: TextStyle; }; + dismissAttachmentUpload: { + dismiss: ViewStyle; + dismissIcon: IconProps; + dismissIconColor: ColorValue; + }; editingBoxContainer: ViewStyle; editingBoxHeader: ViewStyle; editingBoxHeaderTitle: TextStyle; @@ -322,25 +341,24 @@ export type Theme = { editingBoxHeader: ViewStyle; editingBoxHeaderTitle: TextStyle; }; - fileUploadPreview: { - dismiss: ViewStyle; + fileAttachmentUploadPreview: { fileContainer: ViewStyle; filenameText: TextStyle; fileSizeText: TextStyle; fileTextContainer: ViewStyle; + uploadProgressOverlay: ViewStyle; + wrapper: ViewStyle; + }; + fileUploadPreview: { flatList: ViewStyle; }; focusedInputBoxContainer: ViewStyle; - giphyCommandInput: { - giphyContainer: ViewStyle; - giphyText: TextStyle; + imageAttachmentUploadPreview: { + itemContainer: ViewStyle; + upload: ImageStyle; }; imageUploadPreview: { - dismiss: ViewStyle; - dismissIconColor: ColorValue; flatList: ViewStyle; - itemContainer: ViewStyle; - upload: ImageStyle; }; inputBox: TextStyle; inputBoxContainer: ViewStyle; @@ -405,6 +423,12 @@ export type Theme = { indicatorColor: string; overlay: ViewStyle; }; + videoAttachmentUploadPreview: { + recorderIconContainer: ViewStyle; + recorderIcon: IconProps; + itemContainer: ViewStyle; + upload: ImageStyle; + }; }; messageList: { container: ViewStyle; @@ -486,6 +510,9 @@ export type Theme = { unfilledBackgroundColor: ColorValue; }; }; + messagePreview: { + message: TextStyle; + }; messageSimple: { actions: { button: ViewStyle & { @@ -832,8 +859,9 @@ export type Theme = { dateText: TextStyle; headerRow: ViewStyle; infoRow: ViewStyle; - lastReplyText: TextStyle; + parentMessagePreviewContainer: ViewStyle; parentMessageText: TextStyle; + previewMessageContainer: ViewStyle; touchableWrapper: ViewStyle; unreadBubbleText: TextStyle; unreadBubbleWrapper: ViewStyle; @@ -954,7 +982,7 @@ export const defaultTheme: Theme = { contentContainer: {}, date: {}, message: { - fontWeight: '400', + container: {}, }, mutedStatus: { height: 20, @@ -1053,6 +1081,16 @@ export const defaultTheme: Theme = { attachButtonContainer: {}, attachmentSelectionBar: {}, attachmentSeparator: {}, + attachmentUnsupportedIndicator: { + container: {}, + text: {}, + warningIcon: {}, + }, + attachmentUploadPreviewList: { + filesFlatList: {}, + imagesFlatList: {}, + wrapper: {}, + }, audioRecorder: { arrowLeftIcon: {}, checkContainer: {}, @@ -1078,14 +1116,23 @@ export const defaultTheme: Theme = { }, audioRecordingWaveform: { container: {}, waveform: {} }, autoCompleteInputContainer: {}, + commandInput: { + closeButton: {}, + container: {}, + text: {}, + }, commandsButton: {}, - commandsButtonContainer: {}, composerContainer: {}, container: {}, cooldownTimer: { container: {}, text: {}, }, + dismissAttachmentUpload: { + dismiss: {}, + dismissIcon: {}, + dismissIconColor: '', + }, editingBoxContainer: {}, editingBoxHeader: {}, editingBoxHeaderTitle: {}, @@ -1093,25 +1140,24 @@ export const defaultTheme: Theme = { editingBoxHeader: {}, editingBoxHeaderTitle: {}, }, - fileUploadPreview: { - dismiss: {}, + fileAttachmentUploadPreview: { fileContainer: {}, filenameText: {}, fileSizeText: {}, fileTextContainer: {}, + uploadProgressOverlay: {}, + wrapper: {}, + }, + fileUploadPreview: { flatList: {}, }, focusedInputBoxContainer: {}, - giphyCommandInput: { - giphyContainer: {}, - giphyText: {}, + imageAttachmentUploadPreview: { + itemContainer: {}, + upload: {}, }, imageUploadPreview: { - dismiss: {}, - dismissIconColor: '', flatList: {}, - itemContainer: {}, - upload: {}, }, inputBox: {}, inputBoxContainer: {}, @@ -1176,6 +1222,12 @@ export const defaultTheme: Theme = { indicatorColor: '', overlay: {}, }, + videoAttachmentUploadPreview: { + itemContainer: {}, + recorderIcon: {}, + recorderIconContainer: {}, + upload: {}, + }, }, messageList: { container: {}, @@ -1254,6 +1306,9 @@ export const defaultTheme: Theme = { unfilledBackgroundColor: Colors.grey_gainsboro, }, }, + messagePreview: { + message: {}, + }, messageSimple: { actions: { button: {}, @@ -1628,8 +1683,9 @@ export const defaultTheme: Theme = { dateText: {}, headerRow: {}, infoRow: {}, - lastReplyText: {}, + parentMessagePreviewContainer: {}, parentMessageText: {}, + previewMessageContainer: {}, touchableWrapper: {}, unreadBubbleText: {}, unreadBubbleWrapper: {}, diff --git a/package/src/contexts/translationContext/TranslationContext.tsx b/package/src/contexts/translationContext/TranslationContext.tsx index a21eac5cff..4ab6737728 100644 --- a/package/src/contexts/translationContext/TranslationContext.tsx +++ b/package/src/contexts/translationContext/TranslationContext.tsx @@ -2,35 +2,21 @@ import React, { useContext } from 'react'; import Dayjs from 'dayjs'; -import type { TFunction } from 'i18next'; -import type { Moment } from 'moment-timezone'; - import type { TranslationLanguages } from 'stream-chat'; +import { TranslatorFunctions } from './types'; + +import { defaultTranslatorFunction } from '../../utils/i18n/Streami18n'; import { isTestEnvironment } from '../utils/isTestEnvironment'; export const DEFAULT_USER_LANGUAGE: TranslationLanguages = 'en'; -export const isDayOrMoment = (output: TDateTimeParserOutput): output is Dayjs.Dayjs | Moment => - (output as Dayjs.Dayjs | Moment).isSame != null; - -export type TDateTimeParserInput = string | number | Date; - -export type TDateTimeParserOutput = string | number | Date | Dayjs.Dayjs | Moment; - -export type TDateTimeParser = (input?: TDateTimeParserInput) => TDateTimeParserOutput; - -export type TranslatorFunctions = { - t: TFunction | ((key: string) => string); - tDateTimeParser: TDateTimeParser; -}; - export type TranslationContextValue = TranslatorFunctions & { userLanguage: TranslationLanguages; }; const defaultTranslationContextValue: TranslationContextValue = { - t: (key: string) => key, + t: defaultTranslatorFunction, tDateTimeParser: (input) => Dayjs(input), userLanguage: DEFAULT_USER_LANGUAGE, }; diff --git a/package/src/contexts/translationContext/index.ts b/package/src/contexts/translationContext/index.ts new file mode 100644 index 0000000000..b43b713be8 --- /dev/null +++ b/package/src/contexts/translationContext/index.ts @@ -0,0 +1,3 @@ +export * from './TranslationContext'; +export * from './types'; +export * from './isDayOrMoment'; diff --git a/package/src/contexts/translationContext/isDayOrMoment.ts b/package/src/contexts/translationContext/isDayOrMoment.ts new file mode 100644 index 0000000000..766943ef98 --- /dev/null +++ b/package/src/contexts/translationContext/isDayOrMoment.ts @@ -0,0 +1,8 @@ +import Dayjs from 'dayjs'; + +import type { Moment } from 'moment-timezone'; + +import { TDateTimeParserOutput } from './types'; + +export const isDayOrMoment = (output: TDateTimeParserOutput): output is Dayjs.Dayjs | Moment => + (output as Dayjs.Dayjs | Moment).isSame != null; diff --git a/package/src/contexts/translationContext/types.ts b/package/src/contexts/translationContext/types.ts new file mode 100644 index 0000000000..42de16931f --- /dev/null +++ b/package/src/contexts/translationContext/types.ts @@ -0,0 +1,15 @@ +import Dayjs from 'dayjs'; + +import type { TFunction } from 'i18next'; +import type { Moment } from 'moment-timezone'; + +export type TDateTimeParserInput = string | number | Date; + +export type TDateTimeParserOutput = string | number | Date | Dayjs.Dayjs | Moment; + +export type TDateTimeParser = (input?: TDateTimeParserInput) => TDateTimeParserOutput; + +export type TranslatorFunctions = { + t: TFunction; + tDateTimeParser: TDateTimeParser; +}; diff --git a/package/src/emoji-data/index.ts b/package/src/emoji-data/index.ts deleted file mode 100644 index 1779ddb6dd..0000000000 --- a/package/src/emoji-data/index.ts +++ /dev/null @@ -1,5178 +0,0 @@ -export type Emoji = { - id: string; - name: string; - names: string[]; - unicode: string; - skins?: string[]; -}; - -export type Emojis = Emoji[]; - -export const compiledEmojis: Emojis = [ - { - id: '+1', - name: '+1', - names: ['+1', 'thumbsup'], - skins: ['👍🏻', '👍🏼', '👍🏽', '👍🏾', '👍🏿'], - unicode: '👍', - }, - { - id: '-1', - name: '-1', - names: ['-1', 'thumbsdown'], - skins: ['👎🏻', '👎🏼', '👎🏽', '👎🏾', '👎🏿'], - unicode: '👎', - }, - { id: '100', name: '100', names: ['100'], unicode: '💯' }, - { id: '1234', name: '1234', names: ['1234'], unicode: '🔢' }, - { id: '8ball', name: '8ball', names: ['8ball'], unicode: '🎱' }, - { id: 'a', name: 'a', names: ['a'], unicode: '🅰️' }, - { id: 'ab', name: 'ab', names: ['ab'], unicode: '🆎' }, - { id: 'abacus', name: 'abacus', names: ['abacus'], unicode: '🧮' }, - { id: 'abc', name: 'abc', names: ['abc'], unicode: '🔤' }, - { id: 'abcd', name: 'abcd', names: ['abcd'], unicode: '🔡' }, - { id: 'accept', name: 'accept', names: ['accept'], unicode: '🉑' }, - { id: 'accordion', name: 'accordion', names: ['accordion'], unicode: '🪗' }, - { id: 'adhesive_bandage', name: 'adhesive_bandage', names: ['adhesive_bandage'], unicode: '🩹' }, - { - id: 'admission_tickets', - name: 'admission_tickets', - names: ['admission_tickets'], - unicode: '🎟️', - }, - { - id: 'adult', - name: 'adult', - names: ['adult'], - skins: ['🧑🏻', '🧑🏼', '🧑🏽', '🧑🏾', '🧑🏿'], - unicode: '🧑', - }, - { id: 'aerial_tramway', name: 'aerial_tramway', names: ['aerial_tramway'], unicode: '🚡' }, - { id: 'airplane', name: 'airplane', names: ['airplane'], unicode: '✈️' }, - { - id: 'airplane_arriving', - name: 'airplane_arriving', - names: ['airplane_arriving'], - unicode: '🛬', - }, - { - id: 'airplane_departure', - name: 'airplane_departure', - names: ['airplane_departure'], - unicode: '🛫', - }, - { id: 'alarm_clock', name: 'alarm_clock', names: ['alarm_clock'], unicode: '⏰' }, - { id: 'alembic', name: 'alembic', names: ['alembic'], unicode: '⚗️' }, - { id: 'alien', name: 'alien', names: ['alien'], unicode: '👽' }, - { id: 'ambulance', name: 'ambulance', names: ['ambulance'], unicode: '🚑' }, - { id: 'amphora', name: 'amphora', names: ['amphora'], unicode: '🏺' }, - { id: 'anatomical_heart', name: 'anatomical_heart', names: ['anatomical_heart'], unicode: '🫀' }, - { id: 'anchor', name: 'anchor', names: ['anchor'], unicode: '⚓' }, - { - id: 'angel', - name: 'angel', - names: ['angel'], - skins: ['👼🏻', '👼🏼', '👼🏽', '👼🏾', '👼🏿'], - unicode: '👼', - }, - { id: 'anger', name: 'anger', names: ['anger'], unicode: '💢' }, - { id: 'angry', name: 'angry', names: ['angry'], unicode: '😠' }, - { id: 'anguished', name: 'anguished', names: ['anguished'], unicode: '😧' }, - { id: 'ant', name: 'ant', names: ['ant'], unicode: '🐜' }, - { id: 'apple', name: 'apple', names: ['apple'], unicode: '🍎' }, - { id: 'aquarius', name: 'aquarius', names: ['aquarius'], unicode: '♒' }, - { id: 'aries', name: 'aries', names: ['aries'], unicode: '♈' }, - { id: 'arrow_backward', name: 'arrow_backward', names: ['arrow_backward'], unicode: '◀️' }, - { - id: 'arrow_double_down', - name: 'arrow_double_down', - names: ['arrow_double_down'], - unicode: '⏬', - }, - { id: 'arrow_double_up', name: 'arrow_double_up', names: ['arrow_double_up'], unicode: '⏫' }, - { id: 'arrow_down', name: 'arrow_down', names: ['arrow_down'], unicode: '⬇️' }, - { id: 'arrow_down_small', name: 'arrow_down_small', names: ['arrow_down_small'], unicode: '🔽' }, - { id: 'arrow_forward', name: 'arrow_forward', names: ['arrow_forward'], unicode: '▶️' }, - { - id: 'arrow_heading_down', - name: 'arrow_heading_down', - names: ['arrow_heading_down'], - unicode: '⤵️', - }, - { id: 'arrow_heading_up', name: 'arrow_heading_up', names: ['arrow_heading_up'], unicode: '⤴️' }, - { id: 'arrow_left', name: 'arrow_left', names: ['arrow_left'], unicode: '⬅️' }, - { id: 'arrow_lower_left', name: 'arrow_lower_left', names: ['arrow_lower_left'], unicode: '↙️' }, - { - id: 'arrow_lower_right', - name: 'arrow_lower_right', - names: ['arrow_lower_right'], - unicode: '↘️', - }, - { id: 'arrow_right', name: 'arrow_right', names: ['arrow_right'], unicode: '➡️' }, - { id: 'arrow_right_hook', name: 'arrow_right_hook', names: ['arrow_right_hook'], unicode: '↪️' }, - { id: 'arrow_up', name: 'arrow_up', names: ['arrow_up'], unicode: '⬆️' }, - { id: 'arrow_up_down', name: 'arrow_up_down', names: ['arrow_up_down'], unicode: '↕️' }, - { id: 'arrow_up_small', name: 'arrow_up_small', names: ['arrow_up_small'], unicode: '🔼' }, - { id: 'arrow_upper_left', name: 'arrow_upper_left', names: ['arrow_upper_left'], unicode: '↖️' }, - { - id: 'arrow_upper_right', - name: 'arrow_upper_right', - names: ['arrow_upper_right'], - unicode: '↗️', - }, - { id: 'arrows_clockwise', name: 'arrows_clockwise', names: ['arrows_clockwise'], unicode: '🔃' }, - { - id: 'arrows_counterclockwise', - name: 'arrows_counterclockwise', - names: ['arrows_counterclockwise'], - unicode: '🔄', - }, - { id: 'art', name: 'art', names: ['art'], unicode: '🎨' }, - { - id: 'articulated_lorry', - name: 'articulated_lorry', - names: ['articulated_lorry'], - unicode: '🚛', - }, - { - id: 'artist', - name: 'artist', - names: ['artist'], - skins: ['🧑🏻‍🎨', '🧑🏼‍🎨', '🧑🏽‍🎨', '🧑🏾‍🎨', '🧑🏿‍🎨'], - unicode: '🧑‍🎨', - }, - { id: 'astonished', name: 'astonished', names: ['astonished'], unicode: '😲' }, - { - id: 'astronaut', - name: 'astronaut', - names: ['astronaut'], - skins: ['🧑🏻‍🚀', '🧑🏼‍🚀', '🧑🏽‍🚀', '🧑🏾‍🚀', '🧑🏿‍🚀'], - unicode: '🧑‍🚀', - }, - { id: 'athletic_shoe', name: 'athletic_shoe', names: ['athletic_shoe'], unicode: '👟' }, - { id: 'atm', name: 'atm', names: ['atm'], unicode: '🏧' }, - { id: 'atom_symbol', name: 'atom_symbol', names: ['atom_symbol'], unicode: '⚛️' }, - { id: 'auto_rickshaw', name: 'auto_rickshaw', names: ['auto_rickshaw'], unicode: '🛺' }, - { id: 'avocado', name: 'avocado', names: ['avocado'], unicode: '🥑' }, - { id: 'axe', name: 'axe', names: ['axe'], unicode: '🪓' }, - { id: 'b', name: 'b', names: ['b'], unicode: '🅱️' }, - { - id: 'baby', - name: 'baby', - names: ['baby'], - skins: ['👶🏻', '👶🏼', '👶🏽', '👶🏾', '👶🏿'], - unicode: '👶', - }, - { id: 'baby_bottle', name: 'baby_bottle', names: ['baby_bottle'], unicode: '🍼' }, - { id: 'baby_chick', name: 'baby_chick', names: ['baby_chick'], unicode: '🐤' }, - { id: 'baby_symbol', name: 'baby_symbol', names: ['baby_symbol'], unicode: '🚼' }, - { id: 'back', name: 'back', names: ['back'], unicode: '🔙' }, - { id: 'bacon', name: 'bacon', names: ['bacon'], unicode: '🥓' }, - { id: 'badger', name: 'badger', names: ['badger'], unicode: '🦡' }, - { - id: 'badminton_racquet_and_shuttlecock', - name: 'badminton_racquet_and_shuttlecock', - names: ['badminton_racquet_and_shuttlecock'], - unicode: '🏸', - }, - { id: 'bagel', name: 'bagel', names: ['bagel'], unicode: '🥯' }, - { id: 'baggage_claim', name: 'baggage_claim', names: ['baggage_claim'], unicode: '🛄' }, - { id: 'baguette_bread', name: 'baguette_bread', names: ['baguette_bread'], unicode: '🥖' }, - { - id: 'bald_man', - name: 'bald_man', - names: ['bald_man'], - skins: ['👨🏻‍🦲', '👨🏼‍🦲', '👨🏽‍🦲', '👨🏾‍🦲', '👨🏿‍🦲'], - unicode: '👨‍🦲', - }, - { - id: 'bald_person', - name: 'bald_person', - names: ['bald_person'], - skins: ['🧑🏻‍🦲', '🧑🏼‍🦲', '🧑🏽‍🦲', '🧑🏾‍🦲', '🧑🏿‍🦲'], - unicode: '🧑‍🦲', - }, - { - id: 'bald_woman', - name: 'bald_woman', - names: ['bald_woman'], - skins: ['👩🏻‍🦲', '👩🏼‍🦲', '👩🏽‍🦲', '👩🏾‍🦲', '👩🏿‍🦲'], - unicode: '👩‍🦲', - }, - { id: 'ballet_shoes', name: 'ballet_shoes', names: ['ballet_shoes'], unicode: '🩰' }, - { id: 'balloon', name: 'balloon', names: ['balloon'], unicode: '🎈' }, - { - id: 'ballot_box_with_ballot', - name: 'ballot_box_with_ballot', - names: ['ballot_box_with_ballot'], - unicode: '🗳️', - }, - { - id: 'ballot_box_with_check', - name: 'ballot_box_with_check', - names: ['ballot_box_with_check'], - unicode: '☑️', - }, - { id: 'bamboo', name: 'bamboo', names: ['bamboo'], unicode: '🎍' }, - { id: 'banana', name: 'banana', names: ['banana'], unicode: '🍌' }, - { id: 'bangbang', name: 'bangbang', names: ['bangbang'], unicode: '‼️' }, - { id: 'banjo', name: 'banjo', names: ['banjo'], unicode: '🪕' }, - { id: 'bank', name: 'bank', names: ['bank'], unicode: '🏦' }, - { id: 'bar_chart', name: 'bar_chart', names: ['bar_chart'], unicode: '📊' }, - { id: 'barber', name: 'barber', names: ['barber'], unicode: '💈' }, - { - id: 'barely_sunny', - name: 'barely_sunny', - names: ['barely_sunny', 'sun_behind_cloud'], - unicode: '🌥️', - }, - { id: 'baseball', name: 'baseball', names: ['baseball'], unicode: '⚾' }, - { id: 'basket', name: 'basket', names: ['basket'], unicode: '🧺' }, - { id: 'basketball', name: 'basketball', names: ['basketball'], unicode: '🏀' }, - { id: 'bat', name: 'bat', names: ['bat'], unicode: '🦇' }, - { - id: 'bath', - name: 'bath', - names: ['bath'], - skins: ['🛀🏻', '🛀🏼', '🛀🏽', '🛀🏾', '🛀🏿'], - unicode: '🛀', - }, - { id: 'bathtub', name: 'bathtub', names: ['bathtub'], unicode: '🛁' }, - { id: 'battery', name: 'battery', names: ['battery'], unicode: '🔋' }, - { - id: 'beach_with_umbrella', - name: 'beach_with_umbrella', - names: ['beach_with_umbrella'], - unicode: '🏖️', - }, - { id: 'beans', name: 'beans', names: ['beans'], unicode: '🫘' }, - { id: 'bear', name: 'bear', names: ['bear'], unicode: '🐻' }, - { - id: 'bearded_person', - name: 'bearded_person', - names: ['bearded_person'], - skins: ['🧔🏻', '🧔🏼', '🧔🏽', '🧔🏾', '🧔🏿'], - unicode: '🧔', - }, - { id: 'beaver', name: 'beaver', names: ['beaver'], unicode: '🦫' }, - { id: 'bed', name: 'bed', names: ['bed'], unicode: '🛏️' }, - { id: 'bee', name: 'bee', names: ['bee', 'honeybee'], unicode: '🐝' }, - { id: 'beer', name: 'beer', names: ['beer'], unicode: '🍺' }, - { id: 'beers', name: 'beers', names: ['beers'], unicode: '🍻' }, - { id: 'beetle', name: 'beetle', names: ['beetle'], unicode: '🪲' }, - { id: 'beginner', name: 'beginner', names: ['beginner'], unicode: '🔰' }, - { id: 'bell', name: 'bell', names: ['bell'], unicode: '🔔' }, - { id: 'bell_pepper', name: 'bell_pepper', names: ['bell_pepper'], unicode: '🫑' }, - { id: 'bellhop_bell', name: 'bellhop_bell', names: ['bellhop_bell'], unicode: '🛎️' }, - { id: 'bento', name: 'bento', names: ['bento'], unicode: '🍱' }, - { id: 'beverage_box', name: 'beverage_box', names: ['beverage_box'], unicode: '🧃' }, - { - id: 'bicyclist', - name: 'bicyclist', - names: ['bicyclist'], - skins: ['🚴🏻', '🚴🏼', '🚴🏽', '🚴🏾', '🚴🏿'], - unicode: '🚴', - }, - { id: 'bike', name: 'bike', names: ['bike'], unicode: '🚲' }, - { id: 'bikini', name: 'bikini', names: ['bikini'], unicode: '👙' }, - { id: 'billed_cap', name: 'billed_cap', names: ['billed_cap'], unicode: '🧢' }, - { id: 'biohazard_sign', name: 'biohazard_sign', names: ['biohazard_sign'], unicode: '☣️' }, - { id: 'bird', name: 'bird', names: ['bird'], unicode: '🐦' }, - { id: 'birthday', name: 'birthday', names: ['birthday'], unicode: '🎂' }, - { id: 'bison', name: 'bison', names: ['bison'], unicode: '🦬' }, - { id: 'biting_lip', name: 'biting_lip', names: ['biting_lip'], unicode: '🫦' }, - { id: 'black_bird', name: 'black_bird', names: ['black_bird'], unicode: '🐦‍⬛' }, - { id: 'black_cat', name: 'black_cat', names: ['black_cat'], unicode: '🐈‍⬛' }, - { id: 'black_circle', name: 'black_circle', names: ['black_circle'], unicode: '⚫' }, - { - id: 'black_circle_for_record', - name: 'black_circle_for_record', - names: ['black_circle_for_record'], - unicode: '⏺️', - }, - { id: 'black_heart', name: 'black_heart', names: ['black_heart'], unicode: '🖤' }, - { id: 'black_joker', name: 'black_joker', names: ['black_joker'], unicode: '🃏' }, - { - id: 'black_large_square', - name: 'black_large_square', - names: ['black_large_square'], - unicode: '⬛', - }, - { - id: 'black_left_pointing_double_triangle_with_vertical_bar', - name: 'black_left_pointing_double_triangle_with_vertical_bar', - names: ['black_left_pointing_double_triangle_with_vertical_bar'], - unicode: '⏮️', - }, - { - id: 'black_medium_small_square', - name: 'black_medium_small_square', - names: ['black_medium_small_square'], - unicode: '◾', - }, - { - id: 'black_medium_square', - name: 'black_medium_square', - names: ['black_medium_square'], - unicode: '◼️', - }, - { id: 'black_nib', name: 'black_nib', names: ['black_nib'], unicode: '✒️' }, - { - id: 'black_right_pointing_double_triangle_with_vertical_bar', - name: 'black_right_pointing_double_triangle_with_vertical_bar', - names: ['black_right_pointing_double_triangle_with_vertical_bar'], - unicode: '⏭️', - }, - { - id: 'black_right_pointing_triangle_with_double_vertical_bar', - name: 'black_right_pointing_triangle_with_double_vertical_bar', - names: ['black_right_pointing_triangle_with_double_vertical_bar'], - unicode: '⏯️', - }, - { - id: 'black_small_square', - name: 'black_small_square', - names: ['black_small_square'], - unicode: '▪️', - }, - { - id: 'black_square_button', - name: 'black_square_button', - names: ['black_square_button'], - unicode: '🔲', - }, - { - id: 'black_square_for_stop', - name: 'black_square_for_stop', - names: ['black_square_for_stop'], - unicode: '⏹️', - }, - { - id: 'blond-haired-man', - name: 'blond-haired-man', - names: ['blond-haired-man'], - skins: ['👱🏻‍♂️', '👱🏼‍♂️', '👱🏽‍♂️', '👱🏾‍♂️', '👱🏿‍♂️'], - unicode: '👱‍♂️', - }, - { - id: 'blond-haired-woman', - name: 'blond-haired-woman', - names: ['blond-haired-woman'], - skins: ['👱🏻‍♀️', '👱🏼‍♀️', '👱🏽‍♀️', '👱🏾‍♀️', '👱🏿‍♀️'], - unicode: '👱‍♀️', - }, - { id: 'blossom', name: 'blossom', names: ['blossom'], unicode: '🌼' }, - { id: 'blowfish', name: 'blowfish', names: ['blowfish'], unicode: '🐡' }, - { id: 'blue_book', name: 'blue_book', names: ['blue_book'], unicode: '📘' }, - { id: 'blue_car', name: 'blue_car', names: ['blue_car'], unicode: '🚙' }, - { id: 'blue_heart', name: 'blue_heart', names: ['blue_heart'], unicode: '💙' }, - { id: 'blueberries', name: 'blueberries', names: ['blueberries'], unicode: '🫐' }, - { id: 'blush', name: 'blush', names: ['blush'], unicode: '😊' }, - { id: 'boar', name: 'boar', names: ['boar'], unicode: '🐗' }, - { id: 'boat', name: 'boat', names: ['boat', 'sailboat'], unicode: '⛵' }, - { id: 'bomb', name: 'bomb', names: ['bomb'], unicode: '💣' }, - { id: 'bone', name: 'bone', names: ['bone'], unicode: '🦴' }, - { id: 'book', name: 'book', names: ['book', 'open_book'], unicode: '📖' }, - { id: 'bookmark', name: 'bookmark', names: ['bookmark'], unicode: '🔖' }, - { id: 'bookmark_tabs', name: 'bookmark_tabs', names: ['bookmark_tabs'], unicode: '📑' }, - { id: 'books', name: 'books', names: ['books'], unicode: '📚' }, - { id: 'boom', name: 'boom', names: ['boom', 'collision'], unicode: '💥' }, - { id: 'boomerang', name: 'boomerang', names: ['boomerang'], unicode: '🪃' }, - { id: 'boot', name: 'boot', names: ['boot'], unicode: '👢' }, - { id: 'bouquet', name: 'bouquet', names: ['bouquet'], unicode: '💐' }, - { id: 'bow', name: 'bow', names: ['bow'], skins: ['🙇🏻', '🙇🏼', '🙇🏽', '🙇🏾', '🙇🏿'], unicode: '🙇' }, - { id: 'bow_and_arrow', name: 'bow_and_arrow', names: ['bow_and_arrow'], unicode: '🏹' }, - { id: 'bowl_with_spoon', name: 'bowl_with_spoon', names: ['bowl_with_spoon'], unicode: '🥣' }, - { id: 'bowling', name: 'bowling', names: ['bowling'], unicode: '🎳' }, - { id: 'boxing_glove', name: 'boxing_glove', names: ['boxing_glove'], unicode: '🥊' }, - { id: 'boy', name: 'boy', names: ['boy'], skins: ['👦🏻', '👦🏼', '👦🏽', '👦🏾', '👦🏿'], unicode: '👦' }, - { id: 'brain', name: 'brain', names: ['brain'], unicode: '🧠' }, - { id: 'bread', name: 'bread', names: ['bread'], unicode: '🍞' }, - { - id: 'breast-feeding', - name: 'breast-feeding', - names: ['breast-feeding'], - skins: ['🤱🏻', '🤱🏼', '🤱🏽', '🤱🏾', '🤱🏿'], - unicode: '🤱', - }, - { id: 'bricks', name: 'bricks', names: ['bricks'], unicode: '🧱' }, - { - id: 'bride_with_veil', - name: 'bride_with_veil', - names: ['bride_with_veil'], - skins: ['👰🏻', '👰🏼', '👰🏽', '👰🏾', '👰🏿'], - unicode: '👰', - }, - { id: 'bridge_at_night', name: 'bridge_at_night', names: ['bridge_at_night'], unicode: '🌉' }, - { id: 'briefcase', name: 'briefcase', names: ['briefcase'], unicode: '💼' }, - { id: 'briefs', name: 'briefs', names: ['briefs'], unicode: '🩲' }, - { id: 'broccoli', name: 'broccoli', names: ['broccoli'], unicode: '🥦' }, - { id: 'broken_chain', name: 'broken_chain', names: ['broken_chain'], unicode: '⛓️‍💥' }, - { id: 'broken_heart', name: 'broken_heart', names: ['broken_heart'], unicode: '💔' }, - { id: 'broom', name: 'broom', names: ['broom'], unicode: '🧹' }, - { id: 'brown_heart', name: 'brown_heart', names: ['brown_heart'], unicode: '🤎' }, - { id: 'brown_mushroom', name: 'brown_mushroom', names: ['brown_mushroom'], unicode: '🍄‍🟫' }, - { id: 'bubble_tea', name: 'bubble_tea', names: ['bubble_tea'], unicode: '🧋' }, - { id: 'bubbles', name: 'bubbles', names: ['bubbles'], unicode: '🫧' }, - { id: 'bucket', name: 'bucket', names: ['bucket'], unicode: '🪣' }, - { id: 'bug', name: 'bug', names: ['bug'], unicode: '🐛' }, - { - id: 'building_construction', - name: 'building_construction', - names: ['building_construction'], - unicode: '🏗️', - }, - { id: 'bulb', name: 'bulb', names: ['bulb'], unicode: '💡' }, - { - id: 'bullettrain_front', - name: 'bullettrain_front', - names: ['bullettrain_front'], - unicode: '🚅', - }, - { id: 'bullettrain_side', name: 'bullettrain_side', names: ['bullettrain_side'], unicode: '🚄' }, - { id: 'burrito', name: 'burrito', names: ['burrito'], unicode: '🌯' }, - { id: 'bus', name: 'bus', names: ['bus'], unicode: '🚌' }, - { id: 'busstop', name: 'busstop', names: ['busstop'], unicode: '🚏' }, - { - id: 'bust_in_silhouette', - name: 'bust_in_silhouette', - names: ['bust_in_silhouette'], - unicode: '👤', - }, - { - id: 'busts_in_silhouette', - name: 'busts_in_silhouette', - names: ['busts_in_silhouette'], - unicode: '👥', - }, - { id: 'butter', name: 'butter', names: ['butter'], unicode: '🧈' }, - { id: 'butterfly', name: 'butterfly', names: ['butterfly'], unicode: '🦋' }, - { id: 'cactus', name: 'cactus', names: ['cactus'], unicode: '🌵' }, - { id: 'cake', name: 'cake', names: ['cake'], unicode: '🍰' }, - { id: 'calendar', name: 'calendar', names: ['calendar'], unicode: '📆' }, - { - id: 'call_me_hand', - name: 'call_me_hand', - names: ['call_me_hand'], - skins: ['🤙🏻', '🤙🏼', '🤙🏽', '🤙🏾', '🤙🏿'], - unicode: '🤙', - }, - { id: 'calling', name: 'calling', names: ['calling'], unicode: '📲' }, - { id: 'camel', name: 'camel', names: ['camel'], unicode: '🐫' }, - { id: 'camera', name: 'camera', names: ['camera'], unicode: '📷' }, - { - id: 'camera_with_flash', - name: 'camera_with_flash', - names: ['camera_with_flash'], - unicode: '📸', - }, - { id: 'camping', name: 'camping', names: ['camping'], unicode: '🏕️' }, - { id: 'cancer', name: 'cancer', names: ['cancer'], unicode: '♋' }, - { id: 'candle', name: 'candle', names: ['candle'], unicode: '🕯️' }, - { id: 'candy', name: 'candy', names: ['candy'], unicode: '🍬' }, - { id: 'canned_food', name: 'canned_food', names: ['canned_food'], unicode: '🥫' }, - { id: 'canoe', name: 'canoe', names: ['canoe'], unicode: '🛶' }, - { id: 'capital_abcd', name: 'capital_abcd', names: ['capital_abcd'], unicode: '🔠' }, - { id: 'capricorn', name: 'capricorn', names: ['capricorn'], unicode: '♑' }, - { id: 'car', name: 'car', names: ['car', 'red_car'], unicode: '🚗' }, - { id: 'card_file_box', name: 'card_file_box', names: ['card_file_box'], unicode: '🗃️' }, - { id: 'card_index', name: 'card_index', names: ['card_index'], unicode: '📇' }, - { - id: 'card_index_dividers', - name: 'card_index_dividers', - names: ['card_index_dividers'], - unicode: '🗂️', - }, - { id: 'carousel_horse', name: 'carousel_horse', names: ['carousel_horse'], unicode: '🎠' }, - { id: 'carpentry_saw', name: 'carpentry_saw', names: ['carpentry_saw'], unicode: '🪚' }, - { id: 'carrot', name: 'carrot', names: ['carrot'], unicode: '🥕' }, - { id: 'cat', name: 'cat', names: ['cat'], unicode: '🐱' }, - { id: 'cat2', name: 'cat2', names: ['cat2'], unicode: '🐈' }, - { id: 'cd', name: 'cd', names: ['cd'], unicode: '💿' }, - { id: 'chains', name: 'chains', names: ['chains'], unicode: '⛓️' }, - { id: 'chair', name: 'chair', names: ['chair'], unicode: '🪑' }, - { id: 'champagne', name: 'champagne', names: ['champagne'], unicode: '🍾' }, - { id: 'chart', name: 'chart', names: ['chart'], unicode: '💹' }, - { - id: 'chart_with_downwards_trend', - name: 'chart_with_downwards_trend', - names: ['chart_with_downwards_trend'], - unicode: '📉', - }, - { - id: 'chart_with_upwards_trend', - name: 'chart_with_upwards_trend', - names: ['chart_with_upwards_trend'], - unicode: '📈', - }, - { id: 'checkered_flag', name: 'checkered_flag', names: ['checkered_flag'], unicode: '🏁' }, - { id: 'cheese_wedge', name: 'cheese_wedge', names: ['cheese_wedge'], unicode: '🧀' }, - { id: 'cherries', name: 'cherries', names: ['cherries'], unicode: '🍒' }, - { id: 'cherry_blossom', name: 'cherry_blossom', names: ['cherry_blossom'], unicode: '🌸' }, - { id: 'chess_pawn', name: 'chess_pawn', names: ['chess_pawn'], unicode: '♟️' }, - { id: 'chestnut', name: 'chestnut', names: ['chestnut'], unicode: '🌰' }, - { id: 'chicken', name: 'chicken', names: ['chicken'], unicode: '🐔' }, - { - id: 'child', - name: 'child', - names: ['child'], - skins: ['🧒🏻', '🧒🏼', '🧒🏽', '🧒🏾', '🧒🏿'], - unicode: '🧒', - }, - { - id: 'children_crossing', - name: 'children_crossing', - names: ['children_crossing'], - unicode: '🚸', - }, - { id: 'chipmunk', name: 'chipmunk', names: ['chipmunk'], unicode: '🐿️' }, - { id: 'chocolate_bar', name: 'chocolate_bar', names: ['chocolate_bar'], unicode: '🍫' }, - { id: 'chopsticks', name: 'chopsticks', names: ['chopsticks'], unicode: '🥢' }, - { id: 'christmas_tree', name: 'christmas_tree', names: ['christmas_tree'], unicode: '🎄' }, - { id: 'church', name: 'church', names: ['church'], unicode: '⛪' }, - { id: 'cinema', name: 'cinema', names: ['cinema'], unicode: '🎦' }, - { id: 'circus_tent', name: 'circus_tent', names: ['circus_tent'], unicode: '🎪' }, - { id: 'city_sunrise', name: 'city_sunrise', names: ['city_sunrise'], unicode: '🌇' }, - { id: 'city_sunset', name: 'city_sunset', names: ['city_sunset'], unicode: '🌆' }, - { id: 'cityscape', name: 'cityscape', names: ['cityscape'], unicode: '🏙️' }, - { id: 'cl', name: 'cl', names: ['cl'], unicode: '🆑' }, - { - id: 'clap', - name: 'clap', - names: ['clap'], - skins: ['👏🏻', '👏🏼', '👏🏽', '👏🏾', '👏🏿'], - unicode: '👏', - }, - { id: 'clapper', name: 'clapper', names: ['clapper'], unicode: '🎬' }, - { - id: 'classical_building', - name: 'classical_building', - names: ['classical_building'], - unicode: '🏛️', - }, - { id: 'clinking_glasses', name: 'clinking_glasses', names: ['clinking_glasses'], unicode: '🥂' }, - { id: 'clipboard', name: 'clipboard', names: ['clipboard'], unicode: '📋' }, - { id: 'clock1', name: 'clock1', names: ['clock1'], unicode: '🕐' }, - { id: 'clock10', name: 'clock10', names: ['clock10'], unicode: '🕙' }, - { id: 'clock1030', name: 'clock1030', names: ['clock1030'], unicode: '🕥' }, - { id: 'clock11', name: 'clock11', names: ['clock11'], unicode: '🕚' }, - { id: 'clock1130', name: 'clock1130', names: ['clock1130'], unicode: '🕦' }, - { id: 'clock12', name: 'clock12', names: ['clock12'], unicode: '🕛' }, - { id: 'clock1230', name: 'clock1230', names: ['clock1230'], unicode: '🕧' }, - { id: 'clock130', name: 'clock130', names: ['clock130'], unicode: '🕜' }, - { id: 'clock2', name: 'clock2', names: ['clock2'], unicode: '🕑' }, - { id: 'clock230', name: 'clock230', names: ['clock230'], unicode: '🕝' }, - { id: 'clock3', name: 'clock3', names: ['clock3'], unicode: '🕒' }, - { id: 'clock330', name: 'clock330', names: ['clock330'], unicode: '🕞' }, - { id: 'clock4', name: 'clock4', names: ['clock4'], unicode: '🕓' }, - { id: 'clock430', name: 'clock430', names: ['clock430'], unicode: '🕟' }, - { id: 'clock5', name: 'clock5', names: ['clock5'], unicode: '🕔' }, - { id: 'clock530', name: 'clock530', names: ['clock530'], unicode: '🕠' }, - { id: 'clock6', name: 'clock6', names: ['clock6'], unicode: '🕕' }, - { id: 'clock630', name: 'clock630', names: ['clock630'], unicode: '🕡' }, - { id: 'clock7', name: 'clock7', names: ['clock7'], unicode: '🕖' }, - { id: 'clock730', name: 'clock730', names: ['clock730'], unicode: '🕢' }, - { id: 'clock8', name: 'clock8', names: ['clock8'], unicode: '🕗' }, - { id: 'clock830', name: 'clock830', names: ['clock830'], unicode: '🕣' }, - { id: 'clock9', name: 'clock9', names: ['clock9'], unicode: '🕘' }, - { id: 'clock930', name: 'clock930', names: ['clock930'], unicode: '🕤' }, - { id: 'closed_book', name: 'closed_book', names: ['closed_book'], unicode: '📕' }, - { - id: 'closed_lock_with_key', - name: 'closed_lock_with_key', - names: ['closed_lock_with_key'], - unicode: '🔐', - }, - { id: 'closed_umbrella', name: 'closed_umbrella', names: ['closed_umbrella'], unicode: '🌂' }, - { id: 'cloud', name: 'cloud', names: ['cloud'], unicode: '☁️' }, - { id: 'clown_face', name: 'clown_face', names: ['clown_face'], unicode: '🤡' }, - { id: 'clubs', name: 'clubs', names: ['clubs'], unicode: '♣️' }, - { id: 'cn', name: 'cn', names: ['cn', 'flag-cn'], unicode: '🇨🇳' }, - { id: 'coat', name: 'coat', names: ['coat'], unicode: '🧥' }, - { id: 'cockroach', name: 'cockroach', names: ['cockroach'], unicode: '🪳' }, - { id: 'cocktail', name: 'cocktail', names: ['cocktail'], unicode: '🍸' }, - { id: 'coconut', name: 'coconut', names: ['coconut'], unicode: '🥥' }, - { id: 'coffee', name: 'coffee', names: ['coffee'], unicode: '☕' }, - { id: 'coffin', name: 'coffin', names: ['coffin'], unicode: '⚰️' }, - { id: 'coin', name: 'coin', names: ['coin'], unicode: '🪙' }, - { id: 'cold_face', name: 'cold_face', names: ['cold_face'], unicode: '🥶' }, - { id: 'cold_sweat', name: 'cold_sweat', names: ['cold_sweat'], unicode: '😰' }, - { id: 'comet', name: 'comet', names: ['comet'], unicode: '☄️' }, - { id: 'compass', name: 'compass', names: ['compass'], unicode: '🧭' }, - { id: 'compression', name: 'compression', names: ['compression'], unicode: '🗜️' }, - { id: 'computer', name: 'computer', names: ['computer'], unicode: '💻' }, - { id: 'confetti_ball', name: 'confetti_ball', names: ['confetti_ball'], unicode: '🎊' }, - { id: 'confounded', name: 'confounded', names: ['confounded'], unicode: '😖' }, - { id: 'confused', name: 'confused', names: ['confused'], unicode: '😕' }, - { id: 'congratulations', name: 'congratulations', names: ['congratulations'], unicode: '㊗️' }, - { id: 'construction', name: 'construction', names: ['construction'], unicode: '🚧' }, - { - id: 'construction_worker', - name: 'construction_worker', - names: ['construction_worker'], - skins: ['👷🏻', '👷🏼', '👷🏽', '👷🏾', '👷🏿'], - unicode: '👷', - }, - { id: 'control_knobs', name: 'control_knobs', names: ['control_knobs'], unicode: '🎛️' }, - { - id: 'convenience_store', - name: 'convenience_store', - names: ['convenience_store'], - unicode: '🏪', - }, - { - id: 'cook', - name: 'cook', - names: ['cook'], - skins: ['🧑🏻‍🍳', '🧑🏼‍🍳', '🧑🏽‍🍳', '🧑🏾‍🍳', '🧑🏿‍🍳'], - unicode: '🧑‍🍳', - }, - { id: 'cookie', name: 'cookie', names: ['cookie'], unicode: '🍪' }, - { id: 'cool', name: 'cool', names: ['cool'], unicode: '🆒' }, - { id: 'cop', name: 'cop', names: ['cop'], skins: ['👮🏻', '👮🏼', '👮🏽', '👮🏾', '👮🏿'], unicode: '👮' }, - { id: 'copyright', name: 'copyright', names: ['copyright'], unicode: '©️' }, - { id: 'coral', name: 'coral', names: ['coral'], unicode: '🪸' }, - { id: 'corn', name: 'corn', names: ['corn'], unicode: '🌽' }, - { id: 'couch_and_lamp', name: 'couch_and_lamp', names: ['couch_and_lamp'], unicode: '🛋️' }, - { - id: 'couple_with_heart', - name: 'couple_with_heart', - names: ['couple_with_heart'], - skins: [ - '💑🏻', - '💑🏼', - '💑🏽', - '💑🏾', - '💑🏿', - '🧑🏻‍❤️‍🧑🏼', - '🧑🏻‍❤️‍🧑🏽', - '🧑🏻‍❤️‍🧑🏾', - '🧑🏻‍❤️‍🧑🏿', - '🧑🏼‍❤️‍🧑🏻', - '🧑🏼‍❤️‍🧑🏽', - '🧑🏼‍❤️‍🧑🏾', - '🧑🏼‍❤️‍🧑🏿', - '🧑🏽‍❤️‍🧑🏻', - '🧑🏽‍❤️‍🧑🏼', - '🧑🏽‍❤️‍🧑🏾', - '🧑🏽‍❤️‍🧑🏿', - '🧑🏾‍❤️‍🧑🏻', - '🧑🏾‍❤️‍🧑🏼', - '🧑🏾‍❤️‍🧑🏽', - '🧑🏾‍❤️‍🧑🏿', - '🧑🏿‍❤️‍🧑🏻', - '🧑🏿‍❤️‍🧑🏼', - '🧑🏿‍❤️‍🧑🏽', - '🧑🏿‍❤️‍🧑🏾', - ], - unicode: '💑', - }, - { - id: 'couplekiss', - name: 'couplekiss', - names: ['couplekiss'], - skins: [ - '💏🏻', - '💏🏼', - '💏🏽', - '💏🏾', - '💏🏿', - '🧑🏻‍❤️‍💋‍🧑🏼', - '🧑🏻‍❤️‍💋‍🧑🏽', - '🧑🏻‍❤️‍💋‍🧑🏾', - '🧑🏻‍❤️‍💋‍🧑🏿', - '🧑🏼‍❤️‍💋‍🧑🏻', - '🧑🏼‍❤️‍💋‍🧑🏽', - '🧑🏼‍❤️‍💋‍🧑🏾', - '🧑🏼‍❤️‍💋‍🧑🏿', - '🧑🏽‍❤️‍💋‍🧑🏻', - '🧑🏽‍❤️‍💋‍🧑🏼', - '🧑🏽‍❤️‍💋‍🧑🏾', - '🧑🏽‍❤️‍💋‍🧑🏿', - '🧑🏾‍❤️‍💋‍🧑🏻', - '🧑🏾‍❤️‍💋‍🧑🏼', - '🧑🏾‍❤️‍💋‍🧑🏽', - '🧑🏾‍❤️‍💋‍🧑🏿', - '🧑🏿‍❤️‍💋‍🧑🏻', - '🧑🏿‍❤️‍💋‍🧑🏼', - '🧑🏿‍❤️‍💋‍🧑🏽', - '🧑🏿‍❤️‍💋‍🧑🏾', - ], - unicode: '💏', - }, - { id: 'cow', name: 'cow', names: ['cow'], unicode: '🐮' }, - { id: 'cow2', name: 'cow2', names: ['cow2'], unicode: '🐄' }, - { id: 'crab', name: 'crab', names: ['crab'], unicode: '🦀' }, - { id: 'credit_card', name: 'credit_card', names: ['credit_card'], unicode: '💳' }, - { id: 'crescent_moon', name: 'crescent_moon', names: ['crescent_moon'], unicode: '🌙' }, - { id: 'cricket', name: 'cricket', names: ['cricket'], unicode: '🦗' }, - { - id: 'cricket_bat_and_ball', - name: 'cricket_bat_and_ball', - names: ['cricket_bat_and_ball'], - unicode: '🏏', - }, - { id: 'crocodile', name: 'crocodile', names: ['crocodile'], unicode: '🐊' }, - { id: 'croissant', name: 'croissant', names: ['croissant'], unicode: '🥐' }, - { - id: 'crossed_fingers', - name: 'crossed_fingers', - names: ['crossed_fingers', 'hand_with_index_and_middle_fingers_crossed'], - skins: ['🤞🏻', '🤞🏼', '🤞🏽', '🤞🏾', '🤞🏿'], - unicode: '🤞', - }, - { id: 'crossed_flags', name: 'crossed_flags', names: ['crossed_flags'], unicode: '🎌' }, - { id: 'crossed_swords', name: 'crossed_swords', names: ['crossed_swords'], unicode: '⚔️' }, - { id: 'crown', name: 'crown', names: ['crown'], unicode: '👑' }, - { id: 'crutch', name: 'crutch', names: ['crutch'], unicode: '🩼' }, - { id: 'cry', name: 'cry', names: ['cry'], unicode: '😢' }, - { id: 'crying_cat_face', name: 'crying_cat_face', names: ['crying_cat_face'], unicode: '😿' }, - { id: 'crystal_ball', name: 'crystal_ball', names: ['crystal_ball'], unicode: '🔮' }, - { id: 'cucumber', name: 'cucumber', names: ['cucumber'], unicode: '🥒' }, - { id: 'cup_with_straw', name: 'cup_with_straw', names: ['cup_with_straw'], unicode: '🥤' }, - { id: 'cupcake', name: 'cupcake', names: ['cupcake'], unicode: '🧁' }, - { id: 'cupid', name: 'cupid', names: ['cupid'], unicode: '💘' }, - { id: 'curling_stone', name: 'curling_stone', names: ['curling_stone'], unicode: '🥌' }, - { - id: 'curly_haired_man', - name: 'curly_haired_man', - names: ['curly_haired_man'], - skins: ['👨🏻‍🦱', '👨🏼‍🦱', '👨🏽‍🦱', '👨🏾‍🦱', '👨🏿‍🦱'], - unicode: '👨‍🦱', - }, - { - id: 'curly_haired_person', - name: 'curly_haired_person', - names: ['curly_haired_person'], - skins: ['🧑🏻‍🦱', '🧑🏼‍🦱', '🧑🏽‍🦱', '🧑🏾‍🦱', '🧑🏿‍🦱'], - unicode: '🧑‍🦱', - }, - { - id: 'curly_haired_woman', - name: 'curly_haired_woman', - names: ['curly_haired_woman'], - skins: ['👩🏻‍🦱', '👩🏼‍🦱', '👩🏽‍🦱', '👩🏾‍🦱', '👩🏿‍🦱'], - unicode: '👩‍🦱', - }, - { id: 'curly_loop', name: 'curly_loop', names: ['curly_loop'], unicode: '➰' }, - { - id: 'currency_exchange', - name: 'currency_exchange', - names: ['currency_exchange'], - unicode: '💱', - }, - { id: 'curry', name: 'curry', names: ['curry'], unicode: '🍛' }, - { id: 'custard', name: 'custard', names: ['custard'], unicode: '🍮' }, - { id: 'customs', name: 'customs', names: ['customs'], unicode: '🛃' }, - { id: 'cut_of_meat', name: 'cut_of_meat', names: ['cut_of_meat'], unicode: '🥩' }, - { id: 'cyclone', name: 'cyclone', names: ['cyclone'], unicode: '🌀' }, - { id: 'dagger_knife', name: 'dagger_knife', names: ['dagger_knife'], unicode: '🗡️' }, - { - id: 'dancer', - name: 'dancer', - names: ['dancer'], - skins: ['💃🏻', '💃🏼', '💃🏽', '💃🏾', '💃🏿'], - unicode: '💃', - }, - { id: 'dancers', name: 'dancers', names: ['dancers'], unicode: '👯' }, - { id: 'dango', name: 'dango', names: ['dango'], unicode: '🍡' }, - { id: 'dark_sunglasses', name: 'dark_sunglasses', names: ['dark_sunglasses'], unicode: '🕶️' }, - { id: 'dart', name: 'dart', names: ['dart'], unicode: '🎯' }, - { id: 'dash', name: 'dash', names: ['dash'], unicode: '💨' }, - { id: 'date', name: 'date', names: ['date'], unicode: '📅' }, - { id: 'de', name: 'de', names: ['de', 'flag-de'], unicode: '🇩🇪' }, - { - id: 'deaf_man', - name: 'deaf_man', - names: ['deaf_man'], - skins: ['🧏🏻‍♂️', '🧏🏼‍♂️', '🧏🏽‍♂️', '🧏🏾‍♂️', '🧏🏿‍♂️'], - unicode: '🧏‍♂️', - }, - { - id: 'deaf_person', - name: 'deaf_person', - names: ['deaf_person'], - skins: ['🧏🏻', '🧏🏼', '🧏🏽', '🧏🏾', '🧏🏿'], - unicode: '🧏', - }, - { - id: 'deaf_woman', - name: 'deaf_woman', - names: ['deaf_woman'], - skins: ['🧏🏻‍♀️', '🧏🏼‍♀️', '🧏🏽‍♀️', '🧏🏾‍♀️', '🧏🏿‍♀️'], - unicode: '🧏‍♀️', - }, - { id: 'deciduous_tree', name: 'deciduous_tree', names: ['deciduous_tree'], unicode: '🌳' }, - { id: 'deer', name: 'deer', names: ['deer'], unicode: '🦌' }, - { id: 'department_store', name: 'department_store', names: ['department_store'], unicode: '🏬' }, - { - id: 'derelict_house_building', - name: 'derelict_house_building', - names: ['derelict_house_building'], - unicode: '🏚️', - }, - { id: 'desert', name: 'desert', names: ['desert'], unicode: '🏜️' }, - { id: 'desert_island', name: 'desert_island', names: ['desert_island'], unicode: '🏝️' }, - { id: 'desktop_computer', name: 'desktop_computer', names: ['desktop_computer'], unicode: '🖥️' }, - { - id: 'diamond_shape_with_a_dot_inside', - name: 'diamond_shape_with_a_dot_inside', - names: ['diamond_shape_with_a_dot_inside'], - unicode: '💠', - }, - { id: 'diamonds', name: 'diamonds', names: ['diamonds'], unicode: '♦️' }, - { id: 'disappointed', name: 'disappointed', names: ['disappointed'], unicode: '😞' }, - { - id: 'disappointed_relieved', - name: 'disappointed_relieved', - names: ['disappointed_relieved'], - unicode: '😥', - }, - { id: 'disguised_face', name: 'disguised_face', names: ['disguised_face'], unicode: '🥸' }, - { id: 'diving_mask', name: 'diving_mask', names: ['diving_mask'], unicode: '🤿' }, - { id: 'diya_lamp', name: 'diya_lamp', names: ['diya_lamp'], unicode: '🪔' }, - { id: 'dizzy', name: 'dizzy', names: ['dizzy'], unicode: '💫' }, - { id: 'dizzy_face', name: 'dizzy_face', names: ['dizzy_face'], unicode: '😵' }, - { id: 'dna', name: 'dna', names: ['dna'], unicode: '🧬' }, - { id: 'do_not_litter', name: 'do_not_litter', names: ['do_not_litter'], unicode: '🚯' }, - { id: 'dodo', name: 'dodo', names: ['dodo'], unicode: '🦤' }, - { id: 'dog', name: 'dog', names: ['dog'], unicode: '🐶' }, - { id: 'dog2', name: 'dog2', names: ['dog2'], unicode: '🐕' }, - { id: 'dollar', name: 'dollar', names: ['dollar'], unicode: '💵' }, - { id: 'dolls', name: 'dolls', names: ['dolls'], unicode: '🎎' }, - { id: 'dolphin', name: 'dolphin', names: ['dolphin', 'flipper'], unicode: '🐬' }, - { id: 'donkey', name: 'donkey', names: ['donkey'], unicode: '🫏' }, - { id: 'door', name: 'door', names: ['door'], unicode: '🚪' }, - { id: 'dotted_line_face', name: 'dotted_line_face', names: ['dotted_line_face'], unicode: '🫥' }, - { - id: 'double_vertical_bar', - name: 'double_vertical_bar', - names: ['double_vertical_bar'], - unicode: '⏸️', - }, - { id: 'doughnut', name: 'doughnut', names: ['doughnut'], unicode: '🍩' }, - { id: 'dove_of_peace', name: 'dove_of_peace', names: ['dove_of_peace'], unicode: '🕊️' }, - { id: 'dragon', name: 'dragon', names: ['dragon'], unicode: '🐉' }, - { id: 'dragon_face', name: 'dragon_face', names: ['dragon_face'], unicode: '🐲' }, - { id: 'dress', name: 'dress', names: ['dress'], unicode: '👗' }, - { id: 'dromedary_camel', name: 'dromedary_camel', names: ['dromedary_camel'], unicode: '🐪' }, - { id: 'drooling_face', name: 'drooling_face', names: ['drooling_face'], unicode: '🤤' }, - { id: 'drop_of_blood', name: 'drop_of_blood', names: ['drop_of_blood'], unicode: '🩸' }, - { id: 'droplet', name: 'droplet', names: ['droplet'], unicode: '💧' }, - { - id: 'drum_with_drumsticks', - name: 'drum_with_drumsticks', - names: ['drum_with_drumsticks'], - unicode: '🥁', - }, - { id: 'duck', name: 'duck', names: ['duck'], unicode: '🦆' }, - { id: 'dumpling', name: 'dumpling', names: ['dumpling'], unicode: '🥟' }, - { id: 'dvd', name: 'dvd', names: ['dvd'], unicode: '📀' }, - { id: 'e-mail', name: 'e-mail', names: ['e-mail'], unicode: '📧' }, - { id: 'eagle', name: 'eagle', names: ['eagle'], unicode: '🦅' }, - { id: 'ear', name: 'ear', names: ['ear'], skins: ['👂🏻', '👂🏼', '👂🏽', '👂🏾', '👂🏿'], unicode: '👂' }, - { id: 'ear_of_rice', name: 'ear_of_rice', names: ['ear_of_rice'], unicode: '🌾' }, - { - id: 'ear_with_hearing_aid', - name: 'ear_with_hearing_aid', - names: ['ear_with_hearing_aid'], - skins: ['🦻🏻', '🦻🏼', '🦻🏽', '🦻🏾', '🦻🏿'], - unicode: '🦻', - }, - { id: 'earth_africa', name: 'earth_africa', names: ['earth_africa'], unicode: '🌍' }, - { id: 'earth_americas', name: 'earth_americas', names: ['earth_americas'], unicode: '🌎' }, - { id: 'earth_asia', name: 'earth_asia', names: ['earth_asia'], unicode: '🌏' }, - { id: 'egg', name: 'egg', names: ['egg'], unicode: '🥚' }, - { id: 'eggplant', name: 'eggplant', names: ['eggplant'], unicode: '🍆' }, - { id: 'eight', name: 'eight', names: ['eight'], unicode: '8️⃣' }, - { - id: 'eight_pointed_black_star', - name: 'eight_pointed_black_star', - names: ['eight_pointed_black_star'], - unicode: '✴️', - }, - { - id: 'eight_spoked_asterisk', - name: 'eight_spoked_asterisk', - names: ['eight_spoked_asterisk'], - unicode: '✳️', - }, - { id: 'eject', name: 'eject', names: ['eject'], unicode: '⏏️' }, - { id: 'electric_plug', name: 'electric_plug', names: ['electric_plug'], unicode: '🔌' }, - { id: 'elephant', name: 'elephant', names: ['elephant'], unicode: '🐘' }, - { id: 'elevator', name: 'elevator', names: ['elevator'], unicode: '🛗' }, - { id: 'elf', name: 'elf', names: ['elf'], skins: ['🧝🏻', '🧝🏼', '🧝🏽', '🧝🏾', '🧝🏿'], unicode: '🧝' }, - { id: 'email', name: 'email', names: ['email', 'envelope'], unicode: '✉️' }, - { id: 'empty_nest', name: 'empty_nest', names: ['empty_nest'], unicode: '🪹' }, - { id: 'end', name: 'end', names: ['end'], unicode: '🔚' }, - { - id: 'envelope_with_arrow', - name: 'envelope_with_arrow', - names: ['envelope_with_arrow'], - unicode: '📩', - }, - { id: 'es', name: 'es', names: ['es', 'flag-es'], unicode: '🇪🇸' }, - { id: 'euro', name: 'euro', names: ['euro'], unicode: '💶' }, - { id: 'european_castle', name: 'european_castle', names: ['european_castle'], unicode: '🏰' }, - { - id: 'european_post_office', - name: 'european_post_office', - names: ['european_post_office'], - unicode: '🏤', - }, - { id: 'evergreen_tree', name: 'evergreen_tree', names: ['evergreen_tree'], unicode: '🌲' }, - { - id: 'exclamation', - name: 'exclamation', - names: ['exclamation', 'heavy_exclamation_mark'], - unicode: '❗', - }, - { - id: 'exploding_head', - name: 'exploding_head', - names: ['exploding_head', 'shocked_face_with_exploding_head'], - unicode: '🤯', - }, - { id: 'expressionless', name: 'expressionless', names: ['expressionless'], unicode: '😑' }, - { id: 'eye', name: 'eye', names: ['eye'], unicode: '👁️' }, - { - id: 'eye-in-speech-bubble', - name: 'eye-in-speech-bubble', - names: ['eye-in-speech-bubble'], - unicode: '👁️‍🗨️', - }, - { id: 'eyeglasses', name: 'eyeglasses', names: ['eyeglasses'], unicode: '👓' }, - { id: 'eyes', name: 'eyes', names: ['eyes'], unicode: '👀' }, - { id: 'face_exhaling', name: 'face_exhaling', names: ['face_exhaling'], unicode: '😮‍💨' }, - { - id: 'face_holding_back_tears', - name: 'face_holding_back_tears', - names: ['face_holding_back_tears'], - unicode: '🥹', - }, - { id: 'face_in_clouds', name: 'face_in_clouds', names: ['face_in_clouds'], unicode: '😶‍🌫️' }, - { - id: 'face_palm', - name: 'face_palm', - names: ['face_palm'], - skins: ['🤦🏻', '🤦🏼', '🤦🏽', '🤦🏾', '🤦🏿'], - unicode: '🤦', - }, - { - id: 'face_vomiting', - name: 'face_vomiting', - names: ['face_vomiting', 'face_with_open_mouth_vomiting'], - unicode: '🤮', - }, - { - id: 'face_with_cowboy_hat', - name: 'face_with_cowboy_hat', - names: ['face_with_cowboy_hat'], - unicode: '🤠', - }, - { - id: 'face_with_diagonal_mouth', - name: 'face_with_diagonal_mouth', - names: ['face_with_diagonal_mouth'], - unicode: '🫤', - }, - { - id: 'face_with_hand_over_mouth', - name: 'face_with_hand_over_mouth', - names: ['face_with_hand_over_mouth', 'smiling_face_with_smiling_eyes_and_hand_covering_mouth'], - unicode: '🤭', - }, - { - id: 'face_with_head_bandage', - name: 'face_with_head_bandage', - names: ['face_with_head_bandage'], - unicode: '🤕', - }, - { - id: 'face_with_monocle', - name: 'face_with_monocle', - names: ['face_with_monocle'], - unicode: '🧐', - }, - { - id: 'face_with_open_eyes_and_hand_over_mouth', - name: 'face_with_open_eyes_and_hand_over_mouth', - names: ['face_with_open_eyes_and_hand_over_mouth'], - unicode: '🫢', - }, - { - id: 'face_with_peeking_eye', - name: 'face_with_peeking_eye', - names: ['face_with_peeking_eye'], - unicode: '🫣', - }, - { - id: 'face_with_raised_eyebrow', - name: 'face_with_raised_eyebrow', - names: ['face_with_raised_eyebrow', 'face_with_one_eyebrow_raised'], - unicode: '🤨', - }, - { - id: 'face_with_rolling_eyes', - name: 'face_with_rolling_eyes', - names: ['face_with_rolling_eyes'], - unicode: '🙄', - }, - { - id: 'face_with_spiral_eyes', - name: 'face_with_spiral_eyes', - names: ['face_with_spiral_eyes'], - unicode: '😵‍💫', - }, - { - id: 'face_with_symbols_on_mouth', - name: 'face_with_symbols_on_mouth', - names: ['face_with_symbols_on_mouth', 'serious_face_with_symbols_covering_mouth'], - unicode: '🤬', - }, - { - id: 'face_with_thermometer', - name: 'face_with_thermometer', - names: ['face_with_thermometer'], - unicode: '🤒', - }, - { - id: 'facepunch', - name: 'facepunch', - names: ['facepunch', 'punch'], - skins: ['👊🏻', '👊🏼', '👊🏽', '👊🏾', '👊🏿'], - unicode: '👊', - }, - { id: 'factory', name: 'factory', names: ['factory'], unicode: '🏭' }, - { - id: 'factory_worker', - name: 'factory_worker', - names: ['factory_worker'], - skins: ['🧑🏻‍🏭', '🧑🏼‍🏭', '🧑🏽‍🏭', '🧑🏾‍🏭', '🧑🏿‍🏭'], - unicode: '🧑‍🏭', - }, - { - id: 'fairy', - name: 'fairy', - names: ['fairy'], - skins: ['🧚🏻', '🧚🏼', '🧚🏽', '🧚🏾', '🧚🏿'], - unicode: '🧚', - }, - { id: 'falafel', name: 'falafel', names: ['falafel'], unicode: '🧆' }, - { id: 'fallen_leaf', name: 'fallen_leaf', names: ['fallen_leaf'], unicode: '🍂' }, - { id: 'family', name: 'family', names: ['family'], unicode: '👪' }, - { - id: 'family_adult_adult_child', - name: 'family_adult_adult_child', - names: ['family_adult_adult_child'], - unicode: '🧑‍🧑‍🧒', - }, - { - id: 'family_adult_adult_child_child', - name: 'family_adult_adult_child_child', - names: ['family_adult_adult_child_child'], - unicode: '🧑‍🧑‍🧒‍🧒', - }, - { - id: 'family_adult_child', - name: 'family_adult_child', - names: ['family_adult_child'], - unicode: '🧑‍🧒', - }, - { - id: 'family_adult_child_child', - name: 'family_adult_child_child', - names: ['family_adult_child_child'], - unicode: '🧑‍🧒‍🧒', - }, - { - id: 'farmer', - name: 'farmer', - names: ['farmer'], - skins: ['🧑🏻‍🌾', '🧑🏼‍🌾', '🧑🏽‍🌾', '🧑🏾‍🌾', '🧑🏿‍🌾'], - unicode: '🧑‍🌾', - }, - { id: 'fast_forward', name: 'fast_forward', names: ['fast_forward'], unicode: '⏩' }, - { id: 'fax', name: 'fax', names: ['fax'], unicode: '📠' }, - { id: 'fearful', name: 'fearful', names: ['fearful'], unicode: '😨' }, - { id: 'feather', name: 'feather', names: ['feather'], unicode: '🪶' }, - { id: 'feet', name: 'feet', names: ['feet', 'paw_prints'], unicode: '🐾' }, - { - id: 'female-artist', - name: 'female-artist', - names: ['female-artist'], - skins: ['👩🏻‍🎨', '👩🏼‍🎨', '👩🏽‍🎨', '👩🏾‍🎨', '👩🏿‍🎨'], - unicode: '👩‍🎨', - }, - { - id: 'female-astronaut', - name: 'female-astronaut', - names: ['female-astronaut'], - skins: ['👩🏻‍🚀', '👩🏼‍🚀', '👩🏽‍🚀', '👩🏾‍🚀', '👩🏿‍🚀'], - unicode: '👩‍🚀', - }, - { - id: 'female-construction-worker', - name: 'female-construction-worker', - names: ['female-construction-worker'], - skins: ['👷🏻‍♀️', '👷🏼‍♀️', '👷🏽‍♀️', '👷🏾‍♀️', '👷🏿‍♀️'], - unicode: '👷‍♀️', - }, - { - id: 'female-cook', - name: 'female-cook', - names: ['female-cook'], - skins: ['👩🏻‍🍳', '👩🏼‍🍳', '👩🏽‍🍳', '👩🏾‍🍳', '👩🏿‍🍳'], - unicode: '👩‍🍳', - }, - { - id: 'female-detective', - name: 'female-detective', - names: ['female-detective'], - skins: ['🕵🏻‍♀️', '🕵🏼‍♀️', '🕵🏽‍♀️', '🕵🏾‍♀️', '🕵🏿‍♀️'], - unicode: '🕵️‍♀️', - }, - { - id: 'female-doctor', - name: 'female-doctor', - names: ['female-doctor'], - skins: ['👩🏻‍⚕️', '👩🏼‍⚕️', '👩🏽‍⚕️', '👩🏾‍⚕️', '👩🏿‍⚕️'], - unicode: '👩‍⚕️', - }, - { - id: 'female-factory-worker', - name: 'female-factory-worker', - names: ['female-factory-worker'], - skins: ['👩🏻‍🏭', '👩🏼‍🏭', '👩🏽‍🏭', '👩🏾‍🏭', '👩🏿‍🏭'], - unicode: '👩‍🏭', - }, - { - id: 'female-farmer', - name: 'female-farmer', - names: ['female-farmer'], - skins: ['👩🏻‍🌾', '👩🏼‍🌾', '👩🏽‍🌾', '👩🏾‍🌾', '👩🏿‍🌾'], - unicode: '👩‍🌾', - }, - { - id: 'female-firefighter', - name: 'female-firefighter', - names: ['female-firefighter'], - skins: ['👩🏻‍🚒', '👩🏼‍🚒', '👩🏽‍🚒', '👩🏾‍🚒', '👩🏿‍🚒'], - unicode: '👩‍🚒', - }, - { - id: 'female-guard', - name: 'female-guard', - names: ['female-guard'], - skins: ['💂🏻‍♀️', '💂🏼‍♀️', '💂🏽‍♀️', '💂🏾‍♀️', '💂🏿‍♀️'], - unicode: '💂‍♀️', - }, - { - id: 'female-judge', - name: 'female-judge', - names: ['female-judge'], - skins: ['👩🏻‍⚖️', '👩🏼‍⚖️', '👩🏽‍⚖️', '👩🏾‍⚖️', '👩🏿‍⚖️'], - unicode: '👩‍⚖️', - }, - { - id: 'female-mechanic', - name: 'female-mechanic', - names: ['female-mechanic'], - skins: ['👩🏻‍🔧', '👩🏼‍🔧', '👩🏽‍🔧', '👩🏾‍🔧', '👩🏿‍🔧'], - unicode: '👩‍🔧', - }, - { - id: 'female-office-worker', - name: 'female-office-worker', - names: ['female-office-worker'], - skins: ['👩🏻‍💼', '👩🏼‍💼', '👩🏽‍💼', '👩🏾‍💼', '👩🏿‍💼'], - unicode: '👩‍💼', - }, - { - id: 'female-pilot', - name: 'female-pilot', - names: ['female-pilot'], - skins: ['👩🏻‍✈️', '👩🏼‍✈️', '👩🏽‍✈️', '👩🏾‍✈️', '👩🏿‍✈️'], - unicode: '👩‍✈️', - }, - { - id: 'female-police-officer', - name: 'female-police-officer', - names: ['female-police-officer'], - skins: ['👮🏻‍♀️', '👮🏼‍♀️', '👮🏽‍♀️', '👮🏾‍♀️', '👮🏿‍♀️'], - unicode: '👮‍♀️', - }, - { - id: 'female-scientist', - name: 'female-scientist', - names: ['female-scientist'], - skins: ['👩🏻‍🔬', '👩🏼‍🔬', '👩🏽‍🔬', '👩🏾‍🔬', '👩🏿‍🔬'], - unicode: '👩‍🔬', - }, - { - id: 'female-singer', - name: 'female-singer', - names: ['female-singer'], - skins: ['👩🏻‍🎤', '👩🏼‍🎤', '👩🏽‍🎤', '👩🏾‍🎤', '👩🏿‍🎤'], - unicode: '👩‍🎤', - }, - { - id: 'female-student', - name: 'female-student', - names: ['female-student'], - skins: ['👩🏻‍🎓', '👩🏼‍🎓', '👩🏽‍🎓', '👩🏾‍🎓', '👩🏿‍🎓'], - unicode: '👩‍🎓', - }, - { - id: 'female-teacher', - name: 'female-teacher', - names: ['female-teacher'], - skins: ['👩🏻‍🏫', '👩🏼‍🏫', '👩🏽‍🏫', '👩🏾‍🏫', '👩🏿‍🏫'], - unicode: '👩‍🏫', - }, - { - id: 'female-technologist', - name: 'female-technologist', - names: ['female-technologist'], - skins: ['👩🏻‍💻', '👩🏼‍💻', '👩🏽‍💻', '👩🏾‍💻', '👩🏿‍💻'], - unicode: '👩‍💻', - }, - { - id: 'female_elf', - name: 'female_elf', - names: ['female_elf'], - skins: ['🧝🏻‍♀️', '🧝🏼‍♀️', '🧝🏽‍♀️', '🧝🏾‍♀️', '🧝🏿‍♀️'], - unicode: '🧝‍♀️', - }, - { - id: 'female_fairy', - name: 'female_fairy', - names: ['female_fairy'], - skins: ['🧚🏻‍♀️', '🧚🏼‍♀️', '🧚🏽‍♀️', '🧚🏾‍♀️', '🧚🏿‍♀️'], - unicode: '🧚‍♀️', - }, - { id: 'female_genie', name: 'female_genie', names: ['female_genie'], unicode: '🧞‍♀️' }, - { - id: 'female_mage', - name: 'female_mage', - names: ['female_mage'], - skins: ['🧙🏻‍♀️', '🧙🏼‍♀️', '🧙🏽‍♀️', '🧙🏾‍♀️', '🧙🏿‍♀️'], - unicode: '🧙‍♀️', - }, - { id: 'female_sign', name: 'female_sign', names: ['female_sign'], unicode: '♀️' }, - { - id: 'female_superhero', - name: 'female_superhero', - names: ['female_superhero'], - skins: ['🦸🏻‍♀️', '🦸🏼‍♀️', '🦸🏽‍♀️', '🦸🏾‍♀️', '🦸🏿‍♀️'], - unicode: '🦸‍♀️', - }, - { - id: 'female_supervillain', - name: 'female_supervillain', - names: ['female_supervillain'], - skins: ['🦹🏻‍♀️', '🦹🏼‍♀️', '🦹🏽‍♀️', '🦹🏾‍♀️', '🦹🏿‍♀️'], - unicode: '🦹‍♀️', - }, - { - id: 'female_vampire', - name: 'female_vampire', - names: ['female_vampire'], - skins: ['🧛🏻‍♀️', '🧛🏼‍♀️', '🧛🏽‍♀️', '🧛🏾‍♀️', '🧛🏿‍♀️'], - unicode: '🧛‍♀️', - }, - { id: 'female_zombie', name: 'female_zombie', names: ['female_zombie'], unicode: '🧟‍♀️' }, - { id: 'fencer', name: 'fencer', names: ['fencer'], unicode: '🤺' }, - { id: 'ferris_wheel', name: 'ferris_wheel', names: ['ferris_wheel'], unicode: '🎡' }, - { id: 'ferry', name: 'ferry', names: ['ferry'], unicode: '⛴️' }, - { - id: 'field_hockey_stick_and_ball', - name: 'field_hockey_stick_and_ball', - names: ['field_hockey_stick_and_ball'], - unicode: '🏑', - }, - { id: 'file_cabinet', name: 'file_cabinet', names: ['file_cabinet'], unicode: '🗄️' }, - { id: 'file_folder', name: 'file_folder', names: ['file_folder'], unicode: '📁' }, - { id: 'film_frames', name: 'film_frames', names: ['film_frames'], unicode: '🎞️' }, - { id: 'film_projector', name: 'film_projector', names: ['film_projector'], unicode: '📽️' }, - { id: 'fire', name: 'fire', names: ['fire'], unicode: '🔥' }, - { id: 'fire_engine', name: 'fire_engine', names: ['fire_engine'], unicode: '🚒' }, - { - id: 'fire_extinguisher', - name: 'fire_extinguisher', - names: ['fire_extinguisher'], - unicode: '🧯', - }, - { id: 'firecracker', name: 'firecracker', names: ['firecracker'], unicode: '🧨' }, - { - id: 'firefighter', - name: 'firefighter', - names: ['firefighter'], - skins: ['🧑🏻‍🚒', '🧑🏼‍🚒', '🧑🏽‍🚒', '🧑🏾‍🚒', '🧑🏿‍🚒'], - unicode: '🧑‍🚒', - }, - { id: 'fireworks', name: 'fireworks', names: ['fireworks'], unicode: '🎆' }, - { - id: 'first_place_medal', - name: 'first_place_medal', - names: ['first_place_medal'], - unicode: '🥇', - }, - { - id: 'first_quarter_moon', - name: 'first_quarter_moon', - names: ['first_quarter_moon'], - unicode: '🌓', - }, - { - id: 'first_quarter_moon_with_face', - name: 'first_quarter_moon_with_face', - names: ['first_quarter_moon_with_face'], - unicode: '🌛', - }, - { id: 'fish', name: 'fish', names: ['fish'], unicode: '🐟' }, - { id: 'fish_cake', name: 'fish_cake', names: ['fish_cake'], unicode: '🍥' }, - { - id: 'fishing_pole_and_fish', - name: 'fishing_pole_and_fish', - names: ['fishing_pole_and_fish'], - unicode: '🎣', - }, - { - id: 'fist', - name: 'fist', - names: ['fist'], - skins: ['✊🏻', '✊🏼', '✊🏽', '✊🏾', '✊🏿'], - unicode: '✊', - }, - { id: 'five', name: 'five', names: ['five'], unicode: '5️⃣' }, - { id: 'flag-ac', name: 'flag-ac', names: ['flag-ac'], unicode: '🇦🇨' }, - { id: 'flag-ad', name: 'flag-ad', names: ['flag-ad'], unicode: '🇦🇩' }, - { id: 'flag-ae', name: 'flag-ae', names: ['flag-ae'], unicode: '🇦🇪' }, - { id: 'flag-af', name: 'flag-af', names: ['flag-af'], unicode: '🇦🇫' }, - { id: 'flag-ag', name: 'flag-ag', names: ['flag-ag'], unicode: '🇦🇬' }, - { id: 'flag-ai', name: 'flag-ai', names: ['flag-ai'], unicode: '🇦🇮' }, - { id: 'flag-al', name: 'flag-al', names: ['flag-al'], unicode: '🇦🇱' }, - { id: 'flag-am', name: 'flag-am', names: ['flag-am'], unicode: '🇦🇲' }, - { id: 'flag-ao', name: 'flag-ao', names: ['flag-ao'], unicode: '🇦🇴' }, - { id: 'flag-aq', name: 'flag-aq', names: ['flag-aq'], unicode: '🇦🇶' }, - { id: 'flag-ar', name: 'flag-ar', names: ['flag-ar'], unicode: '🇦🇷' }, - { id: 'flag-as', name: 'flag-as', names: ['flag-as'], unicode: '🇦🇸' }, - { id: 'flag-at', name: 'flag-at', names: ['flag-at'], unicode: '🇦🇹' }, - { id: 'flag-au', name: 'flag-au', names: ['flag-au'], unicode: '🇦🇺' }, - { id: 'flag-aw', name: 'flag-aw', names: ['flag-aw'], unicode: '🇦🇼' }, - { id: 'flag-ax', name: 'flag-ax', names: ['flag-ax'], unicode: '🇦🇽' }, - { id: 'flag-az', name: 'flag-az', names: ['flag-az'], unicode: '🇦🇿' }, - { id: 'flag-ba', name: 'flag-ba', names: ['flag-ba'], unicode: '🇧🇦' }, - { id: 'flag-bb', name: 'flag-bb', names: ['flag-bb'], unicode: '🇧🇧' }, - { id: 'flag-bd', name: 'flag-bd', names: ['flag-bd'], unicode: '🇧🇩' }, - { id: 'flag-be', name: 'flag-be', names: ['flag-be'], unicode: '🇧🇪' }, - { id: 'flag-bf', name: 'flag-bf', names: ['flag-bf'], unicode: '🇧🇫' }, - { id: 'flag-bg', name: 'flag-bg', names: ['flag-bg'], unicode: '🇧🇬' }, - { id: 'flag-bh', name: 'flag-bh', names: ['flag-bh'], unicode: '🇧🇭' }, - { id: 'flag-bi', name: 'flag-bi', names: ['flag-bi'], unicode: '🇧🇮' }, - { id: 'flag-bj', name: 'flag-bj', names: ['flag-bj'], unicode: '🇧🇯' }, - { id: 'flag-bl', name: 'flag-bl', names: ['flag-bl'], unicode: '🇧🇱' }, - { id: 'flag-bm', name: 'flag-bm', names: ['flag-bm'], unicode: '🇧🇲' }, - { id: 'flag-bn', name: 'flag-bn', names: ['flag-bn'], unicode: '🇧🇳' }, - { id: 'flag-bo', name: 'flag-bo', names: ['flag-bo'], unicode: '🇧🇴' }, - { id: 'flag-bq', name: 'flag-bq', names: ['flag-bq'], unicode: '🇧🇶' }, - { id: 'flag-br', name: 'flag-br', names: ['flag-br'], unicode: '🇧🇷' }, - { id: 'flag-bs', name: 'flag-bs', names: ['flag-bs'], unicode: '🇧🇸' }, - { id: 'flag-bt', name: 'flag-bt', names: ['flag-bt'], unicode: '🇧🇹' }, - { id: 'flag-bv', name: 'flag-bv', names: ['flag-bv'], unicode: '🇧🇻' }, - { id: 'flag-bw', name: 'flag-bw', names: ['flag-bw'], unicode: '🇧🇼' }, - { id: 'flag-by', name: 'flag-by', names: ['flag-by'], unicode: '🇧🇾' }, - { id: 'flag-bz', name: 'flag-bz', names: ['flag-bz'], unicode: '🇧🇿' }, - { id: 'flag-ca', name: 'flag-ca', names: ['flag-ca'], unicode: '🇨🇦' }, - { id: 'flag-cc', name: 'flag-cc', names: ['flag-cc'], unicode: '🇨🇨' }, - { id: 'flag-cd', name: 'flag-cd', names: ['flag-cd'], unicode: '🇨🇩' }, - { id: 'flag-cf', name: 'flag-cf', names: ['flag-cf'], unicode: '🇨🇫' }, - { id: 'flag-cg', name: 'flag-cg', names: ['flag-cg'], unicode: '🇨🇬' }, - { id: 'flag-ch', name: 'flag-ch', names: ['flag-ch'], unicode: '🇨🇭' }, - { id: 'flag-ci', name: 'flag-ci', names: ['flag-ci'], unicode: '🇨🇮' }, - { id: 'flag-ck', name: 'flag-ck', names: ['flag-ck'], unicode: '🇨🇰' }, - { id: 'flag-cl', name: 'flag-cl', names: ['flag-cl'], unicode: '🇨🇱' }, - { id: 'flag-cm', name: 'flag-cm', names: ['flag-cm'], unicode: '🇨🇲' }, - { id: 'flag-co', name: 'flag-co', names: ['flag-co'], unicode: '🇨🇴' }, - { id: 'flag-cp', name: 'flag-cp', names: ['flag-cp'], unicode: '🇨🇵' }, - { id: 'flag-cr', name: 'flag-cr', names: ['flag-cr'], unicode: '🇨🇷' }, - { id: 'flag-cu', name: 'flag-cu', names: ['flag-cu'], unicode: '🇨🇺' }, - { id: 'flag-cv', name: 'flag-cv', names: ['flag-cv'], unicode: '🇨🇻' }, - { id: 'flag-cw', name: 'flag-cw', names: ['flag-cw'], unicode: '🇨🇼' }, - { id: 'flag-cx', name: 'flag-cx', names: ['flag-cx'], unicode: '🇨🇽' }, - { id: 'flag-cy', name: 'flag-cy', names: ['flag-cy'], unicode: '🇨🇾' }, - { id: 'flag-cz', name: 'flag-cz', names: ['flag-cz'], unicode: '🇨🇿' }, - { id: 'flag-dg', name: 'flag-dg', names: ['flag-dg'], unicode: '🇩🇬' }, - { id: 'flag-dj', name: 'flag-dj', names: ['flag-dj'], unicode: '🇩🇯' }, - { id: 'flag-dk', name: 'flag-dk', names: ['flag-dk'], unicode: '🇩🇰' }, - { id: 'flag-dm', name: 'flag-dm', names: ['flag-dm'], unicode: '🇩🇲' }, - { id: 'flag-do', name: 'flag-do', names: ['flag-do'], unicode: '🇩🇴' }, - { id: 'flag-dz', name: 'flag-dz', names: ['flag-dz'], unicode: '🇩🇿' }, - { id: 'flag-ea', name: 'flag-ea', names: ['flag-ea'], unicode: '🇪🇦' }, - { id: 'flag-ec', name: 'flag-ec', names: ['flag-ec'], unicode: '🇪🇨' }, - { id: 'flag-ee', name: 'flag-ee', names: ['flag-ee'], unicode: '🇪🇪' }, - { id: 'flag-eg', name: 'flag-eg', names: ['flag-eg'], unicode: '🇪🇬' }, - { id: 'flag-eh', name: 'flag-eh', names: ['flag-eh'], unicode: '🇪🇭' }, - { id: 'flag-england', name: 'flag-england', names: ['flag-england'], unicode: '🏴󠁧󠁢󠁥󠁮󠁧󠁿' }, - { id: 'flag-er', name: 'flag-er', names: ['flag-er'], unicode: '🇪🇷' }, - { id: 'flag-et', name: 'flag-et', names: ['flag-et'], unicode: '🇪🇹' }, - { id: 'flag-eu', name: 'flag-eu', names: ['flag-eu'], unicode: '🇪🇺' }, - { id: 'flag-fi', name: 'flag-fi', names: ['flag-fi'], unicode: '🇫🇮' }, - { id: 'flag-fj', name: 'flag-fj', names: ['flag-fj'], unicode: '🇫🇯' }, - { id: 'flag-fk', name: 'flag-fk', names: ['flag-fk'], unicode: '🇫🇰' }, - { id: 'flag-fm', name: 'flag-fm', names: ['flag-fm'], unicode: '🇫🇲' }, - { id: 'flag-fo', name: 'flag-fo', names: ['flag-fo'], unicode: '🇫🇴' }, - { id: 'flag-ga', name: 'flag-ga', names: ['flag-ga'], unicode: '🇬🇦' }, - { id: 'flag-gd', name: 'flag-gd', names: ['flag-gd'], unicode: '🇬🇩' }, - { id: 'flag-ge', name: 'flag-ge', names: ['flag-ge'], unicode: '🇬🇪' }, - { id: 'flag-gf', name: 'flag-gf', names: ['flag-gf'], unicode: '🇬🇫' }, - { id: 'flag-gg', name: 'flag-gg', names: ['flag-gg'], unicode: '🇬🇬' }, - { id: 'flag-gh', name: 'flag-gh', names: ['flag-gh'], unicode: '🇬🇭' }, - { id: 'flag-gi', name: 'flag-gi', names: ['flag-gi'], unicode: '🇬🇮' }, - { id: 'flag-gl', name: 'flag-gl', names: ['flag-gl'], unicode: '🇬🇱' }, - { id: 'flag-gm', name: 'flag-gm', names: ['flag-gm'], unicode: '🇬🇲' }, - { id: 'flag-gn', name: 'flag-gn', names: ['flag-gn'], unicode: '🇬🇳' }, - { id: 'flag-gp', name: 'flag-gp', names: ['flag-gp'], unicode: '🇬🇵' }, - { id: 'flag-gq', name: 'flag-gq', names: ['flag-gq'], unicode: '🇬🇶' }, - { id: 'flag-gr', name: 'flag-gr', names: ['flag-gr'], unicode: '🇬🇷' }, - { id: 'flag-gs', name: 'flag-gs', names: ['flag-gs'], unicode: '🇬🇸' }, - { id: 'flag-gt', name: 'flag-gt', names: ['flag-gt'], unicode: '🇬🇹' }, - { id: 'flag-gu', name: 'flag-gu', names: ['flag-gu'], unicode: '🇬🇺' }, - { id: 'flag-gw', name: 'flag-gw', names: ['flag-gw'], unicode: '🇬🇼' }, - { id: 'flag-gy', name: 'flag-gy', names: ['flag-gy'], unicode: '🇬🇾' }, - { id: 'flag-hk', name: 'flag-hk', names: ['flag-hk'], unicode: '🇭🇰' }, - { id: 'flag-hm', name: 'flag-hm', names: ['flag-hm'], unicode: '🇭🇲' }, - { id: 'flag-hn', name: 'flag-hn', names: ['flag-hn'], unicode: '🇭🇳' }, - { id: 'flag-hr', name: 'flag-hr', names: ['flag-hr'], unicode: '🇭🇷' }, - { id: 'flag-ht', name: 'flag-ht', names: ['flag-ht'], unicode: '🇭🇹' }, - { id: 'flag-hu', name: 'flag-hu', names: ['flag-hu'], unicode: '🇭🇺' }, - { id: 'flag-ic', name: 'flag-ic', names: ['flag-ic'], unicode: '🇮🇨' }, - { id: 'flag-id', name: 'flag-id', names: ['flag-id'], unicode: '🇮🇩' }, - { id: 'flag-ie', name: 'flag-ie', names: ['flag-ie'], unicode: '🇮🇪' }, - { id: 'flag-il', name: 'flag-il', names: ['flag-il'], unicode: '🇮🇱' }, - { id: 'flag-im', name: 'flag-im', names: ['flag-im'], unicode: '🇮🇲' }, - { id: 'flag-in', name: 'flag-in', names: ['flag-in'], unicode: '🇮🇳' }, - { id: 'flag-io', name: 'flag-io', names: ['flag-io'], unicode: '🇮🇴' }, - { id: 'flag-iq', name: 'flag-iq', names: ['flag-iq'], unicode: '🇮🇶' }, - { id: 'flag-ir', name: 'flag-ir', names: ['flag-ir'], unicode: '🇮🇷' }, - { id: 'flag-is', name: 'flag-is', names: ['flag-is'], unicode: '🇮🇸' }, - { id: 'flag-je', name: 'flag-je', names: ['flag-je'], unicode: '🇯🇪' }, - { id: 'flag-jm', name: 'flag-jm', names: ['flag-jm'], unicode: '🇯🇲' }, - { id: 'flag-jo', name: 'flag-jo', names: ['flag-jo'], unicode: '🇯🇴' }, - { id: 'flag-ke', name: 'flag-ke', names: ['flag-ke'], unicode: '🇰🇪' }, - { id: 'flag-kg', name: 'flag-kg', names: ['flag-kg'], unicode: '🇰🇬' }, - { id: 'flag-kh', name: 'flag-kh', names: ['flag-kh'], unicode: '🇰🇭' }, - { id: 'flag-ki', name: 'flag-ki', names: ['flag-ki'], unicode: '🇰🇮' }, - { id: 'flag-km', name: 'flag-km', names: ['flag-km'], unicode: '🇰🇲' }, - { id: 'flag-kn', name: 'flag-kn', names: ['flag-kn'], unicode: '🇰🇳' }, - { id: 'flag-kp', name: 'flag-kp', names: ['flag-kp'], unicode: '🇰🇵' }, - { id: 'flag-kw', name: 'flag-kw', names: ['flag-kw'], unicode: '🇰🇼' }, - { id: 'flag-ky', name: 'flag-ky', names: ['flag-ky'], unicode: '🇰🇾' }, - { id: 'flag-kz', name: 'flag-kz', names: ['flag-kz'], unicode: '🇰🇿' }, - { id: 'flag-la', name: 'flag-la', names: ['flag-la'], unicode: '🇱🇦' }, - { id: 'flag-lb', name: 'flag-lb', names: ['flag-lb'], unicode: '🇱🇧' }, - { id: 'flag-lc', name: 'flag-lc', names: ['flag-lc'], unicode: '🇱🇨' }, - { id: 'flag-li', name: 'flag-li', names: ['flag-li'], unicode: '🇱🇮' }, - { id: 'flag-lk', name: 'flag-lk', names: ['flag-lk'], unicode: '🇱🇰' }, - { id: 'flag-lr', name: 'flag-lr', names: ['flag-lr'], unicode: '🇱🇷' }, - { id: 'flag-ls', name: 'flag-ls', names: ['flag-ls'], unicode: '🇱🇸' }, - { id: 'flag-lt', name: 'flag-lt', names: ['flag-lt'], unicode: '🇱🇹' }, - { id: 'flag-lu', name: 'flag-lu', names: ['flag-lu'], unicode: '🇱🇺' }, - { id: 'flag-lv', name: 'flag-lv', names: ['flag-lv'], unicode: '🇱🇻' }, - { id: 'flag-ly', name: 'flag-ly', names: ['flag-ly'], unicode: '🇱🇾' }, - { id: 'flag-ma', name: 'flag-ma', names: ['flag-ma'], unicode: '🇲🇦' }, - { id: 'flag-mc', name: 'flag-mc', names: ['flag-mc'], unicode: '🇲🇨' }, - { id: 'flag-md', name: 'flag-md', names: ['flag-md'], unicode: '🇲🇩' }, - { id: 'flag-me', name: 'flag-me', names: ['flag-me'], unicode: '🇲🇪' }, - { id: 'flag-mf', name: 'flag-mf', names: ['flag-mf'], unicode: '🇲🇫' }, - { id: 'flag-mg', name: 'flag-mg', names: ['flag-mg'], unicode: '🇲🇬' }, - { id: 'flag-mh', name: 'flag-mh', names: ['flag-mh'], unicode: '🇲🇭' }, - { id: 'flag-mk', name: 'flag-mk', names: ['flag-mk'], unicode: '🇲🇰' }, - { id: 'flag-ml', name: 'flag-ml', names: ['flag-ml'], unicode: '🇲🇱' }, - { id: 'flag-mm', name: 'flag-mm', names: ['flag-mm'], unicode: '🇲🇲' }, - { id: 'flag-mn', name: 'flag-mn', names: ['flag-mn'], unicode: '🇲🇳' }, - { id: 'flag-mo', name: 'flag-mo', names: ['flag-mo'], unicode: '🇲🇴' }, - { id: 'flag-mp', name: 'flag-mp', names: ['flag-mp'], unicode: '🇲🇵' }, - { id: 'flag-mq', name: 'flag-mq', names: ['flag-mq'], unicode: '🇲🇶' }, - { id: 'flag-mr', name: 'flag-mr', names: ['flag-mr'], unicode: '🇲🇷' }, - { id: 'flag-ms', name: 'flag-ms', names: ['flag-ms'], unicode: '🇲🇸' }, - { id: 'flag-mt', name: 'flag-mt', names: ['flag-mt'], unicode: '🇲🇹' }, - { id: 'flag-mu', name: 'flag-mu', names: ['flag-mu'], unicode: '🇲🇺' }, - { id: 'flag-mv', name: 'flag-mv', names: ['flag-mv'], unicode: '🇲🇻' }, - { id: 'flag-mw', name: 'flag-mw', names: ['flag-mw'], unicode: '🇲🇼' }, - { id: 'flag-mx', name: 'flag-mx', names: ['flag-mx'], unicode: '🇲🇽' }, - { id: 'flag-my', name: 'flag-my', names: ['flag-my'], unicode: '🇲🇾' }, - { id: 'flag-mz', name: 'flag-mz', names: ['flag-mz'], unicode: '🇲🇿' }, - { id: 'flag-na', name: 'flag-na', names: ['flag-na'], unicode: '🇳🇦' }, - { id: 'flag-nc', name: 'flag-nc', names: ['flag-nc'], unicode: '🇳🇨' }, - { id: 'flag-ne', name: 'flag-ne', names: ['flag-ne'], unicode: '🇳🇪' }, - { id: 'flag-nf', name: 'flag-nf', names: ['flag-nf'], unicode: '🇳🇫' }, - { id: 'flag-ng', name: 'flag-ng', names: ['flag-ng'], unicode: '🇳🇬' }, - { id: 'flag-ni', name: 'flag-ni', names: ['flag-ni'], unicode: '🇳🇮' }, - { id: 'flag-nl', name: 'flag-nl', names: ['flag-nl'], unicode: '🇳🇱' }, - { id: 'flag-no', name: 'flag-no', names: ['flag-no'], unicode: '🇳🇴' }, - { id: 'flag-np', name: 'flag-np', names: ['flag-np'], unicode: '🇳🇵' }, - { id: 'flag-nr', name: 'flag-nr', names: ['flag-nr'], unicode: '🇳🇷' }, - { id: 'flag-nu', name: 'flag-nu', names: ['flag-nu'], unicode: '🇳🇺' }, - { id: 'flag-nz', name: 'flag-nz', names: ['flag-nz'], unicode: '🇳🇿' }, - { id: 'flag-om', name: 'flag-om', names: ['flag-om'], unicode: '🇴🇲' }, - { id: 'flag-pa', name: 'flag-pa', names: ['flag-pa'], unicode: '🇵🇦' }, - { id: 'flag-pe', name: 'flag-pe', names: ['flag-pe'], unicode: '🇵🇪' }, - { id: 'flag-pf', name: 'flag-pf', names: ['flag-pf'], unicode: '🇵🇫' }, - { id: 'flag-pg', name: 'flag-pg', names: ['flag-pg'], unicode: '🇵🇬' }, - { id: 'flag-ph', name: 'flag-ph', names: ['flag-ph'], unicode: '🇵🇭' }, - { id: 'flag-pk', name: 'flag-pk', names: ['flag-pk'], unicode: '🇵🇰' }, - { id: 'flag-pl', name: 'flag-pl', names: ['flag-pl'], unicode: '🇵🇱' }, - { id: 'flag-pm', name: 'flag-pm', names: ['flag-pm'], unicode: '🇵🇲' }, - { id: 'flag-pn', name: 'flag-pn', names: ['flag-pn'], unicode: '🇵🇳' }, - { id: 'flag-pr', name: 'flag-pr', names: ['flag-pr'], unicode: '🇵🇷' }, - { id: 'flag-ps', name: 'flag-ps', names: ['flag-ps'], unicode: '🇵🇸' }, - { id: 'flag-pt', name: 'flag-pt', names: ['flag-pt'], unicode: '🇵🇹' }, - { id: 'flag-pw', name: 'flag-pw', names: ['flag-pw'], unicode: '🇵🇼' }, - { id: 'flag-py', name: 'flag-py', names: ['flag-py'], unicode: '🇵🇾' }, - { id: 'flag-qa', name: 'flag-qa', names: ['flag-qa'], unicode: '🇶🇦' }, - { id: 'flag-re', name: 'flag-re', names: ['flag-re'], unicode: '🇷🇪' }, - { id: 'flag-ro', name: 'flag-ro', names: ['flag-ro'], unicode: '🇷🇴' }, - { id: 'flag-rs', name: 'flag-rs', names: ['flag-rs'], unicode: '🇷🇸' }, - { id: 'flag-rw', name: 'flag-rw', names: ['flag-rw'], unicode: '🇷🇼' }, - { id: 'flag-sa', name: 'flag-sa', names: ['flag-sa'], unicode: '🇸🇦' }, - { id: 'flag-sb', name: 'flag-sb', names: ['flag-sb'], unicode: '🇸🇧' }, - { id: 'flag-sc', name: 'flag-sc', names: ['flag-sc'], unicode: '🇸🇨' }, - { id: 'flag-scotland', name: 'flag-scotland', names: ['flag-scotland'], unicode: '🏴󠁧󠁢󠁳󠁣󠁴󠁿' }, - { id: 'flag-sd', name: 'flag-sd', names: ['flag-sd'], unicode: '🇸🇩' }, - { id: 'flag-se', name: 'flag-se', names: ['flag-se'], unicode: '🇸🇪' }, - { id: 'flag-sg', name: 'flag-sg', names: ['flag-sg'], unicode: '🇸🇬' }, - { id: 'flag-sh', name: 'flag-sh', names: ['flag-sh'], unicode: '🇸🇭' }, - { id: 'flag-si', name: 'flag-si', names: ['flag-si'], unicode: '🇸🇮' }, - { id: 'flag-sj', name: 'flag-sj', names: ['flag-sj'], unicode: '🇸🇯' }, - { id: 'flag-sk', name: 'flag-sk', names: ['flag-sk'], unicode: '🇸🇰' }, - { id: 'flag-sl', name: 'flag-sl', names: ['flag-sl'], unicode: '🇸🇱' }, - { id: 'flag-sm', name: 'flag-sm', names: ['flag-sm'], unicode: '🇸🇲' }, - { id: 'flag-sn', name: 'flag-sn', names: ['flag-sn'], unicode: '🇸🇳' }, - { id: 'flag-so', name: 'flag-so', names: ['flag-so'], unicode: '🇸🇴' }, - { id: 'flag-sr', name: 'flag-sr', names: ['flag-sr'], unicode: '🇸🇷' }, - { id: 'flag-ss', name: 'flag-ss', names: ['flag-ss'], unicode: '🇸🇸' }, - { id: 'flag-st', name: 'flag-st', names: ['flag-st'], unicode: '🇸🇹' }, - { id: 'flag-sv', name: 'flag-sv', names: ['flag-sv'], unicode: '🇸🇻' }, - { id: 'flag-sx', name: 'flag-sx', names: ['flag-sx'], unicode: '🇸🇽' }, - { id: 'flag-sy', name: 'flag-sy', names: ['flag-sy'], unicode: '🇸🇾' }, - { id: 'flag-sz', name: 'flag-sz', names: ['flag-sz'], unicode: '🇸🇿' }, - { id: 'flag-ta', name: 'flag-ta', names: ['flag-ta'], unicode: '🇹🇦' }, - { id: 'flag-tc', name: 'flag-tc', names: ['flag-tc'], unicode: '🇹🇨' }, - { id: 'flag-td', name: 'flag-td', names: ['flag-td'], unicode: '🇹🇩' }, - { id: 'flag-tf', name: 'flag-tf', names: ['flag-tf'], unicode: '🇹🇫' }, - { id: 'flag-tg', name: 'flag-tg', names: ['flag-tg'], unicode: '🇹🇬' }, - { id: 'flag-th', name: 'flag-th', names: ['flag-th'], unicode: '🇹🇭' }, - { id: 'flag-tj', name: 'flag-tj', names: ['flag-tj'], unicode: '🇹🇯' }, - { id: 'flag-tk', name: 'flag-tk', names: ['flag-tk'], unicode: '🇹🇰' }, - { id: 'flag-tl', name: 'flag-tl', names: ['flag-tl'], unicode: '🇹🇱' }, - { id: 'flag-tm', name: 'flag-tm', names: ['flag-tm'], unicode: '🇹🇲' }, - { id: 'flag-tn', name: 'flag-tn', names: ['flag-tn'], unicode: '🇹🇳' }, - { id: 'flag-to', name: 'flag-to', names: ['flag-to'], unicode: '🇹🇴' }, - { id: 'flag-tr', name: 'flag-tr', names: ['flag-tr'], unicode: '🇹🇷' }, - { id: 'flag-tt', name: 'flag-tt', names: ['flag-tt'], unicode: '🇹🇹' }, - { id: 'flag-tv', name: 'flag-tv', names: ['flag-tv'], unicode: '🇹🇻' }, - { id: 'flag-tw', name: 'flag-tw', names: ['flag-tw'], unicode: '🇹🇼' }, - { id: 'flag-tz', name: 'flag-tz', names: ['flag-tz'], unicode: '🇹🇿' }, - { id: 'flag-ua', name: 'flag-ua', names: ['flag-ua'], unicode: '🇺🇦' }, - { id: 'flag-ug', name: 'flag-ug', names: ['flag-ug'], unicode: '🇺🇬' }, - { id: 'flag-um', name: 'flag-um', names: ['flag-um'], unicode: '🇺🇲' }, - { id: 'flag-un', name: 'flag-un', names: ['flag-un'], unicode: '🇺🇳' }, - { id: 'flag-uy', name: 'flag-uy', names: ['flag-uy'], unicode: '🇺🇾' }, - { id: 'flag-uz', name: 'flag-uz', names: ['flag-uz'], unicode: '🇺🇿' }, - { id: 'flag-va', name: 'flag-va', names: ['flag-va'], unicode: '🇻🇦' }, - { id: 'flag-vc', name: 'flag-vc', names: ['flag-vc'], unicode: '🇻🇨' }, - { id: 'flag-ve', name: 'flag-ve', names: ['flag-ve'], unicode: '🇻🇪' }, - { id: 'flag-vg', name: 'flag-vg', names: ['flag-vg'], unicode: '🇻🇬' }, - { id: 'flag-vi', name: 'flag-vi', names: ['flag-vi'], unicode: '🇻🇮' }, - { id: 'flag-vn', name: 'flag-vn', names: ['flag-vn'], unicode: '🇻🇳' }, - { id: 'flag-vu', name: 'flag-vu', names: ['flag-vu'], unicode: '🇻🇺' }, - { id: 'flag-wales', name: 'flag-wales', names: ['flag-wales'], unicode: '🏴󠁧󠁢󠁷󠁬󠁳󠁿' }, - { id: 'flag-wf', name: 'flag-wf', names: ['flag-wf'], unicode: '🇼🇫' }, - { id: 'flag-ws', name: 'flag-ws', names: ['flag-ws'], unicode: '🇼🇸' }, - { id: 'flag-xk', name: 'flag-xk', names: ['flag-xk'], unicode: '🇽🇰' }, - { id: 'flag-ye', name: 'flag-ye', names: ['flag-ye'], unicode: '🇾🇪' }, - { id: 'flag-yt', name: 'flag-yt', names: ['flag-yt'], unicode: '🇾🇹' }, - { id: 'flag-za', name: 'flag-za', names: ['flag-za'], unicode: '🇿🇦' }, - { id: 'flag-zm', name: 'flag-zm', names: ['flag-zm'], unicode: '🇿🇲' }, - { id: 'flag-zw', name: 'flag-zw', names: ['flag-zw'], unicode: '🇿🇼' }, - { id: 'flags', name: 'flags', names: ['flags'], unicode: '🎏' }, - { id: 'flamingo', name: 'flamingo', names: ['flamingo'], unicode: '🦩' }, - { id: 'flashlight', name: 'flashlight', names: ['flashlight'], unicode: '🔦' }, - { id: 'flatbread', name: 'flatbread', names: ['flatbread'], unicode: '🫓' }, - { id: 'fleur_de_lis', name: 'fleur_de_lis', names: ['fleur_de_lis'], unicode: '⚜️' }, - { id: 'floppy_disk', name: 'floppy_disk', names: ['floppy_disk'], unicode: '💾' }, - { - id: 'flower_playing_cards', - name: 'flower_playing_cards', - names: ['flower_playing_cards'], - unicode: '🎴', - }, - { id: 'flushed', name: 'flushed', names: ['flushed'], unicode: '😳' }, - { id: 'flute', name: 'flute', names: ['flute'], unicode: '🪈' }, - { id: 'fly', name: 'fly', names: ['fly'], unicode: '🪰' }, - { id: 'flying_disc', name: 'flying_disc', names: ['flying_disc'], unicode: '🥏' }, - { id: 'flying_saucer', name: 'flying_saucer', names: ['flying_saucer'], unicode: '🛸' }, - { id: 'fog', name: 'fog', names: ['fog'], unicode: '🌫️' }, - { id: 'foggy', name: 'foggy', names: ['foggy'], unicode: '🌁' }, - { id: 'folding_hand_fan', name: 'folding_hand_fan', names: ['folding_hand_fan'], unicode: '🪭' }, - { id: 'fondue', name: 'fondue', names: ['fondue'], unicode: '🫕' }, - { - id: 'foot', - name: 'foot', - names: ['foot'], - skins: ['🦶🏻', '🦶🏼', '🦶🏽', '🦶🏾', '🦶🏿'], - unicode: '🦶', - }, - { id: 'football', name: 'football', names: ['football'], unicode: '🏈' }, - { id: 'footprints', name: 'footprints', names: ['footprints'], unicode: '👣' }, - { id: 'fork_and_knife', name: 'fork_and_knife', names: ['fork_and_knife'], unicode: '🍴' }, - { id: 'fortune_cookie', name: 'fortune_cookie', names: ['fortune_cookie'], unicode: '🥠' }, - { id: 'fountain', name: 'fountain', names: ['fountain'], unicode: '⛲' }, - { id: 'four', name: 'four', names: ['four'], unicode: '4️⃣' }, - { id: 'four_leaf_clover', name: 'four_leaf_clover', names: ['four_leaf_clover'], unicode: '🍀' }, - { id: 'fox_face', name: 'fox_face', names: ['fox_face'], unicode: '🦊' }, - { id: 'fr', name: 'fr', names: ['fr', 'flag-fr'], unicode: '🇫🇷' }, - { - id: 'frame_with_picture', - name: 'frame_with_picture', - names: ['frame_with_picture'], - unicode: '🖼️', - }, - { id: 'free', name: 'free', names: ['free'], unicode: '🆓' }, - { id: 'fried_egg', name: 'fried_egg', names: ['fried_egg', 'cooking'], unicode: '🍳' }, - { id: 'fried_shrimp', name: 'fried_shrimp', names: ['fried_shrimp'], unicode: '🍤' }, - { id: 'fries', name: 'fries', names: ['fries'], unicode: '🍟' }, - { id: 'frog', name: 'frog', names: ['frog'], unicode: '🐸' }, - { id: 'frowning', name: 'frowning', names: ['frowning'], unicode: '😦' }, - { id: 'fuelpump', name: 'fuelpump', names: ['fuelpump'], unicode: '⛽' }, - { id: 'full_moon', name: 'full_moon', names: ['full_moon'], unicode: '🌕' }, - { - id: 'full_moon_with_face', - name: 'full_moon_with_face', - names: ['full_moon_with_face'], - unicode: '🌝', - }, - { id: 'funeral_urn', name: 'funeral_urn', names: ['funeral_urn'], unicode: '⚱️' }, - { id: 'game_die', name: 'game_die', names: ['game_die'], unicode: '🎲' }, - { id: 'garlic', name: 'garlic', names: ['garlic'], unicode: '🧄' }, - { id: 'gb', name: 'gb', names: ['gb', 'uk', 'flag-gb'], unicode: '🇬🇧' }, - { id: 'gear', name: 'gear', names: ['gear'], unicode: '⚙️' }, - { id: 'gem', name: 'gem', names: ['gem'], unicode: '💎' }, - { id: 'gemini', name: 'gemini', names: ['gemini'], unicode: '♊' }, - { id: 'genie', name: 'genie', names: ['genie'], unicode: '🧞' }, - { id: 'ghost', name: 'ghost', names: ['ghost'], unicode: '👻' }, - { id: 'gift', name: 'gift', names: ['gift'], unicode: '🎁' }, - { id: 'gift_heart', name: 'gift_heart', names: ['gift_heart'], unicode: '💝' }, - { id: 'ginger_root', name: 'ginger_root', names: ['ginger_root'], unicode: '🫚' }, - { id: 'giraffe_face', name: 'giraffe_face', names: ['giraffe_face'], unicode: '🦒' }, - { - id: 'girl', - name: 'girl', - names: ['girl'], - skins: ['👧🏻', '👧🏼', '👧🏽', '👧🏾', '👧🏿'], - unicode: '👧', - }, - { id: 'glass_of_milk', name: 'glass_of_milk', names: ['glass_of_milk'], unicode: '🥛' }, - { - id: 'globe_with_meridians', - name: 'globe_with_meridians', - names: ['globe_with_meridians'], - unicode: '🌐', - }, - { id: 'gloves', name: 'gloves', names: ['gloves'], unicode: '🧤' }, - { id: 'goal_net', name: 'goal_net', names: ['goal_net'], unicode: '🥅' }, - { id: 'goat', name: 'goat', names: ['goat'], unicode: '🐐' }, - { id: 'goggles', name: 'goggles', names: ['goggles'], unicode: '🥽' }, - { id: 'golf', name: 'golf', names: ['golf'], unicode: '⛳' }, - { - id: 'golfer', - name: 'golfer', - names: ['golfer'], - skins: ['🏌🏻', '🏌🏼', '🏌🏽', '🏌🏾', '🏌🏿'], - unicode: '🏌️', - }, - { id: 'goose', name: 'goose', names: ['goose'], unicode: '🪿' }, - { id: 'gorilla', name: 'gorilla', names: ['gorilla'], unicode: '🦍' }, - { id: 'grapes', name: 'grapes', names: ['grapes'], unicode: '🍇' }, - { id: 'green_apple', name: 'green_apple', names: ['green_apple'], unicode: '🍏' }, - { id: 'green_book', name: 'green_book', names: ['green_book'], unicode: '📗' }, - { id: 'green_heart', name: 'green_heart', names: ['green_heart'], unicode: '💚' }, - { id: 'green_salad', name: 'green_salad', names: ['green_salad'], unicode: '🥗' }, - { id: 'grey_exclamation', name: 'grey_exclamation', names: ['grey_exclamation'], unicode: '❕' }, - { id: 'grey_heart', name: 'grey_heart', names: ['grey_heart'], unicode: '🩶' }, - { id: 'grey_question', name: 'grey_question', names: ['grey_question'], unicode: '❔' }, - { id: 'grimacing', name: 'grimacing', names: ['grimacing'], unicode: '😬' }, - { id: 'grin', name: 'grin', names: ['grin'], unicode: '😁' }, - { id: 'grinning', name: 'grinning', names: ['grinning'], unicode: '😀' }, - { - id: 'guardsman', - name: 'guardsman', - names: ['guardsman'], - skins: ['💂🏻', '💂🏼', '💂🏽', '💂🏾', '💂🏿'], - unicode: '💂', - }, - { id: 'guide_dog', name: 'guide_dog', names: ['guide_dog'], unicode: '🦮' }, - { id: 'guitar', name: 'guitar', names: ['guitar'], unicode: '🎸' }, - { id: 'gun', name: 'gun', names: ['gun'], unicode: '🔫' }, - { id: 'hair_pick', name: 'hair_pick', names: ['hair_pick'], unicode: '🪮' }, - { - id: 'haircut', - name: 'haircut', - names: ['haircut'], - skins: ['💇🏻', '💇🏼', '💇🏽', '💇🏾', '💇🏿'], - unicode: '💇', - }, - { id: 'hamburger', name: 'hamburger', names: ['hamburger'], unicode: '🍔' }, - { id: 'hammer', name: 'hammer', names: ['hammer'], unicode: '🔨' }, - { id: 'hammer_and_pick', name: 'hammer_and_pick', names: ['hammer_and_pick'], unicode: '⚒️' }, - { - id: 'hammer_and_wrench', - name: 'hammer_and_wrench', - names: ['hammer_and_wrench'], - unicode: '🛠️', - }, - { id: 'hamsa', name: 'hamsa', names: ['hamsa'], unicode: '🪬' }, - { id: 'hamster', name: 'hamster', names: ['hamster'], unicode: '🐹' }, - { - id: 'hand', - name: 'hand', - names: ['hand', 'raised_hand'], - skins: ['✋🏻', '✋🏼', '✋🏽', '✋🏾', '✋🏿'], - unicode: '✋', - }, - { - id: 'hand_with_index_finger_and_thumb_crossed', - name: 'hand_with_index_finger_and_thumb_crossed', - names: ['hand_with_index_finger_and_thumb_crossed'], - skins: ['🫰🏻', '🫰🏼', '🫰🏽', '🫰🏾', '🫰🏿'], - unicode: '🫰', - }, - { id: 'handbag', name: 'handbag', names: ['handbag'], unicode: '👜' }, - { - id: 'handball', - name: 'handball', - names: ['handball'], - skins: ['🤾🏻', '🤾🏼', '🤾🏽', '🤾🏾', '🤾🏿'], - unicode: '🤾', - }, - { - id: 'handshake', - name: 'handshake', - names: ['handshake'], - skins: [ - '🤝🏻', - '🤝🏼', - '🤝🏽', - '🤝🏾', - '🤝🏿', - '🫱🏻‍🫲🏼', - '🫱🏻‍🫲🏽', - '🫱🏻‍🫲🏾', - '🫱🏻‍🫲🏿', - '🫱🏼‍🫲🏻', - '🫱🏼‍🫲🏽', - '🫱🏼‍🫲🏾', - '🫱🏼‍🫲🏿', - '🫱🏽‍🫲🏻', - '🫱🏽‍🫲🏼', - '🫱🏽‍🫲🏾', - '🫱🏽‍🫲🏿', - '🫱🏾‍🫲🏻', - '🫱🏾‍🫲🏼', - '🫱🏾‍🫲🏽', - '🫱🏾‍🫲🏿', - '🫱🏿‍🫲🏻', - '🫱🏿‍🫲🏼', - '🫱🏿‍🫲🏽', - '🫱🏿‍🫲🏾', - ], - unicode: '🤝', - }, - { id: 'hankey', name: 'hankey', names: ['hankey', 'poop', 'shit'], unicode: '💩' }, - { id: 'hash', name: 'hash', names: ['hash'], unicode: '#️⃣' }, - { id: 'hatched_chick', name: 'hatched_chick', names: ['hatched_chick'], unicode: '🐥' }, - { id: 'hatching_chick', name: 'hatching_chick', names: ['hatching_chick'], unicode: '🐣' }, - { - id: 'head_shaking_horizontally', - name: 'head_shaking_horizontally', - names: ['head_shaking_horizontally'], - unicode: '🙂‍↔️', - }, - { - id: 'head_shaking_vertically', - name: 'head_shaking_vertically', - names: ['head_shaking_vertically'], - unicode: '🙂‍↕️', - }, - { id: 'headphones', name: 'headphones', names: ['headphones'], unicode: '🎧' }, - { id: 'headstone', name: 'headstone', names: ['headstone'], unicode: '🪦' }, - { - id: 'health_worker', - name: 'health_worker', - names: ['health_worker'], - skins: ['🧑🏻‍⚕️', '🧑🏼‍⚕️', '🧑🏽‍⚕️', '🧑🏾‍⚕️', '🧑🏿‍⚕️'], - unicode: '🧑‍⚕️', - }, - { id: 'hear_no_evil', name: 'hear_no_evil', names: ['hear_no_evil'], unicode: '🙉' }, - { id: 'heart', name: 'heart', names: ['heart'], unicode: '❤️' }, - { id: 'heart_decoration', name: 'heart_decoration', names: ['heart_decoration'], unicode: '💟' }, - { id: 'heart_eyes', name: 'heart_eyes', names: ['heart_eyes'], unicode: '😍' }, - { id: 'heart_eyes_cat', name: 'heart_eyes_cat', names: ['heart_eyes_cat'], unicode: '😻' }, - { - id: 'heart_hands', - name: 'heart_hands', - names: ['heart_hands'], - skins: ['🫶🏻', '🫶🏼', '🫶🏽', '🫶🏾', '🫶🏿'], - unicode: '🫶', - }, - { id: 'heart_on_fire', name: 'heart_on_fire', names: ['heart_on_fire'], unicode: '❤️‍🔥' }, - { id: 'heartbeat', name: 'heartbeat', names: ['heartbeat'], unicode: '💓' }, - { id: 'heartpulse', name: 'heartpulse', names: ['heartpulse'], unicode: '💗' }, - { id: 'hearts', name: 'hearts', names: ['hearts'], unicode: '♥️' }, - { id: 'heavy_check_mark', name: 'heavy_check_mark', names: ['heavy_check_mark'], unicode: '✔️' }, - { - id: 'heavy_division_sign', - name: 'heavy_division_sign', - names: ['heavy_division_sign'], - unicode: '➗', - }, - { - id: 'heavy_dollar_sign', - name: 'heavy_dollar_sign', - names: ['heavy_dollar_sign'], - unicode: '💲', - }, - { - id: 'heavy_equals_sign', - name: 'heavy_equals_sign', - names: ['heavy_equals_sign'], - unicode: '🟰', - }, - { - id: 'heavy_heart_exclamation_mark_ornament', - name: 'heavy_heart_exclamation_mark_ornament', - names: ['heavy_heart_exclamation_mark_ornament'], - unicode: '❣️', - }, - { id: 'heavy_minus_sign', name: 'heavy_minus_sign', names: ['heavy_minus_sign'], unicode: '➖' }, - { - id: 'heavy_multiplication_x', - name: 'heavy_multiplication_x', - names: ['heavy_multiplication_x'], - unicode: '✖️', - }, - { id: 'heavy_plus_sign', name: 'heavy_plus_sign', names: ['heavy_plus_sign'], unicode: '➕' }, - { id: 'hedgehog', name: 'hedgehog', names: ['hedgehog'], unicode: '🦔' }, - { id: 'helicopter', name: 'helicopter', names: ['helicopter'], unicode: '🚁' }, - { - id: 'helmet_with_white_cross', - name: 'helmet_with_white_cross', - names: ['helmet_with_white_cross'], - unicode: '⛑️', - }, - { id: 'herb', name: 'herb', names: ['herb'], unicode: '🌿' }, - { id: 'hibiscus', name: 'hibiscus', names: ['hibiscus'], unicode: '🌺' }, - { id: 'high_brightness', name: 'high_brightness', names: ['high_brightness'], unicode: '🔆' }, - { id: 'high_heel', name: 'high_heel', names: ['high_heel'], unicode: '👠' }, - { id: 'hiking_boot', name: 'hiking_boot', names: ['hiking_boot'], unicode: '🥾' }, - { id: 'hindu_temple', name: 'hindu_temple', names: ['hindu_temple'], unicode: '🛕' }, - { id: 'hippopotamus', name: 'hippopotamus', names: ['hippopotamus'], unicode: '🦛' }, - { id: 'hocho', name: 'hocho', names: ['hocho', 'knife'], unicode: '🔪' }, - { id: 'hole', name: 'hole', names: ['hole'], unicode: '🕳️' }, - { id: 'honey_pot', name: 'honey_pot', names: ['honey_pot'], unicode: '🍯' }, - { id: 'hook', name: 'hook', names: ['hook'], unicode: '🪝' }, - { id: 'horse', name: 'horse', names: ['horse'], unicode: '🐴' }, - { - id: 'horse_racing', - name: 'horse_racing', - names: ['horse_racing'], - skins: ['🏇🏻', '🏇🏼', '🏇🏽', '🏇🏾', '🏇🏿'], - unicode: '🏇', - }, - { id: 'hospital', name: 'hospital', names: ['hospital'], unicode: '🏥' }, - { id: 'hot_face', name: 'hot_face', names: ['hot_face'], unicode: '🥵' }, - { id: 'hot_pepper', name: 'hot_pepper', names: ['hot_pepper'], unicode: '🌶️' }, - { id: 'hotdog', name: 'hotdog', names: ['hotdog'], unicode: '🌭' }, - { id: 'hotel', name: 'hotel', names: ['hotel'], unicode: '🏨' }, - { id: 'hotsprings', name: 'hotsprings', names: ['hotsprings'], unicode: '♨️' }, - { id: 'hourglass', name: 'hourglass', names: ['hourglass'], unicode: '⌛' }, - { - id: 'hourglass_flowing_sand', - name: 'hourglass_flowing_sand', - names: ['hourglass_flowing_sand'], - unicode: '⏳', - }, - { id: 'house', name: 'house', names: ['house'], unicode: '🏠' }, - { id: 'house_buildings', name: 'house_buildings', names: ['house_buildings'], unicode: '🏘️' }, - { - id: 'house_with_garden', - name: 'house_with_garden', - names: ['house_with_garden'], - unicode: '🏡', - }, - { id: 'hugging_face', name: 'hugging_face', names: ['hugging_face'], unicode: '🤗' }, - { id: 'hushed', name: 'hushed', names: ['hushed'], unicode: '😯' }, - { id: 'hut', name: 'hut', names: ['hut'], unicode: '🛖' }, - { id: 'hyacinth', name: 'hyacinth', names: ['hyacinth'], unicode: '🪻' }, - { - id: 'i_love_you_hand_sign', - name: 'i_love_you_hand_sign', - names: ['i_love_you_hand_sign'], - skins: ['🤟🏻', '🤟🏼', '🤟🏽', '🤟🏾', '🤟🏿'], - unicode: '🤟', - }, - { id: 'ice_cream', name: 'ice_cream', names: ['ice_cream'], unicode: '🍨' }, - { id: 'ice_cube', name: 'ice_cube', names: ['ice_cube'], unicode: '🧊' }, - { - id: 'ice_hockey_stick_and_puck', - name: 'ice_hockey_stick_and_puck', - names: ['ice_hockey_stick_and_puck'], - unicode: '🏒', - }, - { id: 'ice_skate', name: 'ice_skate', names: ['ice_skate'], unicode: '⛸️' }, - { id: 'icecream', name: 'icecream', names: ['icecream'], unicode: '🍦' }, - { id: 'id', name: 'id', names: ['id'], unicode: '🆔' }, - { - id: 'identification_card', - name: 'identification_card', - names: ['identification_card'], - unicode: '🪪', - }, - { - id: 'ideograph_advantage', - name: 'ideograph_advantage', - names: ['ideograph_advantage'], - unicode: '🉐', - }, - { id: 'imp', name: 'imp', names: ['imp'], unicode: '👿' }, - { id: 'inbox_tray', name: 'inbox_tray', names: ['inbox_tray'], unicode: '📥' }, - { - id: 'incoming_envelope', - name: 'incoming_envelope', - names: ['incoming_envelope'], - unicode: '📨', - }, - { - id: 'index_pointing_at_the_viewer', - name: 'index_pointing_at_the_viewer', - names: ['index_pointing_at_the_viewer'], - skins: ['🫵🏻', '🫵🏼', '🫵🏽', '🫵🏾', '🫵🏿'], - unicode: '🫵', - }, - { id: 'infinity', name: 'infinity', names: ['infinity'], unicode: '♾️' }, - { - id: 'information_desk_person', - name: 'information_desk_person', - names: ['information_desk_person'], - skins: ['💁🏻', '💁🏼', '💁🏽', '💁🏾', '💁🏿'], - unicode: '💁', - }, - { - id: 'information_source', - name: 'information_source', - names: ['information_source'], - unicode: 'ℹ️', - }, - { id: 'innocent', name: 'innocent', names: ['innocent'], unicode: '😇' }, - { id: 'interrobang', name: 'interrobang', names: ['interrobang'], unicode: '⁉️' }, - { id: 'iphone', name: 'iphone', names: ['iphone'], unicode: '📱' }, - { id: 'it', name: 'it', names: ['it', 'flag-it'], unicode: '🇮🇹' }, - { - id: 'izakaya_lantern', - name: 'izakaya_lantern', - names: ['izakaya_lantern', 'lantern'], - unicode: '🏮', - }, - { id: 'jack_o_lantern', name: 'jack_o_lantern', names: ['jack_o_lantern'], unicode: '🎃' }, - { id: 'japan', name: 'japan', names: ['japan'], unicode: '🗾' }, - { id: 'japanese_castle', name: 'japanese_castle', names: ['japanese_castle'], unicode: '🏯' }, - { id: 'japanese_goblin', name: 'japanese_goblin', names: ['japanese_goblin'], unicode: '👺' }, - { id: 'japanese_ogre', name: 'japanese_ogre', names: ['japanese_ogre'], unicode: '👹' }, - { id: 'jar', name: 'jar', names: ['jar'], unicode: '🫙' }, - { id: 'jeans', name: 'jeans', names: ['jeans'], unicode: '👖' }, - { id: 'jellyfish', name: 'jellyfish', names: ['jellyfish'], unicode: '🪼' }, - { id: 'jigsaw', name: 'jigsaw', names: ['jigsaw'], unicode: '🧩' }, - { id: 'joy', name: 'joy', names: ['joy'], unicode: '😂' }, - { id: 'joy_cat', name: 'joy_cat', names: ['joy_cat'], unicode: '😹' }, - { id: 'joystick', name: 'joystick', names: ['joystick'], unicode: '🕹️' }, - { id: 'jp', name: 'jp', names: ['jp', 'flag-jp'], unicode: '🇯🇵' }, - { - id: 'judge', - name: 'judge', - names: ['judge'], - skins: ['🧑🏻‍⚖️', '🧑🏼‍⚖️', '🧑🏽‍⚖️', '🧑🏾‍⚖️', '🧑🏿‍⚖️'], - unicode: '🧑‍⚖️', - }, - { - id: 'juggling', - name: 'juggling', - names: ['juggling'], - skins: ['🤹🏻', '🤹🏼', '🤹🏽', '🤹🏾', '🤹🏿'], - unicode: '🤹', - }, - { id: 'kaaba', name: 'kaaba', names: ['kaaba'], unicode: '🕋' }, - { id: 'kangaroo', name: 'kangaroo', names: ['kangaroo'], unicode: '🦘' }, - { id: 'key', name: 'key', names: ['key'], unicode: '🔑' }, - { id: 'keyboard', name: 'keyboard', names: ['keyboard'], unicode: '⌨️' }, - { id: 'keycap_star', name: 'keycap_star', names: ['keycap_star'], unicode: '*️⃣' }, - { id: 'keycap_ten', name: 'keycap_ten', names: ['keycap_ten'], unicode: '🔟' }, - { id: 'khanda', name: 'khanda', names: ['khanda'], unicode: '🪯' }, - { id: 'kimono', name: 'kimono', names: ['kimono'], unicode: '👘' }, - { id: 'kiss', name: 'kiss', names: ['kiss'], unicode: '💋' }, - { id: 'kissing', name: 'kissing', names: ['kissing'], unicode: '😗' }, - { id: 'kissing_cat', name: 'kissing_cat', names: ['kissing_cat'], unicode: '😽' }, - { - id: 'kissing_closed_eyes', - name: 'kissing_closed_eyes', - names: ['kissing_closed_eyes'], - unicode: '😚', - }, - { id: 'kissing_heart', name: 'kissing_heart', names: ['kissing_heart'], unicode: '😘' }, - { - id: 'kissing_smiling_eyes', - name: 'kissing_smiling_eyes', - names: ['kissing_smiling_eyes'], - unicode: '😙', - }, - { id: 'kite', name: 'kite', names: ['kite'], unicode: '🪁' }, - { id: 'kiwifruit', name: 'kiwifruit', names: ['kiwifruit'], unicode: '🥝' }, - { - id: 'kneeling_person', - name: 'kneeling_person', - names: ['kneeling_person'], - skins: ['🧎🏻', '🧎🏼', '🧎🏽', '🧎🏾', '🧎🏿'], - unicode: '🧎', - }, - { id: 'knife_fork_plate', name: 'knife_fork_plate', names: ['knife_fork_plate'], unicode: '🍽️' }, - { id: 'knot', name: 'knot', names: ['knot'], unicode: '🪢' }, - { id: 'koala', name: 'koala', names: ['koala'], unicode: '🐨' }, - { id: 'koko', name: 'koko', names: ['koko'], unicode: '🈁' }, - { id: 'kr', name: 'kr', names: ['kr', 'flag-kr'], unicode: '🇰🇷' }, - { id: 'lab_coat', name: 'lab_coat', names: ['lab_coat'], unicode: '🥼' }, - { id: 'label', name: 'label', names: ['label'], unicode: '🏷️' }, - { id: 'lacrosse', name: 'lacrosse', names: ['lacrosse'], unicode: '🥍' }, - { id: 'ladder', name: 'ladder', names: ['ladder'], unicode: '🪜' }, - { id: 'ladybug', name: 'ladybug', names: ['ladybug', 'lady_beetle'], unicode: '🐞' }, - { - id: 'large_blue_circle', - name: 'large_blue_circle', - names: ['large_blue_circle'], - unicode: '🔵', - }, - { - id: 'large_blue_diamond', - name: 'large_blue_diamond', - names: ['large_blue_diamond'], - unicode: '🔷', - }, - { - id: 'large_blue_square', - name: 'large_blue_square', - names: ['large_blue_square'], - unicode: '🟦', - }, - { - id: 'large_brown_circle', - name: 'large_brown_circle', - names: ['large_brown_circle'], - unicode: '🟤', - }, - { - id: 'large_brown_square', - name: 'large_brown_square', - names: ['large_brown_square'], - unicode: '🟫', - }, - { - id: 'large_green_circle', - name: 'large_green_circle', - names: ['large_green_circle'], - unicode: '🟢', - }, - { - id: 'large_green_square', - name: 'large_green_square', - names: ['large_green_square'], - unicode: '🟩', - }, - { - id: 'large_orange_circle', - name: 'large_orange_circle', - names: ['large_orange_circle'], - unicode: '🟠', - }, - { - id: 'large_orange_diamond', - name: 'large_orange_diamond', - names: ['large_orange_diamond'], - unicode: '🔶', - }, - { - id: 'large_orange_square', - name: 'large_orange_square', - names: ['large_orange_square'], - unicode: '🟧', - }, - { - id: 'large_purple_circle', - name: 'large_purple_circle', - names: ['large_purple_circle'], - unicode: '🟣', - }, - { - id: 'large_purple_square', - name: 'large_purple_square', - names: ['large_purple_square'], - unicode: '🟪', - }, - { id: 'large_red_square', name: 'large_red_square', names: ['large_red_square'], unicode: '🟥' }, - { - id: 'large_yellow_circle', - name: 'large_yellow_circle', - names: ['large_yellow_circle'], - unicode: '🟡', - }, - { - id: 'large_yellow_square', - name: 'large_yellow_square', - names: ['large_yellow_square'], - unicode: '🟨', - }, - { - id: 'last_quarter_moon', - name: 'last_quarter_moon', - names: ['last_quarter_moon'], - unicode: '🌗', - }, - { - id: 'last_quarter_moon_with_face', - name: 'last_quarter_moon_with_face', - names: ['last_quarter_moon_with_face'], - unicode: '🌜', - }, - { id: 'latin_cross', name: 'latin_cross', names: ['latin_cross'], unicode: '✝️' }, - { id: 'laughing', name: 'laughing', names: ['laughing', 'satisfied'], unicode: '😆' }, - { id: 'leafy_green', name: 'leafy_green', names: ['leafy_green'], unicode: '🥬' }, - { id: 'leaves', name: 'leaves', names: ['leaves'], unicode: '🍃' }, - { id: 'ledger', name: 'ledger', names: ['ledger'], unicode: '📒' }, - { - id: 'left-facing_fist', - name: 'left-facing_fist', - names: ['left-facing_fist'], - skins: ['🤛🏻', '🤛🏼', '🤛🏽', '🤛🏾', '🤛🏿'], - unicode: '🤛', - }, - { id: 'left_luggage', name: 'left_luggage', names: ['left_luggage'], unicode: '🛅' }, - { id: 'left_right_arrow', name: 'left_right_arrow', names: ['left_right_arrow'], unicode: '↔️' }, - { - id: 'left_speech_bubble', - name: 'left_speech_bubble', - names: ['left_speech_bubble'], - unicode: '🗨️', - }, - { - id: 'leftwards_arrow_with_hook', - name: 'leftwards_arrow_with_hook', - names: ['leftwards_arrow_with_hook'], - unicode: '↩️', - }, - { - id: 'leftwards_hand', - name: 'leftwards_hand', - names: ['leftwards_hand'], - skins: ['🫲🏻', '🫲🏼', '🫲🏽', '🫲🏾', '🫲🏿'], - unicode: '🫲', - }, - { - id: 'leftwards_pushing_hand', - name: 'leftwards_pushing_hand', - names: ['leftwards_pushing_hand'], - skins: ['🫷🏻', '🫷🏼', '🫷🏽', '🫷🏾', '🫷🏿'], - unicode: '🫷', - }, - { id: 'leg', name: 'leg', names: ['leg'], skins: ['🦵🏻', '🦵🏼', '🦵🏽', '🦵🏾', '🦵🏿'], unicode: '🦵' }, - { id: 'lemon', name: 'lemon', names: ['lemon'], unicode: '🍋' }, - { id: 'leo', name: 'leo', names: ['leo'], unicode: '♌' }, - { id: 'leopard', name: 'leopard', names: ['leopard'], unicode: '🐆' }, - { id: 'level_slider', name: 'level_slider', names: ['level_slider'], unicode: '🎚️' }, - { id: 'libra', name: 'libra', names: ['libra'], unicode: '♎' }, - { id: 'light_blue_heart', name: 'light_blue_heart', names: ['light_blue_heart'], unicode: '🩵' }, - { id: 'light_rail', name: 'light_rail', names: ['light_rail'], unicode: '🚈' }, - { id: 'lightning', name: 'lightning', names: ['lightning', 'lightning_cloud'], unicode: '🌩️' }, - { id: 'lime', name: 'lime', names: ['lime'], unicode: '🍋‍🟩' }, - { id: 'link', name: 'link', names: ['link'], unicode: '🔗' }, - { - id: 'linked_paperclips', - name: 'linked_paperclips', - names: ['linked_paperclips'], - unicode: '🖇️', - }, - { id: 'lion_face', name: 'lion_face', names: ['lion_face'], unicode: '🦁' }, - { id: 'lips', name: 'lips', names: ['lips'], unicode: '👄' }, - { id: 'lipstick', name: 'lipstick', names: ['lipstick'], unicode: '💄' }, - { id: 'lizard', name: 'lizard', names: ['lizard'], unicode: '🦎' }, - { id: 'llama', name: 'llama', names: ['llama'], unicode: '🦙' }, - { id: 'lobster', name: 'lobster', names: ['lobster'], unicode: '🦞' }, - { id: 'lock', name: 'lock', names: ['lock'], unicode: '🔒' }, - { - id: 'lock_with_ink_pen', - name: 'lock_with_ink_pen', - names: ['lock_with_ink_pen'], - unicode: '🔏', - }, - { id: 'lollipop', name: 'lollipop', names: ['lollipop'], unicode: '🍭' }, - { id: 'long_drum', name: 'long_drum', names: ['long_drum'], unicode: '🪘' }, - { id: 'loop', name: 'loop', names: ['loop'], unicode: '➿' }, - { id: 'lotion_bottle', name: 'lotion_bottle', names: ['lotion_bottle'], unicode: '🧴' }, - { id: 'lotus', name: 'lotus', names: ['lotus'], unicode: '🪷' }, - { id: 'loud_sound', name: 'loud_sound', names: ['loud_sound'], unicode: '🔊' }, - { id: 'loudspeaker', name: 'loudspeaker', names: ['loudspeaker'], unicode: '📢' }, - { id: 'love_hotel', name: 'love_hotel', names: ['love_hotel'], unicode: '🏩' }, - { id: 'love_letter', name: 'love_letter', names: ['love_letter'], unicode: '💌' }, - { id: 'low_battery', name: 'low_battery', names: ['low_battery'], unicode: '🪫' }, - { id: 'low_brightness', name: 'low_brightness', names: ['low_brightness'], unicode: '🔅' }, - { - id: 'lower_left_ballpoint_pen', - name: 'lower_left_ballpoint_pen', - names: ['lower_left_ballpoint_pen'], - unicode: '🖊️', - }, - { - id: 'lower_left_crayon', - name: 'lower_left_crayon', - names: ['lower_left_crayon'], - unicode: '🖍️', - }, - { - id: 'lower_left_fountain_pen', - name: 'lower_left_fountain_pen', - names: ['lower_left_fountain_pen'], - unicode: '🖋️', - }, - { - id: 'lower_left_paintbrush', - name: 'lower_left_paintbrush', - names: ['lower_left_paintbrush'], - unicode: '🖌️', - }, - { id: 'luggage', name: 'luggage', names: ['luggage'], unicode: '🧳' }, - { id: 'lungs', name: 'lungs', names: ['lungs'], unicode: '🫁' }, - { id: 'lying_face', name: 'lying_face', names: ['lying_face'], unicode: '🤥' }, - { id: 'm', name: 'm', names: ['m'], unicode: 'Ⓜ️' }, - { id: 'mag', name: 'mag', names: ['mag'], unicode: '🔍' }, - { id: 'mag_right', name: 'mag_right', names: ['mag_right'], unicode: '🔎' }, - { - id: 'mage', - name: 'mage', - names: ['mage'], - skins: ['🧙🏻', '🧙🏼', '🧙🏽', '🧙🏾', '🧙🏿'], - unicode: '🧙', - }, - { id: 'magic_wand', name: 'magic_wand', names: ['magic_wand'], unicode: '🪄' }, - { id: 'magnet', name: 'magnet', names: ['magnet'], unicode: '🧲' }, - { id: 'mahjong', name: 'mahjong', names: ['mahjong'], unicode: '🀄' }, - { id: 'mailbox', name: 'mailbox', names: ['mailbox'], unicode: '📫' }, - { id: 'mailbox_closed', name: 'mailbox_closed', names: ['mailbox_closed'], unicode: '📪' }, - { - id: 'mailbox_with_mail', - name: 'mailbox_with_mail', - names: ['mailbox_with_mail'], - unicode: '📬', - }, - { - id: 'mailbox_with_no_mail', - name: 'mailbox_with_no_mail', - names: ['mailbox_with_no_mail'], - unicode: '📭', - }, - { - id: 'male-artist', - name: 'male-artist', - names: ['male-artist'], - skins: ['👨🏻‍🎨', '👨🏼‍🎨', '👨🏽‍🎨', '👨🏾‍🎨', '👨🏿‍🎨'], - unicode: '👨‍🎨', - }, - { - id: 'male-astronaut', - name: 'male-astronaut', - names: ['male-astronaut'], - skins: ['👨🏻‍🚀', '👨🏼‍🚀', '👨🏽‍🚀', '👨🏾‍🚀', '👨🏿‍🚀'], - unicode: '👨‍🚀', - }, - { - id: 'male-construction-worker', - name: 'male-construction-worker', - names: ['male-construction-worker'], - skins: ['👷🏻‍♂️', '👷🏼‍♂️', '👷🏽‍♂️', '👷🏾‍♂️', '👷🏿‍♂️'], - unicode: '👷‍♂️', - }, - { - id: 'male-cook', - name: 'male-cook', - names: ['male-cook'], - skins: ['👨🏻‍🍳', '👨🏼‍🍳', '👨🏽‍🍳', '👨🏾‍🍳', '👨🏿‍🍳'], - unicode: '👨‍🍳', - }, - { - id: 'male-detective', - name: 'male-detective', - names: ['male-detective'], - skins: ['🕵🏻‍♂️', '🕵🏼‍♂️', '🕵🏽‍♂️', '🕵🏾‍♂️', '🕵🏿‍♂️'], - unicode: '🕵️‍♂️', - }, - { - id: 'male-doctor', - name: 'male-doctor', - names: ['male-doctor'], - skins: ['👨🏻‍⚕️', '👨🏼‍⚕️', '👨🏽‍⚕️', '👨🏾‍⚕️', '👨🏿‍⚕️'], - unicode: '👨‍⚕️', - }, - { - id: 'male-factory-worker', - name: 'male-factory-worker', - names: ['male-factory-worker'], - skins: ['👨🏻‍🏭', '👨🏼‍🏭', '👨🏽‍🏭', '👨🏾‍🏭', '👨🏿‍🏭'], - unicode: '👨‍🏭', - }, - { - id: 'male-farmer', - name: 'male-farmer', - names: ['male-farmer'], - skins: ['👨🏻‍🌾', '👨🏼‍🌾', '👨🏽‍🌾', '👨🏾‍🌾', '👨🏿‍🌾'], - unicode: '👨‍🌾', - }, - { - id: 'male-firefighter', - name: 'male-firefighter', - names: ['male-firefighter'], - skins: ['👨🏻‍🚒', '👨🏼‍🚒', '👨🏽‍🚒', '👨🏾‍🚒', '👨🏿‍🚒'], - unicode: '👨‍🚒', - }, - { - id: 'male-guard', - name: 'male-guard', - names: ['male-guard'], - skins: ['💂🏻‍♂️', '💂🏼‍♂️', '💂🏽‍♂️', '💂🏾‍♂️', '💂🏿‍♂️'], - unicode: '💂‍♂️', - }, - { - id: 'male-judge', - name: 'male-judge', - names: ['male-judge'], - skins: ['👨🏻‍⚖️', '👨🏼‍⚖️', '👨🏽‍⚖️', '👨🏾‍⚖️', '👨🏿‍⚖️'], - unicode: '👨‍⚖️', - }, - { - id: 'male-mechanic', - name: 'male-mechanic', - names: ['male-mechanic'], - skins: ['👨🏻‍🔧', '👨🏼‍🔧', '👨🏽‍🔧', '👨🏾‍🔧', '👨🏿‍🔧'], - unicode: '👨‍🔧', - }, - { - id: 'male-office-worker', - name: 'male-office-worker', - names: ['male-office-worker'], - skins: ['👨🏻‍💼', '👨🏼‍💼', '👨🏽‍💼', '👨🏾‍💼', '👨🏿‍💼'], - unicode: '👨‍💼', - }, - { - id: 'male-pilot', - name: 'male-pilot', - names: ['male-pilot'], - skins: ['👨🏻‍✈️', '👨🏼‍✈️', '👨🏽‍✈️', '👨🏾‍✈️', '👨🏿‍✈️'], - unicode: '👨‍✈️', - }, - { - id: 'male-police-officer', - name: 'male-police-officer', - names: ['male-police-officer'], - skins: ['👮🏻‍♂️', '👮🏼‍♂️', '👮🏽‍♂️', '👮🏾‍♂️', '👮🏿‍♂️'], - unicode: '👮‍♂️', - }, - { - id: 'male-scientist', - name: 'male-scientist', - names: ['male-scientist'], - skins: ['👨🏻‍🔬', '👨🏼‍🔬', '👨🏽‍🔬', '👨🏾‍🔬', '👨🏿‍🔬'], - unicode: '👨‍🔬', - }, - { - id: 'male-singer', - name: 'male-singer', - names: ['male-singer'], - skins: ['👨🏻‍🎤', '👨🏼‍🎤', '👨🏽‍🎤', '👨🏾‍🎤', '👨🏿‍🎤'], - unicode: '👨‍🎤', - }, - { - id: 'male-student', - name: 'male-student', - names: ['male-student'], - skins: ['👨🏻‍🎓', '👨🏼‍🎓', '👨🏽‍🎓', '👨🏾‍🎓', '👨🏿‍🎓'], - unicode: '👨‍🎓', - }, - { - id: 'male-teacher', - name: 'male-teacher', - names: ['male-teacher'], - skins: ['👨🏻‍🏫', '👨🏼‍🏫', '👨🏽‍🏫', '👨🏾‍🏫', '👨🏿‍🏫'], - unicode: '👨‍🏫', - }, - { - id: 'male-technologist', - name: 'male-technologist', - names: ['male-technologist'], - skins: ['👨🏻‍💻', '👨🏼‍💻', '👨🏽‍💻', '👨🏾‍💻', '👨🏿‍💻'], - unicode: '👨‍💻', - }, - { - id: 'male_elf', - name: 'male_elf', - names: ['male_elf'], - skins: ['🧝🏻‍♂️', '🧝🏼‍♂️', '🧝🏽‍♂️', '🧝🏾‍♂️', '🧝🏿‍♂️'], - unicode: '🧝‍♂️', - }, - { - id: 'male_fairy', - name: 'male_fairy', - names: ['male_fairy'], - skins: ['🧚🏻‍♂️', '🧚🏼‍♂️', '🧚🏽‍♂️', '🧚🏾‍♂️', '🧚🏿‍♂️'], - unicode: '🧚‍♂️', - }, - { id: 'male_genie', name: 'male_genie', names: ['male_genie'], unicode: '🧞‍♂️' }, - { - id: 'male_mage', - name: 'male_mage', - names: ['male_mage'], - skins: ['🧙🏻‍♂️', '🧙🏼‍♂️', '🧙🏽‍♂️', '🧙🏾‍♂️', '🧙🏿‍♂️'], - unicode: '🧙‍♂️', - }, - { id: 'male_sign', name: 'male_sign', names: ['male_sign'], unicode: '♂️' }, - { - id: 'male_superhero', - name: 'male_superhero', - names: ['male_superhero'], - skins: ['🦸🏻‍♂️', '🦸🏼‍♂️', '🦸🏽‍♂️', '🦸🏾‍♂️', '🦸🏿‍♂️'], - unicode: '🦸‍♂️', - }, - { - id: 'male_supervillain', - name: 'male_supervillain', - names: ['male_supervillain'], - skins: ['🦹🏻‍♂️', '🦹🏼‍♂️', '🦹🏽‍♂️', '🦹🏾‍♂️', '🦹🏿‍♂️'], - unicode: '🦹‍♂️', - }, - { - id: 'male_vampire', - name: 'male_vampire', - names: ['male_vampire'], - skins: ['🧛🏻‍♂️', '🧛🏼‍♂️', '🧛🏽‍♂️', '🧛🏾‍♂️', '🧛🏿‍♂️'], - unicode: '🧛‍♂️', - }, - { id: 'male_zombie', name: 'male_zombie', names: ['male_zombie'], unicode: '🧟‍♂️' }, - { id: 'mammoth', name: 'mammoth', names: ['mammoth'], unicode: '🦣' }, - { id: 'man', name: 'man', names: ['man'], skins: ['👨🏻', '👨🏼', '👨🏽', '👨🏾', '👨🏿'], unicode: '👨' }, - { - id: 'man-biking', - name: 'man-biking', - names: ['man-biking'], - skins: ['🚴🏻‍♂️', '🚴🏼‍♂️', '🚴🏽‍♂️', '🚴🏾‍♂️', '🚴🏿‍♂️'], - unicode: '🚴‍♂️', - }, - { - id: 'man-bouncing-ball', - name: 'man-bouncing-ball', - names: ['man-bouncing-ball'], - skins: ['⛹🏻‍♂️', '⛹🏼‍♂️', '⛹🏽‍♂️', '⛹🏾‍♂️', '⛹🏿‍♂️'], - unicode: '⛹️‍♂️', - }, - { - id: 'man-bowing', - name: 'man-bowing', - names: ['man-bowing'], - skins: ['🙇🏻‍♂️', '🙇🏼‍♂️', '🙇🏽‍♂️', '🙇🏾‍♂️', '🙇🏿‍♂️'], - unicode: '🙇‍♂️', - }, - { id: 'man-boy', name: 'man-boy', names: ['man-boy'], unicode: '👨‍👦' }, - { id: 'man-boy-boy', name: 'man-boy-boy', names: ['man-boy-boy'], unicode: '👨‍👦‍👦' }, - { - id: 'man-cartwheeling', - name: 'man-cartwheeling', - names: ['man-cartwheeling'], - skins: ['🤸🏻‍♂️', '🤸🏼‍♂️', '🤸🏽‍♂️', '🤸🏾‍♂️', '🤸🏿‍♂️'], - unicode: '🤸‍♂️', - }, - { - id: 'man-facepalming', - name: 'man-facepalming', - names: ['man-facepalming'], - skins: ['🤦🏻‍♂️', '🤦🏼‍♂️', '🤦🏽‍♂️', '🤦🏾‍♂️', '🤦🏿‍♂️'], - unicode: '🤦‍♂️', - }, - { - id: 'man-frowning', - name: 'man-frowning', - names: ['man-frowning'], - skins: ['🙍🏻‍♂️', '🙍🏼‍♂️', '🙍🏽‍♂️', '🙍🏾‍♂️', '🙍🏿‍♂️'], - unicode: '🙍‍♂️', - }, - { - id: 'man-gesturing-no', - name: 'man-gesturing-no', - names: ['man-gesturing-no'], - skins: ['🙅🏻‍♂️', '🙅🏼‍♂️', '🙅🏽‍♂️', '🙅🏾‍♂️', '🙅🏿‍♂️'], - unicode: '🙅‍♂️', - }, - { - id: 'man-gesturing-ok', - name: 'man-gesturing-ok', - names: ['man-gesturing-ok'], - skins: ['🙆🏻‍♂️', '🙆🏼‍♂️', '🙆🏽‍♂️', '🙆🏾‍♂️', '🙆🏿‍♂️'], - unicode: '🙆‍♂️', - }, - { - id: 'man-getting-haircut', - name: 'man-getting-haircut', - names: ['man-getting-haircut'], - skins: ['💇🏻‍♂️', '💇🏼‍♂️', '💇🏽‍♂️', '💇🏾‍♂️', '💇🏿‍♂️'], - unicode: '💇‍♂️', - }, - { - id: 'man-getting-massage', - name: 'man-getting-massage', - names: ['man-getting-massage'], - skins: ['💆🏻‍♂️', '💆🏼‍♂️', '💆🏽‍♂️', '💆🏾‍♂️', '💆🏿‍♂️'], - unicode: '💆‍♂️', - }, - { id: 'man-girl', name: 'man-girl', names: ['man-girl'], unicode: '👨‍👧' }, - { id: 'man-girl-boy', name: 'man-girl-boy', names: ['man-girl-boy'], unicode: '👨‍👧‍👦' }, - { id: 'man-girl-girl', name: 'man-girl-girl', names: ['man-girl-girl'], unicode: '👨‍👧‍👧' }, - { - id: 'man-golfing', - name: 'man-golfing', - names: ['man-golfing'], - skins: ['🏌🏻‍♂️', '🏌🏼‍♂️', '🏌🏽‍♂️', '🏌🏾‍♂️', '🏌🏿‍♂️'], - unicode: '🏌️‍♂️', - }, - { - id: 'man-heart-man', - name: 'man-heart-man', - names: ['man-heart-man'], - skins: [ - '👨🏻‍❤️‍👨🏻', - '👨🏻‍❤️‍👨🏼', - '👨🏻‍❤️‍👨🏽', - '👨🏻‍❤️‍👨🏾', - '👨🏻‍❤️‍👨🏿', - '👨🏼‍❤️‍👨🏻', - '👨🏼‍❤️‍👨🏼', - '👨🏼‍❤️‍👨🏽', - '👨🏼‍❤️‍👨🏾', - '👨🏼‍❤️‍👨🏿', - '👨🏽‍❤️‍👨🏻', - '👨🏽‍❤️‍👨🏼', - '👨🏽‍❤️‍👨🏽', - '👨🏽‍❤️‍👨🏾', - '👨🏽‍❤️‍👨🏿', - '👨🏾‍❤️‍👨🏻', - '👨🏾‍❤️‍👨🏼', - '👨🏾‍❤️‍👨🏽', - '👨🏾‍❤️‍👨🏾', - '👨🏾‍❤️‍👨🏿', - '👨🏿‍❤️‍👨🏻', - '👨🏿‍❤️‍👨🏼', - '👨🏿‍❤️‍👨🏽', - '👨🏿‍❤️‍👨🏾', - '👨🏿‍❤️‍👨🏿', - ], - unicode: '👨‍❤️‍👨', - }, - { - id: 'man-juggling', - name: 'man-juggling', - names: ['man-juggling'], - skins: ['🤹🏻‍♂️', '🤹🏼‍♂️', '🤹🏽‍♂️', '🤹🏾‍♂️', '🤹🏿‍♂️'], - unicode: '🤹‍♂️', - }, - { - id: 'man-kiss-man', - name: 'man-kiss-man', - names: ['man-kiss-man'], - skins: [ - '👨🏻‍❤️‍💋‍👨🏻', - '👨🏻‍❤️‍💋‍👨🏼', - '👨🏻‍❤️‍💋‍👨🏽', - '👨🏻‍❤️‍💋‍👨🏾', - '👨🏻‍❤️‍💋‍👨🏿', - '👨🏼‍❤️‍💋‍👨🏻', - '👨🏼‍❤️‍💋‍👨🏼', - '👨🏼‍❤️‍💋‍👨🏽', - '👨🏼‍❤️‍💋‍👨🏾', - '👨🏼‍❤️‍💋‍👨🏿', - '👨🏽‍❤️‍💋‍👨🏻', - '👨🏽‍❤️‍💋‍👨🏼', - '👨🏽‍❤️‍💋‍👨🏽', - '👨🏽‍❤️‍💋‍👨🏾', - '👨🏽‍❤️‍💋‍👨🏿', - '👨🏾‍❤️‍💋‍👨🏻', - '👨🏾‍❤️‍💋‍👨🏼', - '👨🏾‍❤️‍💋‍👨🏽', - '👨🏾‍❤️‍💋‍👨🏾', - '👨🏾‍❤️‍💋‍👨🏿', - '👨🏿‍❤️‍💋‍👨🏻', - '👨🏿‍❤️‍💋‍👨🏼', - '👨🏿‍❤️‍💋‍👨🏽', - '👨🏿‍❤️‍💋‍👨🏾', - '👨🏿‍❤️‍💋‍👨🏿', - ], - unicode: '👨‍❤️‍💋‍👨', - }, - { - id: 'man-lifting-weights', - name: 'man-lifting-weights', - names: ['man-lifting-weights'], - skins: ['🏋🏻‍♂️', '🏋🏼‍♂️', '🏋🏽‍♂️', '🏋🏾‍♂️', '🏋🏿‍♂️'], - unicode: '🏋️‍♂️', - }, - { id: 'man-man-boy', name: 'man-man-boy', names: ['man-man-boy'], unicode: '👨‍👨‍👦' }, - { id: 'man-man-boy-boy', name: 'man-man-boy-boy', names: ['man-man-boy-boy'], unicode: '👨‍👨‍👦‍👦' }, - { id: 'man-man-girl', name: 'man-man-girl', names: ['man-man-girl'], unicode: '👨‍👨‍👧' }, - { id: 'man-man-girl-boy', name: 'man-man-girl-boy', names: ['man-man-girl-boy'], unicode: '👨‍👨‍👧‍👦' }, - { - id: 'man-man-girl-girl', - name: 'man-man-girl-girl', - names: ['man-man-girl-girl'], - unicode: '👨‍👨‍👧‍👧', - }, - { - id: 'man-mountain-biking', - name: 'man-mountain-biking', - names: ['man-mountain-biking'], - skins: ['🚵🏻‍♂️', '🚵🏼‍♂️', '🚵🏽‍♂️', '🚵🏾‍♂️', '🚵🏿‍♂️'], - unicode: '🚵‍♂️', - }, - { - id: 'man-playing-handball', - name: 'man-playing-handball', - names: ['man-playing-handball'], - skins: ['🤾🏻‍♂️', '🤾🏼‍♂️', '🤾🏽‍♂️', '🤾🏾‍♂️', '🤾🏿‍♂️'], - unicode: '🤾‍♂️', - }, - { - id: 'man-playing-water-polo', - name: 'man-playing-water-polo', - names: ['man-playing-water-polo'], - skins: ['🤽🏻‍♂️', '🤽🏼‍♂️', '🤽🏽‍♂️', '🤽🏾‍♂️', '🤽🏿‍♂️'], - unicode: '🤽‍♂️', - }, - { - id: 'man-pouting', - name: 'man-pouting', - names: ['man-pouting'], - skins: ['🙎🏻‍♂️', '🙎🏼‍♂️', '🙎🏽‍♂️', '🙎🏾‍♂️', '🙎🏿‍♂️'], - unicode: '🙎‍♂️', - }, - { - id: 'man-raising-hand', - name: 'man-raising-hand', - names: ['man-raising-hand'], - skins: ['🙋🏻‍♂️', '🙋🏼‍♂️', '🙋🏽‍♂️', '🙋🏾‍♂️', '🙋🏿‍♂️'], - unicode: '🙋‍♂️', - }, - { - id: 'man-rowing-boat', - name: 'man-rowing-boat', - names: ['man-rowing-boat'], - skins: ['🚣🏻‍♂️', '🚣🏼‍♂️', '🚣🏽‍♂️', '🚣🏾‍♂️', '🚣🏿‍♂️'], - unicode: '🚣‍♂️', - }, - { - id: 'man-running', - name: 'man-running', - names: ['man-running'], - skins: ['🏃🏻‍♂️', '🏃🏼‍♂️', '🏃🏽‍♂️', '🏃🏾‍♂️', '🏃🏿‍♂️'], - unicode: '🏃‍♂️', - }, - { - id: 'man-shrugging', - name: 'man-shrugging', - names: ['man-shrugging'], - skins: ['🤷🏻‍♂️', '🤷🏼‍♂️', '🤷🏽‍♂️', '🤷🏾‍♂️', '🤷🏿‍♂️'], - unicode: '🤷‍♂️', - }, - { - id: 'man-surfing', - name: 'man-surfing', - names: ['man-surfing'], - skins: ['🏄🏻‍♂️', '🏄🏼‍♂️', '🏄🏽‍♂️', '🏄🏾‍♂️', '🏄🏿‍♂️'], - unicode: '🏄‍♂️', - }, - { - id: 'man-swimming', - name: 'man-swimming', - names: ['man-swimming'], - skins: ['🏊🏻‍♂️', '🏊🏼‍♂️', '🏊🏽‍♂️', '🏊🏾‍♂️', '🏊🏿‍♂️'], - unicode: '🏊‍♂️', - }, - { - id: 'man-tipping-hand', - name: 'man-tipping-hand', - names: ['man-tipping-hand'], - skins: ['💁🏻‍♂️', '💁🏼‍♂️', '💁🏽‍♂️', '💁🏾‍♂️', '💁🏿‍♂️'], - unicode: '💁‍♂️', - }, - { - id: 'man-walking', - name: 'man-walking', - names: ['man-walking'], - skins: ['🚶🏻‍♂️', '🚶🏼‍♂️', '🚶🏽‍♂️', '🚶🏾‍♂️', '🚶🏿‍♂️'], - unicode: '🚶‍♂️', - }, - { - id: 'man-wearing-turban', - name: 'man-wearing-turban', - names: ['man-wearing-turban'], - skins: ['👳🏻‍♂️', '👳🏼‍♂️', '👳🏽‍♂️', '👳🏾‍♂️', '👳🏿‍♂️'], - unicode: '👳‍♂️', - }, - { id: 'man-woman-boy', name: 'man-woman-boy', names: ['man-woman-boy'], unicode: '👨‍👩‍👦' }, - { - id: 'man-woman-boy-boy', - name: 'man-woman-boy-boy', - names: ['man-woman-boy-boy'], - unicode: '👨‍👩‍👦‍👦', - }, - { id: 'man-woman-girl', name: 'man-woman-girl', names: ['man-woman-girl'], unicode: '👨‍👩‍👧' }, - { - id: 'man-woman-girl-boy', - name: 'man-woman-girl-boy', - names: ['man-woman-girl-boy'], - unicode: '👨‍👩‍👧‍👦', - }, - { - id: 'man-woman-girl-girl', - name: 'man-woman-girl-girl', - names: ['man-woman-girl-girl'], - unicode: '👨‍👩‍👧‍👧', - }, - { id: 'man-wrestling', name: 'man-wrestling', names: ['man-wrestling'], unicode: '🤼‍♂️' }, - { - id: 'man_and_woman_holding_hands', - name: 'man_and_woman_holding_hands', - names: ['man_and_woman_holding_hands', 'woman_and_man_holding_hands', 'couple'], - skins: [ - '👫🏻', - '👫🏼', - '👫🏽', - '👫🏾', - '👫🏿', - '👩🏻‍🤝‍👨🏼', - '👩🏻‍🤝‍👨🏽', - '👩🏻‍🤝‍👨🏾', - '👩🏻‍🤝‍👨🏿', - '👩🏼‍🤝‍👨🏻', - '👩🏼‍🤝‍👨🏽', - '👩🏼‍🤝‍👨🏾', - '👩🏼‍🤝‍👨🏿', - '👩🏽‍🤝‍👨🏻', - '👩🏽‍🤝‍👨🏼', - '👩🏽‍🤝‍👨🏾', - '👩🏽‍🤝‍👨🏿', - '👩🏾‍🤝‍👨🏻', - '👩🏾‍🤝‍👨🏼', - '👩🏾‍🤝‍👨🏽', - '👩🏾‍🤝‍👨🏿', - '👩🏿‍🤝‍👨🏻', - '👩🏿‍🤝‍👨🏼', - '👩🏿‍🤝‍👨🏽', - '👩🏿‍🤝‍👨🏾', - ], - unicode: '👫', - }, - { - id: 'man_climbing', - name: 'man_climbing', - names: ['man_climbing'], - skins: ['🧗🏻‍♂️', '🧗🏼‍♂️', '🧗🏽‍♂️', '🧗🏾‍♂️', '🧗🏿‍♂️'], - unicode: '🧗‍♂️', - }, - { - id: 'man_dancing', - name: 'man_dancing', - names: ['man_dancing'], - skins: ['🕺🏻', '🕺🏼', '🕺🏽', '🕺🏾', '🕺🏿'], - unicode: '🕺', - }, - { - id: 'man_feeding_baby', - name: 'man_feeding_baby', - names: ['man_feeding_baby'], - skins: ['👨🏻‍🍼', '👨🏼‍🍼', '👨🏽‍🍼', '👨🏾‍🍼', '👨🏿‍🍼'], - unicode: '👨‍🍼', - }, - { - id: 'man_in_business_suit_levitating', - name: 'man_in_business_suit_levitating', - names: ['man_in_business_suit_levitating'], - skins: ['🕴🏻', '🕴🏼', '🕴🏽', '🕴🏾', '🕴🏿'], - unicode: '🕴️', - }, - { - id: 'man_in_lotus_position', - name: 'man_in_lotus_position', - names: ['man_in_lotus_position'], - skins: ['🧘🏻‍♂️', '🧘🏼‍♂️', '🧘🏽‍♂️', '🧘🏾‍♂️', '🧘🏿‍♂️'], - unicode: '🧘‍♂️', - }, - { - id: 'man_in_manual_wheelchair', - name: 'man_in_manual_wheelchair', - names: ['man_in_manual_wheelchair'], - skins: ['👨🏻‍🦽', '👨🏼‍🦽', '👨🏽‍🦽', '👨🏾‍🦽', '👨🏿‍🦽'], - unicode: '👨‍🦽', - }, - { - id: 'man_in_manual_wheelchair_facing_right', - name: 'man_in_manual_wheelchair_facing_right', - names: ['man_in_manual_wheelchair_facing_right'], - skins: ['👨🏻‍🦽‍➡️', '👨🏼‍🦽‍➡️', '👨🏽‍🦽‍➡️', '👨🏾‍🦽‍➡️', '👨🏿‍🦽‍➡️'], - unicode: '👨‍🦽‍➡️', - }, - { - id: 'man_in_motorized_wheelchair', - name: 'man_in_motorized_wheelchair', - names: ['man_in_motorized_wheelchair'], - skins: ['👨🏻‍🦼', '👨🏼‍🦼', '👨🏽‍🦼', '👨🏾‍🦼', '👨🏿‍🦼'], - unicode: '👨‍🦼', - }, - { - id: 'man_in_motorized_wheelchair_facing_right', - name: 'man_in_motorized_wheelchair_facing_right', - names: ['man_in_motorized_wheelchair_facing_right'], - skins: ['👨🏻‍🦼‍➡️', '👨🏼‍🦼‍➡️', '👨🏽‍🦼‍➡️', '👨🏾‍🦼‍➡️', '👨🏿‍🦼‍➡️'], - unicode: '👨‍🦼‍➡️', - }, - { - id: 'man_in_steamy_room', - name: 'man_in_steamy_room', - names: ['man_in_steamy_room'], - skins: ['🧖🏻‍♂️', '🧖🏼‍♂️', '🧖🏽‍♂️', '🧖🏾‍♂️', '🧖🏿‍♂️'], - unicode: '🧖‍♂️', - }, - { - id: 'man_in_tuxedo', - name: 'man_in_tuxedo', - names: ['man_in_tuxedo'], - skins: ['🤵🏻‍♂️', '🤵🏼‍♂️', '🤵🏽‍♂️', '🤵🏾‍♂️', '🤵🏿‍♂️'], - unicode: '🤵‍♂️', - }, - { - id: 'man_kneeling', - name: 'man_kneeling', - names: ['man_kneeling'], - skins: ['🧎🏻‍♂️', '🧎🏼‍♂️', '🧎🏽‍♂️', '🧎🏾‍♂️', '🧎🏿‍♂️'], - unicode: '🧎‍♂️', - }, - { - id: 'man_kneeling_facing_right', - name: 'man_kneeling_facing_right', - names: ['man_kneeling_facing_right'], - skins: ['🧎🏻‍♂️‍➡️', '🧎🏼‍♂️‍➡️', '🧎🏽‍♂️‍➡️', '🧎🏾‍♂️‍➡️', '🧎🏿‍♂️‍➡️'], - unicode: '🧎‍♂️‍➡️', - }, - { - id: 'man_running_facing_right', - name: 'man_running_facing_right', - names: ['man_running_facing_right'], - skins: ['🏃🏻‍♂️‍➡️', '🏃🏼‍♂️‍➡️', '🏃🏽‍♂️‍➡️', '🏃🏾‍♂️‍➡️', '🏃🏿‍♂️‍➡️'], - unicode: '🏃‍♂️‍➡️', - }, - { - id: 'man_standing', - name: 'man_standing', - names: ['man_standing'], - skins: ['🧍🏻‍♂️', '🧍🏼‍♂️', '🧍🏽‍♂️', '🧍🏾‍♂️', '🧍🏿‍♂️'], - unicode: '🧍‍♂️', - }, - { - id: 'man_walking_facing_right', - name: 'man_walking_facing_right', - names: ['man_walking_facing_right'], - skins: ['🚶🏻‍♂️‍➡️', '🚶🏼‍♂️‍➡️', '🚶🏽‍♂️‍➡️', '🚶🏾‍♂️‍➡️', '🚶🏿‍♂️‍➡️'], - unicode: '🚶‍♂️‍➡️', - }, - { - id: 'man_with_beard', - name: 'man_with_beard', - names: ['man_with_beard'], - skins: ['🧔🏻‍♂️', '🧔🏼‍♂️', '🧔🏽‍♂️', '🧔🏾‍♂️', '🧔🏿‍♂️'], - unicode: '🧔‍♂️', - }, - { - id: 'man_with_gua_pi_mao', - name: 'man_with_gua_pi_mao', - names: ['man_with_gua_pi_mao'], - skins: ['👲🏻', '👲🏼', '👲🏽', '👲🏾', '👲🏿'], - unicode: '👲', - }, - { - id: 'man_with_probing_cane', - name: 'man_with_probing_cane', - names: ['man_with_probing_cane'], - skins: ['👨🏻‍🦯', '👨🏼‍🦯', '👨🏽‍🦯', '👨🏾‍🦯', '👨🏿‍🦯'], - unicode: '👨‍🦯', - }, - { - id: 'man_with_turban', - name: 'man_with_turban', - names: ['man_with_turban'], - skins: ['👳🏻', '👳🏼', '👳🏽', '👳🏾', '👳🏿'], - unicode: '👳', - }, - { - id: 'man_with_veil', - name: 'man_with_veil', - names: ['man_with_veil'], - skins: ['👰🏻‍♂️', '👰🏼‍♂️', '👰🏽‍♂️', '👰🏾‍♂️', '👰🏿‍♂️'], - unicode: '👰‍♂️', - }, - { - id: 'man_with_white_cane_facing_right', - name: 'man_with_white_cane_facing_right', - names: ['man_with_white_cane_facing_right'], - skins: ['👨🏻‍🦯‍➡️', '👨🏼‍🦯‍➡️', '👨🏽‍🦯‍➡️', '👨🏾‍🦯‍➡️', '👨🏿‍🦯‍➡️'], - unicode: '👨‍🦯‍➡️', - }, - { id: 'mango', name: 'mango', names: ['mango'], unicode: '🥭' }, - { id: 'mans_shoe', name: 'mans_shoe', names: ['mans_shoe', 'shoe'], unicode: '👞' }, - { - id: 'mantelpiece_clock', - name: 'mantelpiece_clock', - names: ['mantelpiece_clock'], - unicode: '🕰️', - }, - { - id: 'manual_wheelchair', - name: 'manual_wheelchair', - names: ['manual_wheelchair'], - unicode: '🦽', - }, - { id: 'maple_leaf', name: 'maple_leaf', names: ['maple_leaf'], unicode: '🍁' }, - { id: 'maracas', name: 'maracas', names: ['maracas'], unicode: '🪇' }, - { - id: 'martial_arts_uniform', - name: 'martial_arts_uniform', - names: ['martial_arts_uniform'], - unicode: '🥋', - }, - { id: 'mask', name: 'mask', names: ['mask'], unicode: '😷' }, - { - id: 'massage', - name: 'massage', - names: ['massage'], - skins: ['💆🏻', '💆🏼', '💆🏽', '💆🏾', '💆🏿'], - unicode: '💆', - }, - { id: 'mate_drink', name: 'mate_drink', names: ['mate_drink'], unicode: '🧉' }, - { id: 'meat_on_bone', name: 'meat_on_bone', names: ['meat_on_bone'], unicode: '🍖' }, - { - id: 'mechanic', - name: 'mechanic', - names: ['mechanic'], - skins: ['🧑🏻‍🔧', '🧑🏼‍🔧', '🧑🏽‍🔧', '🧑🏾‍🔧', '🧑🏿‍🔧'], - unicode: '🧑‍🔧', - }, - { id: 'mechanical_arm', name: 'mechanical_arm', names: ['mechanical_arm'], unicode: '🦾' }, - { id: 'mechanical_leg', name: 'mechanical_leg', names: ['mechanical_leg'], unicode: '🦿' }, - { id: 'medal', name: 'medal', names: ['medal'], unicode: '🎖️' }, - { - id: 'medical_symbol', - name: 'medical_symbol', - names: ['medical_symbol', 'staff_of_aesculapius'], - unicode: '⚕️', - }, - { id: 'mega', name: 'mega', names: ['mega'], unicode: '📣' }, - { id: 'melon', name: 'melon', names: ['melon'], unicode: '🍈' }, - { id: 'melting_face', name: 'melting_face', names: ['melting_face'], unicode: '🫠' }, - { id: 'memo', name: 'memo', names: ['memo', 'pencil'], unicode: '📝' }, - { - id: 'men-with-bunny-ears-partying', - name: 'men-with-bunny-ears-partying', - names: ['men-with-bunny-ears-partying', 'man-with-bunny-ears-partying'], - unicode: '👯‍♂️', - }, - { id: 'mending_heart', name: 'mending_heart', names: ['mending_heart'], unicode: '❤️‍🩹' }, - { - id: 'menorah_with_nine_branches', - name: 'menorah_with_nine_branches', - names: ['menorah_with_nine_branches'], - unicode: '🕎', - }, - { id: 'mens', name: 'mens', names: ['mens'], unicode: '🚹' }, - { - id: 'mermaid', - name: 'mermaid', - names: ['mermaid'], - skins: ['🧜🏻‍♀️', '🧜🏼‍♀️', '🧜🏽‍♀️', '🧜🏾‍♀️', '🧜🏿‍♀️'], - unicode: '🧜‍♀️', - }, - { - id: 'merman', - name: 'merman', - names: ['merman'], - skins: ['🧜🏻‍♂️', '🧜🏼‍♂️', '🧜🏽‍♂️', '🧜🏾‍♂️', '🧜🏿‍♂️'], - unicode: '🧜‍♂️', - }, - { - id: 'merperson', - name: 'merperson', - names: ['merperson'], - skins: ['🧜🏻', '🧜🏼', '🧜🏽', '🧜🏾', '🧜🏿'], - unicode: '🧜', - }, - { id: 'metro', name: 'metro', names: ['metro'], unicode: '🚇' }, - { id: 'microbe', name: 'microbe', names: ['microbe'], unicode: '🦠' }, - { id: 'microphone', name: 'microphone', names: ['microphone'], unicode: '🎤' }, - { id: 'microscope', name: 'microscope', names: ['microscope'], unicode: '🔬' }, - { - id: 'middle_finger', - name: 'middle_finger', - names: ['middle_finger', 'reversed_hand_with_middle_finger_extended'], - skins: ['🖕🏻', '🖕🏼', '🖕🏽', '🖕🏾', '🖕🏿'], - unicode: '🖕', - }, - { id: 'military_helmet', name: 'military_helmet', names: ['military_helmet'], unicode: '🪖' }, - { id: 'milky_way', name: 'milky_way', names: ['milky_way'], unicode: '🌌' }, - { id: 'minibus', name: 'minibus', names: ['minibus'], unicode: '🚐' }, - { id: 'minidisc', name: 'minidisc', names: ['minidisc'], unicode: '💽' }, - { id: 'mirror', name: 'mirror', names: ['mirror'], unicode: '🪞' }, - { id: 'mirror_ball', name: 'mirror_ball', names: ['mirror_ball'], unicode: '🪩' }, - { id: 'mobile_phone_off', name: 'mobile_phone_off', names: ['mobile_phone_off'], unicode: '📴' }, - { id: 'money_mouth_face', name: 'money_mouth_face', names: ['money_mouth_face'], unicode: '🤑' }, - { id: 'money_with_wings', name: 'money_with_wings', names: ['money_with_wings'], unicode: '💸' }, - { id: 'moneybag', name: 'moneybag', names: ['moneybag'], unicode: '💰' }, - { id: 'monkey', name: 'monkey', names: ['monkey'], unicode: '🐒' }, - { id: 'monkey_face', name: 'monkey_face', names: ['monkey_face'], unicode: '🐵' }, - { id: 'monorail', name: 'monorail', names: ['monorail'], unicode: '🚝' }, - { id: 'moon', name: 'moon', names: ['moon', 'waxing_gibbous_moon'], unicode: '🌔' }, - { id: 'moon_cake', name: 'moon_cake', names: ['moon_cake'], unicode: '🥮' }, - { id: 'moose', name: 'moose', names: ['moose'], unicode: '🫎' }, - { id: 'mortar_board', name: 'mortar_board', names: ['mortar_board'], unicode: '🎓' }, - { id: 'mosque', name: 'mosque', names: ['mosque'], unicode: '🕌' }, - { id: 'mosquito', name: 'mosquito', names: ['mosquito'], unicode: '🦟' }, - { - id: 'mostly_sunny', - name: 'mostly_sunny', - names: ['mostly_sunny', 'sun_small_cloud'], - unicode: '🌤️', - }, - { id: 'motor_boat', name: 'motor_boat', names: ['motor_boat'], unicode: '🛥️' }, - { id: 'motor_scooter', name: 'motor_scooter', names: ['motor_scooter'], unicode: '🛵' }, - { - id: 'motorized_wheelchair', - name: 'motorized_wheelchair', - names: ['motorized_wheelchair'], - unicode: '🦼', - }, - { id: 'motorway', name: 'motorway', names: ['motorway'], unicode: '🛣️' }, - { id: 'mount_fuji', name: 'mount_fuji', names: ['mount_fuji'], unicode: '🗻' }, - { id: 'mountain', name: 'mountain', names: ['mountain'], unicode: '⛰️' }, - { - id: 'mountain_bicyclist', - name: 'mountain_bicyclist', - names: ['mountain_bicyclist'], - skins: ['🚵🏻', '🚵🏼', '🚵🏽', '🚵🏾', '🚵🏿'], - unicode: '🚵', - }, - { - id: 'mountain_cableway', - name: 'mountain_cableway', - names: ['mountain_cableway'], - unicode: '🚠', - }, - { id: 'mountain_railway', name: 'mountain_railway', names: ['mountain_railway'], unicode: '🚞' }, - { id: 'mouse', name: 'mouse', names: ['mouse'], unicode: '🐭' }, - { id: 'mouse2', name: 'mouse2', names: ['mouse2'], unicode: '🐁' }, - { id: 'mouse_trap', name: 'mouse_trap', names: ['mouse_trap'], unicode: '🪤' }, - { id: 'movie_camera', name: 'movie_camera', names: ['movie_camera'], unicode: '🎥' }, - { id: 'moyai', name: 'moyai', names: ['moyai'], unicode: '🗿' }, - { - id: 'mrs_claus', - name: 'mrs_claus', - names: ['mrs_claus', 'mother_christmas'], - skins: ['🤶🏻', '🤶🏼', '🤶🏽', '🤶🏾', '🤶🏿'], - unicode: '🤶', - }, - { - id: 'muscle', - name: 'muscle', - names: ['muscle'], - skins: ['💪🏻', '💪🏼', '💪🏽', '💪🏾', '💪🏿'], - unicode: '💪', - }, - { id: 'mushroom', name: 'mushroom', names: ['mushroom'], unicode: '🍄' }, - { id: 'musical_keyboard', name: 'musical_keyboard', names: ['musical_keyboard'], unicode: '🎹' }, - { id: 'musical_note', name: 'musical_note', names: ['musical_note'], unicode: '🎵' }, - { id: 'musical_score', name: 'musical_score', names: ['musical_score'], unicode: '🎼' }, - { id: 'mute', name: 'mute', names: ['mute'], unicode: '🔇' }, - { - id: 'mx_claus', - name: 'mx_claus', - names: ['mx_claus'], - skins: ['🧑🏻‍🎄', '🧑🏼‍🎄', '🧑🏽‍🎄', '🧑🏾‍🎄', '🧑🏿‍🎄'], - unicode: '🧑‍🎄', - }, - { - id: 'nail_care', - name: 'nail_care', - names: ['nail_care'], - skins: ['💅🏻', '💅🏼', '💅🏽', '💅🏾', '💅🏿'], - unicode: '💅', - }, - { id: 'name_badge', name: 'name_badge', names: ['name_badge'], unicode: '📛' }, - { id: 'national_park', name: 'national_park', names: ['national_park'], unicode: '🏞️' }, - { id: 'nauseated_face', name: 'nauseated_face', names: ['nauseated_face'], unicode: '🤢' }, - { id: 'nazar_amulet', name: 'nazar_amulet', names: ['nazar_amulet'], unicode: '🧿' }, - { id: 'necktie', name: 'necktie', names: ['necktie'], unicode: '👔' }, - { - id: 'negative_squared_cross_mark', - name: 'negative_squared_cross_mark', - names: ['negative_squared_cross_mark'], - unicode: '❎', - }, - { id: 'nerd_face', name: 'nerd_face', names: ['nerd_face'], unicode: '🤓' }, - { id: 'nest_with_eggs', name: 'nest_with_eggs', names: ['nest_with_eggs'], unicode: '🪺' }, - { id: 'nesting_dolls', name: 'nesting_dolls', names: ['nesting_dolls'], unicode: '🪆' }, - { id: 'neutral_face', name: 'neutral_face', names: ['neutral_face'], unicode: '😐' }, - { id: 'new', name: 'new', names: ['new'], unicode: '🆕' }, - { id: 'new_moon', name: 'new_moon', names: ['new_moon'], unicode: '🌑' }, - { - id: 'new_moon_with_face', - name: 'new_moon_with_face', - names: ['new_moon_with_face'], - unicode: '🌚', - }, - { id: 'newspaper', name: 'newspaper', names: ['newspaper'], unicode: '📰' }, - { id: 'ng', name: 'ng', names: ['ng'], unicode: '🆖' }, - { id: 'night_with_stars', name: 'night_with_stars', names: ['night_with_stars'], unicode: '🌃' }, - { id: 'nine', name: 'nine', names: ['nine'], unicode: '9️⃣' }, - { - id: 'ninja', - name: 'ninja', - names: ['ninja'], - skins: ['🥷🏻', '🥷🏼', '🥷🏽', '🥷🏾', '🥷🏿'], - unicode: '🥷', - }, - { id: 'no_bell', name: 'no_bell', names: ['no_bell'], unicode: '🔕' }, - { id: 'no_bicycles', name: 'no_bicycles', names: ['no_bicycles'], unicode: '🚳' }, - { id: 'no_entry', name: 'no_entry', names: ['no_entry'], unicode: '⛔' }, - { id: 'no_entry_sign', name: 'no_entry_sign', names: ['no_entry_sign'], unicode: '🚫' }, - { - id: 'no_good', - name: 'no_good', - names: ['no_good'], - skins: ['🙅🏻', '🙅🏼', '🙅🏽', '🙅🏾', '🙅🏿'], - unicode: '🙅', - }, - { id: 'no_mobile_phones', name: 'no_mobile_phones', names: ['no_mobile_phones'], unicode: '📵' }, - { id: 'no_mouth', name: 'no_mouth', names: ['no_mouth'], unicode: '😶' }, - { id: 'no_pedestrians', name: 'no_pedestrians', names: ['no_pedestrians'], unicode: '🚷' }, - { id: 'no_smoking', name: 'no_smoking', names: ['no_smoking'], unicode: '🚭' }, - { - id: 'non-potable_water', - name: 'non-potable_water', - names: ['non-potable_water'], - unicode: '🚱', - }, - { - id: 'nose', - name: 'nose', - names: ['nose'], - skins: ['👃🏻', '👃🏼', '👃🏽', '👃🏾', '👃🏿'], - unicode: '👃', - }, - { id: 'notebook', name: 'notebook', names: ['notebook'], unicode: '📓' }, - { - id: 'notebook_with_decorative_cover', - name: 'notebook_with_decorative_cover', - names: ['notebook_with_decorative_cover'], - unicode: '📔', - }, - { id: 'notes', name: 'notes', names: ['notes'], unicode: '🎶' }, - { id: 'nut_and_bolt', name: 'nut_and_bolt', names: ['nut_and_bolt'], unicode: '🔩' }, - { id: 'o', name: 'o', names: ['o'], unicode: '⭕' }, - { id: 'o2', name: 'o2', names: ['o2'], unicode: '🅾️' }, - { id: 'ocean', name: 'ocean', names: ['ocean'], unicode: '🌊' }, - { id: 'octagonal_sign', name: 'octagonal_sign', names: ['octagonal_sign'], unicode: '🛑' }, - { id: 'octopus', name: 'octopus', names: ['octopus'], unicode: '🐙' }, - { id: 'oden', name: 'oden', names: ['oden'], unicode: '🍢' }, - { id: 'office', name: 'office', names: ['office'], unicode: '🏢' }, - { - id: 'office_worker', - name: 'office_worker', - names: ['office_worker'], - skins: ['🧑🏻‍💼', '🧑🏼‍💼', '🧑🏽‍💼', '🧑🏾‍💼', '🧑🏿‍💼'], - unicode: '🧑‍💼', - }, - { id: 'oil_drum', name: 'oil_drum', names: ['oil_drum'], unicode: '🛢️' }, - { id: 'ok', name: 'ok', names: ['ok'], unicode: '🆗' }, - { - id: 'ok_hand', - name: 'ok_hand', - names: ['ok_hand'], - skins: ['👌🏻', '👌🏼', '👌🏽', '👌🏾', '👌🏿'], - unicode: '👌', - }, - { - id: 'ok_woman', - name: 'ok_woman', - names: ['ok_woman'], - skins: ['🙆🏻', '🙆🏼', '🙆🏽', '🙆🏾', '🙆🏿'], - unicode: '🙆', - }, - { id: 'old_key', name: 'old_key', names: ['old_key'], unicode: '🗝️' }, - { - id: 'older_adult', - name: 'older_adult', - names: ['older_adult'], - skins: ['🧓🏻', '🧓🏼', '🧓🏽', '🧓🏾', '🧓🏿'], - unicode: '🧓', - }, - { - id: 'older_man', - name: 'older_man', - names: ['older_man'], - skins: ['👴🏻', '👴🏼', '👴🏽', '👴🏾', '👴🏿'], - unicode: '👴', - }, - { - id: 'older_woman', - name: 'older_woman', - names: ['older_woman'], - skins: ['👵🏻', '👵🏼', '👵🏽', '👵🏾', '👵🏿'], - unicode: '👵', - }, - { id: 'olive', name: 'olive', names: ['olive'], unicode: '🫒' }, - { id: 'om_symbol', name: 'om_symbol', names: ['om_symbol'], unicode: '🕉️' }, - { id: 'on', name: 'on', names: ['on'], unicode: '🔛' }, - { - id: 'oncoming_automobile', - name: 'oncoming_automobile', - names: ['oncoming_automobile'], - unicode: '🚘', - }, - { id: 'oncoming_bus', name: 'oncoming_bus', names: ['oncoming_bus'], unicode: '🚍' }, - { - id: 'oncoming_police_car', - name: 'oncoming_police_car', - names: ['oncoming_police_car'], - unicode: '🚔', - }, - { id: 'oncoming_taxi', name: 'oncoming_taxi', names: ['oncoming_taxi'], unicode: '🚖' }, - { id: 'one', name: 'one', names: ['one'], unicode: '1️⃣' }, - { - id: 'one-piece_swimsuit', - name: 'one-piece_swimsuit', - names: ['one-piece_swimsuit'], - unicode: '🩱', - }, - { id: 'onion', name: 'onion', names: ['onion'], unicode: '🧅' }, - { id: 'open_file_folder', name: 'open_file_folder', names: ['open_file_folder'], unicode: '📂' }, - { - id: 'open_hands', - name: 'open_hands', - names: ['open_hands'], - skins: ['👐🏻', '👐🏼', '👐🏽', '👐🏾', '👐🏿'], - unicode: '👐', - }, - { id: 'open_mouth', name: 'open_mouth', names: ['open_mouth'], unicode: '😮' }, - { id: 'ophiuchus', name: 'ophiuchus', names: ['ophiuchus'], unicode: '⛎' }, - { id: 'orange_book', name: 'orange_book', names: ['orange_book'], unicode: '📙' }, - { id: 'orange_heart', name: 'orange_heart', names: ['orange_heart'], unicode: '🧡' }, - { id: 'orangutan', name: 'orangutan', names: ['orangutan'], unicode: '🦧' }, - { id: 'orthodox_cross', name: 'orthodox_cross', names: ['orthodox_cross'], unicode: '☦️' }, - { id: 'otter', name: 'otter', names: ['otter'], unicode: '🦦' }, - { id: 'outbox_tray', name: 'outbox_tray', names: ['outbox_tray'], unicode: '📤' }, - { id: 'owl', name: 'owl', names: ['owl'], unicode: '🦉' }, - { id: 'ox', name: 'ox', names: ['ox'], unicode: '🐂' }, - { id: 'oyster', name: 'oyster', names: ['oyster'], unicode: '🦪' }, - { id: 'package', name: 'package', names: ['package'], unicode: '📦' }, - { id: 'page_facing_up', name: 'page_facing_up', names: ['page_facing_up'], unicode: '📄' }, - { id: 'page_with_curl', name: 'page_with_curl', names: ['page_with_curl'], unicode: '📃' }, - { id: 'pager', name: 'pager', names: ['pager'], unicode: '📟' }, - { - id: 'palm_down_hand', - name: 'palm_down_hand', - names: ['palm_down_hand'], - skins: ['🫳🏻', '🫳🏼', '🫳🏽', '🫳🏾', '🫳🏿'], - unicode: '🫳', - }, - { id: 'palm_tree', name: 'palm_tree', names: ['palm_tree'], unicode: '🌴' }, - { - id: 'palm_up_hand', - name: 'palm_up_hand', - names: ['palm_up_hand'], - skins: ['🫴🏻', '🫴🏼', '🫴🏽', '🫴🏾', '🫴🏿'], - unicode: '🫴', - }, - { - id: 'palms_up_together', - name: 'palms_up_together', - names: ['palms_up_together'], - skins: ['🤲🏻', '🤲🏼', '🤲🏽', '🤲🏾', '🤲🏿'], - unicode: '🤲', - }, - { id: 'pancakes', name: 'pancakes', names: ['pancakes'], unicode: '🥞' }, - { id: 'panda_face', name: 'panda_face', names: ['panda_face'], unicode: '🐼' }, - { id: 'paperclip', name: 'paperclip', names: ['paperclip'], unicode: '📎' }, - { id: 'parachute', name: 'parachute', names: ['parachute'], unicode: '🪂' }, - { id: 'parking', name: 'parking', names: ['parking'], unicode: '🅿️' }, - { id: 'parrot', name: 'parrot', names: ['parrot'], unicode: '🦜' }, - { - id: 'part_alternation_mark', - name: 'part_alternation_mark', - names: ['part_alternation_mark'], - unicode: '〽️', - }, - { id: 'partly_sunny', name: 'partly_sunny', names: ['partly_sunny'], unicode: '⛅' }, - { - id: 'partly_sunny_rain', - name: 'partly_sunny_rain', - names: ['partly_sunny_rain', 'sun_behind_rain_cloud'], - unicode: '🌦️', - }, - { id: 'partying_face', name: 'partying_face', names: ['partying_face'], unicode: '🥳' }, - { id: 'passenger_ship', name: 'passenger_ship', names: ['passenger_ship'], unicode: '🛳️' }, - { id: 'passport_control', name: 'passport_control', names: ['passport_control'], unicode: '🛂' }, - { id: 'pea_pod', name: 'pea_pod', names: ['pea_pod'], unicode: '🫛' }, - { id: 'peace_symbol', name: 'peace_symbol', names: ['peace_symbol'], unicode: '☮️' }, - { id: 'peach', name: 'peach', names: ['peach'], unicode: '🍑' }, - { id: 'peacock', name: 'peacock', names: ['peacock'], unicode: '🦚' }, - { id: 'peanuts', name: 'peanuts', names: ['peanuts'], unicode: '🥜' }, - { id: 'pear', name: 'pear', names: ['pear'], unicode: '🍐' }, - { id: 'pencil2', name: 'pencil2', names: ['pencil2'], unicode: '✏️' }, - { id: 'penguin', name: 'penguin', names: ['penguin'], unicode: '🐧' }, - { id: 'pensive', name: 'pensive', names: ['pensive'], unicode: '😔' }, - { - id: 'people_holding_hands', - name: 'people_holding_hands', - names: ['people_holding_hands'], - skins: [ - '🧑🏻‍🤝‍🧑🏻', - '🧑🏻‍🤝‍🧑🏼', - '🧑🏻‍🤝‍🧑🏽', - '🧑🏻‍🤝‍🧑🏾', - '🧑🏻‍🤝‍🧑🏿', - '🧑🏼‍🤝‍🧑🏻', - '🧑🏼‍🤝‍🧑🏼', - '🧑🏼‍🤝‍🧑🏽', - '🧑🏼‍🤝‍🧑🏾', - '🧑🏼‍🤝‍🧑🏿', - '🧑🏽‍🤝‍🧑🏻', - '🧑🏽‍🤝‍🧑🏼', - '🧑🏽‍🤝‍🧑🏽', - '🧑🏽‍🤝‍🧑🏾', - '🧑🏽‍🤝‍🧑🏿', - '🧑🏾‍🤝‍🧑🏻', - '🧑🏾‍🤝‍🧑🏼', - '🧑🏾‍🤝‍🧑🏽', - '🧑🏾‍🤝‍🧑🏾', - '🧑🏾‍🤝‍🧑🏿', - '🧑🏿‍🤝‍🧑🏻', - '🧑🏿‍🤝‍🧑🏼', - '🧑🏿‍🤝‍🧑🏽', - '🧑🏿‍🤝‍🧑🏾', - '🧑🏿‍🤝‍🧑🏿', - ], - unicode: '🧑‍🤝‍🧑', - }, - { id: 'people_hugging', name: 'people_hugging', names: ['people_hugging'], unicode: '🫂' }, - { id: 'performing_arts', name: 'performing_arts', names: ['performing_arts'], unicode: '🎭' }, - { id: 'persevere', name: 'persevere', names: ['persevere'], unicode: '😣' }, - { - id: 'person_climbing', - name: 'person_climbing', - names: ['person_climbing'], - skins: ['🧗🏻', '🧗🏼', '🧗🏽', '🧗🏾', '🧗🏿'], - unicode: '🧗', - }, - { - id: 'person_doing_cartwheel', - name: 'person_doing_cartwheel', - names: ['person_doing_cartwheel'], - skins: ['🤸🏻', '🤸🏼', '🤸🏽', '🤸🏾', '🤸🏿'], - unicode: '🤸', - }, - { - id: 'person_feeding_baby', - name: 'person_feeding_baby', - names: ['person_feeding_baby'], - skins: ['🧑🏻‍🍼', '🧑🏼‍🍼', '🧑🏽‍🍼', '🧑🏾‍🍼', '🧑🏿‍🍼'], - unicode: '🧑‍🍼', - }, - { - id: 'person_frowning', - name: 'person_frowning', - names: ['person_frowning'], - skins: ['🙍🏻', '🙍🏼', '🙍🏽', '🙍🏾', '🙍🏿'], - unicode: '🙍', - }, - { - id: 'person_in_lotus_position', - name: 'person_in_lotus_position', - names: ['person_in_lotus_position'], - skins: ['🧘🏻', '🧘🏼', '🧘🏽', '🧘🏾', '🧘🏿'], - unicode: '🧘', - }, - { - id: 'person_in_manual_wheelchair', - name: 'person_in_manual_wheelchair', - names: ['person_in_manual_wheelchair'], - skins: ['🧑🏻‍🦽', '🧑🏼‍🦽', '🧑🏽‍🦽', '🧑🏾‍🦽', '🧑🏿‍🦽'], - unicode: '🧑‍🦽', - }, - { - id: 'person_in_manual_wheelchair_facing_right', - name: 'person_in_manual_wheelchair_facing_right', - names: ['person_in_manual_wheelchair_facing_right'], - skins: ['🧑🏻‍🦽‍➡️', '🧑🏼‍🦽‍➡️', '🧑🏽‍🦽‍➡️', '🧑🏾‍🦽‍➡️', '🧑🏿‍🦽‍➡️'], - unicode: '🧑‍🦽‍➡️', - }, - { - id: 'person_in_motorized_wheelchair', - name: 'person_in_motorized_wheelchair', - names: ['person_in_motorized_wheelchair'], - skins: ['🧑🏻‍🦼', '🧑🏼‍🦼', '🧑🏽‍🦼', '🧑🏾‍🦼', '🧑🏿‍🦼'], - unicode: '🧑‍🦼', - }, - { - id: 'person_in_motorized_wheelchair_facing_right', - name: 'person_in_motorized_wheelchair_facing_right', - names: ['person_in_motorized_wheelchair_facing_right'], - skins: ['🧑🏻‍🦼‍➡️', '🧑🏼‍🦼‍➡️', '🧑🏽‍🦼‍➡️', '🧑🏾‍🦼‍➡️', '🧑🏿‍🦼‍➡️'], - unicode: '🧑‍🦼‍➡️', - }, - { - id: 'person_in_steamy_room', - name: 'person_in_steamy_room', - names: ['person_in_steamy_room'], - skins: ['🧖🏻', '🧖🏼', '🧖🏽', '🧖🏾', '🧖🏿'], - unicode: '🧖', - }, - { - id: 'person_in_tuxedo', - name: 'person_in_tuxedo', - names: ['person_in_tuxedo'], - skins: ['🤵🏻', '🤵🏼', '🤵🏽', '🤵🏾', '🤵🏿'], - unicode: '🤵', - }, - { - id: 'person_kneeling_facing_right', - name: 'person_kneeling_facing_right', - names: ['person_kneeling_facing_right'], - skins: ['🧎🏻‍➡️', '🧎🏼‍➡️', '🧎🏽‍➡️', '🧎🏾‍➡️', '🧎🏿‍➡️'], - unicode: '🧎‍➡️', - }, - { - id: 'person_running_facing_right', - name: 'person_running_facing_right', - names: ['person_running_facing_right'], - skins: ['🏃🏻‍➡️', '🏃🏼‍➡️', '🏃🏽‍➡️', '🏃🏾‍➡️', '🏃🏿‍➡️'], - unicode: '🏃‍➡️', - }, - { - id: 'person_walking_facing_right', - name: 'person_walking_facing_right', - names: ['person_walking_facing_right'], - skins: ['🚶🏻‍➡️', '🚶🏼‍➡️', '🚶🏽‍➡️', '🚶🏾‍➡️', '🚶🏿‍➡️'], - unicode: '🚶‍➡️', - }, - { - id: 'person_with_ball', - name: 'person_with_ball', - names: ['person_with_ball'], - skins: ['⛹🏻', '⛹🏼', '⛹🏽', '⛹🏾', '⛹🏿'], - unicode: '⛹️', - }, - { - id: 'person_with_blond_hair', - name: 'person_with_blond_hair', - names: ['person_with_blond_hair'], - skins: ['👱🏻', '👱🏼', '👱🏽', '👱🏾', '👱🏿'], - unicode: '👱', - }, - { - id: 'person_with_crown', - name: 'person_with_crown', - names: ['person_with_crown'], - skins: ['🫅🏻', '🫅🏼', '🫅🏽', '🫅🏾', '🫅🏿'], - unicode: '🫅', - }, - { - id: 'person_with_headscarf', - name: 'person_with_headscarf', - names: ['person_with_headscarf'], - skins: ['🧕🏻', '🧕🏼', '🧕🏽', '🧕🏾', '🧕🏿'], - unicode: '🧕', - }, - { - id: 'person_with_pouting_face', - name: 'person_with_pouting_face', - names: ['person_with_pouting_face'], - skins: ['🙎🏻', '🙎🏼', '🙎🏽', '🙎🏾', '🙎🏿'], - unicode: '🙎', - }, - { - id: 'person_with_probing_cane', - name: 'person_with_probing_cane', - names: ['person_with_probing_cane'], - skins: ['🧑🏻‍🦯', '🧑🏼‍🦯', '🧑🏽‍🦯', '🧑🏾‍🦯', '🧑🏿‍🦯'], - unicode: '🧑‍🦯', - }, - { - id: 'person_with_white_cane_facing_right', - name: 'person_with_white_cane_facing_right', - names: ['person_with_white_cane_facing_right'], - skins: ['🧑🏻‍🦯‍➡️', '🧑🏼‍🦯‍➡️', '🧑🏽‍🦯‍➡️', '🧑🏾‍🦯‍➡️', '🧑🏿‍🦯‍➡️'], - unicode: '🧑‍🦯‍➡️', - }, - { id: 'petri_dish', name: 'petri_dish', names: ['petri_dish'], unicode: '🧫' }, - { id: 'phoenix', name: 'phoenix', names: ['phoenix'], unicode: '🐦‍🔥' }, - { id: 'phone', name: 'phone', names: ['phone', 'telephone'], unicode: '☎️' }, - { id: 'pick', name: 'pick', names: ['pick'], unicode: '⛏️' }, - { id: 'pickup_truck', name: 'pickup_truck', names: ['pickup_truck'], unicode: '🛻' }, - { id: 'pie', name: 'pie', names: ['pie'], unicode: '🥧' }, - { id: 'pig', name: 'pig', names: ['pig'], unicode: '🐷' }, - { id: 'pig2', name: 'pig2', names: ['pig2'], unicode: '🐖' }, - { id: 'pig_nose', name: 'pig_nose', names: ['pig_nose'], unicode: '🐽' }, - { id: 'pill', name: 'pill', names: ['pill'], unicode: '💊' }, - { - id: 'pilot', - name: 'pilot', - names: ['pilot'], - skins: ['🧑🏻‍✈️', '🧑🏼‍✈️', '🧑🏽‍✈️', '🧑🏾‍✈️', '🧑🏿‍✈️'], - unicode: '🧑‍✈️', - }, - { id: 'pinata', name: 'pinata', names: ['pinata'], unicode: '🪅' }, - { - id: 'pinched_fingers', - name: 'pinched_fingers', - names: ['pinched_fingers'], - skins: ['🤌🏻', '🤌🏼', '🤌🏽', '🤌🏾', '🤌🏿'], - unicode: '🤌', - }, - { - id: 'pinching_hand', - name: 'pinching_hand', - names: ['pinching_hand'], - skins: ['🤏🏻', '🤏🏼', '🤏🏽', '🤏🏾', '🤏🏿'], - unicode: '🤏', - }, - { id: 'pineapple', name: 'pineapple', names: ['pineapple'], unicode: '🍍' }, - { id: 'pink_heart', name: 'pink_heart', names: ['pink_heart'], unicode: '🩷' }, - { id: 'pirate_flag', name: 'pirate_flag', names: ['pirate_flag'], unicode: '🏴‍☠️' }, - { id: 'pisces', name: 'pisces', names: ['pisces'], unicode: '♓' }, - { id: 'pizza', name: 'pizza', names: ['pizza'], unicode: '🍕' }, - { id: 'placard', name: 'placard', names: ['placard'], unicode: '🪧' }, - { id: 'place_of_worship', name: 'place_of_worship', names: ['place_of_worship'], unicode: '🛐' }, - { id: 'playground_slide', name: 'playground_slide', names: ['playground_slide'], unicode: '🛝' }, - { id: 'pleading_face', name: 'pleading_face', names: ['pleading_face'], unicode: '🥺' }, - { id: 'plunger', name: 'plunger', names: ['plunger'], unicode: '🪠' }, - { - id: 'point_down', - name: 'point_down', - names: ['point_down'], - skins: ['👇🏻', '👇🏼', '👇🏽', '👇🏾', '👇🏿'], - unicode: '👇', - }, - { - id: 'point_left', - name: 'point_left', - names: ['point_left'], - skins: ['👈🏻', '👈🏼', '👈🏽', '👈🏾', '👈🏿'], - unicode: '👈', - }, - { - id: 'point_right', - name: 'point_right', - names: ['point_right'], - skins: ['👉🏻', '👉🏼', '👉🏽', '👉🏾', '👉🏿'], - unicode: '👉', - }, - { - id: 'point_up', - name: 'point_up', - names: ['point_up'], - skins: ['☝🏻', '☝🏼', '☝🏽', '☝🏾', '☝🏿'], - unicode: '☝️', - }, - { - id: 'point_up_2', - name: 'point_up_2', - names: ['point_up_2'], - skins: ['👆🏻', '👆🏼', '👆🏽', '👆🏾', '👆🏿'], - unicode: '👆', - }, - { id: 'polar_bear', name: 'polar_bear', names: ['polar_bear'], unicode: '🐻‍❄️' }, - { id: 'police_car', name: 'police_car', names: ['police_car'], unicode: '🚓' }, - { id: 'poodle', name: 'poodle', names: ['poodle'], unicode: '🐩' }, - { id: 'popcorn', name: 'popcorn', names: ['popcorn'], unicode: '🍿' }, - { id: 'post_office', name: 'post_office', names: ['post_office'], unicode: '🏣' }, - { id: 'postal_horn', name: 'postal_horn', names: ['postal_horn'], unicode: '📯' }, - { id: 'postbox', name: 'postbox', names: ['postbox'], unicode: '📮' }, - { id: 'potable_water', name: 'potable_water', names: ['potable_water'], unicode: '🚰' }, - { id: 'potato', name: 'potato', names: ['potato'], unicode: '🥔' }, - { id: 'potted_plant', name: 'potted_plant', names: ['potted_plant'], unicode: '🪴' }, - { id: 'pouch', name: 'pouch', names: ['pouch'], unicode: '👝' }, - { id: 'poultry_leg', name: 'poultry_leg', names: ['poultry_leg'], unicode: '🍗' }, - { id: 'pound', name: 'pound', names: ['pound'], unicode: '💷' }, - { id: 'pouring_liquid', name: 'pouring_liquid', names: ['pouring_liquid'], unicode: '🫗' }, - { id: 'pouting_cat', name: 'pouting_cat', names: ['pouting_cat'], unicode: '😾' }, - { - id: 'pray', - name: 'pray', - names: ['pray'], - skins: ['🙏🏻', '🙏🏼', '🙏🏽', '🙏🏾', '🙏🏿'], - unicode: '🙏', - }, - { id: 'prayer_beads', name: 'prayer_beads', names: ['prayer_beads'], unicode: '📿' }, - { - id: 'pregnant_man', - name: 'pregnant_man', - names: ['pregnant_man'], - skins: ['🫃🏻', '🫃🏼', '🫃🏽', '🫃🏾', '🫃🏿'], - unicode: '🫃', - }, - { - id: 'pregnant_person', - name: 'pregnant_person', - names: ['pregnant_person'], - skins: ['🫄🏻', '🫄🏼', '🫄🏽', '🫄🏾', '🫄🏿'], - unicode: '🫄', - }, - { - id: 'pregnant_woman', - name: 'pregnant_woman', - names: ['pregnant_woman'], - skins: ['🤰🏻', '🤰🏼', '🤰🏽', '🤰🏾', '🤰🏿'], - unicode: '🤰', - }, - { id: 'pretzel', name: 'pretzel', names: ['pretzel'], unicode: '🥨' }, - { - id: 'prince', - name: 'prince', - names: ['prince'], - skins: ['🤴🏻', '🤴🏼', '🤴🏽', '🤴🏾', '🤴🏿'], - unicode: '🤴', - }, - { - id: 'princess', - name: 'princess', - names: ['princess'], - skins: ['👸🏻', '👸🏼', '👸🏽', '👸🏾', '👸🏿'], - unicode: '👸', - }, - { id: 'printer', name: 'printer', names: ['printer'], unicode: '🖨️' }, - { id: 'probing_cane', name: 'probing_cane', names: ['probing_cane'], unicode: '🦯' }, - { id: 'purple_heart', name: 'purple_heart', names: ['purple_heart'], unicode: '💜' }, - { id: 'purse', name: 'purse', names: ['purse'], unicode: '👛' }, - { id: 'pushpin', name: 'pushpin', names: ['pushpin'], unicode: '📌' }, - { - id: 'put_litter_in_its_place', - name: 'put_litter_in_its_place', - names: ['put_litter_in_its_place'], - unicode: '🚮', - }, - { id: 'question', name: 'question', names: ['question'], unicode: '❓' }, - { id: 'rabbit', name: 'rabbit', names: ['rabbit'], unicode: '🐰' }, - { id: 'rabbit2', name: 'rabbit2', names: ['rabbit2'], unicode: '🐇' }, - { id: 'raccoon', name: 'raccoon', names: ['raccoon'], unicode: '🦝' }, - { id: 'racehorse', name: 'racehorse', names: ['racehorse'], unicode: '🐎' }, - { id: 'racing_car', name: 'racing_car', names: ['racing_car'], unicode: '🏎️' }, - { - id: 'racing_motorcycle', - name: 'racing_motorcycle', - names: ['racing_motorcycle'], - unicode: '🏍️', - }, - { id: 'radio', name: 'radio', names: ['radio'], unicode: '📻' }, - { id: 'radio_button', name: 'radio_button', names: ['radio_button'], unicode: '🔘' }, - { id: 'radioactive_sign', name: 'radioactive_sign', names: ['radioactive_sign'], unicode: '☢️' }, - { id: 'rage', name: 'rage', names: ['rage'], unicode: '😡' }, - { id: 'railway_car', name: 'railway_car', names: ['railway_car'], unicode: '🚃' }, - { id: 'railway_track', name: 'railway_track', names: ['railway_track'], unicode: '🛤️' }, - { id: 'rain_cloud', name: 'rain_cloud', names: ['rain_cloud'], unicode: '🌧️' }, - { id: 'rainbow', name: 'rainbow', names: ['rainbow'], unicode: '🌈' }, - { id: 'rainbow-flag', name: 'rainbow-flag', names: ['rainbow-flag'], unicode: '🏳️‍🌈' }, - { - id: 'raised_back_of_hand', - name: 'raised_back_of_hand', - names: ['raised_back_of_hand'], - skins: ['🤚🏻', '🤚🏼', '🤚🏽', '🤚🏾', '🤚🏿'], - unicode: '🤚', - }, - { - id: 'raised_hand_with_fingers_splayed', - name: 'raised_hand_with_fingers_splayed', - names: ['raised_hand_with_fingers_splayed'], - skins: ['🖐🏻', '🖐🏼', '🖐🏽', '🖐🏾', '🖐🏿'], - unicode: '🖐️', - }, - { - id: 'raised_hands', - name: 'raised_hands', - names: ['raised_hands'], - skins: ['🙌🏻', '🙌🏼', '🙌🏽', '🙌🏾', '🙌🏿'], - unicode: '🙌', - }, - { - id: 'raising_hand', - name: 'raising_hand', - names: ['raising_hand'], - skins: ['🙋🏻', '🙋🏼', '🙋🏽', '🙋🏾', '🙋🏿'], - unicode: '🙋', - }, - { id: 'ram', name: 'ram', names: ['ram'], unicode: '🐏' }, - { id: 'ramen', name: 'ramen', names: ['ramen'], unicode: '🍜' }, - { id: 'rat', name: 'rat', names: ['rat'], unicode: '🐀' }, - { id: 'razor', name: 'razor', names: ['razor'], unicode: '🪒' }, - { id: 'receipt', name: 'receipt', names: ['receipt'], unicode: '🧾' }, - { id: 'recycle', name: 'recycle', names: ['recycle'], unicode: '♻️' }, - { id: 'red_circle', name: 'red_circle', names: ['red_circle'], unicode: '🔴' }, - { id: 'red_envelope', name: 'red_envelope', names: ['red_envelope'], unicode: '🧧' }, - { - id: 'red_haired_man', - name: 'red_haired_man', - names: ['red_haired_man'], - skins: ['👨🏻‍🦰', '👨🏼‍🦰', '👨🏽‍🦰', '👨🏾‍🦰', '👨🏿‍🦰'], - unicode: '👨‍🦰', - }, - { - id: 'red_haired_person', - name: 'red_haired_person', - names: ['red_haired_person'], - skins: ['🧑🏻‍🦰', '🧑🏼‍🦰', '🧑🏽‍🦰', '🧑🏾‍🦰', '🧑🏿‍🦰'], - unicode: '🧑‍🦰', - }, - { - id: 'red_haired_woman', - name: 'red_haired_woman', - names: ['red_haired_woman'], - skins: ['👩🏻‍🦰', '👩🏼‍🦰', '👩🏽‍🦰', '👩🏾‍🦰', '👩🏿‍🦰'], - unicode: '👩‍🦰', - }, - { id: 'registered', name: 'registered', names: ['registered'], unicode: '®️' }, - { id: 'relaxed', name: 'relaxed', names: ['relaxed'], unicode: '☺️' }, - { id: 'relieved', name: 'relieved', names: ['relieved'], unicode: '😌' }, - { id: 'reminder_ribbon', name: 'reminder_ribbon', names: ['reminder_ribbon'], unicode: '🎗️' }, - { id: 'repeat', name: 'repeat', names: ['repeat'], unicode: '🔁' }, - { id: 'repeat_one', name: 'repeat_one', names: ['repeat_one'], unicode: '🔂' }, - { id: 'restroom', name: 'restroom', names: ['restroom'], unicode: '🚻' }, - { id: 'revolving_hearts', name: 'revolving_hearts', names: ['revolving_hearts'], unicode: '💞' }, - { id: 'rewind', name: 'rewind', names: ['rewind'], unicode: '⏪' }, - { id: 'rhinoceros', name: 'rhinoceros', names: ['rhinoceros'], unicode: '🦏' }, - { id: 'ribbon', name: 'ribbon', names: ['ribbon'], unicode: '🎀' }, - { id: 'rice', name: 'rice', names: ['rice'], unicode: '🍚' }, - { id: 'rice_ball', name: 'rice_ball', names: ['rice_ball'], unicode: '🍙' }, - { id: 'rice_cracker', name: 'rice_cracker', names: ['rice_cracker'], unicode: '🍘' }, - { id: 'rice_scene', name: 'rice_scene', names: ['rice_scene'], unicode: '🎑' }, - { - id: 'right-facing_fist', - name: 'right-facing_fist', - names: ['right-facing_fist'], - skins: ['🤜🏻', '🤜🏼', '🤜🏽', '🤜🏾', '🤜🏿'], - unicode: '🤜', - }, - { - id: 'right_anger_bubble', - name: 'right_anger_bubble', - names: ['right_anger_bubble'], - unicode: '🗯️', - }, - { - id: 'rightwards_hand', - name: 'rightwards_hand', - names: ['rightwards_hand'], - skins: ['🫱🏻', '🫱🏼', '🫱🏽', '🫱🏾', '🫱🏿'], - unicode: '🫱', - }, - { - id: 'rightwards_pushing_hand', - name: 'rightwards_pushing_hand', - names: ['rightwards_pushing_hand'], - skins: ['🫸🏻', '🫸🏼', '🫸🏽', '🫸🏾', '🫸🏿'], - unicode: '🫸', - }, - { id: 'ring', name: 'ring', names: ['ring'], unicode: '💍' }, - { id: 'ring_buoy', name: 'ring_buoy', names: ['ring_buoy'], unicode: '🛟' }, - { id: 'ringed_planet', name: 'ringed_planet', names: ['ringed_planet'], unicode: '🪐' }, - { id: 'robot_face', name: 'robot_face', names: ['robot_face'], unicode: '🤖' }, - { id: 'rock', name: 'rock', names: ['rock'], unicode: '🪨' }, - { id: 'rocket', name: 'rocket', names: ['rocket'], unicode: '🚀' }, - { id: 'roll_of_paper', name: 'roll_of_paper', names: ['roll_of_paper'], unicode: '🧻' }, - { - id: 'rolled_up_newspaper', - name: 'rolled_up_newspaper', - names: ['rolled_up_newspaper'], - unicode: '🗞️', - }, - { id: 'roller_coaster', name: 'roller_coaster', names: ['roller_coaster'], unicode: '🎢' }, - { id: 'roller_skate', name: 'roller_skate', names: ['roller_skate'], unicode: '🛼' }, - { - id: 'rolling_on_the_floor_laughing', - name: 'rolling_on_the_floor_laughing', - names: ['rolling_on_the_floor_laughing'], - unicode: '🤣', - }, - { id: 'rooster', name: 'rooster', names: ['rooster'], unicode: '🐓' }, - { id: 'rose', name: 'rose', names: ['rose'], unicode: '🌹' }, - { id: 'rosette', name: 'rosette', names: ['rosette'], unicode: '🏵️' }, - { id: 'rotating_light', name: 'rotating_light', names: ['rotating_light'], unicode: '🚨' }, - { id: 'round_pushpin', name: 'round_pushpin', names: ['round_pushpin'], unicode: '📍' }, - { - id: 'rowboat', - name: 'rowboat', - names: ['rowboat'], - skins: ['🚣🏻', '🚣🏼', '🚣🏽', '🚣🏾', '🚣🏿'], - unicode: '🚣', - }, - { id: 'ru', name: 'ru', names: ['ru', 'flag-ru'], unicode: '🇷🇺' }, - { id: 'rugby_football', name: 'rugby_football', names: ['rugby_football'], unicode: '🏉' }, - { - id: 'runner', - name: 'runner', - names: ['runner', 'running'], - skins: ['🏃🏻', '🏃🏼', '🏃🏽', '🏃🏾', '🏃🏿'], - unicode: '🏃', - }, - { - id: 'running_shirt_with_sash', - name: 'running_shirt_with_sash', - names: ['running_shirt_with_sash'], - unicode: '🎽', - }, - { id: 'sa', name: 'sa', names: ['sa'], unicode: '🈂️' }, - { id: 'safety_pin', name: 'safety_pin', names: ['safety_pin'], unicode: '🧷' }, - { id: 'safety_vest', name: 'safety_vest', names: ['safety_vest'], unicode: '🦺' }, - { id: 'sagittarius', name: 'sagittarius', names: ['sagittarius'], unicode: '♐' }, - { id: 'sake', name: 'sake', names: ['sake'], unicode: '🍶' }, - { id: 'salt', name: 'salt', names: ['salt'], unicode: '🧂' }, - { id: 'saluting_face', name: 'saluting_face', names: ['saluting_face'], unicode: '🫡' }, - { id: 'sandal', name: 'sandal', names: ['sandal'], unicode: '👡' }, - { id: 'sandwich', name: 'sandwich', names: ['sandwich'], unicode: '🥪' }, - { - id: 'santa', - name: 'santa', - names: ['santa'], - skins: ['🎅🏻', '🎅🏼', '🎅🏽', '🎅🏾', '🎅🏿'], - unicode: '🎅', - }, - { id: 'sari', name: 'sari', names: ['sari'], unicode: '🥻' }, - { id: 'satellite', name: 'satellite', names: ['satellite'], unicode: '🛰️' }, - { - id: 'satellite_antenna', - name: 'satellite_antenna', - names: ['satellite_antenna'], - unicode: '📡', - }, - { id: 'sauropod', name: 'sauropod', names: ['sauropod'], unicode: '🦕' }, - { id: 'saxophone', name: 'saxophone', names: ['saxophone'], unicode: '🎷' }, - { id: 'scales', name: 'scales', names: ['scales'], unicode: '⚖️' }, - { id: 'scarf', name: 'scarf', names: ['scarf'], unicode: '🧣' }, - { id: 'school', name: 'school', names: ['school'], unicode: '🏫' }, - { id: 'school_satchel', name: 'school_satchel', names: ['school_satchel'], unicode: '🎒' }, - { - id: 'scientist', - name: 'scientist', - names: ['scientist'], - skins: ['🧑🏻‍🔬', '🧑🏼‍🔬', '🧑🏽‍🔬', '🧑🏾‍🔬', '🧑🏿‍🔬'], - unicode: '🧑‍🔬', - }, - { id: 'scissors', name: 'scissors', names: ['scissors'], unicode: '✂️' }, - { id: 'scooter', name: 'scooter', names: ['scooter'], unicode: '🛴' }, - { id: 'scorpion', name: 'scorpion', names: ['scorpion'], unicode: '🦂' }, - { id: 'scorpius', name: 'scorpius', names: ['scorpius'], unicode: '♏' }, - { id: 'scream', name: 'scream', names: ['scream'], unicode: '😱' }, - { id: 'scream_cat', name: 'scream_cat', names: ['scream_cat'], unicode: '🙀' }, - { id: 'screwdriver', name: 'screwdriver', names: ['screwdriver'], unicode: '🪛' }, - { id: 'scroll', name: 'scroll', names: ['scroll'], unicode: '📜' }, - { id: 'seal', name: 'seal', names: ['seal'], unicode: '🦭' }, - { id: 'seat', name: 'seat', names: ['seat'], unicode: '💺' }, - { - id: 'second_place_medal', - name: 'second_place_medal', - names: ['second_place_medal'], - unicode: '🥈', - }, - { id: 'secret', name: 'secret', names: ['secret'], unicode: '㊙️' }, - { id: 'see_no_evil', name: 'see_no_evil', names: ['see_no_evil'], unicode: '🙈' }, - { id: 'seedling', name: 'seedling', names: ['seedling'], unicode: '🌱' }, - { - id: 'selfie', - name: 'selfie', - names: ['selfie'], - skins: ['🤳🏻', '🤳🏼', '🤳🏽', '🤳🏾', '🤳🏿'], - unicode: '🤳', - }, - { id: 'service_dog', name: 'service_dog', names: ['service_dog'], unicode: '🐕‍🦺' }, - { id: 'seven', name: 'seven', names: ['seven'], unicode: '7️⃣' }, - { id: 'sewing_needle', name: 'sewing_needle', names: ['sewing_needle'], unicode: '🪡' }, - { id: 'shaking_face', name: 'shaking_face', names: ['shaking_face'], unicode: '🫨' }, - { - id: 'shallow_pan_of_food', - name: 'shallow_pan_of_food', - names: ['shallow_pan_of_food'], - unicode: '🥘', - }, - { id: 'shamrock', name: 'shamrock', names: ['shamrock'], unicode: '☘️' }, - { id: 'shark', name: 'shark', names: ['shark'], unicode: '🦈' }, - { id: 'shaved_ice', name: 'shaved_ice', names: ['shaved_ice'], unicode: '🍧' }, - { id: 'sheep', name: 'sheep', names: ['sheep'], unicode: '🐑' }, - { id: 'shell', name: 'shell', names: ['shell'], unicode: '🐚' }, - { id: 'shield', name: 'shield', names: ['shield'], unicode: '🛡️' }, - { id: 'shinto_shrine', name: 'shinto_shrine', names: ['shinto_shrine'], unicode: '⛩️' }, - { id: 'ship', name: 'ship', names: ['ship'], unicode: '🚢' }, - { id: 'shirt', name: 'shirt', names: ['shirt', 'tshirt'], unicode: '👕' }, - { id: 'shopping_bags', name: 'shopping_bags', names: ['shopping_bags'], unicode: '🛍️' }, - { id: 'shopping_trolley', name: 'shopping_trolley', names: ['shopping_trolley'], unicode: '🛒' }, - { id: 'shorts', name: 'shorts', names: ['shorts'], unicode: '🩳' }, - { id: 'shower', name: 'shower', names: ['shower'], unicode: '🚿' }, - { id: 'shrimp', name: 'shrimp', names: ['shrimp'], unicode: '🦐' }, - { - id: 'shrug', - name: 'shrug', - names: ['shrug'], - skins: ['🤷🏻', '🤷🏼', '🤷🏽', '🤷🏾', '🤷🏿'], - unicode: '🤷', - }, - { - id: 'shushing_face', - name: 'shushing_face', - names: ['shushing_face', 'face_with_finger_covering_closed_lips'], - unicode: '🤫', - }, - { id: 'signal_strength', name: 'signal_strength', names: ['signal_strength'], unicode: '📶' }, - { - id: 'singer', - name: 'singer', - names: ['singer'], - skins: ['🧑🏻‍🎤', '🧑🏼‍🎤', '🧑🏽‍🎤', '🧑🏾‍🎤', '🧑🏿‍🎤'], - unicode: '🧑‍🎤', - }, - { id: 'six', name: 'six', names: ['six'], unicode: '6️⃣' }, - { id: 'six_pointed_star', name: 'six_pointed_star', names: ['six_pointed_star'], unicode: '🔯' }, - { id: 'skateboard', name: 'skateboard', names: ['skateboard'], unicode: '🛹' }, - { id: 'ski', name: 'ski', names: ['ski'], unicode: '🎿' }, - { id: 'skier', name: 'skier', names: ['skier'], unicode: '⛷️' }, - { id: 'skin-tone-2', name: 'skin-tone-2', names: ['skin-tone-2'], unicode: '🏻' }, - { id: 'skin-tone-3', name: 'skin-tone-3', names: ['skin-tone-3'], unicode: '🏼' }, - { id: 'skin-tone-4', name: 'skin-tone-4', names: ['skin-tone-4'], unicode: '🏽' }, - { id: 'skin-tone-5', name: 'skin-tone-5', names: ['skin-tone-5'], unicode: '🏾' }, - { id: 'skin-tone-6', name: 'skin-tone-6', names: ['skin-tone-6'], unicode: '🏿' }, - { id: 'skull', name: 'skull', names: ['skull'], unicode: '💀' }, - { - id: 'skull_and_crossbones', - name: 'skull_and_crossbones', - names: ['skull_and_crossbones'], - unicode: '☠️', - }, - { id: 'skunk', name: 'skunk', names: ['skunk'], unicode: '🦨' }, - { id: 'sled', name: 'sled', names: ['sled'], unicode: '🛷' }, - { id: 'sleeping', name: 'sleeping', names: ['sleeping'], unicode: '😴' }, - { - id: 'sleeping_accommodation', - name: 'sleeping_accommodation', - names: ['sleeping_accommodation'], - skins: ['🛌🏻', '🛌🏼', '🛌🏽', '🛌🏾', '🛌🏿'], - unicode: '🛌', - }, - { id: 'sleepy', name: 'sleepy', names: ['sleepy'], unicode: '😪' }, - { - id: 'sleuth_or_spy', - name: 'sleuth_or_spy', - names: ['sleuth_or_spy'], - skins: ['🕵🏻', '🕵🏼', '🕵🏽', '🕵🏾', '🕵🏿'], - unicode: '🕵️', - }, - { - id: 'slightly_frowning_face', - name: 'slightly_frowning_face', - names: ['slightly_frowning_face'], - unicode: '🙁', - }, - { - id: 'slightly_smiling_face', - name: 'slightly_smiling_face', - names: ['slightly_smiling_face'], - unicode: '🙂', - }, - { id: 'slot_machine', name: 'slot_machine', names: ['slot_machine'], unicode: '🎰' }, - { id: 'sloth', name: 'sloth', names: ['sloth'], unicode: '🦥' }, - { id: 'small_airplane', name: 'small_airplane', names: ['small_airplane'], unicode: '🛩️' }, - { - id: 'small_blue_diamond', - name: 'small_blue_diamond', - names: ['small_blue_diamond'], - unicode: '🔹', - }, - { - id: 'small_orange_diamond', - name: 'small_orange_diamond', - names: ['small_orange_diamond'], - unicode: '🔸', - }, - { - id: 'small_red_triangle', - name: 'small_red_triangle', - names: ['small_red_triangle'], - unicode: '🔺', - }, - { - id: 'small_red_triangle_down', - name: 'small_red_triangle_down', - names: ['small_red_triangle_down'], - unicode: '🔻', - }, - { id: 'smile', name: 'smile', names: ['smile'], unicode: '😄' }, - { id: 'smile_cat', name: 'smile_cat', names: ['smile_cat'], unicode: '😸' }, - { id: 'smiley', name: 'smiley', names: ['smiley'], unicode: '😃' }, - { id: 'smiley_cat', name: 'smiley_cat', names: ['smiley_cat'], unicode: '😺' }, - { - id: 'smiling_face_with_3_hearts', - name: 'smiling_face_with_3_hearts', - names: ['smiling_face_with_3_hearts'], - unicode: '🥰', - }, - { - id: 'smiling_face_with_tear', - name: 'smiling_face_with_tear', - names: ['smiling_face_with_tear'], - unicode: '🥲', - }, - { id: 'smiling_imp', name: 'smiling_imp', names: ['smiling_imp'], unicode: '😈' }, - { id: 'smirk', name: 'smirk', names: ['smirk'], unicode: '😏' }, - { id: 'smirk_cat', name: 'smirk_cat', names: ['smirk_cat'], unicode: '😼' }, - { id: 'smoking', name: 'smoking', names: ['smoking'], unicode: '🚬' }, - { id: 'snail', name: 'snail', names: ['snail'], unicode: '🐌' }, - { id: 'snake', name: 'snake', names: ['snake'], unicode: '🐍' }, - { id: 'sneezing_face', name: 'sneezing_face', names: ['sneezing_face'], unicode: '🤧' }, - { - id: 'snow_capped_mountain', - name: 'snow_capped_mountain', - names: ['snow_capped_mountain'], - unicode: '🏔️', - }, - { id: 'snow_cloud', name: 'snow_cloud', names: ['snow_cloud'], unicode: '🌨️' }, - { - id: 'snowboarder', - name: 'snowboarder', - names: ['snowboarder'], - skins: ['🏂🏻', '🏂🏼', '🏂🏽', '🏂🏾', '🏂🏿'], - unicode: '🏂', - }, - { id: 'snowflake', name: 'snowflake', names: ['snowflake'], unicode: '❄️' }, - { id: 'snowman', name: 'snowman', names: ['snowman'], unicode: '☃️' }, - { - id: 'snowman_without_snow', - name: 'snowman_without_snow', - names: ['snowman_without_snow'], - unicode: '⛄', - }, - { id: 'soap', name: 'soap', names: ['soap'], unicode: '🧼' }, - { id: 'sob', name: 'sob', names: ['sob'], unicode: '😭' }, - { id: 'soccer', name: 'soccer', names: ['soccer'], unicode: '⚽' }, - { id: 'socks', name: 'socks', names: ['socks'], unicode: '🧦' }, - { id: 'softball', name: 'softball', names: ['softball'], unicode: '🥎' }, - { id: 'soon', name: 'soon', names: ['soon'], unicode: '🔜' }, - { id: 'sos', name: 'sos', names: ['sos'], unicode: '🆘' }, - { id: 'sound', name: 'sound', names: ['sound'], unicode: '🔉' }, - { id: 'space_invader', name: 'space_invader', names: ['space_invader'], unicode: '👾' }, - { id: 'spades', name: 'spades', names: ['spades'], unicode: '♠️' }, - { id: 'spaghetti', name: 'spaghetti', names: ['spaghetti'], unicode: '🍝' }, - { id: 'sparkle', name: 'sparkle', names: ['sparkle'], unicode: '❇️' }, - { id: 'sparkler', name: 'sparkler', names: ['sparkler'], unicode: '🎇' }, - { id: 'sparkles', name: 'sparkles', names: ['sparkles'], unicode: '✨' }, - { id: 'sparkling_heart', name: 'sparkling_heart', names: ['sparkling_heart'], unicode: '💖' }, - { id: 'speak_no_evil', name: 'speak_no_evil', names: ['speak_no_evil'], unicode: '🙊' }, - { id: 'speaker', name: 'speaker', names: ['speaker'], unicode: '🔈' }, - { - id: 'speaking_head_in_silhouette', - name: 'speaking_head_in_silhouette', - names: ['speaking_head_in_silhouette'], - unicode: '🗣️', - }, - { id: 'speech_balloon', name: 'speech_balloon', names: ['speech_balloon'], unicode: '💬' }, - { id: 'speedboat', name: 'speedboat', names: ['speedboat'], unicode: '🚤' }, - { id: 'spider', name: 'spider', names: ['spider'], unicode: '🕷️' }, - { id: 'spider_web', name: 'spider_web', names: ['spider_web'], unicode: '🕸️' }, - { - id: 'spiral_calendar_pad', - name: 'spiral_calendar_pad', - names: ['spiral_calendar_pad'], - unicode: '🗓️', - }, - { id: 'spiral_note_pad', name: 'spiral_note_pad', names: ['spiral_note_pad'], unicode: '🗒️' }, - { - id: 'spock-hand', - name: 'spock-hand', - names: ['spock-hand'], - skins: ['🖖🏻', '🖖🏼', '🖖🏽', '🖖🏾', '🖖🏿'], - unicode: '🖖', - }, - { id: 'sponge', name: 'sponge', names: ['sponge'], unicode: '🧽' }, - { id: 'spoon', name: 'spoon', names: ['spoon'], unicode: '🥄' }, - { id: 'sports_medal', name: 'sports_medal', names: ['sports_medal'], unicode: '🏅' }, - { id: 'squid', name: 'squid', names: ['squid'], unicode: '🦑' }, - { id: 'stadium', name: 'stadium', names: ['stadium'], unicode: '🏟️' }, - { - id: 'standing_person', - name: 'standing_person', - names: ['standing_person'], - skins: ['🧍🏻', '🧍🏼', '🧍🏽', '🧍🏾', '🧍🏿'], - unicode: '🧍', - }, - { id: 'star', name: 'star', names: ['star'], unicode: '⭐' }, - { - id: 'star-struck', - name: 'star-struck', - names: ['star-struck', 'grinning_face_with_star_eyes'], - unicode: '🤩', - }, - { id: 'star2', name: 'star2', names: ['star2'], unicode: '🌟' }, - { - id: 'star_and_crescent', - name: 'star_and_crescent', - names: ['star_and_crescent'], - unicode: '☪️', - }, - { id: 'star_of_david', name: 'star_of_david', names: ['star_of_david'], unicode: '✡️' }, - { id: 'stars', name: 'stars', names: ['stars'], unicode: '🌠' }, - { id: 'station', name: 'station', names: ['station'], unicode: '🚉' }, - { - id: 'statue_of_liberty', - name: 'statue_of_liberty', - names: ['statue_of_liberty'], - unicode: '🗽', - }, - { id: 'steam_locomotive', name: 'steam_locomotive', names: ['steam_locomotive'], unicode: '🚂' }, - { id: 'stethoscope', name: 'stethoscope', names: ['stethoscope'], unicode: '🩺' }, - { id: 'stew', name: 'stew', names: ['stew'], unicode: '🍲' }, - { id: 'stopwatch', name: 'stopwatch', names: ['stopwatch'], unicode: '⏱️' }, - { id: 'straight_ruler', name: 'straight_ruler', names: ['straight_ruler'], unicode: '📏' }, - { id: 'strawberry', name: 'strawberry', names: ['strawberry'], unicode: '🍓' }, - { id: 'stuck_out_tongue', name: 'stuck_out_tongue', names: ['stuck_out_tongue'], unicode: '😛' }, - { - id: 'stuck_out_tongue_closed_eyes', - name: 'stuck_out_tongue_closed_eyes', - names: ['stuck_out_tongue_closed_eyes'], - unicode: '😝', - }, - { - id: 'stuck_out_tongue_winking_eye', - name: 'stuck_out_tongue_winking_eye', - names: ['stuck_out_tongue_winking_eye'], - unicode: '😜', - }, - { - id: 'student', - name: 'student', - names: ['student'], - skins: ['🧑🏻‍🎓', '🧑🏼‍🎓', '🧑🏽‍🎓', '🧑🏾‍🎓', '🧑🏿‍🎓'], - unicode: '🧑‍🎓', - }, - { - id: 'studio_microphone', - name: 'studio_microphone', - names: ['studio_microphone'], - unicode: '🎙️', - }, - { - id: 'stuffed_flatbread', - name: 'stuffed_flatbread', - names: ['stuffed_flatbread'], - unicode: '🥙', - }, - { id: 'sun_with_face', name: 'sun_with_face', names: ['sun_with_face'], unicode: '🌞' }, - { id: 'sunflower', name: 'sunflower', names: ['sunflower'], unicode: '🌻' }, - { id: 'sunglasses', name: 'sunglasses', names: ['sunglasses'], unicode: '😎' }, - { id: 'sunny', name: 'sunny', names: ['sunny'], unicode: '☀️' }, - { id: 'sunrise', name: 'sunrise', names: ['sunrise'], unicode: '🌅' }, - { - id: 'sunrise_over_mountains', - name: 'sunrise_over_mountains', - names: ['sunrise_over_mountains'], - unicode: '🌄', - }, - { - id: 'superhero', - name: 'superhero', - names: ['superhero'], - skins: ['🦸🏻', '🦸🏼', '🦸🏽', '🦸🏾', '🦸🏿'], - unicode: '🦸', - }, - { - id: 'supervillain', - name: 'supervillain', - names: ['supervillain'], - skins: ['🦹🏻', '🦹🏼', '🦹🏽', '🦹🏾', '🦹🏿'], - unicode: '🦹', - }, - { - id: 'surfer', - name: 'surfer', - names: ['surfer'], - skins: ['🏄🏻', '🏄🏼', '🏄🏽', '🏄🏾', '🏄🏿'], - unicode: '🏄', - }, - { id: 'sushi', name: 'sushi', names: ['sushi'], unicode: '🍣' }, - { - id: 'suspension_railway', - name: 'suspension_railway', - names: ['suspension_railway'], - unicode: '🚟', - }, - { id: 'swan', name: 'swan', names: ['swan'], unicode: '🦢' }, - { id: 'sweat', name: 'sweat', names: ['sweat'], unicode: '😓' }, - { id: 'sweat_drops', name: 'sweat_drops', names: ['sweat_drops'], unicode: '💦' }, - { id: 'sweat_smile', name: 'sweat_smile', names: ['sweat_smile'], unicode: '😅' }, - { id: 'sweet_potato', name: 'sweet_potato', names: ['sweet_potato'], unicode: '🍠' }, - { - id: 'swimmer', - name: 'swimmer', - names: ['swimmer'], - skins: ['🏊🏻', '🏊🏼', '🏊🏽', '🏊🏾', '🏊🏿'], - unicode: '🏊', - }, - { id: 'symbols', name: 'symbols', names: ['symbols'], unicode: '🔣' }, - { id: 'synagogue', name: 'synagogue', names: ['synagogue'], unicode: '🕍' }, - { id: 'syringe', name: 'syringe', names: ['syringe'], unicode: '💉' }, - { id: 't-rex', name: 't-rex', names: ['t-rex'], unicode: '🦖' }, - { - id: 'table_tennis_paddle_and_ball', - name: 'table_tennis_paddle_and_ball', - names: ['table_tennis_paddle_and_ball'], - unicode: '🏓', - }, - { id: 'taco', name: 'taco', names: ['taco'], unicode: '🌮' }, - { id: 'tada', name: 'tada', names: ['tada'], unicode: '🎉' }, - { id: 'takeout_box', name: 'takeout_box', names: ['takeout_box'], unicode: '🥡' }, - { id: 'tamale', name: 'tamale', names: ['tamale'], unicode: '🫔' }, - { id: 'tanabata_tree', name: 'tanabata_tree', names: ['tanabata_tree'], unicode: '🎋' }, - { id: 'tangerine', name: 'tangerine', names: ['tangerine'], unicode: '🍊' }, - { id: 'taurus', name: 'taurus', names: ['taurus'], unicode: '♉' }, - { id: 'taxi', name: 'taxi', names: ['taxi'], unicode: '🚕' }, - { id: 'tea', name: 'tea', names: ['tea'], unicode: '🍵' }, - { - id: 'teacher', - name: 'teacher', - names: ['teacher'], - skins: ['🧑🏻‍🏫', '🧑🏼‍🏫', '🧑🏽‍🏫', '🧑🏾‍🏫', '🧑🏿‍🏫'], - unicode: '🧑‍🏫', - }, - { id: 'teapot', name: 'teapot', names: ['teapot'], unicode: '🫖' }, - { - id: 'technologist', - name: 'technologist', - names: ['technologist'], - skins: ['🧑🏻‍💻', '🧑🏼‍💻', '🧑🏽‍💻', '🧑🏾‍💻', '🧑🏿‍💻'], - unicode: '🧑‍💻', - }, - { id: 'teddy_bear', name: 'teddy_bear', names: ['teddy_bear'], unicode: '🧸' }, - { - id: 'telephone_receiver', - name: 'telephone_receiver', - names: ['telephone_receiver'], - unicode: '📞', - }, - { id: 'telescope', name: 'telescope', names: ['telescope'], unicode: '🔭' }, - { id: 'tennis', name: 'tennis', names: ['tennis'], unicode: '🎾' }, - { id: 'tent', name: 'tent', names: ['tent'], unicode: '⛺' }, - { id: 'test_tube', name: 'test_tube', names: ['test_tube'], unicode: '🧪' }, - { - id: 'the_horns', - name: 'the_horns', - names: ['the_horns', 'sign_of_the_horns'], - skins: ['🤘🏻', '🤘🏼', '🤘🏽', '🤘🏾', '🤘🏿'], - unicode: '🤘', - }, - { id: 'thermometer', name: 'thermometer', names: ['thermometer'], unicode: '🌡️' }, - { id: 'thinking_face', name: 'thinking_face', names: ['thinking_face'], unicode: '🤔' }, - { - id: 'third_place_medal', - name: 'third_place_medal', - names: ['third_place_medal'], - unicode: '🥉', - }, - { id: 'thong_sandal', name: 'thong_sandal', names: ['thong_sandal'], unicode: '🩴' }, - { id: 'thought_balloon', name: 'thought_balloon', names: ['thought_balloon'], unicode: '💭' }, - { id: 'thread', name: 'thread', names: ['thread'], unicode: '🧵' }, - { id: 'three', name: 'three', names: ['three'], unicode: '3️⃣' }, - { - id: 'three_button_mouse', - name: 'three_button_mouse', - names: ['three_button_mouse'], - unicode: '🖱️', - }, - { - id: 'thunder_cloud_and_rain', - name: 'thunder_cloud_and_rain', - names: ['thunder_cloud_and_rain'], - unicode: '⛈️', - }, - { id: 'ticket', name: 'ticket', names: ['ticket'], unicode: '🎫' }, - { id: 'tiger', name: 'tiger', names: ['tiger'], unicode: '🐯' }, - { id: 'tiger2', name: 'tiger2', names: ['tiger2'], unicode: '🐅' }, - { id: 'timer_clock', name: 'timer_clock', names: ['timer_clock'], unicode: '⏲️' }, - { id: 'tired_face', name: 'tired_face', names: ['tired_face'], unicode: '😫' }, - { id: 'tm', name: 'tm', names: ['tm'], unicode: '™️' }, - { id: 'toilet', name: 'toilet', names: ['toilet'], unicode: '🚽' }, - { id: 'tokyo_tower', name: 'tokyo_tower', names: ['tokyo_tower'], unicode: '🗼' }, - { id: 'tomato', name: 'tomato', names: ['tomato'], unicode: '🍅' }, - { id: 'tongue', name: 'tongue', names: ['tongue'], unicode: '👅' }, - { id: 'toolbox', name: 'toolbox', names: ['toolbox'], unicode: '🧰' }, - { id: 'tooth', name: 'tooth', names: ['tooth'], unicode: '🦷' }, - { id: 'toothbrush', name: 'toothbrush', names: ['toothbrush'], unicode: '🪥' }, - { id: 'top', name: 'top', names: ['top'], unicode: '🔝' }, - { id: 'tophat', name: 'tophat', names: ['tophat'], unicode: '🎩' }, - { id: 'tornado', name: 'tornado', names: ['tornado', 'tornado_cloud'], unicode: '🌪️' }, - { id: 'trackball', name: 'trackball', names: ['trackball'], unicode: '🖲️' }, - { id: 'tractor', name: 'tractor', names: ['tractor'], unicode: '🚜' }, - { id: 'traffic_light', name: 'traffic_light', names: ['traffic_light'], unicode: '🚥' }, - { id: 'train', name: 'train', names: ['train'], unicode: '🚋' }, - { id: 'train2', name: 'train2', names: ['train2'], unicode: '🚆' }, - { id: 'tram', name: 'tram', names: ['tram'], unicode: '🚊' }, - { - id: 'transgender_flag', - name: 'transgender_flag', - names: ['transgender_flag'], - unicode: '🏳️‍⚧️', - }, - { - id: 'transgender_symbol', - name: 'transgender_symbol', - names: ['transgender_symbol'], - unicode: '⚧️', - }, - { - id: 'triangular_flag_on_post', - name: 'triangular_flag_on_post', - names: ['triangular_flag_on_post'], - unicode: '🚩', - }, - { id: 'triangular_ruler', name: 'triangular_ruler', names: ['triangular_ruler'], unicode: '📐' }, - { id: 'trident', name: 'trident', names: ['trident'], unicode: '🔱' }, - { id: 'triumph', name: 'triumph', names: ['triumph'], unicode: '😤' }, - { id: 'troll', name: 'troll', names: ['troll'], unicode: '🧌' }, - { id: 'trolleybus', name: 'trolleybus', names: ['trolleybus'], unicode: '🚎' }, - { id: 'trophy', name: 'trophy', names: ['trophy'], unicode: '🏆' }, - { id: 'tropical_drink', name: 'tropical_drink', names: ['tropical_drink'], unicode: '🍹' }, - { id: 'tropical_fish', name: 'tropical_fish', names: ['tropical_fish'], unicode: '🐠' }, - { id: 'truck', name: 'truck', names: ['truck'], unicode: '🚚' }, - { id: 'trumpet', name: 'trumpet', names: ['trumpet'], unicode: '🎺' }, - { id: 'tulip', name: 'tulip', names: ['tulip'], unicode: '🌷' }, - { id: 'tumbler_glass', name: 'tumbler_glass', names: ['tumbler_glass'], unicode: '🥃' }, - { id: 'turkey', name: 'turkey', names: ['turkey'], unicode: '🦃' }, - { id: 'turtle', name: 'turtle', names: ['turtle'], unicode: '🐢' }, - { id: 'tv', name: 'tv', names: ['tv'], unicode: '📺' }, - { - id: 'twisted_rightwards_arrows', - name: 'twisted_rightwards_arrows', - names: ['twisted_rightwards_arrows'], - unicode: '🔀', - }, - { id: 'two', name: 'two', names: ['two'], unicode: '2️⃣' }, - { id: 'two_hearts', name: 'two_hearts', names: ['two_hearts'], unicode: '💕' }, - { - id: 'two_men_holding_hands', - name: 'two_men_holding_hands', - names: ['two_men_holding_hands', 'men_holding_hands'], - skins: [ - '👬🏻', - '👬🏼', - '👬🏽', - '👬🏾', - '👬🏿', - '👨🏻‍🤝‍👨🏼', - '👨🏻‍🤝‍👨🏽', - '👨🏻‍🤝‍👨🏾', - '👨🏻‍🤝‍👨🏿', - '👨🏼‍🤝‍👨🏻', - '👨🏼‍🤝‍👨🏽', - '👨🏼‍🤝‍👨🏾', - '👨🏼‍🤝‍👨🏿', - '👨🏽‍🤝‍👨🏻', - '👨🏽‍🤝‍👨🏼', - '👨🏽‍🤝‍👨🏾', - '👨🏽‍🤝‍👨🏿', - '👨🏾‍🤝‍👨🏻', - '👨🏾‍🤝‍👨🏼', - '👨🏾‍🤝‍👨🏽', - '👨🏾‍🤝‍👨🏿', - '👨🏿‍🤝‍👨🏻', - '👨🏿‍🤝‍👨🏼', - '👨🏿‍🤝‍👨🏽', - '👨🏿‍🤝‍👨🏾', - ], - unicode: '👬', - }, - { - id: 'two_women_holding_hands', - name: 'two_women_holding_hands', - names: ['two_women_holding_hands', 'women_holding_hands'], - skins: [ - '👭🏻', - '👭🏼', - '👭🏽', - '👭🏾', - '👭🏿', - '👩🏻‍🤝‍👩🏼', - '👩🏻‍🤝‍👩🏽', - '👩🏻‍🤝‍👩🏾', - '👩🏻‍🤝‍👩🏿', - '👩🏼‍🤝‍👩🏻', - '👩🏼‍🤝‍👩🏽', - '👩🏼‍🤝‍👩🏾', - '👩🏼‍🤝‍👩🏿', - '👩🏽‍🤝‍👩🏻', - '👩🏽‍🤝‍👩🏼', - '👩🏽‍🤝‍👩🏾', - '👩🏽‍🤝‍👩🏿', - '👩🏾‍🤝‍👩🏻', - '👩🏾‍🤝‍👩🏼', - '👩🏾‍🤝‍👩🏽', - '👩🏾‍🤝‍👩🏿', - '👩🏿‍🤝‍👩🏻', - '👩🏿‍🤝‍👩🏼', - '👩🏿‍🤝‍👩🏽', - '👩🏿‍🤝‍👩🏾', - ], - unicode: '👭', - }, - { id: 'u5272', name: 'u5272', names: ['u5272'], unicode: '🈹' }, - { id: 'u5408', name: 'u5408', names: ['u5408'], unicode: '🈴' }, - { id: 'u55b6', name: 'u55b6', names: ['u55b6'], unicode: '🈺' }, - { id: 'u6307', name: 'u6307', names: ['u6307'], unicode: '🈯' }, - { id: 'u6708', name: 'u6708', names: ['u6708'], unicode: '🈷️' }, - { id: 'u6709', name: 'u6709', names: ['u6709'], unicode: '🈶' }, - { id: 'u6e80', name: 'u6e80', names: ['u6e80'], unicode: '🈵' }, - { id: 'u7121', name: 'u7121', names: ['u7121'], unicode: '🈚' }, - { id: 'u7533', name: 'u7533', names: ['u7533'], unicode: '🈸' }, - { id: 'u7981', name: 'u7981', names: ['u7981'], unicode: '🈲' }, - { id: 'u7a7a', name: 'u7a7a', names: ['u7a7a'], unicode: '🈳' }, - { id: 'umbrella', name: 'umbrella', names: ['umbrella'], unicode: '☂️' }, - { - id: 'umbrella_on_ground', - name: 'umbrella_on_ground', - names: ['umbrella_on_ground'], - unicode: '⛱️', - }, - { - id: 'umbrella_with_rain_drops', - name: 'umbrella_with_rain_drops', - names: ['umbrella_with_rain_drops'], - unicode: '☔', - }, - { id: 'unamused', name: 'unamused', names: ['unamused'], unicode: '😒' }, - { id: 'underage', name: 'underage', names: ['underage'], unicode: '🔞' }, - { id: 'unicorn_face', name: 'unicorn_face', names: ['unicorn_face'], unicode: '🦄' }, - { id: 'unlock', name: 'unlock', names: ['unlock'], unicode: '🔓' }, - { id: 'up', name: 'up', names: ['up'], unicode: '🆙' }, - { id: 'upside_down_face', name: 'upside_down_face', names: ['upside_down_face'], unicode: '🙃' }, - { id: 'us', name: 'us', names: ['us', 'flag-us'], unicode: '🇺🇸' }, - { id: 'v', name: 'v', names: ['v'], skins: ['✌🏻', '✌🏼', '✌🏽', '✌🏾', '✌🏿'], unicode: '✌️' }, - { - id: 'vampire', - name: 'vampire', - names: ['vampire'], - skins: ['🧛🏻', '🧛🏼', '🧛🏽', '🧛🏾', '🧛🏿'], - unicode: '🧛', - }, - { - id: 'vertical_traffic_light', - name: 'vertical_traffic_light', - names: ['vertical_traffic_light'], - unicode: '🚦', - }, - { id: 'vhs', name: 'vhs', names: ['vhs'], unicode: '📼' }, - { id: 'vibration_mode', name: 'vibration_mode', names: ['vibration_mode'], unicode: '📳' }, - { id: 'video_camera', name: 'video_camera', names: ['video_camera'], unicode: '📹' }, - { id: 'video_game', name: 'video_game', names: ['video_game'], unicode: '🎮' }, - { id: 'violin', name: 'violin', names: ['violin'], unicode: '🎻' }, - { id: 'virgo', name: 'virgo', names: ['virgo'], unicode: '♍' }, - { id: 'volcano', name: 'volcano', names: ['volcano'], unicode: '🌋' }, - { id: 'volleyball', name: 'volleyball', names: ['volleyball'], unicode: '🏐' }, - { id: 'vs', name: 'vs', names: ['vs'], unicode: '🆚' }, - { id: 'waffle', name: 'waffle', names: ['waffle'], unicode: '🧇' }, - { - id: 'walking', - name: 'walking', - names: ['walking'], - skins: ['🚶🏻', '🚶🏼', '🚶🏽', '🚶🏾', '🚶🏿'], - unicode: '🚶', - }, - { - id: 'waning_crescent_moon', - name: 'waning_crescent_moon', - names: ['waning_crescent_moon'], - unicode: '🌘', - }, - { - id: 'waning_gibbous_moon', - name: 'waning_gibbous_moon', - names: ['waning_gibbous_moon'], - unicode: '🌖', - }, - { id: 'warning', name: 'warning', names: ['warning'], unicode: '⚠️' }, - { id: 'wastebasket', name: 'wastebasket', names: ['wastebasket'], unicode: '🗑️' }, - { id: 'watch', name: 'watch', names: ['watch'], unicode: '⌚' }, - { id: 'water_buffalo', name: 'water_buffalo', names: ['water_buffalo'], unicode: '🐃' }, - { - id: 'water_polo', - name: 'water_polo', - names: ['water_polo'], - skins: ['🤽🏻', '🤽🏼', '🤽🏽', '🤽🏾', '🤽🏿'], - unicode: '🤽', - }, - { id: 'watermelon', name: 'watermelon', names: ['watermelon'], unicode: '🍉' }, - { - id: 'wave', - name: 'wave', - names: ['wave'], - skins: ['👋🏻', '👋🏼', '👋🏽', '👋🏾', '👋🏿'], - unicode: '👋', - }, - { - id: 'waving_black_flag', - name: 'waving_black_flag', - names: ['waving_black_flag'], - unicode: '🏴', - }, - { - id: 'waving_white_flag', - name: 'waving_white_flag', - names: ['waving_white_flag'], - unicode: '🏳️', - }, - { id: 'wavy_dash', name: 'wavy_dash', names: ['wavy_dash'], unicode: '〰️' }, - { - id: 'waxing_crescent_moon', - name: 'waxing_crescent_moon', - names: ['waxing_crescent_moon'], - unicode: '🌒', - }, - { id: 'wc', name: 'wc', names: ['wc'], unicode: '🚾' }, - { id: 'weary', name: 'weary', names: ['weary'], unicode: '😩' }, - { id: 'wedding', name: 'wedding', names: ['wedding'], unicode: '💒' }, - { - id: 'weight_lifter', - name: 'weight_lifter', - names: ['weight_lifter'], - skins: ['🏋🏻', '🏋🏼', '🏋🏽', '🏋🏾', '🏋🏿'], - unicode: '🏋️', - }, - { id: 'whale', name: 'whale', names: ['whale'], unicode: '🐳' }, - { id: 'whale2', name: 'whale2', names: ['whale2'], unicode: '🐋' }, - { id: 'wheel', name: 'wheel', names: ['wheel'], unicode: '🛞' }, - { id: 'wheel_of_dharma', name: 'wheel_of_dharma', names: ['wheel_of_dharma'], unicode: '☸️' }, - { id: 'wheelchair', name: 'wheelchair', names: ['wheelchair'], unicode: '♿' }, - { id: 'white_check_mark', name: 'white_check_mark', names: ['white_check_mark'], unicode: '✅' }, - { id: 'white_circle', name: 'white_circle', names: ['white_circle'], unicode: '⚪' }, - { id: 'white_flower', name: 'white_flower', names: ['white_flower'], unicode: '💮' }, - { - id: 'white_frowning_face', - name: 'white_frowning_face', - names: ['white_frowning_face'], - unicode: '☹️', - }, - { - id: 'white_haired_man', - name: 'white_haired_man', - names: ['white_haired_man'], - skins: ['👨🏻‍🦳', '👨🏼‍🦳', '👨🏽‍🦳', '👨🏾‍🦳', '👨🏿‍🦳'], - unicode: '👨‍🦳', - }, - { - id: 'white_haired_person', - name: 'white_haired_person', - names: ['white_haired_person'], - skins: ['🧑🏻‍🦳', '🧑🏼‍🦳', '🧑🏽‍🦳', '🧑🏾‍🦳', '🧑🏿‍🦳'], - unicode: '🧑‍🦳', - }, - { - id: 'white_haired_woman', - name: 'white_haired_woman', - names: ['white_haired_woman'], - skins: ['👩🏻‍🦳', '👩🏼‍🦳', '👩🏽‍🦳', '👩🏾‍🦳', '👩🏿‍🦳'], - unicode: '👩‍🦳', - }, - { id: 'white_heart', name: 'white_heart', names: ['white_heart'], unicode: '🤍' }, - { - id: 'white_large_square', - name: 'white_large_square', - names: ['white_large_square'], - unicode: '⬜', - }, - { - id: 'white_medium_small_square', - name: 'white_medium_small_square', - names: ['white_medium_small_square'], - unicode: '◽', - }, - { - id: 'white_medium_square', - name: 'white_medium_square', - names: ['white_medium_square'], - unicode: '◻️', - }, - { - id: 'white_small_square', - name: 'white_small_square', - names: ['white_small_square'], - unicode: '▫️', - }, - { - id: 'white_square_button', - name: 'white_square_button', - names: ['white_square_button'], - unicode: '🔳', - }, - { id: 'wilted_flower', name: 'wilted_flower', names: ['wilted_flower'], unicode: '🥀' }, - { - id: 'wind_blowing_face', - name: 'wind_blowing_face', - names: ['wind_blowing_face'], - unicode: '🌬️', - }, - { id: 'wind_chime', name: 'wind_chime', names: ['wind_chime'], unicode: '🎐' }, - { id: 'window', name: 'window', names: ['window'], unicode: '🪟' }, - { id: 'wine_glass', name: 'wine_glass', names: ['wine_glass'], unicode: '🍷' }, - { id: 'wing', name: 'wing', names: ['wing'], unicode: '🪽' }, - { id: 'wink', name: 'wink', names: ['wink'], unicode: '😉' }, - { id: 'wireless', name: 'wireless', names: ['wireless'], unicode: '🛜' }, - { id: 'wolf', name: 'wolf', names: ['wolf'], unicode: '🐺' }, - { - id: 'woman', - name: 'woman', - names: ['woman'], - skins: ['👩🏻', '👩🏼', '👩🏽', '👩🏾', '👩🏿'], - unicode: '👩', - }, - { - id: 'woman-biking', - name: 'woman-biking', - names: ['woman-biking'], - skins: ['🚴🏻‍♀️', '🚴🏼‍♀️', '🚴🏽‍♀️', '🚴🏾‍♀️', '🚴🏿‍♀️'], - unicode: '🚴‍♀️', - }, - { - id: 'woman-bouncing-ball', - name: 'woman-bouncing-ball', - names: ['woman-bouncing-ball'], - skins: ['⛹🏻‍♀️', '⛹🏼‍♀️', '⛹🏽‍♀️', '⛹🏾‍♀️', '⛹🏿‍♀️'], - unicode: '⛹️‍♀️', - }, - { - id: 'woman-bowing', - name: 'woman-bowing', - names: ['woman-bowing'], - skins: ['🙇🏻‍♀️', '🙇🏼‍♀️', '🙇🏽‍♀️', '🙇🏾‍♀️', '🙇🏿‍♀️'], - unicode: '🙇‍♀️', - }, - { id: 'woman-boy', name: 'woman-boy', names: ['woman-boy'], unicode: '👩‍👦' }, - { id: 'woman-boy-boy', name: 'woman-boy-boy', names: ['woman-boy-boy'], unicode: '👩‍👦‍👦' }, - { - id: 'woman-cartwheeling', - name: 'woman-cartwheeling', - names: ['woman-cartwheeling'], - skins: ['🤸🏻‍♀️', '🤸🏼‍♀️', '🤸🏽‍♀️', '🤸🏾‍♀️', '🤸🏿‍♀️'], - unicode: '🤸‍♀️', - }, - { - id: 'woman-facepalming', - name: 'woman-facepalming', - names: ['woman-facepalming'], - skins: ['🤦🏻‍♀️', '🤦🏼‍♀️', '🤦🏽‍♀️', '🤦🏾‍♀️', '🤦🏿‍♀️'], - unicode: '🤦‍♀️', - }, - { - id: 'woman-frowning', - name: 'woman-frowning', - names: ['woman-frowning'], - skins: ['🙍🏻‍♀️', '🙍🏼‍♀️', '🙍🏽‍♀️', '🙍🏾‍♀️', '🙍🏿‍♀️'], - unicode: '🙍‍♀️', - }, - { - id: 'woman-gesturing-no', - name: 'woman-gesturing-no', - names: ['woman-gesturing-no'], - skins: ['🙅🏻‍♀️', '🙅🏼‍♀️', '🙅🏽‍♀️', '🙅🏾‍♀️', '🙅🏿‍♀️'], - unicode: '🙅‍♀️', - }, - { - id: 'woman-gesturing-ok', - name: 'woman-gesturing-ok', - names: ['woman-gesturing-ok'], - skins: ['🙆🏻‍♀️', '🙆🏼‍♀️', '🙆🏽‍♀️', '🙆🏾‍♀️', '🙆🏿‍♀️'], - unicode: '🙆‍♀️', - }, - { - id: 'woman-getting-haircut', - name: 'woman-getting-haircut', - names: ['woman-getting-haircut'], - skins: ['💇🏻‍♀️', '💇🏼‍♀️', '💇🏽‍♀️', '💇🏾‍♀️', '💇🏿‍♀️'], - unicode: '💇‍♀️', - }, - { - id: 'woman-getting-massage', - name: 'woman-getting-massage', - names: ['woman-getting-massage'], - skins: ['💆🏻‍♀️', '💆🏼‍♀️', '💆🏽‍♀️', '💆🏾‍♀️', '💆🏿‍♀️'], - unicode: '💆‍♀️', - }, - { id: 'woman-girl', name: 'woman-girl', names: ['woman-girl'], unicode: '👩‍👧' }, - { id: 'woman-girl-boy', name: 'woman-girl-boy', names: ['woman-girl-boy'], unicode: '👩‍👧‍👦' }, - { id: 'woman-girl-girl', name: 'woman-girl-girl', names: ['woman-girl-girl'], unicode: '👩‍👧‍👧' }, - { - id: 'woman-golfing', - name: 'woman-golfing', - names: ['woman-golfing'], - skins: ['🏌🏻‍♀️', '🏌🏼‍♀️', '🏌🏽‍♀️', '🏌🏾‍♀️', '🏌🏿‍♀️'], - unicode: '🏌️‍♀️', - }, - { - id: 'woman-heart-man', - name: 'woman-heart-man', - names: ['woman-heart-man'], - skins: [ - '👩🏻‍❤️‍👨🏻', - '👩🏻‍❤️‍👨🏼', - '👩🏻‍❤️‍👨🏽', - '👩🏻‍❤️‍👨🏾', - '👩🏻‍❤️‍👨🏿', - '👩🏼‍❤️‍👨🏻', - '👩🏼‍❤️‍👨🏼', - '👩🏼‍❤️‍👨🏽', - '👩🏼‍❤️‍👨🏾', - '👩🏼‍❤️‍👨🏿', - '👩🏽‍❤️‍👨🏻', - '👩🏽‍❤️‍👨🏼', - '👩🏽‍❤️‍👨🏽', - '👩🏽‍❤️‍👨🏾', - '👩🏽‍❤️‍👨🏿', - '👩🏾‍❤️‍👨🏻', - '👩🏾‍❤️‍👨🏼', - '👩🏾‍❤️‍👨🏽', - '👩🏾‍❤️‍👨🏾', - '👩🏾‍❤️‍👨🏿', - '👩🏿‍❤️‍👨🏻', - '👩🏿‍❤️‍👨🏼', - '👩🏿‍❤️‍👨🏽', - '👩🏿‍❤️‍👨🏾', - '👩🏿‍❤️‍👨🏿', - ], - unicode: '👩‍❤️‍👨', - }, - { - id: 'woman-heart-woman', - name: 'woman-heart-woman', - names: ['woman-heart-woman'], - skins: [ - '👩🏻‍❤️‍👩🏻', - '👩🏻‍❤️‍👩🏼', - '👩🏻‍❤️‍👩🏽', - '👩🏻‍❤️‍👩🏾', - '👩🏻‍❤️‍👩🏿', - '👩🏼‍❤️‍👩🏻', - '👩🏼‍❤️‍👩🏼', - '👩🏼‍❤️‍👩🏽', - '👩🏼‍❤️‍👩🏾', - '👩🏼‍❤️‍👩🏿', - '👩🏽‍❤️‍👩🏻', - '👩🏽‍❤️‍👩🏼', - '👩🏽‍❤️‍👩🏽', - '👩🏽‍❤️‍👩🏾', - '👩🏽‍❤️‍👩🏿', - '👩🏾‍❤️‍👩🏻', - '👩🏾‍❤️‍👩🏼', - '👩🏾‍❤️‍👩🏽', - '👩🏾‍❤️‍👩🏾', - '👩🏾‍❤️‍👩🏿', - '👩🏿‍❤️‍👩🏻', - '👩🏿‍❤️‍👩🏼', - '👩🏿‍❤️‍👩🏽', - '👩🏿‍❤️‍👩🏾', - '👩🏿‍❤️‍👩🏿', - ], - unicode: '👩‍❤️‍👩', - }, - { - id: 'woman-juggling', - name: 'woman-juggling', - names: ['woman-juggling'], - skins: ['🤹🏻‍♀️', '🤹🏼‍♀️', '🤹🏽‍♀️', '🤹🏾‍♀️', '🤹🏿‍♀️'], - unicode: '🤹‍♀️', - }, - { - id: 'woman-kiss-man', - name: 'woman-kiss-man', - names: ['woman-kiss-man'], - skins: [ - '👩🏻‍❤️‍💋‍👨🏻', - '👩🏻‍❤️‍💋‍👨🏼', - '👩🏻‍❤️‍💋‍👨🏽', - '👩🏻‍❤️‍💋‍👨🏾', - '👩🏻‍❤️‍💋‍👨🏿', - '👩🏼‍❤️‍💋‍👨🏻', - '👩🏼‍❤️‍💋‍👨🏼', - '👩🏼‍❤️‍💋‍👨🏽', - '👩🏼‍❤️‍💋‍👨🏾', - '👩🏼‍❤️‍💋‍👨🏿', - '👩🏽‍❤️‍💋‍👨🏻', - '👩🏽‍❤️‍💋‍👨🏼', - '👩🏽‍❤️‍💋‍👨🏽', - '👩🏽‍❤️‍💋‍👨🏾', - '👩🏽‍❤️‍💋‍👨🏿', - '👩🏾‍❤️‍💋‍👨🏻', - '👩🏾‍❤️‍💋‍👨🏼', - '👩🏾‍❤️‍💋‍👨🏽', - '👩🏾‍❤️‍💋‍👨🏾', - '👩🏾‍❤️‍💋‍👨🏿', - '👩🏿‍❤️‍💋‍👨🏻', - '👩🏿‍❤️‍💋‍👨🏼', - '👩🏿‍❤️‍💋‍👨🏽', - '👩🏿‍❤️‍💋‍👨🏾', - '👩🏿‍❤️‍💋‍👨🏿', - ], - unicode: '👩‍❤️‍💋‍👨', - }, - { - id: 'woman-kiss-woman', - name: 'woman-kiss-woman', - names: ['woman-kiss-woman'], - skins: [ - '👩🏻‍❤️‍💋‍👩🏻', - '👩🏻‍❤️‍💋‍👩🏼', - '👩🏻‍❤️‍💋‍👩🏽', - '👩🏻‍❤️‍💋‍👩🏾', - '👩🏻‍❤️‍💋‍👩🏿', - '👩🏼‍❤️‍💋‍👩🏻', - '👩🏼‍❤️‍💋‍👩🏼', - '👩🏼‍❤️‍💋‍👩🏽', - '👩🏼‍❤️‍💋‍👩🏾', - '👩🏼‍❤️‍💋‍👩🏿', - '👩🏽‍❤️‍💋‍👩🏻', - '👩🏽‍❤️‍💋‍👩🏼', - '👩🏽‍❤️‍💋‍👩🏽', - '👩🏽‍❤️‍💋‍👩🏾', - '👩🏽‍❤️‍💋‍👩🏿', - '👩🏾‍❤️‍💋‍👩🏻', - '👩🏾‍❤️‍💋‍👩🏼', - '👩🏾‍❤️‍💋‍👩🏽', - '👩🏾‍❤️‍💋‍👩🏾', - '👩🏾‍❤️‍💋‍👩🏿', - '👩🏿‍❤️‍💋‍👩🏻', - '👩🏿‍❤️‍💋‍👩🏼', - '👩🏿‍❤️‍💋‍👩🏽', - '👩🏿‍❤️‍💋‍👩🏾', - '👩🏿‍❤️‍💋‍👩🏿', - ], - unicode: '👩‍❤️‍💋‍👩', - }, - { - id: 'woman-lifting-weights', - name: 'woman-lifting-weights', - names: ['woman-lifting-weights'], - skins: ['🏋🏻‍♀️', '🏋🏼‍♀️', '🏋🏽‍♀️', '🏋🏾‍♀️', '🏋🏿‍♀️'], - unicode: '🏋️‍♀️', - }, - { - id: 'woman-mountain-biking', - name: 'woman-mountain-biking', - names: ['woman-mountain-biking'], - skins: ['🚵🏻‍♀️', '🚵🏼‍♀️', '🚵🏽‍♀️', '🚵🏾‍♀️', '🚵🏿‍♀️'], - unicode: '🚵‍♀️', - }, - { - id: 'woman-playing-handball', - name: 'woman-playing-handball', - names: ['woman-playing-handball'], - skins: ['🤾🏻‍♀️', '🤾🏼‍♀️', '🤾🏽‍♀️', '🤾🏾‍♀️', '🤾🏿‍♀️'], - unicode: '🤾‍♀️', - }, - { - id: 'woman-playing-water-polo', - name: 'woman-playing-water-polo', - names: ['woman-playing-water-polo'], - skins: ['🤽🏻‍♀️', '🤽🏼‍♀️', '🤽🏽‍♀️', '🤽🏾‍♀️', '🤽🏿‍♀️'], - unicode: '🤽‍♀️', - }, - { - id: 'woman-pouting', - name: 'woman-pouting', - names: ['woman-pouting'], - skins: ['🙎🏻‍♀️', '🙎🏼‍♀️', '🙎🏽‍♀️', '🙎🏾‍♀️', '🙎🏿‍♀️'], - unicode: '🙎‍♀️', - }, - { - id: 'woman-raising-hand', - name: 'woman-raising-hand', - names: ['woman-raising-hand'], - skins: ['🙋🏻‍♀️', '🙋🏼‍♀️', '🙋🏽‍♀️', '🙋🏾‍♀️', '🙋🏿‍♀️'], - unicode: '🙋‍♀️', - }, - { - id: 'woman-rowing-boat', - name: 'woman-rowing-boat', - names: ['woman-rowing-boat'], - skins: ['🚣🏻‍♀️', '🚣🏼‍♀️', '🚣🏽‍♀️', '🚣🏾‍♀️', '🚣🏿‍♀️'], - unicode: '🚣‍♀️', - }, - { - id: 'woman-running', - name: 'woman-running', - names: ['woman-running'], - skins: ['🏃🏻‍♀️', '🏃🏼‍♀️', '🏃🏽‍♀️', '🏃🏾‍♀️', '🏃🏿‍♀️'], - unicode: '🏃‍♀️', - }, - { - id: 'woman-shrugging', - name: 'woman-shrugging', - names: ['woman-shrugging'], - skins: ['🤷🏻‍♀️', '🤷🏼‍♀️', '🤷🏽‍♀️', '🤷🏾‍♀️', '🤷🏿‍♀️'], - unicode: '🤷‍♀️', - }, - { - id: 'woman-surfing', - name: 'woman-surfing', - names: ['woman-surfing'], - skins: ['🏄🏻‍♀️', '🏄🏼‍♀️', '🏄🏽‍♀️', '🏄🏾‍♀️', '🏄🏿‍♀️'], - unicode: '🏄‍♀️', - }, - { - id: 'woman-swimming', - name: 'woman-swimming', - names: ['woman-swimming'], - skins: ['🏊🏻‍♀️', '🏊🏼‍♀️', '🏊🏽‍♀️', '🏊🏾‍♀️', '🏊🏿‍♀️'], - unicode: '🏊‍♀️', - }, - { - id: 'woman-tipping-hand', - name: 'woman-tipping-hand', - names: ['woman-tipping-hand'], - skins: ['💁🏻‍♀️', '💁🏼‍♀️', '💁🏽‍♀️', '💁🏾‍♀️', '💁🏿‍♀️'], - unicode: '💁‍♀️', - }, - { - id: 'woman-walking', - name: 'woman-walking', - names: ['woman-walking'], - skins: ['🚶🏻‍♀️', '🚶🏼‍♀️', '🚶🏽‍♀️', '🚶🏾‍♀️', '🚶🏿‍♀️'], - unicode: '🚶‍♀️', - }, - { - id: 'woman-wearing-turban', - name: 'woman-wearing-turban', - names: ['woman-wearing-turban'], - skins: ['👳🏻‍♀️', '👳🏼‍♀️', '👳🏽‍♀️', '👳🏾‍♀️', '👳🏿‍♀️'], - unicode: '👳‍♀️', - }, - { id: 'woman-woman-boy', name: 'woman-woman-boy', names: ['woman-woman-boy'], unicode: '👩‍👩‍👦' }, - { - id: 'woman-woman-boy-boy', - name: 'woman-woman-boy-boy', - names: ['woman-woman-boy-boy'], - unicode: '👩‍👩‍👦‍👦', - }, - { id: 'woman-woman-girl', name: 'woman-woman-girl', names: ['woman-woman-girl'], unicode: '👩‍👩‍👧' }, - { - id: 'woman-woman-girl-boy', - name: 'woman-woman-girl-boy', - names: ['woman-woman-girl-boy'], - unicode: '👩‍👩‍👧‍👦', - }, - { - id: 'woman-woman-girl-girl', - name: 'woman-woman-girl-girl', - names: ['woman-woman-girl-girl'], - unicode: '👩‍👩‍👧‍👧', - }, - { id: 'woman-wrestling', name: 'woman-wrestling', names: ['woman-wrestling'], unicode: '🤼‍♀️' }, - { - id: 'woman_climbing', - name: 'woman_climbing', - names: ['woman_climbing'], - skins: ['🧗🏻‍♀️', '🧗🏼‍♀️', '🧗🏽‍♀️', '🧗🏾‍♀️', '🧗🏿‍♀️'], - unicode: '🧗‍♀️', - }, - { - id: 'woman_feeding_baby', - name: 'woman_feeding_baby', - names: ['woman_feeding_baby'], - skins: ['👩🏻‍🍼', '👩🏼‍🍼', '👩🏽‍🍼', '👩🏾‍🍼', '👩🏿‍🍼'], - unicode: '👩‍🍼', - }, - { - id: 'woman_in_lotus_position', - name: 'woman_in_lotus_position', - names: ['woman_in_lotus_position'], - skins: ['🧘🏻‍♀️', '🧘🏼‍♀️', '🧘🏽‍♀️', '🧘🏾‍♀️', '🧘🏿‍♀️'], - unicode: '🧘‍♀️', - }, - { - id: 'woman_in_manual_wheelchair', - name: 'woman_in_manual_wheelchair', - names: ['woman_in_manual_wheelchair'], - skins: ['👩🏻‍🦽', '👩🏼‍🦽', '👩🏽‍🦽', '👩🏾‍🦽', '👩🏿‍🦽'], - unicode: '👩‍🦽', - }, - { - id: 'woman_in_manual_wheelchair_facing_right', - name: 'woman_in_manual_wheelchair_facing_right', - names: ['woman_in_manual_wheelchair_facing_right'], - skins: ['👩🏻‍🦽‍➡️', '👩🏼‍🦽‍➡️', '👩🏽‍🦽‍➡️', '👩🏾‍🦽‍➡️', '👩🏿‍🦽‍➡️'], - unicode: '👩‍🦽‍➡️', - }, - { - id: 'woman_in_motorized_wheelchair', - name: 'woman_in_motorized_wheelchair', - names: ['woman_in_motorized_wheelchair'], - skins: ['👩🏻‍🦼', '👩🏼‍🦼', '👩🏽‍🦼', '👩🏾‍🦼', '👩🏿‍🦼'], - unicode: '👩‍🦼', - }, - { - id: 'woman_in_motorized_wheelchair_facing_right', - name: 'woman_in_motorized_wheelchair_facing_right', - names: ['woman_in_motorized_wheelchair_facing_right'], - skins: ['👩🏻‍🦼‍➡️', '👩🏼‍🦼‍➡️', '👩🏽‍🦼‍➡️', '👩🏾‍🦼‍➡️', '👩🏿‍🦼‍➡️'], - unicode: '👩‍🦼‍➡️', - }, - { - id: 'woman_in_steamy_room', - name: 'woman_in_steamy_room', - names: ['woman_in_steamy_room'], - skins: ['🧖🏻‍♀️', '🧖🏼‍♀️', '🧖🏽‍♀️', '🧖🏾‍♀️', '🧖🏿‍♀️'], - unicode: '🧖‍♀️', - }, - { - id: 'woman_in_tuxedo', - name: 'woman_in_tuxedo', - names: ['woman_in_tuxedo'], - skins: ['🤵🏻‍♀️', '🤵🏼‍♀️', '🤵🏽‍♀️', '🤵🏾‍♀️', '🤵🏿‍♀️'], - unicode: '🤵‍♀️', - }, - { - id: 'woman_kneeling', - name: 'woman_kneeling', - names: ['woman_kneeling'], - skins: ['🧎🏻‍♀️', '🧎🏼‍♀️', '🧎🏽‍♀️', '🧎🏾‍♀️', '🧎🏿‍♀️'], - unicode: '🧎‍♀️', - }, - { - id: 'woman_kneeling_facing_right', - name: 'woman_kneeling_facing_right', - names: ['woman_kneeling_facing_right'], - skins: ['🧎🏻‍♀️‍➡️', '🧎🏼‍♀️‍➡️', '🧎🏽‍♀️‍➡️', '🧎🏾‍♀️‍➡️', '🧎🏿‍♀️‍➡️'], - unicode: '🧎‍♀️‍➡️', - }, - { - id: 'woman_running_facing_right', - name: 'woman_running_facing_right', - names: ['woman_running_facing_right'], - skins: ['🏃🏻‍♀️‍➡️', '🏃🏼‍♀️‍➡️', '🏃🏽‍♀️‍➡️', '🏃🏾‍♀️‍➡️', '🏃🏿‍♀️‍➡️'], - unicode: '🏃‍♀️‍➡️', - }, - { - id: 'woman_standing', - name: 'woman_standing', - names: ['woman_standing'], - skins: ['🧍🏻‍♀️', '🧍🏼‍♀️', '🧍🏽‍♀️', '🧍🏾‍♀️', '🧍🏿‍♀️'], - unicode: '🧍‍♀️', - }, - { - id: 'woman_walking_facing_right', - name: 'woman_walking_facing_right', - names: ['woman_walking_facing_right'], - skins: ['🚶🏻‍♀️‍➡️', '🚶🏼‍♀️‍➡️', '🚶🏽‍♀️‍➡️', '🚶🏾‍♀️‍➡️', '🚶🏿‍♀️‍➡️'], - unicode: '🚶‍♀️‍➡️', - }, - { - id: 'woman_with_beard', - name: 'woman_with_beard', - names: ['woman_with_beard'], - skins: ['🧔🏻‍♀️', '🧔🏼‍♀️', '🧔🏽‍♀️', '🧔🏾‍♀️', '🧔🏿‍♀️'], - unicode: '🧔‍♀️', - }, - { - id: 'woman_with_probing_cane', - name: 'woman_with_probing_cane', - names: ['woman_with_probing_cane'], - skins: ['👩🏻‍🦯', '👩🏼‍🦯', '👩🏽‍🦯', '👩🏾‍🦯', '👩🏿‍🦯'], - unicode: '👩‍🦯', - }, - { - id: 'woman_with_veil', - name: 'woman_with_veil', - names: ['woman_with_veil'], - skins: ['👰🏻‍♀️', '👰🏼‍♀️', '👰🏽‍♀️', '👰🏾‍♀️', '👰🏿‍♀️'], - unicode: '👰‍♀️', - }, - { - id: 'woman_with_white_cane_facing_right', - name: 'woman_with_white_cane_facing_right', - names: ['woman_with_white_cane_facing_right'], - skins: ['👩🏻‍🦯‍➡️', '👩🏼‍🦯‍➡️', '👩🏽‍🦯‍➡️', '👩🏾‍🦯‍➡️', '👩🏿‍🦯‍➡️'], - unicode: '👩‍🦯‍➡️', - }, - { id: 'womans_clothes', name: 'womans_clothes', names: ['womans_clothes'], unicode: '👚' }, - { id: 'womans_flat_shoe', name: 'womans_flat_shoe', names: ['womans_flat_shoe'], unicode: '🥿' }, - { id: 'womans_hat', name: 'womans_hat', names: ['womans_hat'], unicode: '👒' }, - { - id: 'women-with-bunny-ears-partying', - name: 'women-with-bunny-ears-partying', - names: ['women-with-bunny-ears-partying', 'woman-with-bunny-ears-partying'], - unicode: '👯‍♀️', - }, - { id: 'womens', name: 'womens', names: ['womens'], unicode: '🚺' }, - { id: 'wood', name: 'wood', names: ['wood'], unicode: '🪵' }, - { id: 'woozy_face', name: 'woozy_face', names: ['woozy_face'], unicode: '🥴' }, - { id: 'world_map', name: 'world_map', names: ['world_map'], unicode: '🗺️' }, - { id: 'worm', name: 'worm', names: ['worm'], unicode: '🪱' }, - { id: 'worried', name: 'worried', names: ['worried'], unicode: '😟' }, - { id: 'wrench', name: 'wrench', names: ['wrench'], unicode: '🔧' }, - { id: 'wrestlers', name: 'wrestlers', names: ['wrestlers'], unicode: '🤼' }, - { - id: 'writing_hand', - name: 'writing_hand', - names: ['writing_hand'], - skins: ['✍🏻', '✍🏼', '✍🏽', '✍🏾', '✍🏿'], - unicode: '✍️', - }, - { id: 'x', name: 'x', names: ['x'], unicode: '❌' }, - { id: 'x-ray', name: 'x-ray', names: ['x-ray'], unicode: '🩻' }, - { id: 'yarn', name: 'yarn', names: ['yarn'], unicode: '🧶' }, - { id: 'yawning_face', name: 'yawning_face', names: ['yawning_face'], unicode: '🥱' }, - { id: 'yellow_heart', name: 'yellow_heart', names: ['yellow_heart'], unicode: '💛' }, - { id: 'yen', name: 'yen', names: ['yen'], unicode: '💴' }, - { id: 'yin_yang', name: 'yin_yang', names: ['yin_yang'], unicode: '☯️' }, - { id: 'yo-yo', name: 'yo-yo', names: ['yo-yo'], unicode: '🪀' }, - { id: 'yum', name: 'yum', names: ['yum'], unicode: '😋' }, - { - id: 'zany_face', - name: 'zany_face', - names: ['zany_face', 'grinning_face_with_one_large_and_one_small_eye'], - unicode: '🤪', - }, - { id: 'zap', name: 'zap', names: ['zap'], unicode: '⚡' }, - { id: 'zebra_face', name: 'zebra_face', names: ['zebra_face'], unicode: '🦓' }, - { id: 'zero', name: 'zero', names: ['zero'], unicode: '0️⃣' }, - { - id: 'zipper_mouth_face', - name: 'zipper_mouth_face', - names: ['zipper_mouth_face'], - unicode: '🤐', - }, - { id: 'zombie', name: 'zombie', names: ['zombie'], unicode: '🧟' }, - { id: 'zzz', name: 'zzz', names: ['zzz'], unicode: '💤' }, -]; diff --git a/package/src/hooks/useAttachmentPickerBottomSheet.ts b/package/src/hooks/useAttachmentPickerBottomSheet.ts new file mode 100644 index 0000000000..c03f87cc7a --- /dev/null +++ b/package/src/hooks/useAttachmentPickerBottomSheet.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import BottomSheet from '@gorhom/bottom-sheet'; +import { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; + +/** + * This hook is used to manage the state of the attachment picker bottom sheet. + * It provides functions to open and close the bottom sheet, as well as a reference to the bottom sheet itself. + * It also handles the cleanup of the timeout used to close the bottom sheet. + * The bottom sheet is used to display the attachment picker UI. + * The `openPicker` function opens the bottom sheet, and the `closePicker` function closes it. + * The `bottomSheetRef` is a reference to the bottom sheet component, which allows for programmatic control of the bottom sheet. + * The `bottomSheetCloseTimeoutRef` is used to store the timeout ID for the close operation, allowing for cleanup if necessary. + */ +export const useAttachmentPickerBottomSheet = () => { + const bottomSheetCloseTimeoutRef = useRef>(undefined); + const bottomSheetRef = useRef(null); + + useEffect( + () => + // cleanup the timeout if the component unmounts + () => { + if (bottomSheetCloseTimeoutRef.current) { + clearTimeout(bottomSheetCloseTimeoutRef.current); + } + }, + [], + ); + + const openPicker = useCallback((ref: React.RefObject) => { + if (bottomSheetCloseTimeoutRef.current) { + clearTimeout(bottomSheetCloseTimeoutRef.current); + } + if (ref.current?.snapToIndex) { + ref.current.snapToIndex(0); + } else { + console.warn('bottom and top insets must be set for the image picker to work correctly'); + } + }, []); + + const closePicker = useCallback((ref: React.RefObject) => { + if (ref.current?.close) { + if (bottomSheetCloseTimeoutRef.current) { + clearTimeout(bottomSheetCloseTimeoutRef.current); + } + ref.current.close(); + // Attempt to close the bottomsheet again to circumvent accidental opening on Android. + // Details: This to prevent a race condition where the close function is called during the point when a internal container layout happens within the bottomsheet due to keyboard affecting the layout + // If the container layout measures a shorter height than previous but if the close snapped to the previous height's position, the bottom sheet will show up + // this short delay ensures that close function is always called after a container layout due to keyboard change + // NOTE: this timeout has to be above 500 as the keyboardAnimationDuration is 500 in the bottomsheet library - see src/hooks/useKeyboard.ts there for more details + bottomSheetCloseTimeoutRef.current = setTimeout(() => { + ref.current?.close(); + }, 600); + } + }, []); + + useEffect(() => { + closePicker(bottomSheetRef); + }, [closePicker]); + + return { + bottomSheetCloseTimeoutRef, + bottomSheetRef, + closePicker, + openPicker, + }; +}; diff --git a/package/src/hooks/useStreami18n.ts b/package/src/hooks/useStreami18n.ts index d663fcbaf6..efccc93b21 100644 --- a/package/src/hooks/useStreami18n.ts +++ b/package/src/hooks/useStreami18n.ts @@ -4,12 +4,12 @@ import Dayjs from 'dayjs'; import { useIsMountedRef } from './useIsMountedRef'; -import type { TranslatorFunctions } from '../contexts/translationContext/TranslationContext'; -import { Streami18n } from '../utils/i18n/Streami18n'; +import type { TranslatorFunctions } from '../contexts/translationContext/types'; +import { defaultTranslatorFunction, Streami18n } from '../utils/i18n/Streami18n'; export const useStreami18n = (i18nInstance?: Streami18n) => { const [translators, setTranslators] = useState({ - t: (key: string) => key, + t: defaultTranslatorFunction, tDateTimeParser: (input?: string | number | Date) => Dayjs(input), }); const isMounted = useIsMountedRef(); diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 945fa7a97e..50c8e05620 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -68,6 +68,7 @@ "Only visible to you": "Only visible to you", "Open Settings": "Open Settings", "Option": "Option", + "Option already exists": "Option already exists", "Options": "Options", "Photo": "Photo", "Photos and Videos": "Photos and Videos", @@ -85,7 +86,7 @@ "Reply to Message": "Reply to Message", "Resend": "Resend", "SEND": "SEND", - "Search GIFs": "Search GIFs", + "Search": "Search", "See all {{count}} options_one": "See all {{count}} options", "See all {{count}} options_other": "See all {{count}} options", "Select More Photos": "Select More Photos", @@ -103,7 +104,6 @@ "The message has been reported to a moderator.": "The message has been reported to a moderator.", "The source message was deleted": "The source message was deleted", "Thinking...": "Thinking...", - "This is already an option": "This is already an option", "This reply was deleted": "This reply was deleted", "Thread Reply": "Thread Reply", "Type a number from 2 to 10": "Type a number from 2 to 10", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index 1834ea8e69..942b04bab5 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -68,6 +68,7 @@ "Only visible to you": "Solo visible para ti", "Open Settings": "Configuración abierta", "Option": "Opción", + "Option already exists": "La opción ya existe", "Options": "Opciones", "Photo": "Foto", "Photos and Videos": "Fotos y videos", @@ -85,7 +86,7 @@ "Reply to Message": "Responder al mensaje", "Resend": "Reenviar", "SEND": "ENVIAR", - "Search GIFs": "Buscar GIFs", + "Search": "Buscar", "See all {{count}} options_many": "Ver las {{count}} opciones", "See all {{count}} options_one": "Ver las {{count}} opciones", "See all {{count}} options_other": "Ver las {{count}} opciones", @@ -105,7 +106,6 @@ "The message has been reported to a moderator.": "El mensaje ha sido reportado a un moderador.", "The source message was deleted": "El mensaje original fue eliminado", "Thinking...": "Pensando...", - "This is already an option": "Esto ya es una opción", "This reply was deleted": "Esta respuesta fue eliminada", "Thread Reply": "Respuesta de hilo", "Type a number from 2 to 10": "Escribe un número de 2 a 10", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index fd071140d2..6d88580b3e 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -68,6 +68,7 @@ "Only visible to you": "Seulement visible par vous", "Open Settings": "Ouvrir les paramètres", "Option": "Option", + "Option already exists": "L'option existe déjà", "Options": "Options", "Photo": "Photo", "Photos and Videos": "Photos et vidéos", @@ -85,7 +86,7 @@ "Reply to Message": "Répondre au message", "Resend": "Renvoyer", "SEND": "ENVOYER", - "Search GIFs": "Rechercher des GIF", + "Search": "Rechercher", "See all {{count}} options_many": "Voir les {{count}} options", "See all {{count}} options_one": "Voir les {{count}} options", "See all {{count}} options_other": "Voir les {{count}} options", @@ -105,7 +106,6 @@ "The message has been reported to a moderator.": "Le message a été signalé à un modérateur.", "The source message was deleted": "Le message source a été supprimé", "Thinking...": "Réflexion...", - "This is already an option": "C'est déjà une option", "This reply was deleted": "Cette réponse a été supprimée", "Thread Reply": "Réponse à la discussion", "Type a number from 2 to 10": "Entrez un nombre de 2 à 10", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 588a74be54..7bba444c1c 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -68,6 +68,7 @@ "Only visible to you": "גלוי רק לך", "Open Settings": "פתח את ההגדרות", "Option": "אפשרות", + "Option already exists": "האפשרות כבר קיימת", "Options": "אפשרויות", "Photo": "תמונה", "Photos and Videos": "תמונות ווידאו", @@ -85,7 +86,7 @@ "Reply to Message": "השב/י להודעה", "Resend": "שלח/י שוב", "SEND": "שלח", - "Search GIFs": "חפש/י GIFs", + "Search": "חפש/י", "See all {{count}} options_one": "הצג את כל {{count}} האפשרויות", "See all {{count}} options_other": "הצג את כל {{count}} האפשרויות", "See all {{count}} options_two": "הצג את כל {{count}} האפשרויות", @@ -105,7 +106,6 @@ "The message has been reported to a moderator.": "ההודעה דווחה למנהל", "The source message was deleted": "ההודעה המקורית נמחקה", "Thinking...": "חושב...", - "This is already an option": "זו כבר אפשרות קיימת", "This reply was deleted": "התגובה הזו נמחקה", "Thread Reply": "הגב/י בשרשור", "Type a number from 2 to 10": "הקלד מספר בין 2 ל-10", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 022973da0d..6df7889c03 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -67,6 +67,7 @@ "Only visible to you": "केवल आपको दिखाई दे रहा है", "Open Settings": "सेटिंग्स खोलें", "Option": "विकल्प", + "Option already exists": "विकल्प पहले से मौजूद है", "Options": "विकल्प", "Photo": "तस्वीर", "Photos and Videos": "तस्वीरें और वीडियों", @@ -84,7 +85,7 @@ "Reply to Message": "संदेश का जवाब दें", "Resend": "पुन: भेजें", "SEND": "भेजें", - "Search GIFs": "GIF खोजें", + "Search": "खोजें", "See all {{count}} options_one": "सभी {{count}} विकल्प देखें", "See all {{count}} options_other": "सभी {{count}} विकल्प देखें", "Select More Photos": "अधिक फ़ोटो चुनें", @@ -102,7 +103,6 @@ "The message has been reported to a moderator.": "संदेश एक मॉडरेटर को सूचित किया गया है।", "The source message was deleted": "स्रोत संदेश हटा दिया गया है", "Thinking...": "सोच रहा है...", - "This is already an option": "यह पहले से एक विकल्प है", "This reply was deleted": "यह उत्तर हटा दिया गया है", "Thread Reply": "धागा जवाब", "Type a number from 2 to 10": "2 से 10 के बीच एक संख्या दर्ज करें", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 9538280326..144729cf36 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -68,6 +68,7 @@ "Only visible to you": "Visibile solo a te", "Open Settings": "Apri Impostazioni", "Option": "Opzione", + "Option already exists": "L'opzione esiste già", "Options": "Opzioni", "Photo": "Foto", "Photos and Videos": "Foto e Video", @@ -85,7 +86,7 @@ "Reply to Message": "Rispondi al messaggio", "Resend": "Invia di nuovo", "SEND": "INVIA", - "Search GIFs": "Cerca GIF", + "Search": "Cerca", "See all {{count}} options_many": "Vedi tutte le {{count}} opzioni", "See all {{count}} options_one": "Vedi tutte le {{count}} opzioni", "See all {{count}} options_other": "Vedi tutte le {{count}} opzioni", @@ -105,7 +106,6 @@ "The message has been reported to a moderator.": "Il messaggio è stato segnalato a un moderatore.", "The source message was deleted": "Il messaggio originale è stato eliminato", "Thinking...": "Pensando...", - "This is already an option": "Questa è già un'opzione", "This reply was deleted": "Questa risposta è stata eliminata", "Thread Reply": "Rispondi alla Discussione", "Type a number from 2 to 10": "Digita un numero da 2 a 10", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index a83c3ba93f..f92a1e564f 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -66,6 +66,7 @@ "Only visible to you": "あなただけに見える", "Open Settings": "設定を開く", "Option": "オプション", + "Option already exists": "オプションはすでに存在します", "Options": "オプション", "Photo": "写真", "Photos and Videos": "写真と動画", @@ -83,7 +84,7 @@ "Reply to Message": "メッセージに返信", "Resend": "再送", "SEND": "送信", - "Search GIFs": "GIFの探索", + "Search": "検索", "See all {{count}} options_one": "{{count}} 個のオプションをすべて表示", "See all {{count}} options_other": "{{count}} 個のオプションをすべて表示", "Select More Photos": "さらに写真を選択", @@ -101,7 +102,6 @@ "The message has been reported to a moderator.": "メッセージはモデレーターに報告されました。", "The source message was deleted": "元のメッセージが削除されました", "Thinking...": "考え中...", - "This is already an option": "これはすでにオプションです", "This reply was deleted": "この返信は削除されました", "Thread Reply": "スレッドの返信", "Type a number from 2 to 10": "2から10の数字を入力してください", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index a55b7d9b56..19a10d2db7 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -66,6 +66,7 @@ "Only visible to you": "당신만 볼 수 있습니다", "Open Settings": "설정 열기", "Option": "옵션", + "Option already exists": "옵션은 이미 존재합니다", "Options": "옵션", "Photo": "사진", "Photos and Videos": "사진과 동영상", @@ -83,7 +84,7 @@ "Reply to Message": "메시지에 답장", "Resend": "재전송", "SEND": "보내기", - "Search GIFs": "GIF의 검색", + "Search": "검색", "See all {{count}} options_one": "모든 {{count}} 옵션 보기", "See all {{count}} options_other": "모든 {{count}} 옵션 보기", "Select More Photos": "추가 사진 선택", @@ -101,7 +102,6 @@ "The message has been reported to a moderator.": "메시지는 운영자에보고되었습니다.", "The source message was deleted": "원본 메시지가 삭제되었습니다", "Thinking...": "생각 중...", - "This is already an option": "이미 존재하는 옵션입니다", "This reply was deleted": "이 답글은 삭제되었습니다", "Thread Reply": "스레드\u3000답장", "Type a number from 2 to 10": "2에서 10 사이의 숫자를 입력하세요", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index aaaa7a73bb..7c10de1c72 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -67,6 +67,7 @@ "Only visible to you": "Alleen zichtbaar voor jou", "Open Settings": "Open instellingen", "Option": "Optie", + "Option already exists": "Optie bestaat al", "Options": "Opties", "Photo": "Foto", "Photos and Videos": "Foto's en video's", @@ -84,7 +85,7 @@ "Reply to Message": "Beantwoord bericht", "Resend": "Opnieuw versturen", "SEND": "VERZENDEN", - "Search GIFs": "Zoek GIF's", + "Search": "Zoeken", "See all {{count}} options_one": "Bekijk alle {{count}} opties", "See all {{count}} options_other": "Bekijk alle {{count}} opties", "Select More Photos": "Selecteer Meer foto's", @@ -102,7 +103,6 @@ "The message has been reported to a moderator.": "Het bericht is gerapporteerd aan een moderator.", "The source message was deleted": "Het oorspronkelijke bericht is verwijderd", "Thinking...": "Aan het denken...", - "This is already an option": "Dit is al een optie", "This reply was deleted": "Deze reactie is verwijderd", "Thread Reply": "Discussie beantwoorden", "Type a number from 2 to 10": "Typ een getal van 2 tot 10", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index f94e35254e..600a5733a2 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -68,6 +68,7 @@ "Only visible to you": "Apenas visível para você", "Open Settings": "Abrir Configurações", "Option": "Opção", + "Option already exists": "A opção já existe", "Options": "Opções", "Photo": "Foto", "Photos and Videos": "Fotos e Vídeos", @@ -85,7 +86,7 @@ "Reply to Message": "Responder à Mensagem", "Resend": "Reenviar", "SEND": "ENVIAR", - "Search GIFs": "Pesquisar GIFs", + "Search": "Pesquisar", "See all {{count}} options_many": "Ver todas as {{count}} opções", "See all {{count}} options_one": "Ver todas as {{count}} opções", "See all {{count}} options_other": "Ver todas as {{count}} opções", @@ -105,7 +106,6 @@ "The message has been reported to a moderator.": "A mensagem foi relatada a um moderador.", "The source message was deleted": "A mensagem original foi excluída", "Thinking...": "Pensando...", - "This is already an option": "Isso já é uma opção", "This reply was deleted": "Esta resposta foi excluída", "Thread Reply": "Respostas de Tópico", "Type a number from 2 to 10": "Digite um número de 2 a 10", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index c8aa36e1a8..621fcb7d41 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -69,6 +69,7 @@ "Only visible to you": "Видно только вам", "Open Settings": "Открыть настройки", "Option": "Вариант", + "Option already exists": "Вариант уже существует", "Options": "Варианты", "Photo": "Фото", "Photos and Videos": "Фото и видео", @@ -86,7 +87,7 @@ "Reply to Message": "Ответить на сообщение", "Resend": "Отправить", "SEND": "ОТПРАВИТЬ", - "Search GIFs": "Поиск GIF", + "Search": "Поиск", "See all {{count}} options_few": "Посмотреть все {{count}} вариантов", "See all {{count}} options_many": "Посмотреть все {{count}} вариантов", "See all {{count}} options_one": "Посмотреть все {{count}} вариант", @@ -108,7 +109,6 @@ "The message has been reported to a moderator.": "Сообщение отправлено модератору.", "The source message was deleted": "Исходное сообщение было удалено", "Thinking...": "Думаю...", - "This is already an option": "Это уже вариант", "This reply was deleted": "Этот ответ был удалён", "Thread Reply": "Тема Ответить", "Type a number from 2 to 10": "Введите число от 2 до 10", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index efc6cd8ac0..860ee05199 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -67,6 +67,7 @@ "Only visible to you": "Sadece siz görebilirsiniz", "Open Settings": "Ayarları aç", "Option": "Seçenek", + "Option already exists": "Seçenek zaten var", "Options": "Seçenekler", "Photo": "Fotoğraf", "Photos and Videos": "Fotoğraflar ve Videolar", @@ -84,7 +85,7 @@ "Reply to Message": "Mesajı Yanıtla", "Resend": "Yeniden gönder", "SEND": "GÖNDER", - "Search GIFs": "GIF Ara", + "Search": "Ara", "See all {{count}} options_one": "Tüm {{count}} seçeneği gör", "See all {{count}} options_other": "Tüm {{count}} seçeneği gör", "Select More Photos": "Daha Fazla Fotoğraf Seçin", @@ -102,7 +103,6 @@ "The message has been reported to a moderator.": "Mesaj moderatöre bildirildi.", "The source message was deleted": "Kaynak mesaj silindi", "Thinking...": "Düşünüyor...", - "This is already an option": "Bu zaten bir seçenek", "This reply was deleted": "Bu yanıt silindi", "Thread Reply": "Konu Yanıtı", "Type a number from 2 to 10": "2 ile 10 arasında bir sayı girin", diff --git a/package/src/icons/Search.tsx b/package/src/icons/Search.tsx index c5c574fa1b..060e210780 100644 --- a/package/src/icons/Search.tsx +++ b/package/src/icons/Search.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { IconProps, RootPath, RootSvg } from './utils/base'; export const Search = (props: IconProps) => ( - + ( - + ( - + { + const { localMetadata, ...attachment } = localAttachment; + + if (isLocalImageAttachment(localAttachment)) { + const isRemoteUri = !!attachment.image_url; + + if (isRemoteUri) return attachment as Attachment; + + return { + ...attachment, + image_url: localMetadata?.previewUri, + originalFile: localMetadata.file, + } as Attachment; + } else { + const isRemoteUri = !!attachment.asset_url; + if (isRemoteUri) return attachment as Attachment; + + return { + ...attachment, + asset_url: (localMetadata.file as FileReference).uri, + originalFile: localMetadata.file, + } as Attachment; + } +}; + +export const createAttachmentsCompositionMiddleware = ( + composer: MessageComposer, +): MessageCompositionMiddleware => ({ + handlers: { + compose: ({ + state, + next, + forward, + }: MiddlewareHandlerParams) => { + const { attachmentManager } = composer; + if (!attachmentManager) return forward(); + + const attachments = (state.message.attachments ?? []).concat( + attachmentManager.attachments.map(localAttachmentToAttachment), + ); + + // prevent introducing attachments array into the payload sent to the server + if (!attachments.length) return forward(); + + return next({ + ...state, + localMessage: { + ...state.localMessage, + attachments: [...attachments], + }, + message: { + ...state.message, + attachments: [...attachments], + }, + }); + }, + }, + id: 'stream-io/message-composer-middleware/attachments', +}); + +export const createDraftAttachmentsCompositionMiddleware = ( + composer: MessageComposer, +): MessageDraftCompositionMiddleware => ({ + handlers: { + compose: ({ + state, + next, + forward, + }: MiddlewareHandlerParams) => { + const { attachmentManager } = composer; + if (!attachmentManager) return forward(); + + const attachments = (state.draft.attachments ?? []).concat( + attachmentManager.attachments.map(localAttachmentToAttachment), + ); + + return next({ + ...state, + draft: { + ...state.draft, + attachments, + }, + }); + }, + }, + id: 'stream-io/message-composer-middleware/draft-attachments', +}); diff --git a/package/src/middlewares/emojiControl.ts b/package/src/middlewares/emojiControl.ts new file mode 100644 index 0000000000..776848cbfb --- /dev/null +++ b/package/src/middlewares/emojiControl.ts @@ -0,0 +1,192 @@ +import mergeWith from 'lodash/mergeWith'; +import type { + Middleware, + SearchSourceOptions, + SearchSourceType, + TextComposerMiddlewareExecutorState, + TextComposerMiddlewareOptions, + TextComposerSuggestion, +} from 'stream-chat'; +import { + BaseSearchSource, + getTokenizedSuggestionDisplayName, + getTriggerCharWithToken, + insertItemWithTrigger, + replaceWordWithEntity, +} from 'stream-chat'; + +import { Emoji, EmojiSearchIndex } from '../types/types'; + +export type EmojiSuggestion = TextComposerSuggestion; + +class EmojiSearchSource> extends BaseSearchSource { + readonly type: SearchSourceType = 'emoji'; + private emojiSearchIndex: EmojiSearchIndex; + + constructor(emojiSearchIndex: EmojiSearchIndex, options?: SearchSourceOptions) { + super(options); + this.emojiSearchIndex = emojiSearchIndex; + } + + async query(searchQuery: string) { + if (searchQuery.length === 0) { + return { items: [] as T[], next: null }; + } + const emojis = (await this.emojiSearchIndex.search(searchQuery)) ?? []; + + // emojiIndex.search sometimes returns undefined values, so filter those out first + return { + items: emojis + .filter(Boolean) + .slice(0, 7) + .map(({ emoticons = [], id, name, native, skins = [] }) => { + const [firstSkin] = skins; + + return { + emoticons, + id, + name, + native: native ?? firstSkin.native, + }; + }) as T[], + next: null, // todo: generate cursor + }; + } + + protected filterQueryResults(items: T[]): T[] | Promise { + return items.map((item) => ({ + ...item, + ...getTokenizedSuggestionDisplayName({ + displayName: item.id, + searchToken: this.searchQuery, + }), + })); + } +} + +const DEFAULT_OPTIONS: TextComposerMiddlewareOptions = { minChars: 1, trigger: ':' }; + +export type EmojiMiddleware = Middleware< + TextComposerMiddlewareExecutorState>, + 'onChange' | 'onSuggestionItemSelect' +>; + +/** + * TextComposer middleware for mentions + * Usage: + * + * const textComposer = new TextComposer(options); + * + * textComposer.use(new createTextComposerEmojiMiddleware(emojiSearchIndex, { + * minChars: 2 + * })); + * + * @param emojiSearchIndex + * @param {{ + * minChars: number; + * trigger: string; + * }} options + * @returns + */ +export const createTextComposerEmojiMiddleware = ({ + emojiSearchIndex, + options, +}: { + emojiSearchIndex: EmojiSearchIndex; + options?: Partial; +}): EmojiMiddleware => { + const finalOptions = mergeWith(DEFAULT_OPTIONS, options ?? {}); + const emojiSearchSource = new EmojiSearchSource(emojiSearchIndex); + emojiSearchSource.activate(); + + return { + handlers: { + onChange: async ({ complete, forward, next, state }) => { + if (!state.selection) { + return forward(); + } + + const triggerWithToken = getTriggerCharWithToken({ + acceptTrailingSpaces: false, + text: state.text.slice(0, state.selection.end), + trigger: finalOptions.trigger, + }); + + const triggerWasRemoved = + !triggerWithToken || triggerWithToken.length < finalOptions.minChars; + + if (triggerWasRemoved) { + const hasSuggestionsForTrigger = state.suggestions?.trigger === finalOptions.trigger; + const newState = { ...state }; + if (hasSuggestionsForTrigger && newState.suggestions) { + delete newState.suggestions; + } + return next(newState); + } + + const newSearchTriggerred = triggerWithToken && triggerWithToken === finalOptions.trigger; + + if (newSearchTriggerred) { + emojiSearchSource.resetStateAndActivate(); + } + + const textWithReplacedWord = await replaceWordWithEntity({ + caretPosition: state.selection.end, + getEntityString: async (word: string) => { + const { items } = await emojiSearchSource.query(word); + + const emoji = items + .filter(Boolean) + .slice(0, 10) + .find(({ emoticons }) => !!emoticons?.includes(word)); + + if (!emoji) { + return null; + } + + const [firstSkin] = emoji.skins ?? []; + + return emoji.native ?? firstSkin.native; + }, + text: state.text, + }); + + if (textWithReplacedWord !== state.text) { + return complete({ + ...state, + suggestions: undefined, // to prevent the TextComposerMiddlewareExecutor to run the first page query + text: textWithReplacedWord, + }); + } + + return complete({ + ...state, + suggestions: { + query: triggerWithToken.slice(1), + searchSource: emojiSearchSource, + trigger: finalOptions.trigger, + }, + }); + }, + onSuggestionItemSelect: ({ complete, forward, state }) => { + const { selectedSuggestion } = state.change ?? {}; + if (!selectedSuggestion || state.suggestions?.trigger !== finalOptions.trigger) { + return forward(); + } + + emojiSearchSource.resetStateAndActivate(); + return complete({ + ...state, + ...insertItemWithTrigger({ + insertText: `${'native' in selectedSuggestion ? selectedSuggestion.native : ''} `, + selection: state.selection, + text: state.text, + trigger: finalOptions.trigger, + }), + suggestions: undefined, // Clear suggestions after selection + }); + }, + }, + id: 'stream-io/react-native-sdk/emoji-middleware', + }; +}; diff --git a/package/src/middlewares/index.ts b/package/src/middlewares/index.ts new file mode 100644 index 0000000000..060ef459db --- /dev/null +++ b/package/src/middlewares/index.ts @@ -0,0 +1,2 @@ +export * from './attachments'; +export * from './emojiControl'; diff --git a/package/src/mock-builders/api/channelMocks.tsx b/package/src/mock-builders/api/channelMocks.tsx index 35839a1e70..9c41c63fe1 100644 --- a/package/src/mock-builders/api/channelMocks.tsx +++ b/package/src/mock-builders/api/channelMocks.tsx @@ -1,8 +1,9 @@ import type { Attachment, Channel, LocalMessage, MessageResponse, UserResponse } from 'stream-chat'; import { + CHANNEL_MEMBERS, GROUP_CHANNEL_MEMBERS_MOCK, - ONE_MEMBER_WITH_EMPTY_USER_MOCK, + ONE_MEMBER_WITH_EMPTY_USER, } from '../../mock-builders/api/queryMembers'; const channelName = 'okechukwu'; @@ -12,140 +13,118 @@ const CHANNEL = { } as unknown as Channel; const CHANNEL_WITH_MESSAGES_TEXT = { - data: { name: channelName }, - state: { - members: GROUP_CHANNEL_MEMBERS_MOCK, - messages: [ - { - args: 'string', - attachments: [], - channel: CHANNEL, - cid: 'stridkncnng', - command: 'giphy', - command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - id: 'ljkblk', - text: 'jkbkbiubicbi', - type: 'MessageLabel', - user: { id: 'okechukwu' } as unknown as UserResponse, - } as unknown as MessageResponse, - { - args: 'string', - attachments: [], - channel: CHANNEL, - cid: 'stridodong', - command: 'giphy', - command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - id: 'jbkjb', - text: 'jkbkbiubicbi', - type: 'MessageLabel', - user: { id: 'okechukwu' } as unknown as UserResponse, - } as unknown as MessageResponse, - ], - }, -} as unknown as Channel; + members: CHANNEL_MEMBERS, + messages: [ + { + args: 'string', + attachments: [], + channel: CHANNEL, + cid: 'stridkncnng', + command: 'giphy', + command_info: { name: 'string' }, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + id: 'ljkblk', + text: 'jkbkbiubicbi', + type: 'MessageLabel', + user: { id: 'okechukwu' } as unknown as UserResponse, + } as unknown as MessageResponse, + { + args: 'string', + attachments: [], + channel: CHANNEL, + cid: 'stridodong', + command: 'giphy', + command_info: { name: 'string' }, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + id: 'jbkjb', + text: 'jkbkbiubicbi', + type: 'MessageLabel', + user: { id: 'okechukwu' } as unknown as UserResponse, + } as unknown as MessageResponse, + ], + name: channelName, +}; -const CHANNEL_WITH_DELETED_MESSAGES = { - data: { name: channelName }, - state: { - members: GROUP_CHANNEL_MEMBERS_MOCK, - messages: [ - { - type: 'deleted', - } as unknown as MessageResponse, - { - type: 'deleted', - } as unknown as MessageResponse, - ], - }, -} as unknown as Channel; +const CHANNEL_WITH_DELETED_MESSAGES = {}; const CHANNEL_WITH_NO_MESSAGES = { - data: { name: channelName }, - state: { - members: GROUP_CHANNEL_MEMBERS_MOCK, - messages: [], - }, -} as unknown as Channel; + members: CHANNEL_MEMBERS, + messages: [], + name: channelName, +}; const CHANNEL_WITH_MESSAGE_COMMAND = { - data: { name: channelName }, - state: { - members: GROUP_CHANNEL_MEMBERS_MOCK, - messages: [ - { - args: 'string', - attachments: [], - channel: CHANNEL, - cid: 'stridkncnng', - command: 'giphy', - command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - id: 'ljkblk', - user: { id: 'okechukwu' } as unknown as UserResponse, - } as unknown as MessageResponse, - { - args: 'string', - attachments: [], - channel: CHANNEL, - cid: 'stridodong', - command: 'giphy', - command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - id: 'jbkjb', - user: { id: 'okechukwu' } as unknown as UserResponse, - } as unknown as MessageResponse, - ], - }, -} as unknown as Channel; + members: CHANNEL_MEMBERS, + messages: [ + { + args: 'string', + attachments: [], + channel: CHANNEL, + cid: 'stridkncnng', + command: 'giphy', + command_info: { name: 'string' }, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + id: 'ljkblk', + user: { id: 'okechukwu' } as unknown as UserResponse, + } as unknown as MessageResponse, + { + args: 'string', + attachments: [], + channel: CHANNEL, + cid: 'stridodong', + command: 'giphy', + command_info: { name: 'string' }, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + id: 'jbkjb', + user: { id: 'okechukwu' } as unknown as UserResponse, + } as unknown as MessageResponse, + ], +}; const CHANNEL_WITH_MESSAGES_ATTACHMENTS = { - data: { name: channelName }, - state: { - members: GROUP_CHANNEL_MEMBERS_MOCK, - messages: [ - { - args: 'string', - attachments: [ - { - actions: [], - asset_url: 'string', - author_icon: 'string', - author_link: 'string', - author_name: 'string', - color: 'string', - fallback: 'string', - fields: [], - file_size: 25, - footer: 'string', - footer_icon: 'string', - image_url: 'string', - mime_type: 'string', - og_scrape_url: 'string', - original_height: 5, - original_width: 4, - pretext: 'string', - text: 'string', - thumb_url: 'string', - title: 'string', - title_link: 'string', - type: 'string', - } as Attachment, - ], - channel: CHANNEL, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - id: 'ljkblk', - user: { id: 'okechukwu' } as unknown as UserResponse, - } as unknown as MessageResponse, - ], - }, -} as unknown as Channel; + members: CHANNEL_MEMBERS, + messages: [ + { + args: 'string', + attachments: [ + { + actions: [], + asset_url: 'string', + author_icon: 'string', + author_link: 'string', + author_name: 'string', + color: 'string', + fallback: 'string', + fields: [], + file_size: 25, + footer: 'string', + footer_icon: 'string', + image_url: 'string', + mime_type: 'string', + og_scrape_url: 'string', + original_height: 5, + original_width: 4, + pretext: 'string', + text: 'string', + thumb_url: 'string', + title: 'string', + title_link: 'string', + type: 'string', + } as Attachment, + ], + channel: CHANNEL, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + id: 'ljkblk', + user: { id: 'okechukwu' } as unknown as UserResponse, + } as unknown as MessageResponse, + ], + name: channelName, +}; const LATEST_MESSAGE = { args: 'string', @@ -173,74 +152,70 @@ const FORMATTED_MESSAGE: LocalMessage = { }; const CHANNEL_WITH_MENTIONED_USERS = { - state: { - members: ONE_MEMBER_WITH_EMPTY_USER_MOCK, - messages: [ - { - args: 'string', - attachments: [], - cid: 'stridkncnng', - command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - mentioned_users: [ - { id: 'Max', name: 'Max' }, - { id: 'Ada', name: 'Ada' }, - { id: 'Enzo', name: 'Enzo' }, - ] as UserResponse[], - text: 'Max', - } as unknown as MessageResponse, - { - args: 'string', - attachments: [], - cid: 'stridodong', - command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - mentioned_users: [ - { id: 'Max', name: 'Max' }, - { id: 'Ada', name: 'Ada' }, - { id: 'Enzo', name: 'Enzo' }, - ] as UserResponse[], - text: 'Max', - } as unknown as MessageResponse, - ], - }, -} as unknown as Channel; + members: ONE_MEMBER_WITH_EMPTY_USER, + messages: [ + { + args: 'string', + attachments: [], + cid: 'stridkncnng', + command_info: { name: 'string' }, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + mentioned_users: [ + { id: 'Max', name: 'Max' }, + { id: 'Ada', name: 'Ada' }, + { id: 'Enzo', name: 'Enzo' }, + ] as UserResponse[], + text: 'Max', + } as unknown as MessageResponse, + { + args: 'string', + attachments: [], + cid: 'stridodong', + command_info: { name: 'string' }, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + mentioned_users: [ + { id: 'Max', name: 'Max' }, + { id: 'Ada', name: 'Ada' }, + { id: 'Enzo', name: 'Enzo' }, + ] as UserResponse[], + text: 'Max', + } as unknown as MessageResponse, + ], +}; const CHANNEL_WITH_EMPTY_MESSAGE = { - state: { - members: ONE_MEMBER_WITH_EMPTY_USER_MOCK, - messages: [ - { - args: 'string', - attachments: [], - cid: 'stridkncnng', - command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - mentioned_users: [ - { id: 'Max', name: 'Max' }, - { id: 'Ada', name: 'Ada' }, - { id: 'Enzo', name: 'Enzo' }, - ] as UserResponse[], - } as unknown as MessageResponse, - { - args: 'string', - attachments: [], - cid: 'stridodong', - command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), - mentioned_users: [ - { id: 'Max', name: 'Max' }, - { id: 'Ada', name: 'Ada' }, - { id: 'Enzo', name: 'Enzo' }, - ] as UserResponse[], - } as unknown as MessageResponse, - ], - }, -} as unknown as Channel; + members: ONE_MEMBER_WITH_EMPTY_USER, + messages: [ + { + args: 'string', + attachments: [], + cid: 'stridkncnng', + command_info: { name: 'string' }, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + mentioned_users: [ + { id: 'Max', name: 'Max' }, + { id: 'Ada', name: 'Ada' }, + { id: 'Enzo', name: 'Enzo' }, + ] as UserResponse[], + } as unknown as MessageResponse, + { + args: 'string', + attachments: [], + cid: 'stridodong', + command_info: { name: 'string' }, + created_at: new Date('2021-02-12T12:12:35.862Z'), + deleted_at: new Date('2021-02-12T12:12:35.862Z'), + mentioned_users: [ + { id: 'Max', name: 'Max' }, + { id: 'Ada', name: 'Ada' }, + { id: 'Enzo', name: 'Enzo' }, + ] as UserResponse[], + } as unknown as MessageResponse, + ], +}; const CHANNEL_WITH_MESSAGES = { data: { name: channelName }, @@ -248,7 +223,7 @@ const CHANNEL_WITH_MESSAGES = { members: GROUP_CHANNEL_MEMBERS_MOCK, messages: [FORMATTED_MESSAGE, FORMATTED_MESSAGE], }, -} as unknown as Channel; +}; export { CHANNEL, diff --git a/package/src/mock-builders/api/getOrCreateChannel.ts b/package/src/mock-builders/api/getOrCreateChannel.ts index b84c59fa67..c88e600897 100644 --- a/package/src/mock-builders/api/getOrCreateChannel.ts +++ b/package/src/mock-builders/api/getOrCreateChannel.ts @@ -2,9 +2,12 @@ import { mockedApiResponse } from './utils'; export type GetOrCreateChannelApiParams = { + draft?: Record; channel?: Record; members?: Record[]; messages?: Record[]; + pinnedMessages?: Record[]; + read?: Record[]; }; /** @@ -17,15 +20,21 @@ export type GetOrCreateChannelApiParams = { export const getOrCreateChannelApi = ( channel: GetOrCreateChannelApiParams = { channel: {}, + draft: {}, members: [], messages: [], + pinnedMessages: [], + read: [], }, ) => { const result = { channel: channel.channel, + draft: channel.draft, duration: 0.01, members: channel.members, messages: channel.messages, + pinnedMessages: channel.pinnedMessages, + read: channel.read, }; return mockedApiResponse(result, 'post'); diff --git a/package/src/mock-builders/api/initiateClientWithChannels.js b/package/src/mock-builders/api/initiateClientWithChannels.js new file mode 100644 index 0000000000..e783c012c6 --- /dev/null +++ b/package/src/mock-builders/api/initiateClientWithChannels.js @@ -0,0 +1,39 @@ +import { getOrCreateChannelApi } from './getOrCreateChannel'; +import { useMockedApis } from './useMockedApis'; + +import { generateChannel } from '../generator/channel'; +import { generateMember } from '../generator/member'; +import { generateUser } from '../generator/user'; +import { getTestClientWithUser } from '../mock'; + +const initChannelFromData = async ({ channelData, client, defaultGenerateChannelOptions }) => { + const mockedChannelData = generateChannel({ + ...defaultGenerateChannelOptions, + ...channelData, + }); + + useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); + const channel = client.channel(mockedChannelData.channel.type, mockedChannelData.channel.id); + await channel.watch(); + jest.spyOn(channel, 'getConfig').mockImplementation(() => mockedChannelData.channel.config); + // jest + // .spyOn(channel, 'getDraft') + // .mockImplementation(() => generateMessageDraft({ channel_cid: channel.cid })); + return channel; +}; + +export const initiateClientWithChannels = async ({ channelsData, customUser } = {}) => { + const user = customUser || generateUser(); + const client = await getTestClientWithUser(user); + + const defaultGenerateChannelOptions = { + members: [generateMember({ user })], + }; + const channels = await Promise.all( + (channelsData ?? [defaultGenerateChannelOptions]).map((channelData) => + initChannelFromData({ channelData, client, defaultGenerateChannelOptions }), + ), + ); + + return { channels, client }; +}; diff --git a/package/src/mock-builders/api/queryMembers.js b/package/src/mock-builders/api/queryMembers.js index 74006ea1b6..e0bc27c003 100644 --- a/package/src/mock-builders/api/queryMembers.js +++ b/package/src/mock-builders/api/queryMembers.js @@ -15,24 +15,8 @@ export const queryMembersApi = (members = []) => { return mockedApiResponse(result, 'get'); }; -export const ONE_CHANNEL_MEMBER_MOCK = { - okey: { - banned: false, - channel_role: 'channel_member', - created_at: '2021-01-27T11:54:34.173125Z', - role: 'member', - shadow_banned: false, - updated_at: '2021-02-12T12:12:35.862282Z', - user: { - id: 'okechukwu nwagba martin', - name: 'okechukwu nwagba martin', - }, - user_id: 'okechukwu nwagba martin', - }, -}; - -export const GROUP_CHANNEL_MEMBERS_MOCK = { - ben: { +export const CHANNEL_MEMBERS = [ + { banned: false, channel_role: 'channel_member', created_at: '2021-01-27T11:54:34.173125Z', @@ -45,7 +29,7 @@ export const GROUP_CHANNEL_MEMBERS_MOCK = { }, user_id: 'ben', }, - nick: { + { banned: false, channel_role: 'channel_member', created_at: '2021-01-27T11:54:34.173125Z', @@ -58,7 +42,7 @@ export const GROUP_CHANNEL_MEMBERS_MOCK = { }, user_id: 'nick', }, - okey: { + { banned: false, channel_role: 'channel_member', created_at: '2021-01-27T11:54:34.173125Z', @@ -71,7 +55,7 @@ export const GROUP_CHANNEL_MEMBERS_MOCK = { }, user_id: 'okechukwu nwagba', }, - qatest1: { + { banned: false, channel_role: 'channel_member', created_at: '2021-01-28T09:08:43.274508Z', @@ -85,7 +69,7 @@ export const GROUP_CHANNEL_MEMBERS_MOCK = { user_id: 'qatest1', }, - thierry: { + { banned: false, channel_role: 'channel_member', created_at: '2021-01-27T11:54:34.173125Z', @@ -98,10 +82,35 @@ export const GROUP_CHANNEL_MEMBERS_MOCK = { }, user_id: 'thierry', }, +]; + +export const ONE_CHANNEL_MEMBER = [ + { + banned: false, + channel_role: 'channel_member', + created_at: '2021-01-27T11:54:34.173125Z', + role: 'member', + shadow_banned: false, + updated_at: '2021-02-12T12:12:35.862282Z', + user: { + id: 'okechukwu nwagba martin', + name: 'okechukwu nwagba martin', + }, + user_id: 'okechukwu nwagba martin', + }, +]; + +export const ONE_CHANNEL_MEMBER_MOCK = { + okey: ONE_CHANNEL_MEMBER[0], }; -export const ONE_MEMBER_WITH_EMPTY_USER_MOCK = { - okey: { +export const GROUP_CHANNEL_MEMBERS_MOCK = CHANNEL_MEMBERS.reduce((acc, member) => { + acc[member.user_id] = member; + return acc; +}, {}); + +export const ONE_MEMBER_WITH_EMPTY_USER = [ + { banned: false, channel_role: 'channel_member', created_at: '2021-01-27T11:54:34.173125Z', @@ -111,4 +120,8 @@ export const ONE_MEMBER_WITH_EMPTY_USER_MOCK = { user: {}, user_id: 'okechukwu nwagba martin', }, +]; + +export const ONE_MEMBER_WITH_EMPTY_USER_MOCK = { + okey: ONE_MEMBER_WITH_EMPTY_USER[0], }; diff --git a/package/src/mock-builders/attachments.js b/package/src/mock-builders/attachments.js new file mode 100644 index 0000000000..19de51d537 --- /dev/null +++ b/package/src/mock-builders/attachments.js @@ -0,0 +1,55 @@ +import { generateRandomId } from '../utils/utils'; + +export const generateLocalAttachmentData = () => ({ + localMetadata: { + id: generateRandomId(), + }, +}); + +export const generateLocalFileUploadAttachmentData = (overrides, attachmentData) => ({ + localMetadata: { + ...generateLocalAttachmentData().localMetadata, + ...overrides, + file: generateFileReference(overrides?.file ?? {}), + }, + type: 'file', + ...attachmentData, +}); + +export const generateImageAttachment = (a) => ({ + fallback: generateRandomId() + '.png', + image_url: 'https://' + generateRandomId() + '.png', + type: 'image', + ...a, +}); + +export const generateAudioAttachment = (a) => ({ + asset_url: 'https://' + generateRandomId() + '.mp3', + fallback: generateRandomId() + '.mp3', + type: 'audio', + ...a, +}); + +export const generateFileAttachment = (a) => ({ + asset_url: 'https://' + generateRandomId() + '.xls', + fallback: generateRandomId() + '.xls', + type: 'file', + ...a, +}); + +export const generateVideoAttachment = (a) => ({ + fallback: generateRandomId() + '.mp4', + image_url: 'https://' + generateRandomId() + '.mp4', + type: 'video', + ...a, +}); + +const fileName = generateRandomId() + '.png'; + +export const generateFileReference = (a) => ({ + name: fileName, + size: 1000, + type: 'image/png', + uri: 'file://' + generateRandomId() + '.png', + ...a, +}); diff --git a/package/src/mock-builders/generator/channel.ts b/package/src/mock-builders/generator/channel.ts index 542a2f285a..8b0efad2ad 100644 --- a/package/src/mock-builders/generator/channel.ts +++ b/package/src/mock-builders/generator/channel.ts @@ -58,8 +58,7 @@ const getChannelDefaults = ( { id, type }: { [key: string]: any } = { id: uuidv4(), type: 'messaging' }, ) => ({ _client: {}, - cid: `${type}:${id}`, - data: { + channel: { cid: `${type}:${id}`, config: { ...defaultConfig, @@ -74,7 +73,9 @@ const getChannelDefaults = ( type, updated_at: '2020-04-28T11:20:48.578147Z', }, + cid: `${type}:${id}`, id, + messages: [], state: defaultState, type, }); @@ -115,7 +116,7 @@ export const generateChannelResponse = ( const defaults = getChannelDefaults(); return { channel: { - ...defaults.data, + ...defaults.channel, ...{ cid: `${type}:${id}`, ...channel, diff --git a/package/src/polyfills.ts b/package/src/polyfills.ts index 55f82860bf..c687cae293 100644 --- a/package/src/polyfills.ts +++ b/package/src/polyfills.ts @@ -1,4 +1,9 @@ +import structuredClone from '@ungap/structured-clone'; + (function () { + if (!window.structuredClone) { + window.structuredClone = structuredClone; + } if (!Array.prototype.at) { // eslint-disable-next-line no-extend-native Object.defineProperty(Array.prototype, 'at', { diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index 6f6173018d..254a5cf2de 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -16,12 +16,6 @@ export class OfflineDB extends AbstractOfflineDB { super({ client }); } - getDraft = () => Promise.resolve(null); - - upsertDraft = () => Promise.resolve([]); - - deleteDraft = () => Promise.resolve([]); - upsertCidsForQuery = api.upsertCidsForQuery; upsertChannels = api.upsertChannels; @@ -36,6 +30,12 @@ export class OfflineDB extends AbstractOfflineDB { upsertPoll = api.upsertPoll; + upsertDraft = api.upsertDraft; + + getDraft = api.getDraft; + + deleteDraft = api.deleteDraft; + upsertChannelData = api.upsertChannelData; upsertReads = api.upsertReads; diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index d507c7a3d7..b3d9072b76 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -28,7 +28,7 @@ import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './typ * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 9; + static dbVersion = 11; static dbName = DB_NAME; static dbLocation = DB_LOCATION; diff --git a/package/src/store/apis/addPendingTask.ts b/package/src/store/apis/addPendingTask.ts index 5d2cbc8290..28b141e691 100644 --- a/package/src/store/apis/addPendingTask.ts +++ b/package/src/store/apis/addPendingTask.ts @@ -14,8 +14,22 @@ import { SqliteClient } from '../SqliteClient'; */ export const addPendingTask = async (task: PendingTask) => { const storable = mapTaskToStorable(task); - const { channelId, channelType, payload, type } = storable; - const query = createUpsertQuery('pendingTasks', storable); + const { channelId, channelType, threadId, payload, type } = storable; + const queries = []; + if (type === 'create-draft' || type === 'delete-draft') { + // Only one draft pending task is allowed per entity (i.e thread, channel etc). + // If multiple arrive, we'll simply take the last one (since deleteDraft does not + // fail as an API if a draft doesn't exist). + queries.push( + createDeleteQuery('pendingTasks', { + channelId, + channelType, + threadId, + type: ['create-draft', 'delete-draft'], + }), + ); + } + queries.push(createUpsertQuery('pendingTasks', storable)); SqliteClient.logger?.('info', 'addPendingTask', { channelId, channelType, @@ -23,7 +37,7 @@ export const addPendingTask = async (task: PendingTask) => { type, }); - await SqliteClient.executeSql.apply(null, query); + await SqliteClient.executeSqlBatch(queries); return async () => { SqliteClient.logger?.('info', 'deletePendingTaskAfterAddition', { @@ -39,6 +53,6 @@ export const addPendingTask = async (task: PendingTask) => { type, }); - await SqliteClient.executeSql.apply(null, query); + await SqliteClient.executeSqlBatch([query]); }; }; diff --git a/package/src/store/apis/deleteDraft.ts b/package/src/store/apis/deleteDraft.ts new file mode 100644 index 0000000000..9989ac0315 --- /dev/null +++ b/package/src/store/apis/deleteDraft.ts @@ -0,0 +1,28 @@ +import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; +import { SqliteClient } from '../SqliteClient'; + +export const deleteDraft = async ({ + cid, + parent_id, + execute = true, +}: { + cid: string; + parent_id?: string; + execute?: boolean; +}) => { + const query = createDeleteQuery('draft', { + cid, + parentId: parent_id, + }); + + SqliteClient.logger?.('info', 'deleteDraft', { + cid, + execute, + }); + + if (execute) { + await SqliteClient.executeSql.apply(null, query); + } + + return [query]; +}; diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index 5302045e23..7fdfff9533 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -33,6 +33,8 @@ export const getChannelMessages = async ({ } messageIdVsReactions[reaction.messageId].push(reaction); }); + + // Populate the polls. const messageIdsVsPolls: Record> = {}; const pollsById: Record> = {}; const messagesWithPolls = messageRows.filter((message) => !!message.poll_id); diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index 72a94e1d33..5258584ec0 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -1,6 +1,7 @@ import type { ChannelAPIResponse } from 'stream-chat'; import { getChannelMessages } from './getChannelMessages'; +import { getDraftForChannels } from './getDraftsForChannels'; import { getMembers } from './getMembers'; import { getReads } from './getReads'; import { selectChannels } from './queries/selectChannels'; @@ -26,8 +27,9 @@ export const getChannels = async ({ }): Promise[]> => { SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId }); - const [channels, cidVsMembers, cidVsReads, cidVsMessages] = await Promise.all([ + const [channels, cidVsDraft, cidVsMembers, cidVsReads, cidVsMessages] = await Promise.all([ selectChannels({ channelIds }), + getDraftForChannels({ channelIds, currentUserId }), getMembers({ channelIds }), getReads({ channelIds }), getChannelMessages({ @@ -39,6 +41,7 @@ export const getChannels = async ({ // Enrich the channels with state return channels.map((c) => ({ ...mapStorableToChannel(c), + draft: cidVsDraft[c.cid], members: cidVsMembers[c.cid] || [], membership: (cidVsMembers[c.cid] || []).find((member) => member.user_id === currentUserId), messages: cidVsMessages[c.cid] || [], diff --git a/package/src/store/apis/getDraft.ts b/package/src/store/apis/getDraft.ts new file mode 100644 index 0000000000..83ef191352 --- /dev/null +++ b/package/src/store/apis/getDraft.ts @@ -0,0 +1,57 @@ +import { selectDraftMessageFromDraft } from './queries/selectDraftMessageFromDraft'; +import { selectMessageForId } from './queries/selectMessageById'; + +import { mapStorableToDraft } from '../mappers/mapStorableToDraft'; +import { createSelectQuery } from '../sqlite-utils/createSelectQuery'; +import { SqliteClient } from '../SqliteClient'; +import { TableRow } from '../types'; + +export const getDraft = async ({ + cid, + userId, + parent_id, +}: { + cid: string; + userId: string; + parent_id?: string; +}) => { + SqliteClient.logger?.('info', 'getDraft', { cid, parent_id }); + + try { + const draftRowsWithMessage = await selectDraftMessageFromDraft({ + cid, + parent_id: parent_id ?? null, + }); + + if (!draftRowsWithMessage) return null; + + const draftRowWithMessage = draftRowsWithMessage; + + if (!draftRowWithMessage) { + return null; + } + + const channelQuery = createSelectQuery('channels', ['*'], { cid }); + const channelRows = await SqliteClient.executeSql.apply(null, channelQuery); + + const quotedMessageRows = await selectMessageForId(draftRowWithMessage.quotedMessageId); + + const polls = (await SqliteClient.executeSql.apply( + null, + createSelectQuery('poll', ['*'], { + id: quotedMessageRows?.poll_id, + }), + )) as unknown as TableRow<'poll'>[]; + + return mapStorableToDraft({ + channelRow: channelRows[0] as unknown as TableRow<'channels'>, + currentUserId: userId, + draftRow: draftRowWithMessage, + pollRow: polls[0], + quotedMessageRow: quotedMessageRows, + }); + } catch (error) { + console.error('Error in getDraft:', error); + throw error; + } +}; diff --git a/package/src/store/apis/getDraftsForChannels.ts b/package/src/store/apis/getDraftsForChannels.ts new file mode 100644 index 0000000000..f2d497fe94 --- /dev/null +++ b/package/src/store/apis/getDraftsForChannels.ts @@ -0,0 +1,53 @@ +import { DraftResponse } from 'stream-chat'; + +import { selectDraftMessageFromDraftForChannels } from './queries/selectDraftMessageFromDraftForChannels'; +import { selectMessageForId } from './queries/selectMessageById'; + +import { mapStorableToDraft } from '../mappers/mapStorableToDraft'; +import { createSelectQuery } from '../sqlite-utils/createSelectQuery'; +import { SqliteClient } from '../SqliteClient'; +import { TableRow } from '../types'; + +export const getDraftForChannels = async ({ + channelIds, + currentUserId, +}: { + channelIds: string[]; + currentUserId: string; +}) => { + SqliteClient.logger?.('info', 'getDraftsForChannel', { channelIds }); + + const draftRowsWithMessage = await selectDraftMessageFromDraftForChannels(channelIds); + + /** + * Filter out drafts without a parent ID, as we will not show them in the channel. + * The above `createSelectQuery` was not able to filter out drafts without a parent ID. + * TODO: Fix the query to filter out drafts without a parent ID. This can be a bit faster. + */ + const rowsWithoutParentID = draftRowsWithMessage.filter((row) => row.parentId === null); + + const cidVsDrafts: Record = {}; + + for (const row of rowsWithoutParentID) { + const channelQuery = createSelectQuery('channels', ['*'], { cid: row.cid }); + const channelRows = await SqliteClient.executeSql.apply(null, channelQuery); + + const quotedMessageRow = await selectMessageForId(row.quotedMessageId); + + const polls = (await SqliteClient.executeSql.apply( + null, + createSelectQuery('poll', ['*'], { + id: quotedMessageRow?.poll_id, + }), + )) as unknown as TableRow<'poll'>[]; + + cidVsDrafts[row.cid] = mapStorableToDraft({ + channelRow: channelRows[0] as unknown as TableRow<'channels'>, + currentUserId, + draftRow: row, + pollRow: polls[0], + quotedMessageRow, + }); + } + return cidVsDrafts; +}; diff --git a/package/src/store/apis/index.ts b/package/src/store/apis/index.ts index a3201a5c69..1d1d66686c 100644 --- a/package/src/store/apis/index.ts +++ b/package/src/store/apis/index.ts @@ -31,3 +31,6 @@ export * from './getPendingTasks'; export * from './softDeleteMessage'; export * from './channelExists'; export * from './dropPendingTasks'; +export * from './getDraft'; +export * from './upsertDraft'; +export * from './deleteDraft'; diff --git a/package/src/store/apis/queries/selectDraftMessageFromDraft.ts b/package/src/store/apis/queries/selectDraftMessageFromDraft.ts new file mode 100644 index 0000000000..b67ad80fca --- /dev/null +++ b/package/src/store/apis/queries/selectDraftMessageFromDraft.ts @@ -0,0 +1,41 @@ +import { tables } from '../../schema'; +import { SqliteClient } from '../../SqliteClient'; +import { TableRowJoinedDraftMessage } from '../../types'; + +export const selectDraftMessageFromDraft = async ({ + cid, + parent_id, +}: { + cid: string; + parent_id: string | null; +}): Promise | undefined> => { + const draftColumnNames = Object.keys(tables.draft.columns) + .map((name) => `'${name}', a.${name}`) + .join(', '); + const draftMessageColumnNames = Object.keys(tables.draftMessage.columns) + .map((name) => `'${name}', b.${name}`) + .join(', '); + + SqliteClient.logger?.('info', 'selectDraftMessageFromDraft', { + cid, + parent_id, + }); + + const result = await SqliteClient.executeSql( + `SELECT + json_object( + 'draftMessage', json_object( + ${draftMessageColumnNames} + ), + ${draftColumnNames} + ) as value + FROM draft a + LEFT JOIN + draftMessage b + ON b.id = a.draftMessageId + WHERE a.cid = ? AND a.parentId is ?`, + [cid, parent_id], + ); + + return result[0] ? JSON.parse(result[0].value) : undefined; +}; diff --git a/package/src/store/apis/queries/selectDraftMessageFromDraftForChannels.ts b/package/src/store/apis/queries/selectDraftMessageFromDraftForChannels.ts new file mode 100644 index 0000000000..146c834b3c --- /dev/null +++ b/package/src/store/apis/queries/selectDraftMessageFromDraftForChannels.ts @@ -0,0 +1,41 @@ +import { tables } from '../../schema'; +import { SqliteClient } from '../../SqliteClient'; +import type { TableRowJoinedDraftMessage } from '../../types'; + +export const selectDraftMessageFromDraftForChannels = async ( + cids?: string[], +): Promise[]> => { + if (!cids || cids.length === 0) { + return []; + } + + const questionMarks = Array(cids.length).fill('?').join(','); + const draftColumnNames = Object.keys(tables.draft.columns) + .map((name) => `'${name}', a.${name}`) + .join(', '); + const draftMessageColumnNames = Object.keys(tables.draftMessage.columns) + .map((name) => `'${name}', b.${name}`) + .join(', '); + + SqliteClient.logger?.('info', 'selectDraftMessageFromDraftForChannels', { + cids, + }); + + const result = await SqliteClient.executeSql( + `SELECT + json_object( + 'draftMessage', json_object( + ${draftMessageColumnNames} + ), + ${draftColumnNames} + ) as value + FROM draft a + LEFT JOIN + draftMessage b + ON b.id = a.draftMessageId + WHERE cid in (${questionMarks}) ORDER BY datetime(a.createdAt) DESC`, + cids, + ); + + return result.map((r) => JSON.parse(r.value)); +}; diff --git a/package/src/store/apis/queries/selectMessageById.ts b/package/src/store/apis/queries/selectMessageById.ts new file mode 100644 index 0000000000..a8c28db840 --- /dev/null +++ b/package/src/store/apis/queries/selectMessageById.ts @@ -0,0 +1,44 @@ +import { tables } from '../../schema'; +import { SqliteClient } from '../../SqliteClient'; +import type { TableRowJoinedUser } from '../../types'; + +export const selectMessageForId = async ( + msgId?: string, +): Promise | undefined> => { + if (!msgId) { + return undefined; + } + + const messagesColumnNames = Object.keys(tables.messages.columns) + .map((name) => `'${name}', a.${name}`) + .join(', '); + const userColumnNames = Object.keys(tables.users.columns) + .map((name) => `'${name}', b.${name}`) + .join(', '); + + SqliteClient.logger?.('info', 'selectMessagesForId', { + msgId, + }); + + const result = await SqliteClient.executeSql( + `SELECT + json_object( + 'user', json_object( + ${userColumnNames} + ), + ${messagesColumnNames} + ) as value + FROM ( + SELECT + * + FROM messages + WHERE id = ? + ) a + LEFT JOIN + users b + ON b.id = a.userId`, + [msgId], + ); + + return result[0] ? JSON.parse(result[0].value) : undefined; +}; diff --git a/package/src/store/apis/queries/selectMessagesForChannels.ts b/package/src/store/apis/queries/selectMessagesForChannels.ts index 71bf431f10..c7c62074f5 100644 --- a/package/src/store/apis/queries/selectMessagesForChannels.ts +++ b/package/src/store/apis/queries/selectMessagesForChannels.ts @@ -38,7 +38,7 @@ export const selectMessagesForChannels = async ( LEFT JOIN users b ON b.id = a.userId - WHERE RowNum < 200 + WHERE RowNum < 25 ORDER BY a.createdAt ASC`, cids, ); diff --git a/package/src/store/apis/upsertChannels.ts b/package/src/store/apis/upsertChannels.ts index 26cc87bf77..8bfc83896b 100644 --- a/package/src/store/apis/upsertChannels.ts +++ b/package/src/store/apis/upsertChannels.ts @@ -1,5 +1,6 @@ import type { ChannelAPIResponse, ChannelMemberResponse } from 'stream-chat'; +import { upsertDraft } from './upsertDraft'; import { upsertMembers } from './upsertMembers'; import { upsertMessages } from './upsertMessages'; @@ -31,13 +32,18 @@ export const upsertChannels = async ({ for (const channel of channels) { queries.push(createUpsertQuery('channels', mapChannelDataToStorable(channel.channel))); - const { members, membership, messages, read } = channel; + const { draft, members, membership, messages, read } = channel; if ( membership && !members.includes((m: ChannelMemberResponse) => m.user?.id === membership.user?.id) ) { members.push({ ...membership, user_id: membership.user?.id }); } + + if (draft) { + queries = queries.concat(await upsertDraft({ draft, execute: false })); + } + queries = queries.concat( await upsertMembers({ cid: channel.channel.cid, diff --git a/package/src/store/apis/upsertDraft.ts b/package/src/store/apis/upsertDraft.ts new file mode 100644 index 0000000000..b774b0ff6e --- /dev/null +++ b/package/src/store/apis/upsertDraft.ts @@ -0,0 +1,63 @@ +import { DraftResponse } from 'stream-chat'; + +import { upsertMessages } from './upsertMessages'; + +import { mapDraftMessageToStorable } from '../mappers/mapDraftMessageToStorable'; +import { mapDraftToStorable } from '../mappers/mapDraftToStorable'; +import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; +import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; +import { SqliteClient } from '../SqliteClient'; +import type { PreparedQueries } from '../types'; + +export const upsertDraft = async ({ + execute = true, + draft, +}: { + draft: DraftResponse; + execute?: boolean; +}) => { + const queries: PreparedQueries[] = []; + const { channel_cid, parent_id, message } = draft; + + // Delete existing draft message if it exists. + const deleteQuery = createDeleteQuery('draft', { + cid: channel_cid, + parentId: parent_id, + }); + + queries.push(deleteQuery); + + // Important: Make sure you create a draft only after a draft message is created. + const storableDraftMessage = mapDraftMessageToStorable({ draftMessage: message }); + + queries.push(createUpsertQuery('draftMessage', storableDraftMessage)); + + const storableDraft = mapDraftToStorable({ draft }); + + queries.push(createUpsertQuery('draft', storableDraft)); + + SqliteClient.logger?.('info', 'upsertDraft', { + draftMessage: storableDraftMessage, + }); + + const messagesToUpsert = []; + + if (draft.quoted_message) { + messagesToUpsert.push(draft.quoted_message); + } + + if (draft.parent_message) { + messagesToUpsert.push(draft.parent_message); + } + + if (messagesToUpsert.length > 0) { + const query = await upsertMessages({ execute: false, messages: messagesToUpsert }); + queries.concat(query); + } + + if (execute) { + await SqliteClient.executeSqlBatch(queries); + } + + return queries; +}; diff --git a/package/src/store/apis/upsertMessages.ts b/package/src/store/apis/upsertMessages.ts index 84a13e22aa..6e7d68b396 100644 --- a/package/src/store/apis/upsertMessages.ts +++ b/package/src/store/apis/upsertMessages.ts @@ -1,4 +1,4 @@ -import type { MessageResponse } from 'stream-chat'; +import type { LocalMessage, MessageResponse } from 'stream-chat'; import { mapMessageToStorable } from '../mappers/mapMessageToStorable'; import { mapPollToStorable } from '../mappers/mapPollToStorable'; @@ -11,7 +11,7 @@ export const upsertMessages = async ({ execute = true, messages, }: { - messages: MessageResponse[]; + messages: (MessageResponse | LocalMessage)[]; execute?: boolean; }) => { const storableMessages: Array> = []; @@ -19,7 +19,7 @@ export const upsertMessages = async ({ const storableReactions: Array> = []; const storablePolls: Array> = []; - messages?.forEach((message: MessageResponse) => { + messages?.forEach((message: MessageResponse | LocalMessage) => { storableMessages.push(mapMessageToStorable(message)); if (message.user) { storableUsers.push(mapUserToStorable(message.user)); diff --git a/package/src/store/mappers/mapDraftMessageToStorable.ts b/package/src/store/mappers/mapDraftMessageToStorable.ts new file mode 100644 index 0000000000..4cdc216e50 --- /dev/null +++ b/package/src/store/mappers/mapDraftMessageToStorable.ts @@ -0,0 +1,37 @@ +import { DraftMessage } from 'stream-chat'; + +import { TableRow } from '../types'; + +export const mapDraftMessageToStorable = ({ + draftMessage, +}: { + draftMessage: DraftMessage; +}): TableRow<'draftMessage'> => { + const { + id, + custom, + text, + attachments, + mentioned_users, + parent_id, + poll_id, + quoted_message_id, + show_in_channel, + silent, + type, + } = draftMessage; + + return { + attachments: attachments ? JSON.stringify(attachments) : undefined, + custom: custom ? JSON.stringify(custom) : undefined, + id, + mentionedUsers: mentioned_users ? JSON.stringify(mentioned_users) : undefined, + parentId: parent_id, + poll_id, + quotedMessageId: quoted_message_id, + showInChannel: show_in_channel, + silent, + text, + type, + }; +}; diff --git a/package/src/store/mappers/mapDraftToStorable.ts b/package/src/store/mappers/mapDraftToStorable.ts new file mode 100644 index 0000000000..a72ba6c21e --- /dev/null +++ b/package/src/store/mappers/mapDraftToStorable.ts @@ -0,0 +1,18 @@ +import type { DraftResponse } from 'stream-chat'; + +import { mapDateTimeToStorable } from './mapDateTimeToStorable'; + +import type { TableRow } from '../types'; + +export const mapDraftToStorable = ({ draft }: { draft: DraftResponse }): TableRow<'draft'> => { + const { channel_cid, created_at, parent_id, message, quoted_message } = draft; + const createdAt = mapDateTimeToStorable(created_at); + + return { + cid: channel_cid, + createdAt, + draftMessageId: message.id, + parentId: parent_id, + quotedMessageId: quoted_message?.id, + }; +}; diff --git a/package/src/store/mappers/mapStorableToDraft.ts b/package/src/store/mappers/mapStorableToDraft.ts new file mode 100644 index 0000000000..9e98c4d53c --- /dev/null +++ b/package/src/store/mappers/mapStorableToDraft.ts @@ -0,0 +1,41 @@ +import { DraftResponse } from 'stream-chat'; + +import { mapStorableToChannel } from './mapStorableToChannel'; +import { mapStorableToDraftMessage } from './mapStorableToDraftMessage'; + +import { mapStorableToMessage } from './mapStorableToMessage'; + +import type { TableRow, TableRowJoinedDraftMessage, TableRowJoinedUser } from '../types'; + +export const mapStorableToDraft = ({ + currentUserId, + draftRow, + channelRow, + pollRow, + quotedMessageRow, +}: { + currentUserId: string; + draftRow: TableRowJoinedDraftMessage<'draft'>; + channelRow: TableRow<'channels'>; + pollRow: TableRow<'poll'>; + quotedMessageRow?: TableRowJoinedUser<'messages'>; +}): DraftResponse => { + const { createdAt, cid, parentId } = draftRow; + + const message = mapStorableToDraftMessage(draftRow.draftMessage); + + const channel = mapStorableToChannel(channelRow); + + const quotedMessage = quotedMessageRow + ? mapStorableToMessage({ currentUserId, messageRow: quotedMessageRow, pollRow }) + : undefined; + + return { + channel: channel.channel, + channel_cid: cid, + created_at: createdAt, + message, + parent_id: parentId, + quoted_message: quotedMessage, + }; +}; diff --git a/package/src/store/mappers/mapStorableToDraftMessage.ts b/package/src/store/mappers/mapStorableToDraftMessage.ts new file mode 100644 index 0000000000..3d194a9448 --- /dev/null +++ b/package/src/store/mappers/mapStorableToDraftMessage.ts @@ -0,0 +1,35 @@ +import { DraftMessage } from 'stream-chat'; + +import { TableRow } from '../types'; + +export const mapStorableToDraftMessage = ( + draftMessageRow: TableRow<'draftMessage'>, +): DraftMessage => { + const { + id, + custom, + text, + attachments, + mentionedUsers, + parentId, + poll_id, + quotedMessageId, + showInChannel, + silent, + type, + } = draftMessageRow; + + return { + attachments: attachments ? JSON.parse(attachments) : undefined, + custom: custom ? JSON.parse(custom) : undefined, + id, + mentioned_users: mentionedUsers ? JSON.parse(mentionedUsers) : undefined, + parent_id: parentId, + poll_id, + quoted_message_id: quotedMessageId, + show_in_channel: showInChannel, + silent, + text, + type, + }; +}; diff --git a/package/src/store/mappers/mapStorableToMessage.ts b/package/src/store/mappers/mapStorableToMessage.ts index f94cae17f2..a1f709ad5c 100644 --- a/package/src/store/mappers/mapStorableToMessage.ts +++ b/package/src/store/mappers/mapStorableToMessage.ts @@ -16,7 +16,7 @@ export const mapStorableToMessage = ({ currentUserId: string; messageRow: TableRowJoinedUser<'messages'>; pollRow: TableRow<'poll'>; - reactionRows: TableRowJoinedUser<'reactions'>[]; + reactionRows?: TableRowJoinedUser<'reactions'>[]; }): MessageResponse => { const { createdAt, diff --git a/package/src/store/mappers/mapStorableToTask.ts b/package/src/store/mappers/mapStorableToTask.ts index c7eff1c117..c47a9081fc 100644 --- a/package/src/store/mappers/mapStorableToTask.ts +++ b/package/src/store/mappers/mapStorableToTask.ts @@ -3,13 +3,14 @@ import { PendingTask } from 'stream-chat'; import type { TableRowJoinedUser } from '../types'; export const mapStorableToTask = (row: TableRowJoinedUser<'pendingTasks'>): PendingTask => { - const { channelId, channelType, id, messageId, type } = row; + const { channelId, channelType, threadId, id, messageId, type } = row; return { channelId, channelType, id, messageId, payload: JSON.parse(row.payload), + threadId, type, }; }; diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index 43b451e5e8..18b2211966 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -60,6 +60,47 @@ export const tables: Tables = { }, primaryKey: ['cid'], }, + draft: { + columns: { + cid: 'TEXT NOT NULL', + createdAt: 'TEXT', + draftMessageId: 'TEXT NOT NULL', + parentId: 'TEXT', + quotedMessageId: 'TEXT', + }, + foreignKeys: [ + { + column: 'draftMessageId', + onDeleteAction: 'CASCADE', + referenceTable: 'draftMessage', + referenceTableColumn: 'id', + }, + ], + indexes: [ + { + columns: ['cid', 'draftMessageId'], + name: 'index_draft', + unique: false, + }, + ], + primaryKey: ['cid', 'draftMessageId'], + }, + draftMessage: { + columns: { + attachments: 'TEXT', + custom: 'TEXT', + id: 'TEXT NOT NULL', + mentionedUsers: 'TEXT', + parentId: 'TEXT', + poll_id: 'TEXT', + quotedMessageId: 'TEXT', + showInChannel: 'BOOLEAN DEFAULT FALSE', + silent: 'BOOLEAN DEFAULT FALSE', + text: 'TEXT', + type: 'TEXT', + }, + primaryKey: ['id'], + }, members: { columns: { archivedAt: 'TEXT', @@ -135,6 +176,7 @@ export const tables: Tables = { id: 'INTEGER PRIMARY KEY AUTOINCREMENT', messageId: 'TEXT', payload: 'TEXT', + threadId: 'TEXT', type: 'TEXT', }, }, @@ -269,6 +311,26 @@ export type Schema = { truncatedById?: string; updatedAt?: string; }; + draft: { + draftMessageId: string; + cid: string; + createdAt: string; + parentId?: string; + quotedMessageId?: string; + }; + draftMessage: { + id: string; + attachments?: string; + custom?: string; + mentionedUsers?: string; + parentId?: string; + poll_id?: string; + quotedMessageId?: string; + showInChannel?: boolean; + silent?: boolean; + text: string; + type?: MessageLabel; + }; members: { archivedAt?: string; cid: string; @@ -306,6 +368,7 @@ export type Schema = { createdAt: string; id: number; messageId: string; + threadId: string; payload: string; type: ValueOf; }; diff --git a/package/src/store/types.ts b/package/src/store/types.ts index 5289fda145..bc42c5c68d 100644 --- a/package/src/store/types.ts +++ b/package/src/store/types.ts @@ -8,6 +8,10 @@ export type TableRowJoinedUser = Schema[T] & { user: TableRow<'users'>; }; +export type TableRowJoinedDraftMessage = Schema[T] & { + draftMessage: TableRow<'draftMessage'>; +}; + export type TableColumnNames = keyof Schema[T]; export type TableColumnValue = string | boolean | number | undefined; export type Scalar = string | number | boolean | null | ArrayBuffer | ArrayBufferView; diff --git a/package/src/types/stream-chat-common-custom-data.d.ts b/package/src/types/stream-chat-common-custom-data.d.ts index fb4a24ed4e..7ce5447fed 100644 --- a/package/src/types/stream-chat-common-custom-data.d.ts +++ b/package/src/types/stream-chat-common-custom-data.d.ts @@ -1,4 +1,5 @@ import 'stream-chat'; + import { DefaultAttachmentData, DefaultChannelData, @@ -38,5 +39,7 @@ declare module 'stream-chat' { interface CustomThreadData extends DefaultThreadData {} + interface CustomMessageComposerData {} + /* eslint-enable @typescript-eslint/no-empty-object-type */ } diff --git a/package/src/types/types.ts b/package/src/types/types.ts index 9e3057ef4b..68a5b8a4ec 100644 --- a/package/src/types/types.ts +++ b/package/src/types/types.ts @@ -1,6 +1,12 @@ -import type { ChannelFilters, ChannelSort, ChannelState, FileReference } from 'stream-chat'; - -import type { FileStateValue } from '../utils/utils'; +import type { + ChannelFilters, + ChannelSort, + ChannelState, + FileReference, + LocalAudioAttachment, + LocalUploadAttachment, + LocalVoiceRecordingAttachment, +} from 'stream-chat'; export enum FileTypes { Audio = 'audio', @@ -14,41 +20,29 @@ export enum FileTypes { export type File = FileReference; -/** - * This is nothing but a substitute for the attachment type prior to sending the message. - * This will change if we unify the file uploads to attachments. - */ -export type FileUpload = { - file: File; - id: string; - state: FileStateValue; - - mime_type?: string; - - type?: FileTypes; - url?: string; - - thumb_url?: string; +export type LocalAudioAttachmentType> = + | LocalAudioAttachment + | LocalVoiceRecordingAttachment; +export type AudioConfig = { duration?: number; - waveform_data?: number[]; - - height?: number; - width?: number; -}; - -export type AudioUpload = FileUpload & { progress?: number; paused?: boolean; }; +export type AudioUpload> = + LocalAudioAttachmentType & AudioConfig; + +export type UploadAttachmentPreviewProps = { + attachment: A; + handleRetry: ( + attachment: LocalUploadAttachment, + ) => void | Promise; + removeAttachments: (ids: string[]) => void; +}; + export interface DefaultAttachmentData { - duration?: number; - file_size?: number; - mime_type?: string; originalFile?: File; - originalImage?: File; - waveform_data?: number[]; } export interface DefaultUserData { @@ -526,3 +520,16 @@ export type RNCLIRecordingOptions = { export type RNCLIAudioRecordingConfiguration = { options?: RNCLIRecordingOptions; }; + +export type Emoji = { + id: string; + name: string; + skins: Array<{ native: string }>; + emoticons?: Array; + keywords?: Array; + native?: string; +}; + +export type EmojiSearchIndex = { + search: (query: string) => PromiseLike> | Array | null; +}; diff --git a/package/src/utils/ACITriggerSettings.ts b/package/src/utils/ACITriggerSettings.ts deleted file mode 100644 index 610a7af945..0000000000 --- a/package/src/utils/ACITriggerSettings.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { DebouncedFunc } from 'lodash'; -import type { Channel, CommandResponse, StreamChat } from 'stream-chat'; - -import { defaultAutoCompleteSuggestionsLimit, defaultMentionAllAppUsersQuery } from './constants'; -import { getMembersAndWatchers, queryMembersDebounced, QueryMembersFunction } from './queryMembers'; -import { queryUsersDebounced, QueryUsersFunction } from './queryUsers'; - -import type { - EmojiSearchIndex, - MentionAllAppUsersQuery, -} from '../contexts/messageInputContext/MessageInputContext'; -import type { - SuggestionCommand, - SuggestionComponentType, - SuggestionUser, -} from '../contexts/suggestionsContext/SuggestionsContext'; -import { Emoji } from '../emoji-data'; - -const getCommands = (channel: Channel) => channel.getConfig()?.commands || []; - -export type TriggerSettingsOutputType = { - caretPosition: string; - key: string; - text: string; -}; - -export type TriggerSettings = { - '/'?: { - dataProvider: ( - query: CommandResponse['name'], - text: string, - onReady?: (data: CommandResponse[], q: CommandResponse['name']) => void, - options?: { - limit?: number; - }, - ) => SuggestionCommand[]; - output: (entity: CommandResponse) => TriggerSettingsOutputType; - type: SuggestionComponentType; - }; - ':'?: { - dataProvider: ( - query: Emoji['name'], - _: string, - onReady?: (data: Emoji[], q: Emoji['name']) => void, - ) => Emoji[] | Promise; - output: (entity: Emoji) => TriggerSettingsOutputType; - type: SuggestionComponentType; - }; - '@'?: { - callback: (item: SuggestionUser) => void; - dataProvider: ( - query: SuggestionUser['name'], - _: string, - onReady?: (data: SuggestionUser[], q: SuggestionUser['name']) => void, - options?: { - limit?: number; - mentionAllAppUsersEnabled?: boolean; - mentionAllAppUsersQuery?: MentionAllAppUsersQuery; - }, - ) => SuggestionUser[] | Promise | void; - output: (entity: SuggestionUser) => TriggerSettingsOutputType; - type: SuggestionComponentType; - }; -}; - -export type ACITriggerSettingsParams = { - channel: Channel; - client: StreamChat; - onMentionSelectItem: (item: SuggestionUser) => void; - emojiSearchIndex?: EmojiSearchIndex; -}; - -export const isCommandTrigger = (trigger: Trigger): trigger is '/' => trigger === '/'; - -export const isEmojiTrigger = (trigger: Trigger): trigger is ':' => trigger === ':'; - -export const isMentionTrigger = (trigger: Trigger): trigger is '@' => trigger === '@'; - -export type Trigger = '/' | '@' | ':'; - -/** - * ACI = AutoCompleteInput - * - * DataProvider accepts `onReady` function, which will execute once the data is ready. - * Another approach would have been to simply return the data from dataProvider and let the - * component await for it and then execute the required logic. We are going for callback instead - * of async-await since we have debounce function in dataProvider. Which will delay the execution - * of api call on trailing end of debounce (lets call it a1) but will return with result of - * previous call without waiting for a1. So in this case, we want to execute onReady, when trailing - * end of debounce executes. - */ -export const ACITriggerSettings = ({ - channel, - client, - emojiSearchIndex, - onMentionSelectItem, -}: ACITriggerSettingsParams): TriggerSettings => ({ - '/': { - dataProvider: (query, text, onReady, options = {}) => { - try { - if (text.indexOf('/') !== 0) { - return []; - } - - const { limit = defaultAutoCompleteSuggestionsLimit } = options; - const selectedCommands = !query - ? getCommands(channel) - : getCommands(channel).filter((command) => query && command.name?.indexOf(query) !== -1); - - // sort alphabetically unless the you're matching the first char - selectedCommands.sort((a, b) => { - let nameA = a.name?.toLowerCase() || ''; - let nameB = b.name?.toLowerCase() || ''; - if (query && nameA.indexOf(query) === 0) { - nameA = `0${nameA}`; - } - if (query && nameB.indexOf(query) === 0) { - nameB = `0${nameB}`; - } - if (nameA < nameB) { - return -1; - } - if (nameA > nameB) { - return 1; - } - - return 0; - }); - - const result = selectedCommands.slice(0, limit); - - if (onReady) { - onReady(result, query); - } - - return result; - } catch (error) { - console.warn('Error querying commands while using "/":', error); - throw error; - } - }, - output: (entity) => ({ - caretPosition: 'next', - key: `${entity.name}`, - text: `/${entity.name}`, - }), - type: 'command', - }, - ':': { - dataProvider: async (query, _, onReady) => { - try { - if (!query) { - return []; - } - - const emojis = (await emojiSearchIndex?.search(query)) ?? []; - - if (onReady) { - onReady(emojis, query); - } - - return emojis; - } catch (error) { - console.warn('Error querying emojis while using ":":', error); - throw error; - } - }, - output: (entity) => ({ - caretPosition: 'next', - key: entity.name, - text: entity.unicode, - }), - type: 'emoji', - }, - '@': { - callback: (item) => { - onMentionSelectItem(item); - }, - dataProvider: ( - query, - _, - onReady, - options = { - limit: defaultAutoCompleteSuggestionsLimit, - mentionAllAppUsersEnabled: false, - mentionAllAppUsersQuery: defaultMentionAllAppUsersQuery, - }, - ) => { - try { - if (!query) { - return []; - } - if (options?.mentionAllAppUsersEnabled) { - return (queryUsersDebounced as DebouncedFunc)( - client, - query, - (data) => { - if (onReady) { - onReady(data, query); - } - }, - { - limit: options.limit, - mentionAllAppUsersQuery: options.mentionAllAppUsersQuery, - }, - ); - } - /** - * By default, we return maximum 100 members via queryChannels api call. - * Thus it is safe to assume, that if number of members in channel.state is < 100, - * then all the members are already available on client side and we don't need to - * make any api call to queryMembers endpoint. - */ - if (Object.values(channel.state.members).length < 100) { - const users = getMembersAndWatchers(channel); - - const matchingUsers = users.filter((user) => { - if (!query) { - return true; - } - // Don't show current authenticated user in the list - if (user.id === client.userID) { - return false; - } - if (user.name?.toLowerCase().indexOf(query.toLowerCase()) !== -1) { - return true; - } - if (user.id.toLowerCase().indexOf(query.toLowerCase()) !== -1) { - return true; - } - return false; - }); - - const data = matchingUsers.slice(0, options?.limit); - - if (onReady) { - onReady(data, query); - } - - return data; - } - - return (queryMembersDebounced as DebouncedFunc)( - client, - channel, - query, - (data) => { - if (onReady) { - onReady(data, query); - } - }, - { - limit: options.limit, - }, - ); - } catch (error) { - console.warn("Error querying users/members while using '@':", error); - throw error; - } - }, - output: (entity) => ({ - caretPosition: 'next', - key: entity.id, - text: `@${entity.name || entity.id}`, - }), - type: 'mention', - }, -}); diff --git a/package/src/utils/constants.ts b/package/src/utils/constants.ts index aab284f2a6..df34a63b25 100644 --- a/package/src/utils/constants.ts +++ b/package/src/utils/constants.ts @@ -4,3 +4,4 @@ export const defaultMentionAllAppUsersQuery = { options: {}, sort: {}, }; +export const POLL_OPTION_HEIGHT = 71; diff --git a/package/src/utils/getTrimmedAttachmentTitle.ts b/package/src/utils/getTrimmedAttachmentTitle.ts index d7a9315734..c5a94ed84e 100644 --- a/package/src/utils/getTrimmedAttachmentTitle.ts +++ b/package/src/utils/getTrimmedAttachmentTitle.ts @@ -1,16 +1,17 @@ -import { lookup } from 'mime-types'; +// Add dots in between the title if it is too long and then append the remaining last characters. +// Eg: "This is a very long title" => "This is a very long ti...le" +export const getTrimmedAttachmentTitle = (title?: string, maxLength?: number) => { + const maxLengthValue = maxLength || 18; + if (!title) return ''; -export const getTrimmedAttachmentTitle = (title?: string) => { - if (!title) { - return ''; - } + const ellipsis = '...'; - const mimeType = lookup(title); - if (mimeType) { - const lastIndexOfDot = title.lastIndexOf('.'); - return title.length < 12 ? title : title.slice(0, 12) + '...' + title.slice(lastIndexOfDot); - } else { - // shorten title - return title.length < 20 ? title : title.slice(0, 20) + '...'; + if (title.length <= maxLengthValue) { + return title; } + + const start = title.slice(0, maxLengthValue / 2); + const end = title.slice(title.length - maxLengthValue / 2); + + return `${start}${ellipsis}${end}`; }; diff --git a/package/src/utils/i18n/Streami18n.ts b/package/src/utils/i18n/Streami18n.ts index bd43691c28..23d6c58a6e 100644 --- a/package/src/utils/i18n/Streami18n.ts +++ b/package/src/utils/i18n/Streami18n.ts @@ -10,13 +10,10 @@ import i18n, { FallbackLng, TFunction } from 'i18next'; import type momentTimezone from 'moment-timezone'; import { calendarFormats } from './calendarFormats'; -import { - CustomFormatters, - PredefinedFormatters, - predefinedFormatters, -} from './predefinedFormatters'; +import { predefinedFormatters } from './predefinedFormatters'; +import { CustomFormatters, PredefinedFormatters } from './types'; -import type { TDateTimeParser } from '../../contexts/translationContext/TranslationContext'; +import type { TDateTimeParser } from '../../contexts/translationContext/types'; import enTranslations from '../../i18n/en.json'; import esTranslations from '../../i18n/es.json'; import frTranslations from '../../i18n/fr.json'; @@ -358,6 +355,8 @@ const defaultStreami18nOptions = { logger: (msg?: string) => console.warn(msg), }; +export const defaultTranslatorFunction = ((key: string) => key) as TFunction; + export class Streami18n { i18nInstance = i18n.createInstance(); Dayjs = null; @@ -376,7 +375,7 @@ export class Streami18n { */ private queuedTFunctionOverride: TFunction | undefined; - t: TFunction = (key: string) => key; + t: TFunction = defaultTranslatorFunction; tDateTimeParser: TDateTimeParser; translations: { diff --git a/package/src/utils/i18n/getDateString.ts b/package/src/utils/i18n/getDateString.ts index 0130ddc81a..f836a8a818 100644 --- a/package/src/utils/i18n/getDateString.ts +++ b/package/src/utils/i18n/getDateString.ts @@ -1,9 +1,7 @@ -import type { TimestampFormatterOptions } from './predefinedFormatters'; +import { TimestampFormatterOptions } from './types'; -import { - isDayOrMoment, - TranslatorFunctions, -} from '../../contexts/translationContext/TranslationContext'; +import { TranslatorFunctions } from '../../contexts/translationContext'; +import { isDayOrMoment } from '../../contexts/translationContext/isDayOrMoment'; type DateFormatterOptions = TimestampFormatterOptions & Partial & { diff --git a/package/src/utils/i18n/predefinedFormatters.ts b/package/src/utils/i18n/predefinedFormatters.ts index 3b1c3998ec..96174208f6 100644 --- a/package/src/utils/i18n/predefinedFormatters.ts +++ b/package/src/utils/i18n/predefinedFormatters.ts @@ -1,27 +1,5 @@ import { getDateString } from './getDateString'; -import { Streami18n } from './Streami18n'; - -export type TimestampFormatterOptions = { - /* If true, call the `Day.js` calendar function to get the date string to display (e.g. "Yesterday at 3:58 PM"). */ - calendar?: boolean | null; - /* Object specifying date display formats for dates formatted with calendar extension. Active only if calendar prop enabled. */ - calendarFormats?: Record; - /* Overrides the default timestamp format if calendar is disabled. */ - format?: string; -}; - -export type FormatterFactory = ( - streamI18n: Streami18n, -) => (value: V, lng: string | undefined, options: Record) => string; - -// Here is any used, because we do not want to enforce any specific rules and -// want to leave the type declaration to the integrator -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export type CustomFormatters = Record>; - -export type PredefinedFormatters = { - timestampFormatter: FormatterFactory; -}; +import { PredefinedFormatters, TimestampFormatterOptions } from './types'; export const predefinedFormatters: PredefinedFormatters = { timestampFormatter: diff --git a/package/src/utils/i18n/types.ts b/package/src/utils/i18n/types.ts new file mode 100644 index 0000000000..4bf79463cb --- /dev/null +++ b/package/src/utils/i18n/types.ts @@ -0,0 +1,23 @@ +import { Streami18n } from './Streami18n'; + +export type FormatterFactory = ( + streamI18n: Streami18n, +) => (value: V, lng: string | undefined, options: Record) => string; + +// Here is any used, because we do not want to enforce any specific rules and +// want to leave the type declaration to the integrator +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type CustomFormatters = Record>; + +export type TimestampFormatterOptions = { + /* If true, call the `Day.js` calendar function to get the date string to display (e.g. "Yesterday at 3:58 PM"). */ + calendar?: boolean | null; + /* Object specifying date display formats for dates formatted with calendar extension. Active only if calendar prop enabled. */ + calendarFormats?: Record; + /* Overrides the default timestamp format if calendar is disabled. */ + format?: string; +}; + +export type PredefinedFormatters = { + timestampFormatter: FormatterFactory; +}; diff --git a/package/src/utils/patchMessageTextCommand.ts b/package/src/utils/patchMessageTextCommand.ts index 3a05097b1e..58807b434f 100644 --- a/package/src/utils/patchMessageTextCommand.ts +++ b/package/src/utils/patchMessageTextCommand.ts @@ -27,6 +27,7 @@ export function patchMessageTextCommand(messageText: string, mentionedUserIds: s * The required format is "/ban @userid reason" */ if (trimmedMessageText.startsWith('/ban ')) { + // TODO: There is a bug here. If the name has two words, the reason will be the surname of the user. const reasonText = trimmedMessageText.split(' ').pop() ?? ''; return `/ban @${mentionedUserIds[0]} ${reasonText}`.trim(); } diff --git a/package/src/utils/queryMembers.ts b/package/src/utils/queryMembers.ts deleted file mode 100644 index 19c5723c61..0000000000 --- a/package/src/utils/queryMembers.ts +++ /dev/null @@ -1,90 +0,0 @@ -import debounce from 'lodash/debounce'; -import type { Channel, ChannelMemberAPIResponse, StreamChat, User } from 'stream-chat'; - -import { defaultAutoCompleteSuggestionsLimit } from './constants'; - -import type { SuggestionUser } from '../contexts/suggestionsContext/SuggestionsContext'; - -const getMembers = (channel: Channel) => { - const members = channel.state.members; - return members ? Object.values(members).map(({ user }) => user) : []; -}; - -const getWatchers = (channel: Channel) => { - const watchers = channel.state.watchers; - return watchers ? Object.values(watchers) : []; -}; - -export const getMembersAndWatchers = (channel: Channel) => { - const members = getMembers(channel); - const watchers = getWatchers(channel); - const users = [...members, ...watchers]; - - // make sure we don't list users twice - const seenUsers = new Set(); - const uniqueUsers: User[] = []; - - for (const user of users) { - if (user && !seenUsers.has(user.id)) { - uniqueUsers.push(user); - seenUsers.add(user.id); - } - } - - return uniqueUsers; -}; - -const isUserResponse = (user: SuggestionUser | undefined): user is SuggestionUser => - (user as SuggestionUser) !== undefined; - -export type QueryMembersFunction = ( - client: StreamChat, - channel: Channel, - query: SuggestionUser['name'], - onReady?: (users: SuggestionUser[]) => void, - options?: { - limit?: number; - }, -) => Promise; - -const queryMembers = async ( - client: StreamChat, - channel: Channel, - query: SuggestionUser['name'], - onReady?: (users: SuggestionUser[]) => void, - options: { - limit?: number; - } = {}, -): Promise => { - if (!query) { - return; - } - try { - const { limit = defaultAutoCompleteSuggestionsLimit } = options; - - const { members } = (await (channel as unknown as Channel).queryMembers( - { - name: { $autocomplete: query }, - }, - {}, - { limit }, - )) as ChannelMemberAPIResponse; - - const users: SuggestionUser[] = []; - members - .filter((member) => member.user?.id !== client.userID) - .forEach((member) => isUserResponse(member.user) && users.push(member.user)); - - if (onReady && users) { - onReady(users); - } - } catch (error) { - console.warn('Error querying members:', error); - throw error; - } -}; - -export const queryMembersDebounced = debounce(queryMembers, 200, { - leading: false, - trailing: true, -}); diff --git a/package/src/utils/queryUsers.ts b/package/src/utils/queryUsers.ts deleted file mode 100644 index 0a1048db10..0000000000 --- a/package/src/utils/queryUsers.ts +++ /dev/null @@ -1,61 +0,0 @@ -import debounce from 'lodash/debounce'; -import type { StreamChat } from 'stream-chat'; - -import { defaultAutoCompleteSuggestionsLimit, defaultMentionAllAppUsersQuery } from './constants'; - -import type { MentionAllAppUsersQuery } from '../contexts/messageInputContext/MessageInputContext'; -import type { SuggestionUser } from '../contexts/suggestionsContext/SuggestionsContext'; - -export type QueryUsersFunction = ( - client: StreamChat, - query: SuggestionUser['name'], - onReady?: (users: SuggestionUser[]) => void, - options?: { - limit?: number; - mentionAllAppUsersQuery?: MentionAllAppUsersQuery; - }, -) => Promise; - -const queryUsers = async ( - client: StreamChat, - query: SuggestionUser['name'], - onReady?: (users: SuggestionUser[]) => void, - options: { - limit?: number; - mentionAllAppUsersQuery?: MentionAllAppUsersQuery; - } = {}, -): Promise => { - if (!query) { - return; - } - try { - const { - limit = defaultAutoCompleteSuggestionsLimit, - mentionAllAppUsersQuery = defaultMentionAllAppUsersQuery, - } = options; - - const filters = { - $or: [{ id: { $autocomplete: query } }, { name: { $autocomplete: query } }], - ...mentionAllAppUsersQuery?.filters, - }; - - const { users } = await client.queryUsers( - // @ts-ignore - filters, - { id: 1, ...mentionAllAppUsersQuery?.sort }, - { limit, ...mentionAllAppUsersQuery?.options }, - ); - const usersWithoutClientUserId = users.filter((user) => user.id !== client.userID); - if (onReady && users) { - onReady(usersWithoutClientUserId); - } - } catch (error) { - console.warn('Error querying users:', error); - throw error; - } -}; - -export const queryUsersDebounced = debounce(queryUsers, 200, { - leading: false, - trailing: true, -}); diff --git a/package/src/utils/setupCommandUIMiddlewares.ts b/package/src/utils/setupCommandUIMiddlewares.ts new file mode 100644 index 0000000000..472e241864 --- /dev/null +++ b/package/src/utils/setupCommandUIMiddlewares.ts @@ -0,0 +1,30 @@ +import { + createActiveCommandGuardMiddleware, + createCommandInjectionMiddleware, + createCommandStringExtractionMiddleware, + createDraftCommandInjectionMiddleware, + MessageComposer, + TextComposerMiddleware, +} from 'stream-chat'; + +export const setupCommandUIMiddlewares = (messageComposer: MessageComposer) => { + messageComposer.compositionMiddlewareExecutor.insert({ + middleware: [createCommandInjectionMiddleware(messageComposer)], + position: { after: 'stream-io/message-composer-middleware/attachments' }, + }); + + messageComposer.draftCompositionMiddlewareExecutor.insert({ + middleware: [createDraftCommandInjectionMiddleware(messageComposer)], + position: { after: 'stream-io/message-composer-middleware/draft-attachments' }, + }); + + messageComposer.textComposer.middlewareExecutor.insert({ + middleware: [createActiveCommandGuardMiddleware() as TextComposerMiddleware], + position: { before: 'stream-io/text-composer/commands-middleware' }, + }); + + messageComposer.textComposer.middlewareExecutor.insert({ + middleware: [createCommandStringExtractionMiddleware() as TextComposerMiddleware], + position: { after: 'stream-io/text-composer/commands-middleware' }, + }); +}; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 5ec4c605f0..928893f9b6 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -2,13 +2,16 @@ import type React from 'react'; import dayjs from 'dayjs'; import EmojiRegex from 'emoji-regex'; -import type { ChannelState, LocalMessage, MessageResponse } from 'stream-chat'; +import type { + AttachmentLoadingState, + ChannelState, + LocalMessage, + MessageResponse, +} from 'stream-chat'; import { IconProps } from '../../src/icons/utils/base'; -import type { EmojiSearchIndex } from '../contexts/messageInputContext/MessageInputContext'; -import { compiledEmojis } from '../emoji-data'; import type { TableRowJoinedUser } from '../store/types'; -import { FileTypes, ValueOf } from '../types/types'; +import { ValueOf } from '../types/types'; export type ReactionData = { Icon: React.ComponentType; @@ -16,13 +19,10 @@ export type ReactionData = { }; export const FileState = Object.freeze({ - // finished and uploaded state are the same thing. First is set on frontend, - // while later is set on backend side - // TODO: Unify both of them + BLOCKED: 'blocked', + FAILED: 'failed', FINISHED: 'finished', - NOT_SUPPORTED: 'not_supported', - UPLOAD_FAILED: 'upload_failed', - UPLOADED: 'uploaded', + PENDING: 'pending', UPLOADING: 'uploading', }); @@ -30,11 +30,13 @@ export const ProgressIndicatorTypes: { IN_PROGRESS: 'in_progress'; INACTIVE: 'inactive'; NOT_SUPPORTED: 'not_supported'; + PENDING: 'pending'; RETRY: 'retry'; } = Object.freeze({ IN_PROGRESS: 'in_progress', INACTIVE: 'inactive', NOT_SUPPORTED: 'not_supported', + PENDING: 'pending', RETRY: 'retry', }); @@ -44,25 +46,22 @@ export const MessageStatusTypes = { SENDING: 'sending', }; -export type FileStateValue = (typeof FileState)[keyof typeof FileState]; - -type Progress = ValueOf; -type IndicatorStatesMap = Record, Progress | null>; +export type Progress = ValueOf; +type IndicatorStatesMap = Record; export const getIndicatorTypeForFileState = ( - fileState: FileStateValue, + fileState: AttachmentLoadingState, enableOfflineSupport: boolean, -): Progress | null => { +): Progress | undefined => { const indicatorMap: IndicatorStatesMap = { [FileState.UPLOADING]: enableOfflineSupport ? ProgressIndicatorTypes.INACTIVE : ProgressIndicatorTypes.IN_PROGRESS, - // If offline support is disabled, then there is no need - [FileState.UPLOAD_FAILED]: enableOfflineSupport + [FileState.BLOCKED]: ProgressIndicatorTypes.NOT_SUPPORTED, + [FileState.FAILED]: enableOfflineSupport ? ProgressIndicatorTypes.INACTIVE : ProgressIndicatorTypes.RETRY, - [FileState.NOT_SUPPORTED]: ProgressIndicatorTypes.NOT_SUPPORTED, - [FileState.UPLOADED]: ProgressIndicatorTypes.INACTIVE, + [FileState.PENDING]: ProgressIndicatorTypes.PENDING, [FileState.FINISHED]: ProgressIndicatorTypes.INACTIVE, }; @@ -97,48 +96,6 @@ export const isBouncedMessage = (message: LocalMessage) => */ export const isEditedMessage = (message: LocalMessage) => !!message.message_text_updated_at; -/** - * Default emoji search index for auto complete text input - */ -export const defaultEmojiSearchIndex: EmojiSearchIndex = { - search: (query) => { - try { - const results = []; - - for (const emoji of compiledEmojis) { - if (results.length >= 10) { - return results; - } - if (emoji.names.some((name) => name.includes(query))) { - // Aggregate skins as different toned emojis - if skins are present - if (emoji.skins) { - results.push({ - ...emoji, - name: `${emoji.name}-tone-1`, - skins: undefined, - }); - emoji.skins.forEach((tone, index) => - results.push({ - ...emoji, - name: `${emoji.name}-tone-${index + 2}`, - skins: undefined, - unicode: tone, - }), - ); - } else { - results.push(emoji); - } - } - } - - return results; - } catch (error) { - console.warn('Error searching emojis:', error); - throw error; - } - }, -}; - export const makeImageCompatibleUrl = (url: string) => (url.indexOf('//') === 0 ? `https:${url}` : url).trim(); @@ -155,7 +112,7 @@ export const getUrlWithoutParams = (url?: string) => { return url.substring(0, url.indexOf('?')); }; -export const isLocalUrl = (url: string) => url.indexOf('http') !== 0; +export const isLocalUrl = (url: string) => !url.includes('http'); export const generateRandomId = (a = ''): string => a @@ -186,8 +143,15 @@ export const hasOnlyEmojis = (text: string) => { * @param {LocalMessage} message - the message object to be stringified * @returns {string} The stringified message */ -export const stringifyMessage = (message: MessageResponse | LocalMessage): string => { +export const stringifyMessage = ({ + message, + includeReactions = true, +}: { + message: MessageResponse | LocalMessage; + includeReactions?: boolean; +}): string => { const { + attachments, deleted_at, i18n, latest_reactions, @@ -198,6 +162,10 @@ export const stringifyMessage = (message: MessageResponse | LocalMessage): strin type, updated_at, } = message; + const baseFieldsString = `${type}${deleted_at}${text}${reply_count}${status}${updated_at}${JSON.stringify(i18n)}${attachments?.length}`; + if (!includeReactions) { + return baseFieldsString; + } return `${ latest_reactions ? latest_reactions.map(({ type, user }) => `${type}${user?.id}`).join() : '' }${ @@ -209,7 +177,7 @@ export const stringifyMessage = (message: MessageResponse | LocalMessage): strin ) .join() : '' - }${type}${deleted_at}${text}${reply_count}${status}${updated_at}${JSON.stringify(i18n)}`; + }${baseFieldsString}`; }; /** @@ -218,7 +186,13 @@ export const stringifyMessage = (message: MessageResponse | LocalMessage): strin * @returns {string} The mapped message string */ export const reduceMessagesToString = (messages: LocalMessage[]): string => - messages.map(stringifyMessage).join(); + messages + .map((message) => + message?.quoted_message + ? `${stringifyMessage({ message })}_${message.quoted_message.type}_${message.quoted_message.deleted_at}_${message.quoted_message.text}_${message.quoted_message.updated_at}` + : stringifyMessage({ message }), + ) + .join(); /** * Utility to get the file name from the path using regex. @@ -234,18 +208,6 @@ export const getFileNameFromPath = (path: string) => { return match ? match[0] : ''; }; -export const getFileTypeFromMimeType = (mimeType: string) => { - const fileType = mimeType.split('/')[0]; - if (fileType === 'image') { - return FileTypes.Image; - } else if (fileType === 'video') { - return FileTypes.Video; - } else if (fileType === 'audio') { - return FileTypes.Audio; - } - return FileTypes.File; -}; - /** * Utility to get the duration label from the duration in seconds. * @param duration number @@ -330,3 +292,63 @@ export const findInMessagesByDate = ( return { index: -1 }; }; + +/** + * The purpose of this function is to compare two messages and determine if they are equal. + * It checks various properties of the messages, such as status, type, text, pinned state, updated_at timestamp, i18n data, and reply count. + * If all these properties match, it returns true, indicating that the messages are considered equal. + * If any of the properties differ, it returns false, indicating that the messages are not equal. + * Useful for the `areEqual` logic in the React.memo of the Message component/sub-components. + */ +export const checkMessageEquality = ( + prevMessage?: LocalMessage, + nextMessage?: LocalMessage, +): boolean => { + const prevMessageExists = !!prevMessage; + const nextMessageExists = !!nextMessage; + if (!prevMessageExists && !nextMessageExists) { + return true; + } + if (prevMessageExists !== nextMessageExists) { + return false; + } + const messageEqual = + prevMessage?.status === nextMessage?.status && + prevMessage?.type === nextMessage?.type && + prevMessage?.text === nextMessage?.text && + prevMessage?.pinned === nextMessage?.pinned && + prevMessage?.i18n === nextMessage?.i18n && + prevMessage?.reply_count === nextMessage?.reply_count && + prevMessage?.updated_at?.getTime?.() === nextMessage?.updated_at?.getTime?.() && + prevMessage?.deleted_at?.getTime?.() === nextMessage?.deleted_at?.getTime?.(); + + return messageEqual; +}; + +/** + * The purpose of this function is to compare two quoted messages and determine if they are equal. + * It checks various properties of the messages, such as status, type, text, updated_at timestamp, and deleted_at. + * If all these properties match, it returns true, indicating that the messages are considered equal. + * If any of the properties differ, it returns false, indicating that the messages are not equal. + * Useful for the `areEqual` logic in the React.memo of the Message component/sub-components. + */ +export const checkQuotedMessageEquality = ( + prevQuotedMessage?: LocalMessage, + nextQuotedMessage?: LocalMessage, +): boolean => { + const prevQuotedMessageExists = !!prevQuotedMessage; + const nextQuotedMessageExists = !!nextQuotedMessage; + if (!prevQuotedMessageExists && !nextQuotedMessageExists) { + return true; + } + if (prevQuotedMessageExists !== nextQuotedMessageExists) { + return false; + } + const quotedMessageEqual = + prevQuotedMessage?.type === nextQuotedMessage?.type && + prevQuotedMessage?.text === nextQuotedMessage?.text && + prevQuotedMessage?.updated_at?.getTime?.() === nextQuotedMessage?.updated_at?.getTime?.() && + prevQuotedMessage?.deleted_at?.getTime?.() === nextQuotedMessage?.deleted_at?.getTime?.(); + + return quotedMessageEqual; +}; diff --git a/package/yarn.lock b/package/yarn.lock index d04fd59ac0..322175475c 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -1153,11 +1153,6 @@ "@babel/plugin-transform-modules-commonjs" "^7.25.9" "@babel/plugin-transform-typescript" "^7.25.9" -"@babel/runtime@^7.17.2", "@babel/runtime@^7.27.6": - version "7.27.6" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" - integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== - "@babel/runtime@^7.23.2", "@babel/runtime@^7.25.0", "@babel/runtime@^7.8.4": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433" @@ -1165,6 +1160,11 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.27.1", "@babel/runtime@^7.27.6": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" + integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== + "@babel/template@^7.0.0", "@babel/template@^7.25.0", "@babel/template@^7.25.9", "@babel/template@^7.26.9", "@babel/template@^7.3.3": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.26.9.tgz#4577ad3ddf43d194528cff4e1fa6b232fa609bb2" @@ -2346,6 +2346,11 @@ resolved "https://registry.yarnpkg.com/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz#51b1c00b516a5774ada5d611e65eb123f988ef8d" integrity sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA== +"@types/ungap__structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/ungap__structured-clone/-/ungap__structured-clone-1.2.0.tgz#12b9fd4ab3e6a82292d60048492b05eb75b4a48f" + integrity sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA== + "@types/unist@^2", "@types/unist@^2.0.2": version "2.0.11" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" @@ -5107,13 +5112,6 @@ i18next-parser@^9.3.0: vinyl "^3.0.0" vinyl-fs "^4.0.0" -i18next@^21.10.0: - version "21.10.0" - resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.10.0.tgz#85429af55fdca4858345d0e16b584ec29520197d" - integrity sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg== - dependencies: - "@babel/runtime" "^7.17.2" - "i18next@^23.5.1 || ^24.2.0": version "24.2.2" resolved "https://registry.yarnpkg.com/i18next/-/i18next-24.2.2.tgz#3ba3d213302068d569142737f03f30929de696de" @@ -5121,6 +5119,13 @@ i18next@^21.10.0: dependencies: "@babel/runtime" "^7.23.2" +i18next@^25.2.1: + version "25.2.1" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.2.1.tgz#23cf8794904f551f577558d93c84b0fb6cd489a2" + integrity sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw== + dependencies: + "@babel/runtime" "^7.27.1" + iconv-lite@0.6.3, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -8309,10 +8314,10 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat@^9.7.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.7.0.tgz#8302a4dfd2b68115c57cd0a102976542a79cf132" - integrity sha512-8K4RQAUFfznCxpJ5CMIrMQQLroaZ1snB4aR/Xnwa9UpxNCzn3kIi61AVkfsaHTHGojPz5LA3c3faVb251u4HnA== +stream-chat@^9.9.0: + version "9.9.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.9.0.tgz#59ac996e6e0ca6b3e3a0041954ae99a43541ec13" + integrity sha512-4tqedL7NfDhwJIKRBKGdvNu4x0ifKO+qxyc9TEWe+LLaW3Qed4txKysrVKnDfj/rx3iZuIwrMV7VeW5yxZfP5w== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"