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) => (
-
+
(
-