From 9f873ca34163e188735879ef8b57b248dd3c1a61 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:33:19 +0200 Subject: [PATCH 01/13] fix: avoid prepending http before native supported url schemes (#2661) * fix: avoid prepending http before native supported url schemes * fix: move check to link parsing module --- .../MessageSimple/utils/parseLinks.test.ts | 1 + .../Message/MessageSimple/utils/parseLinks.ts | 17 +++++++++++++---- .../Message/MessageSimple/utils/renderText.tsx | 10 ++-------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/package/src/components/Message/MessageSimple/utils/parseLinks.test.ts b/package/src/components/Message/MessageSimple/utils/parseLinks.test.ts index 7b32e22df5..b038b0fc47 100644 --- a/package/src/components/Message/MessageSimple/utils/parseLinks.test.ts +++ b/package/src/components/Message/MessageSimple/utils/parseLinks.test.ts @@ -31,6 +31,7 @@ describe('parseLinksFromText', () => { ['[https://www.google.com](https://www.google.com)', undefined], ['[abc]()', undefined], ['[](https://www.google.com)', undefined], + ['slack:some-slack', undefined], ])('Returns the encoded value of %p as %p', (link, expected) => { const result = parseLinksFromText(link); expect(result[0]?.url).toBe(expected); diff --git a/package/src/components/Message/MessageSimple/utils/parseLinks.ts b/package/src/components/Message/MessageSimple/utils/parseLinks.ts index 7713d5f8ce..1438ed2f69 100644 --- a/package/src/components/Message/MessageSimple/utils/parseLinks.ts +++ b/package/src/components/Message/MessageSimple/utils/parseLinks.ts @@ -26,10 +26,19 @@ export const parseLinksFromText = (input: string): LinkInfo[] => { const links = find(strippedInput, 'url'); const emails = find(strippedInput, 'email'); - const result: LinkInfo[] = [...links, ...emails].map(({ href, value }) => ({ - raw: value, - url: href, - })); + const result: LinkInfo[] = [...links, ...emails].map(({ href, value }) => { + let hrefWithProtocol = href; + // Matching these: https://reactnative.dev/docs/0.73/linking?syntax=ios#built-in-url-schemes + const pattern = new RegExp(/^(mailto:|tel:|sms:|\S+:\/\/)/); + if (!pattern.test(hrefWithProtocol)) { + hrefWithProtocol = 'http://' + hrefWithProtocol; + } + + return { + raw: value, + url: hrefWithProtocol, + }; + }); return result; }; diff --git a/package/src/components/Message/MessageSimple/utils/renderText.tsx b/package/src/components/Message/MessageSimple/utils/renderText.tsx index 5096ea0390..24a52eadb7 100644 --- a/package/src/components/Message/MessageSimple/utils/renderText.tsx +++ b/package/src/components/Message/MessageSimple/utils/renderText.tsx @@ -132,16 +132,10 @@ export const renderText = < }, }; - const onLink = (url: string) => { - const pattern = new RegExp(/^\S+:\/\//); - if (!pattern.test(url)) { - url = 'http://' + url; - } - - return onLinkParams + const onLink = (url: string) => + onLinkParams ? onLinkParams(url) : Linking.canOpenURL(url).then((canOpenUrl) => canOpenUrl && Linking.openURL(url)); - }; let previousLink: string | undefined; const linkReact: ReactNodeOutput = (node, output, { ...state }) => { From de2724a137bbb1f07e76390d28a73b9456b050ff Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:00:39 +0200 Subject: [PATCH 02/13] fix: bad memoisation in window, screen dimension listener hooks (#2664) * fix: bad memoisation in window, screen dimension listener hooks * remove unused variable --- package/src/hooks/useScreenDimensions.ts | 49 +++++++++++++++--------- package/src/hooks/useViewport.ts | 48 +++++++++-------------- 2 files changed, 48 insertions(+), 49 deletions(-) diff --git a/package/src/hooks/useScreenDimensions.ts b/package/src/hooks/useScreenDimensions.ts index e5d51d8916..e97b4a4842 100644 --- a/package/src/hooks/useScreenDimensions.ts +++ b/package/src/hooks/useScreenDimensions.ts @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Dimensions } from 'react-native'; +import { useCallback, useEffect, useState } from 'react'; +import { Dimensions, ScaledSize } from 'react-native'; /** * A custom hook that provides functions to calculate dimensions based on @@ -9,10 +9,10 @@ import { Dimensions } from 'react-native'; * @returns {Object} An object containing functions vh and vw. */ export const useScreenDimensions = (rounded?: boolean) => { - const [screenDimensions, setScreenDimensions] = useState(Dimensions.get('screen')); + const [screenDimensions, setScreenDimensions] = useState(() => Dimensions.get('screen')); useEffect(() => { - const subscriptions = Dimensions.addEventListener('change', ({ screen }) => { + const handleChange = ({ screen }: { screen: ScaledSize }) => { setScreenDimensions((prev) => { const { height, width } = screen; if (prev.height !== height || prev.width !== width) { @@ -20,24 +20,35 @@ export const useScreenDimensions = (rounded?: boolean) => { } return prev; }); - }); + }; + const subscription = Dimensions.addEventListener('change', handleChange); - return () => subscriptions?.remove(); - }, []); + // We might have missed an update between calling `get` in render and + // `addEventListener` in this handler, so we set it here. If there was + // no change, React will filter out this update as a no-op. + // pattern ref: react-native-repo/packages/react-native/Libraries/Utilities/useWindowDimensions.js + handleChange({ screen: Dimensions.get('screen') }); - // eslint-disable-next-line react-hooks/exhaustive-deps - const vw = (percentageWidth: number) => { - const value = screenDimensions.width * (percentageWidth / 100); - return rounded ? Math.round(value) : value; - }; + return () => { + subscription.remove(); + }; + }, []); - // eslint-disable-next-line react-hooks/exhaustive-deps - const vh = (percentageHeight: number) => { - const value = screenDimensions.height * (percentageHeight / 100); - return rounded ? Math.round(value) : value; - }; + const vw = useCallback( + (percentageWidth: number) => { + const value = screenDimensions.width * (percentageWidth / 100); + return rounded ? Math.round(value) : value; + }, + [rounded, screenDimensions.width], + ); - const screenDimensionFunctions = useMemo(() => ({ vh, vw }), [vh, vw]); + const vh = useCallback( + (percentageHeight: number) => { + const value = screenDimensions.height * (percentageHeight / 100); + return rounded ? Math.round(value) : value; + }, + [rounded, screenDimensions.height], + ); - return screenDimensionFunctions; + return { vh, vw }; }; diff --git a/package/src/hooks/useViewport.ts b/package/src/hooks/useViewport.ts index 92fa4fd4ee..7f1f887c71 100644 --- a/package/src/hooks/useViewport.ts +++ b/package/src/hooks/useViewport.ts @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Dimensions } from 'react-native'; +import { useCallback } from 'react'; +import { useWindowDimensions } from 'react-native'; /** * A custom hook that provides functions to calculate dimensions based on @@ -9,35 +9,23 @@ import { Dimensions } from 'react-native'; * @returns {Object} An object containing functions vh and vw. */ export const useViewport = (rounded?: boolean) => { - const [viewportDimensions, setViewportDimensions] = useState(Dimensions.get('window')); + const viewportDimensions = useWindowDimensions(); - useEffect(() => { - const subscriptions = Dimensions.addEventListener('change', ({ window }) => { - setViewportDimensions((prev) => { - const { height, width } = window; - if (prev.height !== height || prev.width !== width) { - return window; - } - return prev; - }); - }); + const vw = useCallback( + (percentageWidth: number) => { + const value = viewportDimensions.width * (percentageWidth / 100); + return rounded ? Math.round(value) : value; + }, + [rounded, viewportDimensions.width], + ); - return () => subscriptions?.remove(); - }, []); + const vh = useCallback( + (percentageHeight: number) => { + const value = viewportDimensions.height * (percentageHeight / 100); + return rounded ? Math.round(value) : value; + }, + [rounded, viewportDimensions.height], + ); - // eslint-disable-next-line react-hooks/exhaustive-deps - const vw = (percentageWidth: number) => { - const value = viewportDimensions.width * (percentageWidth / 100); - return rounded ? Math.round(value) : value; - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - const vh = (percentageHeight: number) => { - const value = viewportDimensions.height * (percentageHeight / 100); - return rounded ? Math.round(value) : value; - }; - - const viewportFunctions = useMemo(() => ({ vh, vw }), [vh, vw]); - - return viewportFunctions; + return { vh, vw }; }; From 68cc67a4e40628f7bd050ca584caa00e55c824ca Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 10 Sep 2024 16:40:51 +0530 Subject: [PATCH 03/13] feat: add create chat client hook for easy usage (#2660) * feat: add create chat client hook for easy usage * docs: use useCreateChatClient hook for client creation --- .../docs/reactnative/core-components/chat.mdx | 48 +++++-- .../reactnative/ui-components/overview.mdx | 4 + .../ExpoMessaging/components/ChatWrapper.tsx | 7 +- .../ExpoMessaging/hooks/useChatClient.tsx | 46 ------ examples/TypeScriptMessaging/App.tsx | 133 +++++++++--------- .../AuthProgressLoader.tsx | 17 +++ .../Chat/hooks/useCreateChatClient.ts | 57 ++++++++ package/src/components/index.ts | 1 + 8 files changed, 184 insertions(+), 129 deletions(-) delete mode 100644 examples/ExpoMessaging/hooks/useChatClient.tsx create mode 100644 examples/TypeScriptMessaging/AuthProgressLoader.tsx create mode 100644 package/src/components/Chat/hooks/useCreateChatClient.ts diff --git a/docusaurus/docs/reactnative/core-components/chat.mdx b/docusaurus/docs/reactnative/core-components/chat.mdx index 59cf435ab5..2b71afb5cf 100644 --- a/docusaurus/docs/reactnative/core-components/chat.mdx +++ b/docusaurus/docs/reactnative/core-components/chat.mdx @@ -23,21 +23,45 @@ We recommend using only one instance of `Chat` provider per application unless a ```tsx import { StreamChat } from 'stream-chat'; -import { ChannelList, Chat, OverlayProvider } from 'stream-chat-react-native'; - -const client = StreamChat.getInstance('api_key'); - -export const App = () => ( - - // highlight-next-line - - +import { ChannelList, Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-react-native'; + +// highlight-start +const chatApiKey = 'REPLACE_WITH_API_KEY'; +const chatUserId = 'REPLACE_WITH_USER_ID'; +const chatUserName = 'REPLACE_WITH_USER_NAME'; +const chatUserToken = 'REPLACE_WITH_USER_TOKEN'; +// highlight-end + +const user = { + id: chatUserId, + name: chatUserName, +}; + +export const App = () => { + // highlight-start + const chatClient = useCreateChatClient({ + apiKey: chatApiKey, + userData: user, + tokenOrProvider: chatUserToken, + }); + // highlight-end + + return ( + // highlight-next-line - - -); + + + // highlight-next-line + + + ); +}; ``` +:::tip +You can use the `useCreateChatClient` hook from `stream-chat-react-native`/`stream-chat-expo` to create a client instance and automatically connect/disconnect a user as per the example above, for simplicity. +::: + ## Context Providers `Chat` contains providers for the `ChatContext`, `ThemeContext`, and `TranslationContext`. diff --git a/docusaurus/docs/reactnative/ui-components/overview.mdx b/docusaurus/docs/reactnative/ui-components/overview.mdx index 29886c4d33..7105b63d4b 100644 --- a/docusaurus/docs/reactnative/ui-components/overview.mdx +++ b/docusaurus/docs/reactnative/ui-components/overview.mdx @@ -67,6 +67,10 @@ To disconnect a user you can call `disconnectUser` on the client. await client.disconnectUser(); ``` +:::tip +Alternatively, you can also use the `useCreateChatClient` hook from `stream-chat-react-native`/`stream-chat-expo` to create a client instance and automatically connect/disconnect a user. +::: + ## Creating a Channel Channels are at the core of Stream Chat, they are where messages are contained, sent, and interacted with. diff --git a/examples/ExpoMessaging/components/ChatWrapper.tsx b/examples/ExpoMessaging/components/ChatWrapper.tsx index 831fbb63f4..2b94215ffd 100644 --- a/examples/ExpoMessaging/components/ChatWrapper.tsx +++ b/examples/ExpoMessaging/components/ChatWrapper.tsx @@ -1,6 +1,5 @@ import React, { PropsWithChildren } from 'react'; -import { Chat, OverlayProvider, Streami18n } from 'stream-chat-expo'; -import { useChatClient } from '../hooks/useChatClient'; +import { Chat, OverlayProvider, Streami18n, useCreateChatClient } from 'stream-chat-expo'; import { AuthProgressLoader } from './AuthProgressLoader'; import { StreamChatGenerics } from '../types'; import { STREAM_API_KEY, user, userToken } from '../constants'; @@ -12,7 +11,7 @@ const streami18n = new Streami18n({ export const ChatWrapper = ({ children }: PropsWithChildren<{}>) => { const { bottom } = useSafeAreaInsets(); - const chatClient = useChatClient({ + const chatClient = useCreateChatClient({ apiKey: STREAM_API_KEY, userData: user, tokenOrProvider: userToken, @@ -24,7 +23,7 @@ export const ChatWrapper = ({ children }: PropsWithChildren<{}>) => { return ( bottomInset={bottom} i18nInstance={streami18n}> - + {children} diff --git a/examples/ExpoMessaging/hooks/useChatClient.tsx b/examples/ExpoMessaging/hooks/useChatClient.tsx deleted file mode 100644 index 73408bff0d..0000000000 --- a/examples/ExpoMessaging/hooks/useChatClient.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import {useEffect, useState} from 'react'; -import {StreamChat, OwnUserResponse, UserResponse} from 'stream-chat'; -import {StreamChatGenerics} from '../types'; - -export const useChatClient = < - SCG extends StreamChatGenerics = StreamChatGenerics, ->({ - apiKey, - userData, - tokenOrProvider, -}: { - apiKey: string; - userData?: OwnUserResponse | UserResponse; - tokenOrProvider?: string; -}) => { - const [chatClient, setChatClient] = useState | null>(null); - - useEffect(() => { - const client = new StreamChat(apiKey); - - if (!userData) { - return; - } - - let didUserConnectInterrupt = false; - let connectionPromise = client - .connectUser(userData, tokenOrProvider) - .then(() => { - if (!didUserConnectInterrupt) { - setChatClient(client); - } - }); - - return () => { - didUserConnectInterrupt = true; - setChatClient(null); - connectionPromise - .then(() => client.disconnectUser()) - .then(() => { - console.log('Connection closed'); - }); - }; - }, [apiKey, userData, tokenOrProvider]); - - return chatClient; -}; diff --git a/examples/TypeScriptMessaging/App.tsx b/examples/TypeScriptMessaging/App.tsx index 16859bd69f..44bd02155a 100644 --- a/examples/TypeScriptMessaging/App.tsx +++ b/examples/TypeScriptMessaging/App.tsx @@ -4,7 +4,7 @@ import { DarkTheme, DefaultTheme, NavigationContainer, RouteProp } from '@react- import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'; import { useHeaderHeight } from '@react-navigation/elements'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Channel as ChannelType, ChannelSort, StreamChat } from 'stream-chat'; +import { Channel as ChannelType, ChannelSort } from 'stream-chat'; import { Channel, ChannelList, @@ -18,12 +18,14 @@ import { Thread, ThreadContextValue, useAttachmentPickerContext, + useCreateChatClient, useOverlayContext, } from 'stream-chat-react-native'; import { useStreamChatTheme } from './useStreamChatTheme'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { useFlipper } from 'stream-chat-react-native-devtools'; +import { AuthProgressLoader } from './AuthProgressLoader'; LogBox.ignoreAllLogs(true); @@ -62,7 +64,7 @@ QuickSqliteClient.logger = (level, message, extraData) => { console.log(level, `QuickSqliteClient: ${message}`, extraData); }; -const chatClient = StreamChat.getInstance('q95x9hkbyd6p'); +const apiKey = 'q95x9hkbyd6p'; const userToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm9uIn0.eRVjxLvd4aqCEHY_JRa97g6k7WpHEhxL7Z4K4yTot1c'; @@ -219,85 +221,82 @@ type AppContextType = { const AppContext = React.createContext({} as AppContextType); const App = () => { - const colorScheme = useColorScheme(); const { bottom } = useSafeAreaInsets(); const theme = useStreamChatTheme(); + const { channel } = useContext(AppContext); - const [channel, setChannel] = useState>(); - const [clientReady, setClientReady] = useState(false); - const [thread, setThread] = useState['thread']>(); - - useEffect(() => { - const setupClient = async () => { - const connectPromise = chatClient.connectUser(user, userToken); - setClientReady(true); - await connectPromise; - }; + const chatClient = useCreateChatClient({ + apiKey, + userData: user, + tokenOrProvider: userToken, + }); - setupClient(); - }, []); + if (!chatClient) { + return ; + } return ( - - - - - - bottomInset={bottom} - i18nInstance={streami18n} - value={{ style: theme }} - > - - {clientReady && ( - - ({ - headerBackTitle: 'Back', - headerRight: EmptyHeader, - headerTitle: channel?.data?.name, - })} - /> - - ({ headerLeft: EmptyHeader })} - /> - - )} - - - - - - + + bottomInset={bottom} + i18nInstance={streami18n} + value={{ style: theme }} + > + + + ({ + headerBackTitle: 'Back', + headerRight: EmptyHeader, + headerTitle: channel?.data?.name, + })} + /> + + ({ headerLeft: EmptyHeader })} + /> + + + ); }; export default () => { + const [channel, setChannel] = useState>(); + const [thread, setThread] = useState['thread']>(); const theme = useStreamChatTheme(); + const colorScheme = useColorScheme(); return ( - + + + + + + + + + ); }; diff --git a/examples/TypeScriptMessaging/AuthProgressLoader.tsx b/examples/TypeScriptMessaging/AuthProgressLoader.tsx new file mode 100644 index 0000000000..a98d5d643a --- /dev/null +++ b/examples/TypeScriptMessaging/AuthProgressLoader.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export const AuthProgressLoader = () => { + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/package/src/components/Chat/hooks/useCreateChatClient.ts b/package/src/components/Chat/hooks/useCreateChatClient.ts new file mode 100644 index 0000000000..f98af175a3 --- /dev/null +++ b/package/src/components/Chat/hooks/useCreateChatClient.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; + +import { StreamChat } from 'stream-chat'; + +import type { + DefaultGenerics, + ExtendableGenerics, + OwnUserResponse, + StreamChatOptions, + TokenOrProvider, + UserResponse, +} from 'stream-chat'; + +/** + * React hook to create, connect and return `StreamChat` client. + */ +export const useCreateChatClient = ({ + apiKey, + options, + tokenOrProvider, + userData, +}: { + apiKey: string; + tokenOrProvider: TokenOrProvider; + userData: OwnUserResponse | UserResponse; + options?: StreamChatOptions; +}) => { + const [chatClient, setChatClient] = useState | null>(null); + const [cachedUserData, setCachedUserData] = useState(userData); + + if (userData.id !== cachedUserData.id) { + setCachedUserData(userData); + } + + const [cachedOptions] = useState(options); + + useEffect(() => { + const client = new StreamChat(apiKey, undefined, cachedOptions); + let didUserConnectInterrupt = false; + + const connectionPromise = client.connectUser(cachedUserData, tokenOrProvider).then(() => { + if (!didUserConnectInterrupt) setChatClient(client); + }); + + return () => { + didUserConnectInterrupt = true; + setChatClient(null); + connectionPromise + .then(() => client.disconnectUser()) + .then(() => { + console.log(`Connection for user "${cachedUserData.id}" has been closed`); + }); + }; + }, [apiKey, cachedUserData, cachedOptions, tokenOrProvider]); + + return chatClient; +}; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 3a43e98a15..abeb80568e 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -69,6 +69,7 @@ export * from './ChannelPreview/hooks/useChannelPreviewDisplayPresence'; export * from './ChannelPreview/hooks/useLatestMessagePreview'; export * from './Chat/Chat'; +export * from './Chat/hooks/useCreateChatClient'; export * from './Chat/hooks/useCreateChatContext'; export * from './Chat/hooks/useIsOnline'; export * from './Chat/hooks/useMutedUsers'; From aea64f11b6b4875d49407e9e9f63ba3f0af85e02 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Wed, 11 Sep 2024 11:04:31 +0100 Subject: [PATCH 04/13] fix: bump fastlane plugin version (#2665) --- examples/SampleApp/Gemfile.lock | 4 ++-- examples/SampleApp/fastlane/Pluginfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/SampleApp/Gemfile.lock b/examples/SampleApp/Gemfile.lock index 6825064713..04279ae202 100644 --- a/examples/SampleApp/Gemfile.lock +++ b/examples/SampleApp/Gemfile.lock @@ -163,7 +163,7 @@ GEM google-apis-firebaseappdistribution_v1 (~> 0.3.0) google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) fastlane-plugin-load_json (0.0.1) - fastlane-plugin-stream_actions (0.3.67) + fastlane-plugin-stream_actions (0.3.68) xctest_list (= 1.2.1) ffi (1.17.0) fourflusher (2.3.1) @@ -320,7 +320,7 @@ DEPENDENCIES fastlane fastlane-plugin-firebase_app_distribution fastlane-plugin-load_json - fastlane-plugin-stream_actions (= 0.3.67) + fastlane-plugin-stream_actions (= 0.3.68) rubocop-performance rubocop-require_tools diff --git a/examples/SampleApp/fastlane/Pluginfile b/examples/SampleApp/fastlane/Pluginfile index f2703d5b51..889c42a4f7 100644 --- a/examples/SampleApp/fastlane/Pluginfile +++ b/examples/SampleApp/fastlane/Pluginfile @@ -4,4 +4,4 @@ gem 'fastlane-plugin-firebase_app_distribution' gem 'fastlane-plugin-load_json' -gem 'fastlane-plugin-stream_actions', '0.3.67' +gem 'fastlane-plugin-stream_actions', '0.3.68' From 2944c077e2a372bb5c6deb84b040fa2202035f69 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 12 Sep 2024 16:00:50 +0530 Subject: [PATCH 05/13] fix: add theme properties for EmptyStateIndicator for message list (#2667) * fix: add theme properties for EmptyStateIndicator for message list * fix: update snapshots --- .../Indicators/EmptyStateIndicator.tsx | 65 ++++++++++--------- .../src/components/Indicators/LoadingDot.tsx | 2 +- .../Indicators/LoadingErrorIndicator.tsx | 36 +++++----- .../Indicators/LoadingIndicator.tsx | 26 ++++---- .../TypingIndicator.test.js.snap | 6 +- .../src/contexts/themeContext/utils/theme.ts | 4 ++ 6 files changed, 72 insertions(+), 67 deletions(-) diff --git a/package/src/components/Indicators/EmptyStateIndicator.tsx b/package/src/components/Indicators/EmptyStateIndicator.tsx index 114c0f906d..f372a75b01 100644 --- a/package/src/components/Indicators/EmptyStateIndicator.tsx +++ b/package/src/components/Indicators/EmptyStateIndicator.tsx @@ -6,33 +6,6 @@ import { useTranslationContext } from '../../contexts/translationContext/Transla import { useViewport } from '../../hooks/useViewport'; import { ChatIcon, MessageIcon } from '../../icons'; -const styles = StyleSheet.create({ - channelContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - channelDetails: { - fontSize: 14, - textAlign: 'center', - }, - channelTitle: { - fontSize: 16, - paddingBottom: 8, - paddingTop: 16, - }, - messageContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - messageTitle: { - fontSize: 20, - fontWeight: 'bold', - paddingBottom: 8, - }, -}); - export type EmptyStateProps = { listType?: 'channel' | 'message' | 'default'; }; @@ -41,7 +14,13 @@ export const EmptyStateIndicator = ({ listType }: EmptyStateProps) => { const { theme: { colors: { black, grey, grey_gainsboro }, - emptyStateIndicator: { channelContainer, channelDetails, channelTitle }, + emptyStateIndicator: { + channelContainer, + channelDetails, + channelTitle, + messageContainer, + messageTitle, + }, }, } = useTheme(); const { vw } = useViewport(); @@ -51,7 +30,7 @@ export const EmptyStateIndicator = ({ listType }: EmptyStateProps) => { switch (listType) { case 'channel': return ( - + { ); case 'message': return ( - + - + {t('No chats here yet…')} ); default: - return No items exist; + return No items exist; } }; + +const styles = StyleSheet.create({ + channelDetails: { + fontSize: 14, + textAlign: 'center', + }, + channelTitle: { + fontSize: 16, + paddingBottom: 8, + paddingTop: 16, + }, + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + messageTitle: { + fontSize: 20, + fontWeight: 'bold', + paddingBottom: 8, + }, +}); diff --git a/package/src/components/Indicators/LoadingDot.tsx b/package/src/components/Indicators/LoadingDot.tsx index 9af121284f..6aa45b1835 100644 --- a/package/src/components/Indicators/LoadingDot.tsx +++ b/package/src/components/Indicators/LoadingDot.tsx @@ -62,8 +62,8 @@ export const LoadingDot = (props: Props) => { width: diameter, }, style, - loadingDot, dotStyle, + loadingDot, ]} /> ); diff --git a/package/src/components/Indicators/LoadingErrorIndicator.tsx b/package/src/components/Indicators/LoadingErrorIndicator.tsx index 39a8692204..aaef5047b2 100644 --- a/package/src/components/Indicators/LoadingErrorIndicator.tsx +++ b/package/src/components/Indicators/LoadingErrorIndicator.tsx @@ -4,24 +4,6 @@ import { StyleSheet, Text, TouchableOpacity } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - height: '100%', - justifyContent: 'center', - width: '100%', - }, - errorText: { - fontSize: 14, - fontWeight: '600', - marginTop: 20, - }, - retryText: { - fontSize: 30, - fontWeight: '600', - }, -}); - type LoadingErrorWrapperProps = { text: string; onPress?: () => void; @@ -85,3 +67,21 @@ export const LoadingErrorIndicator = (props: LoadingErrorProps) => { }; LoadingErrorIndicator.displayName = 'LoadingErrorIndicator{loadingErrorIndicator}'; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + height: '100%', + justifyContent: 'center', + width: '100%', + }, + errorText: { + fontSize: 14, + fontWeight: '600', + marginTop: 20, + }, + retryText: { + fontSize: 30, + fontWeight: '600', + }, +}); diff --git a/package/src/components/Indicators/LoadingIndicator.tsx b/package/src/components/Indicators/LoadingIndicator.tsx index 223483a7ad..abbdbdd130 100644 --- a/package/src/components/Indicators/LoadingIndicator.tsx +++ b/package/src/components/Indicators/LoadingIndicator.tsx @@ -5,19 +5,6 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { Spinner } from '../Spinner/Spinner'; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - loadingText: { - fontSize: 14, - fontWeight: '600', - marginTop: 20, - }, -}); - type LoadingIndicatorWrapperProps = { text: string }; const LoadingIndicatorWrapper = ({ text }: LoadingIndicatorWrapperProps) => { @@ -66,3 +53,16 @@ export const LoadingIndicator = (props: LoadingProps) => { }; LoadingIndicator.displayName = 'LoadingIndicator{loadingIndicator}'; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + loadingText: { + fontSize: 14, + fontWeight: '600', + marginTop: 20, + }, +}); diff --git a/package/src/components/MessageList/__tests__/__snapshots__/TypingIndicator.test.js.snap b/package/src/components/MessageList/__tests__/__snapshots__/TypingIndicator.test.js.snap index 6298396665..6f5ecd9ac1 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/TypingIndicator.test.js.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/TypingIndicator.test.js.snap @@ -43,10 +43,10 @@ exports[`TypingIndicator should match typing indicator snapshot 1`] = ` { "marginRight": 2, }, - {}, { "opacity": -0.3333333333333333, }, + {}, ] } /> @@ -62,10 +62,10 @@ exports[`TypingIndicator should match typing indicator snapshot 1`] = ` { "marginHorizontal": 2, }, - {}, { "opacity": 0.3333333333333333, }, + {}, ] } /> @@ -81,10 +81,10 @@ exports[`TypingIndicator should match typing indicator snapshot 1`] = ` { "marginLeft": 2, }, - {}, { "opacity": 1, }, + {}, ] } /> diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 25d78eccae..96ba8b0fb2 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -167,6 +167,8 @@ export type Theme = { channelContainer: ViewStyle; channelDetails: TextStyle; channelTitle: TextStyle; + messageContainer: ViewStyle; + messageTitle: TextStyle; }; groupAvatar: { container: ViewStyle; @@ -753,6 +755,8 @@ export const defaultTheme: Theme = { channelContainer: {}, channelDetails: {}, channelTitle: {}, + messageContainer: {}, + messageTitle: {}, }, groupAvatar: { container: {}, From c5951bc43c8e584501c98f2780689802f9edebd7 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 12 Sep 2024 21:05:37 +0530 Subject: [PATCH 06/13] fix: apply card cover theme property order --- package/src/components/Attachment/Card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/src/components/Attachment/Card.tsx b/package/src/components/Attachment/Card.tsx index 9bfaa1e0a6..b791fbb231 100644 --- a/package/src/components/Attachment/Card.tsx +++ b/package/src/components/Attachment/Card.tsx @@ -207,7 +207,7 @@ const CardWithContext = < imageStyle={styles.cardCover} resizeMode='cover' source={{ uri: makeImageCompatibleUrl(uri) }} - style={[styles.cardCover, cover, stylesProp.cardCover]} + style={[styles.cardCover, stylesProp.cardCover, cover]} > {isVideoCard ? ( Date: Fri, 13 Sep 2024 09:14:20 +0200 Subject: [PATCH 07/13] fix: pagination typescript errors and db synchronization bugs (#2669) * fix: pagination typescript errors and db synchronization bugs * chore: write test for db serialization issue * fix: linter issues --- examples/SampleApp/ios/Podfile.lock | 2 +- examples/SampleApp/yarn.lock | 8 +-- package/native-package/yarn.lock | 8 +-- package/package.json | 2 +- .../offline-support/offline-feature.js | 66 ++++++++++++------- package/src/components/Channel/Channel.tsx | 4 ++ package/src/store/apis/getChannels.ts | 6 +- package/yarn.lock | 8 +-- 8 files changed, 63 insertions(+), 41 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index ee61c1e367..0cf42f1873 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -1637,4 +1637,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 751ee2c534898a790da0a7dba7d623f1f21ae757 -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 5f226d763c..1ee84391a6 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -6837,10 +6837,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@5.36.1: - version "5.36.1" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-5.36.1.tgz#4d3fd3354916a783b77a37c684dd8c126dfd7e10" - integrity sha512-5r2zIOsKE+jXkId/V0BnnO/6HY0/HSjjhS1gDrij1vTGBiTXijlDu0SD+L0fNpEsy7k5qoJMSXoyRZKlrEAAZQ== +stream-chat-react-native-core@5.37.0: + version "5.37.0" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-5.37.0.tgz#39bc4c1f702bb80af2a0fdc7e6ea7fdd12a05368" + integrity sha512-Z/yRDVAD1e/flDnCjQG1XpNNY+YMaFLzThAeQUYfdHgFna8nrfiTOuA4/6BIharjAtWCcQ7SqTztHY0pkhRv8g== dependencies: "@gorhom/bottom-sheet" "^4.6.4" dayjs "1.10.5" diff --git a/package/native-package/yarn.lock b/package/native-package/yarn.lock index 368013ee3e..e004b8dcbe 100644 --- a/package/native-package/yarn.lock +++ b/package/native-package/yarn.lock @@ -4244,10 +4244,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@5.36.2: - version "5.36.2" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-5.36.2.tgz#f02b95c7e29d75a0997d00a3b552492d1f59c608" - integrity sha512-fwqBTtSCTIuFqeoOxY7kJpA2iNMZRbX949VtWyvPGdin0eunSX89fRr6u6jT9Z/Vs5pWCISzVnJiJVOsYnyvOA== +stream-chat-react-native-core@5.37.0: + version "5.37.0" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-5.37.0.tgz#39bc4c1f702bb80af2a0fdc7e6ea7fdd12a05368" + integrity sha512-Z/yRDVAD1e/flDnCjQG1XpNNY+YMaFLzThAeQUYfdHgFna8nrfiTOuA4/6BIharjAtWCcQ7SqTztHY0pkhRv8g== dependencies: "@gorhom/bottom-sheet" "^4.6.4" dayjs "1.10.5" diff --git a/package/package.json b/package/package.json index 32972dcf0a..3635b7eda7 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^1.3.0", - "stream-chat": "8.38.0" + "stream-chat": "8.39.0" }, "peerDependencies": { "react-native-quick-sqlite": ">=5.1.0", diff --git a/package/src/__tests__/offline-support/offline-feature.js b/package/src/__tests__/offline-support/offline-feature.js index f581d49318..7f10b3fa28 100644 --- a/package/src/__tests__/offline-support/offline-feature.js +++ b/package/src/__tests__/offline-support/offline-feature.js @@ -115,7 +115,7 @@ export const Generic = () => { let allReactions; let allReads; const getRandomInt = (lower, upper) => Math.floor(lower + Math.random() * (upper - lower + 1)); - const createChannel = () => { + const createChannel = (messagesOverride) => { const id = uuidv4(); const cid = `messaging:${id}`; const begin = getRandomInt(0, allUsers.length - 2); // begin shouldn't be the end of users.length @@ -127,31 +127,33 @@ export const Generic = () => { user, }), ); - const messages = Array(10) - .fill(1) - .map(() => { - const id = uuidv4(); - const user = usersForMembers[getRandomInt(0, usersForMembers.length - 1)]; - - const begin = getRandomInt(0, usersForMembers.length - 2); // begin shouldn't be the end of users.length - const end = getRandomInt(begin + 1, usersForMembers.length - 1); - - const usersForReactions = usersForMembers.slice(begin, end); - const reactions = usersForReactions.map((user) => - generateReaction({ - message_id: id, + const messages = + messagesOverride || + Array(10) + .fill(1) + .map(() => { + const id = uuidv4(); + const user = usersForMembers[getRandomInt(0, usersForMembers.length - 1)]; + + const begin = getRandomInt(0, usersForMembers.length - 2); // begin shouldn't be the end of users.length + const end = getRandomInt(begin + 1, usersForMembers.length - 1); + + const usersForReactions = usersForMembers.slice(begin, end); + const reactions = usersForReactions.map((user) => + generateReaction({ + message_id: id, + user, + }), + ); + allReactions.push(...reactions); + return generateMessage({ + cid, + id, + latest_reactions: reactions, user, - }), - ); - allReactions.push(...reactions); - return generateMessage({ - cid, - id, - latest_reactions: reactions, - user, - userId: user.id, + userId: user.id, + }); }); - }); const reads = members.map((member) => ({ last_read: new Date(new Date().setDate(new Date().getDate() - getRandomInt(0, 20))), @@ -189,6 +191,7 @@ export const Generic = () => { afterEach(() => { BetterSqlite.dropAllTables(); cleanup(); + jest.clearAllMocks(); }); const filters = { @@ -313,6 +316,21 @@ export const Generic = () => { expectAllChannelsWithStateToBeInDB(screen.queryAllByLabelText); }); + it('should fetch channels from the db correctly even if they are empty', async () => { + const emptyChannel = createChannel([]); + useMockedApis(chatClient, [queryChannelsApi([emptyChannel])]); + jest.spyOn(chatClient, 'hydrateActiveChannels'); + + renderComponent(); + await act(() => dispatchConnectionChangedEvent(chatClient)); + await waitFor(() => { + expect(screen.getByTestId('channel-list')).toBeTruthy(); + expect(screen.getByTestId(emptyChannel.cid)).toBeTruthy(); + expect(chatClient.hydrateActiveChannels).toHaveBeenCalledTimes(2); + expect(chatClient.hydrateActiveChannels.mock.calls[0][0]).toStrictEqual([emptyChannel]); + }); + }); + it('should add a new message to database', async () => { useMockedApis(chatClient, [queryChannelsApi(channels)]); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 236df723c3..46ea57c257 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1110,6 +1110,10 @@ const ChannelWithContext = < isCurrent: false, isLatest: true, messages: [], + pagination: { + hasNext: true, + hasPrev: true, + }, }); }); diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index 2b0818c2aa..a032042617 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -40,9 +40,9 @@ export const getChannels = < // Enrich the channels with state return channels.map((c) => ({ ...mapStorableToChannel(c), - members: cidVsMembers[c.cid], - messages: cidVsMessages[c.cid], + members: cidVsMembers[c.cid] || [], + messages: cidVsMessages[c.cid] || [], pinned_messages: [], - read: cidVsReads[c.cid], + read: cidVsReads[c.cid] || [], })); }; diff --git a/package/yarn.lock b/package/yarn.lock index b80b0e39c6..5a51ef985b 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -10664,10 +10664,10 @@ stream-browserify@^2.0.1: inherits "~2.0.1" readable-stream "^2.0.2" -stream-chat@8.38.0: - version "8.38.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.38.0.tgz#b7a82faf7734d010a5797ecb2420cc5ea8a40f7c" - integrity sha512-hhnbKaH1aqYYySN8+YX8kIyDqcsNhAxQEMxCmhIIBA8i8FSK6/X4dYe/Z0VHlwxUQAf95si5iR1jZCOeAiiOiw== +stream-chat@8.39.0: + version "8.39.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.39.0.tgz#f4cb86bd5cac4c1272c24cd66ed4752bcda8d717" + integrity sha512-zQZR1tPrgGBbu+Gnv9F9KQx3OPUMvb0FN+39BEjkjgjRPm2JYhF78jfcYutQMiC538t3V+NgFGgj5N4sZvSsUA== dependencies: "@babel/runtime" "^7.16.3" "@types/jsonwebtoken" "~9.0.0" From 98a2f6c7e14b7134703df8934df3a92f8676ab4d Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Tue, 17 Sep 2024 09:06:27 +0100 Subject: [PATCH 08/13] [CI] Bump max tolerance for sdk size analysis (#2674) --- examples/SampleApp/Gemfile.lock | 4 ++-- examples/SampleApp/fastlane/Pluginfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/SampleApp/Gemfile.lock b/examples/SampleApp/Gemfile.lock index 04279ae202..e0d353669e 100644 --- a/examples/SampleApp/Gemfile.lock +++ b/examples/SampleApp/Gemfile.lock @@ -163,7 +163,7 @@ GEM google-apis-firebaseappdistribution_v1 (~> 0.3.0) google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) fastlane-plugin-load_json (0.0.1) - fastlane-plugin-stream_actions (0.3.68) + fastlane-plugin-stream_actions (0.3.69) xctest_list (= 1.2.1) ffi (1.17.0) fourflusher (2.3.1) @@ -320,7 +320,7 @@ DEPENDENCIES fastlane fastlane-plugin-firebase_app_distribution fastlane-plugin-load_json - fastlane-plugin-stream_actions (= 0.3.68) + fastlane-plugin-stream_actions (= 0.3.69) rubocop-performance rubocop-require_tools diff --git a/examples/SampleApp/fastlane/Pluginfile b/examples/SampleApp/fastlane/Pluginfile index 889c42a4f7..4d5f3ebd70 100644 --- a/examples/SampleApp/fastlane/Pluginfile +++ b/examples/SampleApp/fastlane/Pluginfile @@ -4,4 +4,4 @@ gem 'fastlane-plugin-firebase_app_distribution' gem 'fastlane-plugin-load_json' -gem 'fastlane-plugin-stream_actions', '0.3.68' +gem 'fastlane-plugin-stream_actions', '0.3.69' From 9179398e2b87a2f0201a6644912a113e2f89b0cf Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 17 Sep 2024 10:54:50 +0200 Subject: [PATCH 09/13] chore: bump sample app version to v1.30.0 --- examples/SampleApp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index cd877fa723..0d7744cde7 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -1,6 +1,6 @@ { "name": "sampleapp", - "version": "1.29.0", + "version": "1.30.0", "private": true, "repository": { "type": "git", From 3497bcb1be88b550372db5f77a48a287161036eb Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 18 Sep 2024 10:40:59 +0530 Subject: [PATCH 10/13] fix: remove nin and ne operator usage in the SDK and the sample app (#2672) * fix: remove and operator usage in the SDK and the sample app * fix: remove console log * fix: change console log to warn * fix: add improvemnts * fix: import of debouncefunc * fix: import of debouncefunc * fix: restructure queryMembers, queryUsers and ACItriggersettings * fix: error bubbling for suggestions in auto complete input --- .../SampleApp/src/hooks/usePaginatedUsers.ts | 25 +- .../AutoCompleteInput/AutoCompleteInput.tsx | 9 +- .../__tests__/AutoCompleteInput.test.js | 2 +- .../MessageInputContext.tsx | 47 +- package/src/index.ts | 3 + package/src/utils/ACITriggerSettings.ts | 273 ++++++++++ package/src/utils/constants.ts | 6 + package/src/utils/queryMembers.ts | 109 ++++ package/src/utils/queryUsers.ts | 64 +++ package/src/utils/utils.ts | 472 ++---------------- 10 files changed, 525 insertions(+), 485 deletions(-) create mode 100644 package/src/utils/ACITriggerSettings.ts create mode 100644 package/src/utils/constants.ts create mode 100644 package/src/utils/queryMembers.ts create mode 100644 package/src/utils/queryUsers.ts diff --git a/examples/SampleApp/src/hooks/usePaginatedUsers.ts b/examples/SampleApp/src/hooks/usePaginatedUsers.ts index 416b69f8d1..9c5f486a05 100644 --- a/examples/SampleApp/src/hooks/usePaginatedUsers.ts +++ b/examples/SampleApp/src/hooks/usePaginatedUsers.ts @@ -121,9 +121,6 @@ export const usePaginatedUsers = (): PaginatedUsers => { try { queryInProgress.current = true; const filter: UserFilters = { - id: { - $nin: [chatClient?.userID], - }, role: 'user', }; @@ -143,7 +140,7 @@ export const usePaginatedUsers = (): PaginatedUsers => { return; } - const res = await chatClient?.queryUsers( + const { users } = await chatClient?.queryUsers( filter, { name: 1 }, { @@ -153,33 +150,25 @@ export const usePaginatedUsers = (): PaginatedUsers => { }, ); - if (!res?.users) { - queryInProgress.current = false; - return; - } - - // Dumb check to avoid duplicates - if (query === searchText && results.findIndex((r) => res?.users[0].id === r.id) > -1) { - queryInProgress.current = false; - return; - } + const usersWithoutClientUserId = users.filter((user) => user.id !== chatClient.userID); setResults((r) => { if (query !== searchText) { - return res?.users; + return usersWithoutClientUserId; } - return r.concat(res?.users || []); + return r.concat(usersWithoutClientUserId); }); - if (res?.users.length < 10 && (offset.current === 0 || query === searchText)) { + if (usersWithoutClientUserId.length < 10 && (offset.current === 0 || query === searchText)) { hasMoreResults.current = false; } if (!query && offset.current === 0) { - setInitialResults(res?.users || []); + setInitialResults(usersWithoutClientUserId); } } catch (e) { // do nothing; + console.log('Error fetching users', e); } queryInProgress.current = false; setLoading(false); diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index 177f30da48..1962de80ec 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -28,9 +28,12 @@ import { } from '../../contexts/translationContext/TranslationContext'; import type { Emoji } from '../../emoji-data'; import type { DefaultStreamChatGenerics } from '../../types/types'; -import { isCommandTrigger, isEmojiTrigger, isMentionTrigger } from '../../utils/utils'; - -import type { Trigger } from '../../utils/utils'; +import { + isCommandTrigger, + isEmojiTrigger, + isMentionTrigger, + Trigger, +} from '../../utils/ACITriggerSettings'; const styles = StyleSheet.create({ inputBox: { diff --git a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js index 707e9f8a32..c3755f1a22 100644 --- a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js +++ b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js @@ -8,7 +8,7 @@ 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/utils'; +import { ACITriggerSettings } from '../../../utils/ACITriggerSettings'; import { Chat } from '../../Chat/Chat'; import { AutoCompleteInput } from '../AutoCompleteInput'; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index d27de877fe..a892504328 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -63,17 +63,19 @@ import { ImageUpload, UnknownType, } from '../../types/types'; -import { compressedImageURI } from '../../utils/compressImage'; -import { removeReservedFields } from '../../utils/removeReservedFields'; import { ACITriggerSettings, ACITriggerSettingsParams, + TriggerSettings, +} from '../../utils/ACITriggerSettings'; +import { compressedImageURI } from '../../utils/compressImage'; +import { removeReservedFields } from '../../utils/removeReservedFields'; +import { FileState, FileStateValue, generateRandomId, getFileNameFromPath, isBouncedMessage, - TriggerSettings, } from '../../utils/utils'; import { useAttachmentPickerContext } from '../attachmentPickerContext/AttachmentPickerContext'; import { ChannelContextValue, useChannelContext } from '../channelContext/ChannelContext'; @@ -1073,25 +1075,30 @@ export const MessageInputProvider = < }; const getTriggerSettings = () => { - let triggerSettings: TriggerSettings = {}; - if (channel) { - if (value.autoCompleteTriggerSettings) { - triggerSettings = value.autoCompleteTriggerSettings({ - channel, - client, - emojiSearchIndex: value.emojiSearchIndex, - onMentionSelectItem: onSelectItem, - }); - } else { - triggerSettings = ACITriggerSettings({ - channel, - client, - emojiSearchIndex: value.emojiSearchIndex, - onMentionSelectItem: onSelectItem, - }); + try { + let triggerSettings: TriggerSettings = {}; + if (channel) { + if (value.autoCompleteTriggerSettings) { + triggerSettings = value.autoCompleteTriggerSettings({ + channel, + client, + emojiSearchIndex: value.emojiSearchIndex, + onMentionSelectItem: onSelectItem, + }); + } else { + triggerSettings = ACITriggerSettings({ + channel, + client, + emojiSearchIndex: value.emojiSearchIndex, + onMentionSelectItem: onSelectItem, + }); + } } + return triggerSettings; + } catch (error) { + console.warn('Error in getting trigger settings', error); + throw error; } - return triggerSettings; }; const triggerSettings = getTriggerSettings(); diff --git a/package/src/index.ts b/package/src/index.ts index 24edca108c..9c4c8156f8 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -11,8 +11,11 @@ export * from './icons'; export * from './types/types'; +export * from './utils/ACITriggerSettings'; export * from './utils/patchMessageTextCommand'; export * from './utils/i18n/Streami18n'; +export * from './utils/queryMembers'; +export * from './utils/queryUsers'; export * from './utils/utils'; export * from './utils/StreamChatRN'; diff --git a/package/src/utils/ACITriggerSettings.ts b/package/src/utils/ACITriggerSettings.ts new file mode 100644 index 0000000000..4f1c580c54 --- /dev/null +++ b/package/src/utils/ACITriggerSettings.ts @@ -0,0 +1,273 @@ +import type { DebouncedFunc } from 'lodash'; +import type { Channel, CommandResponse, StreamChat } from 'stream-chat'; + +import { defaultAutoCompleteSuggestionsLimit, defaultMentionAllAppUsersQuery } from './constants'; +import { getMembersAndWatchers, queryMembersDebounced, QueryMembersFunction } from './queryMembers'; +import { queryUsersDebounced, QueryUsersFunction } from './queryUsers'; + +import type { + EmojiSearchIndex, + MentionAllAppUsersQuery, +} from '../contexts/messageInputContext/MessageInputContext'; +import type { + SuggestionCommand, + SuggestionComponentType, + SuggestionUser, +} from '../contexts/suggestionsContext/SuggestionsContext'; +import { Emoji } from '../emoji-data'; +import type { DefaultStreamChatGenerics } from '../types/types'; + +const getCommands = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + channel: Channel, +) => channel.getConfig()?.commands || []; + +export type TriggerSettingsOutputType = { + caretPosition: string; + key: string; + text: string; +}; + +export type TriggerSettings< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + '/'?: { + dataProvider: ( + query: CommandResponse['name'], + text: string, + onReady?: ( + data: CommandResponse[], + q: CommandResponse['name'], + ) => void, + options?: { + limit?: number; + }, + ) => SuggestionCommand[]; + output: (entity: CommandResponse) => TriggerSettingsOutputType; + type: SuggestionComponentType; + }; + ':'?: { + dataProvider: ( + query: Emoji['name'], + _: string, + onReady?: (data: Emoji[], q: Emoji['name']) => void, + ) => Emoji[] | Promise; + output: (entity: Emoji) => TriggerSettingsOutputType; + type: SuggestionComponentType; + }; + '@'?: { + callback: (item: SuggestionUser) => void; + dataProvider: ( + query: SuggestionUser['name'], + _: string, + onReady?: ( + data: SuggestionUser[], + q: SuggestionUser['name'], + ) => void, + options?: { + limit?: number; + mentionAllAppUsersEnabled?: boolean; + mentionAllAppUsersQuery?: MentionAllAppUsersQuery; + }, + ) => SuggestionUser[] | Promise | void; + output: (entity: SuggestionUser) => TriggerSettingsOutputType; + type: SuggestionComponentType; + }; +}; + +export type ACITriggerSettingsParams< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + channel: Channel; + client: StreamChat; + onMentionSelectItem: (item: SuggestionUser) => void; + emojiSearchIndex?: EmojiSearchIndex; +}; + +export const isCommandTrigger = (trigger: Trigger): trigger is '/' => trigger === '/'; + +export const isEmojiTrigger = (trigger: Trigger): trigger is ':' => trigger === ':'; + +export const isMentionTrigger = (trigger: Trigger): trigger is '@' => trigger === '@'; + +export type Trigger = '/' | '@' | ':'; + +/** + * ACI = AutoCompleteInput + * + * DataProvider accepts `onReady` function, which will execute once the data is ready. + * Another approach would have been to simply return the data from dataProvider and let the + * component await for it and then execute the required logic. We are going for callback instead + * of async-await since we have debounce function in dataProvider. Which will delay the execution + * of api call on trailing end of debounce (lets call it a1) but will return with result of + * previous call without waiting for a1. So in this case, we want to execute onReady, when trailing + * end of debounce executes. + */ +export const ACITriggerSettings = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + channel, + client, + emojiSearchIndex, + onMentionSelectItem, +}: ACITriggerSettingsParams): TriggerSettings => ({ + '/': { + dataProvider: (query, text, onReady, options = {}) => { + try { + if (text.indexOf('/') !== 0) return []; + + const { limit = defaultAutoCompleteSuggestionsLimit } = options; + const selectedCommands = !query + ? getCommands(channel) + : getCommands(channel).filter((command) => query && command.name?.indexOf(query) !== -1); + + // sort alphabetically unless the you're matching the first char + selectedCommands.sort((a, b) => { + let nameA = a.name?.toLowerCase() || ''; + let nameB = b.name?.toLowerCase() || ''; + if (query && nameA.indexOf(query) === 0) { + nameA = `0${nameA}`; + } + if (query && nameB.indexOf(query) === 0) { + nameB = `0${nameB}`; + } + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + + return 0; + }); + + const result = selectedCommands.slice(0, limit); + + if (onReady) { + onReady(result, query); + } + + return result; + } catch (error) { + console.warn('Error querying commands while using "/":', error); + throw error; + } + }, + output: (entity) => ({ + caretPosition: 'next', + key: `${entity.name}`, + text: `/${entity.name}`, + }), + type: 'command', + }, + ':': { + dataProvider: async (query, _, onReady) => { + try { + if (!query) return []; + + const emojis = (await emojiSearchIndex?.search(query)) ?? []; + + if (onReady) { + onReady(emojis, query); + } + + return emojis; + } catch (error) { + console.warn('Error querying emojis while using ":":', error); + throw error; + } + }, + output: (entity) => ({ + caretPosition: 'next', + key: entity.name, + text: entity.unicode, + }), + type: 'emoji', + }, + '@': { + callback: (item) => { + onMentionSelectItem(item); + }, + dataProvider: ( + query, + _, + onReady, + options = { + limit: defaultAutoCompleteSuggestionsLimit, + mentionAllAppUsersEnabled: false, + mentionAllAppUsersQuery: defaultMentionAllAppUsersQuery, + }, + ) => { + try { + if (!query) return []; + if (options?.mentionAllAppUsersEnabled) { + return (queryUsersDebounced as DebouncedFunc>)( + client, + query, + (data) => { + if (onReady) { + onReady(data, query); + } + }, + { + limit: options.limit, + mentionAllAppUsersQuery: options.mentionAllAppUsersQuery, + }, + ); + } + /** + * By default, we return maximum 100 members via queryChannels api call. + * Thus it is safe to assume, that if number of members in channel.state is < 100, + * then all the members are already available on client side and we don't need to + * make any api call to queryMembers endpoint. + */ + if (Object.values(channel.state.members).length < 100) { + const users = getMembersAndWatchers(channel); + + const matchingUsers = users.filter((user) => { + if (!query) return true; + // Don't show current authenticated user in the list + if (user.id === client.userID) { + return false; + } + if (user.name?.toLowerCase().indexOf(query.toLowerCase()) !== -1) { + return true; + } + if (user.id.toLowerCase().indexOf(query.toLowerCase()) !== -1) { + return true; + } + return false; + }); + + const data = matchingUsers.slice(0, options?.limit); + + if (onReady) { + onReady(data, query); + } + + return data; + } + + return (queryMembersDebounced as DebouncedFunc>)( + client, + channel, + query, + (data) => { + if (onReady) { + onReady(data, query); + } + }, + { + limit: options.limit, + }, + ); + } catch (error) { + console.warn("Error querying users/members while using '@':", error); + throw error; + } + }, + output: (entity) => ({ + caretPosition: 'next', + key: entity.id, + text: `@${entity.name || entity.id}`, + }), + type: 'mention', + }, +}); diff --git a/package/src/utils/constants.ts b/package/src/utils/constants.ts new file mode 100644 index 0000000000..aab284f2a6 --- /dev/null +++ b/package/src/utils/constants.ts @@ -0,0 +1,6 @@ +export const defaultAutoCompleteSuggestionsLimit = 10; +export const defaultMentionAllAppUsersQuery = { + filters: {}, + options: {}, + sort: {}, +}; diff --git a/package/src/utils/queryMembers.ts b/package/src/utils/queryMembers.ts new file mode 100644 index 0000000000..840069ef9b --- /dev/null +++ b/package/src/utils/queryMembers.ts @@ -0,0 +1,109 @@ +import debounce from 'lodash/debounce'; +import type { Channel, ChannelMemberAPIResponse, StreamChat, User } from 'stream-chat'; + +import { defaultAutoCompleteSuggestionsLimit } from './constants'; + +import type { SuggestionUser } from '../contexts/suggestionsContext/SuggestionsContext'; +import type { DefaultStreamChatGenerics } from '../types/types'; + +const getMembers = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + channel: Channel, +) => { + const members = channel.state.members; + return members ? Object.values(members).map(({ user }) => user) : []; +}; + +const getWatchers = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + channel: Channel, +) => { + const watchers = channel.state.watchers; + return watchers ? Object.values(watchers) : []; +}; + +export const getMembersAndWatchers = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + channel: Channel, +) => { + const members = getMembers(channel); + const watchers = getWatchers(channel); + const users = [...members, ...watchers]; + + // make sure we don't list users twice + const seenUsers = new Set(); + const uniqueUsers: User[] = []; + + for (const user of users) { + if (user && !seenUsers.has(user.id)) { + uniqueUsers.push(user); + seenUsers.add(user.id); + } + } + + return uniqueUsers; +}; + +const isUserResponse = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + user: SuggestionUser | undefined, +): user is SuggestionUser => + (user as SuggestionUser) !== undefined; + +export type QueryMembersFunction< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = ( + client: StreamChat, + channel: Channel, + query: SuggestionUser['name'], + onReady?: (users: SuggestionUser[]) => void, + options?: { + limit?: number; + }, +) => Promise; + +const queryMembers = async < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + client: StreamChat, + channel: Channel, + query: SuggestionUser['name'], + onReady?: (users: SuggestionUser[]) => void, + options: { + limit?: number; + } = {}, +): Promise => { + if (!query) return; + try { + const { limit = defaultAutoCompleteSuggestionsLimit } = options; + + const { members } = (await (channel as unknown as Channel).queryMembers( + { + name: { $autocomplete: query }, + }, + {}, + { limit }, + )) as ChannelMemberAPIResponse; + + const users: SuggestionUser[] = []; + members + .filter((member) => member.user?.id !== client.userID) + .forEach((member) => isUserResponse(member.user) && users.push(member.user)); + + if (onReady && users) { + onReady(users); + } + } catch (error) { + console.warn('Error querying members:', error); + throw error; + } +}; + +export const queryMembersDebounced = debounce(queryMembers, 200, { + leading: false, + trailing: true, +}); diff --git a/package/src/utils/queryUsers.ts b/package/src/utils/queryUsers.ts new file mode 100644 index 0000000000..f3dfef5980 --- /dev/null +++ b/package/src/utils/queryUsers.ts @@ -0,0 +1,64 @@ +import debounce from 'lodash/debounce'; +import type { StreamChat } from 'stream-chat'; + +import { defaultAutoCompleteSuggestionsLimit, defaultMentionAllAppUsersQuery } from './constants'; + +import type { MentionAllAppUsersQuery } from '../contexts/messageInputContext/MessageInputContext'; +import type { SuggestionUser } from '../contexts/suggestionsContext/SuggestionsContext'; +import type { DefaultStreamChatGenerics } from '../types/types'; + +export type QueryUsersFunction< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = ( + client: StreamChat, + query: SuggestionUser['name'], + onReady?: (users: SuggestionUser[]) => void, + options?: { + limit?: number; + mentionAllAppUsersQuery?: MentionAllAppUsersQuery; + }, +) => Promise; + +const queryUsers = async < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + client: StreamChat, + query: SuggestionUser['name'], + onReady?: (users: SuggestionUser[]) => void, + options: { + limit?: number; + mentionAllAppUsersQuery?: MentionAllAppUsersQuery; + } = {}, +): Promise => { + if (!query) return; + try { + const { + limit = defaultAutoCompleteSuggestionsLimit, + mentionAllAppUsersQuery = defaultMentionAllAppUsersQuery, + } = options; + + const filters = { + $or: [{ id: { $autocomplete: query } }, { name: { $autocomplete: query } }], + ...mentionAllAppUsersQuery?.filters, + }; + + const { users } = await client.queryUsers( + // @ts-ignore + filters, + { id: 1, ...mentionAllAppUsersQuery?.sort }, + { limit, ...mentionAllAppUsersQuery?.options }, + ); + const usersWithoutClientUserId = users.filter((user) => user.id !== client.userID); + if (onReady && users) { + onReady(usersWithoutClientUserId); + } + } catch (error) { + console.warn('Error querying users:', error); + throw error; + } +}; + +export const queryUsersDebounced = debounce(queryUsers, 200, { + leading: false, + trailing: true, +}); diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index c7b0165480..3cffd7600a 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -2,31 +2,12 @@ import type React from 'react'; import dayjs from 'dayjs'; import EmojiRegex from 'emoji-regex'; -import type { DebouncedFunc } from 'lodash'; -import debounce from 'lodash/debounce'; -import type { - Channel, - ChannelMemberAPIResponse, - ChannelMemberResponse, - CommandResponse, - FormatMessageResponse, - MessageResponse, - StreamChat, - UserResponse, -} from 'stream-chat'; +import type { FormatMessageResponse, MessageResponse } from 'stream-chat'; import { IconProps } from '../../src/icons/utils/base'; import { MessageType } from '../components/MessageList/hooks/useMessageList'; -import type { - EmojiSearchIndex, - MentionAllAppUsersQuery, -} from '../contexts/messageInputContext/MessageInputContext'; -import type { - SuggestionCommand, - SuggestionComponentType, - SuggestionUser, -} from '../contexts/suggestionsContext/SuggestionsContext'; -import { compiledEmojis, Emoji } from '../emoji-data'; +import type { EmojiSearchIndex } from '../contexts/messageInputContext/MessageInputContext'; +import { compiledEmojis } from '../emoji-data'; import type { TableRowJoinedUser } from '../store/types'; import type { DefaultStreamChatGenerics, ValueOf } from '../types/types'; @@ -126,440 +107,45 @@ export const isEditedMessage = < message: MessageType, ) => !!message.message_text_updated_at; -const defaultAutoCompleteSuggestionsLimit = 10; -const defaultMentionAllAppUsersQuery = { - filters: {}, - options: {}, - sort: {}, -}; - -const isUserResponse = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->( - user: SuggestionUser | undefined, -): user is SuggestionUser => - (user as SuggestionUser) !== undefined; - -const getCommands = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->( - channel: Channel, -) => channel.getConfig()?.commands || []; - -const getMembers = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->( - channel: Channel, -) => { - const members = channel.state.members; - - return Object.values(members).length - ? ( - Object.values(members).filter((member) => member.user) as Array< - ChannelMemberResponse & { user: UserResponse } - > - ).map((member) => member.user) - : []; -}; - -const getWatchers = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->( - channel: Channel, -) => { - const watchers = channel.state.watchers; - return Object.values(watchers).length ? [...Object.values(watchers)] : []; -}; - -const getMembersAndWatchers = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->( - channel: Channel, -) => { - const users = [...getMembers(channel), ...getWatchers(channel)]; - - return Object.values( - users.reduce((acc, cur) => { - if (!acc[cur.id]) { - acc[cur.id] = cur; - } - - return acc; - }, {} as { [key: string]: SuggestionUser }), - ); -}; - -const queryMembers = async < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->( - channel: Channel, - query: SuggestionUser['name'], - onReady?: (users: SuggestionUser[]) => void, - options: { - limit?: number; - } = {}, -): Promise => { - const { limit = defaultAutoCompleteSuggestionsLimit } = options; - - if (typeof query === 'string') { - const response = (await (channel as unknown as Channel).queryMembers( - { - name: { $autocomplete: query }, - }, - {}, - { limit }, - )) as ChannelMemberAPIResponse; - - const users: SuggestionUser[] = []; - response.members.forEach((member) => isUserResponse(member.user) && users.push(member.user)); - if (onReady && users) { - onReady(users); - } - } -}; - -export const queryMembersDebounced = debounce(queryMembers, 200, { - leading: false, - trailing: true, -}); - -const queryUsers = async < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->( - client: StreamChat, - query: SuggestionUser['name'], - onReady?: (users: SuggestionUser[]) => void, - options: { - limit?: number; - mentionAllAppUsersQuery?: MentionAllAppUsersQuery; - } = {}, -): Promise => { - if (typeof query === 'string') { - const { - limit = defaultAutoCompleteSuggestionsLimit, - mentionAllAppUsersQuery = defaultMentionAllAppUsersQuery, - } = options; - const filters = { - id: { $ne: client.userID }, - ...mentionAllAppUsersQuery?.filters, - }; - - if (query) { - // @ts-ignore - filters.$or = [{ id: { $autocomplete: query } }, { name: { $autocomplete: query } }]; - } - - const response = await client.queryUsers( - // @ts-ignore - filters, - { id: 1, ...mentionAllAppUsersQuery?.sort }, - { limit, ...mentionAllAppUsersQuery?.options }, - ); - const users: SuggestionUser[] = []; - response.users.forEach((user) => isUserResponse(user) && users.push(user)); - if (onReady && users) { - onReady(users); - } - } -}; - -export const queryUsersDebounced = debounce(queryUsers, 200, { - leading: false, - trailing: true, -}); - -export const isCommandTrigger = (trigger: Trigger): trigger is '/' => trigger === '/'; - -export const isEmojiTrigger = (trigger: Trigger): trigger is ':' => trigger === ':'; - -export const isMentionTrigger = (trigger: Trigger): trigger is '@' => trigger === '@'; - -export type Trigger = '/' | '@' | ':'; - -export type TriggerSettings< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, -> = { - '/'?: { - dataProvider: ( - query: CommandResponse['name'], - text: string, - onReady?: ( - data: CommandResponse[], - q: CommandResponse['name'], - ) => void, - options?: { - limit?: number; - }, - ) => SuggestionCommand[]; - output: (entity: CommandResponse) => { - caretPosition: string; - key: string; - text: string; - }; - type: SuggestionComponentType; - }; - ':'?: { - dataProvider: ( - query: Emoji['name'], - _: string, - onReady?: (data: Emoji[], q: Emoji['name']) => void, - ) => Emoji[] | Promise; - output: (entity: Emoji) => { - caretPosition: string; - key: string; - text: string; - }; - type: SuggestionComponentType; - }; - '@'?: { - callback: (item: SuggestionUser) => void; - dataProvider: ( - query: SuggestionUser['name'], - _: string, - onReady?: ( - data: SuggestionUser[], - q: SuggestionUser['name'], - ) => void, - options?: { - limit?: number; - mentionAllAppUsersEnabled?: boolean; - mentionAllAppUsersQuery?: MentionAllAppUsersQuery; - }, - ) => SuggestionUser[] | Promise | void; - output: (entity: SuggestionUser) => { - caretPosition: string; - key: string; - text: string; - }; - type: SuggestionComponentType; - }; -}; - -export type ACITriggerSettingsParams< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, -> = { - channel: Channel; - client: StreamChat; - onMentionSelectItem: (item: SuggestionUser) => void; - emojiSearchIndex?: EmojiSearchIndex; -}; - -export type QueryUsersFunction< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, -> = ( - client: StreamChat, - query: SuggestionUser['name'], - onReady?: (users: SuggestionUser[]) => void, - options?: { - limit?: number; - mentionAllAppUsersQuery?: MentionAllAppUsersQuery; - }, -) => Promise; - -export type QueryMembersFunction< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, -> = ( - channel: Channel, - query: SuggestionUser['name'], - onReady?: (users: SuggestionUser[]) => void, - options?: { - limit?: number; - }, -) => Promise; - /** * Default emoji search index for auto complete text input */ export const defaultEmojiSearchIndex: EmojiSearchIndex = { search: (query) => { - const results = []; - - for (const emoji of compiledEmojis) { - if (results.length >= 10) return results; - if (emoji.names.some((name) => name.includes(query))) { - // Aggregate skins as different toned emojis - if skins are present - if (emoji.skins) { - results.push({ - ...emoji, - name: `${emoji.name}-tone-1`, - skins: undefined, - }); - emoji.skins.forEach((tone, index) => + try { + const results = []; + + for (const emoji of compiledEmojis) { + if (results.length >= 10) return results; + if (emoji.names.some((name) => name.includes(query))) { + // Aggregate skins as different toned emojis - if skins are present + if (emoji.skins) { results.push({ ...emoji, - name: `${emoji.name}-tone-${index + 2}`, + name: `${emoji.name}-tone-1`, skins: undefined, - unicode: tone, - }), - ); - } else { - results.push(emoji); - } - } - } - - return results; - }, -}; - -/** - * ACI = AutoCompleteInput - * - * DataProvider accepts `onReady` function, which will execute once the data is ready. - * Another approach would have been to simply return the data from dataProvider and let the - * component await for it and then execute the required logic. We are going for callback instead - * of async-await since we have debounce function in dataProvider. Which will delay the execution - * of api call on trailing end of debounce (lets call it a1) but will return with result of - * previous call without waiting for a1. So in this case, we want to execute onReady, when trailing - * end of debounce executes. - */ -export const ACITriggerSettings = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->({ - channel, - client, - emojiSearchIndex, - onMentionSelectItem, -}: ACITriggerSettingsParams): TriggerSettings => ({ - '/': { - dataProvider: (query, text, onReady, options = {}) => { - if (text.indexOf('/') !== 0) return []; - - const { limit = defaultAutoCompleteSuggestionsLimit } = options; - const selectedCommands = !query - ? getCommands(channel) - : getCommands(channel).filter((command) => query && command.name?.indexOf(query) !== -1); - - // sort alphabetically unless the you're matching the first char - selectedCommands.sort((a, b) => { - let nameA = a.name?.toLowerCase() || ''; - let nameB = b.name?.toLowerCase() || ''; - if (query && nameA.indexOf(query) === 0) { - nameA = `0${nameA}`; - } - if (query && nameB.indexOf(query) === 0) { - nameB = `0${nameB}`; - } - if (nameA < nameB) return -1; - if (nameA > nameB) return 1; - - return 0; - }); - - const result = selectedCommands.slice(0, limit); - - if (onReady) { - onReady(result, query); - } - - return result; - }, - output: (entity) => ({ - caretPosition: 'next', - key: `${entity.name}`, - text: `/${entity.name}`, - }), - type: 'command', - }, - ':': { - dataProvider: async (query, _, onReady) => { - if (!query) return []; - - const emojis = (await emojiSearchIndex?.search(query)) ?? []; - - if (onReady) { - onReady(emojis, query); - } - - return emojis; - }, - output: (entity) => ({ - caretPosition: 'next', - key: entity.name, - text: entity.unicode, - }), - type: 'emoji', - }, - '@': { - callback: (item) => { - onMentionSelectItem(item); - }, - dataProvider: ( - query, - _, - onReady, - options = { - limit: defaultAutoCompleteSuggestionsLimit, - mentionAllAppUsersEnabled: false, - mentionAllAppUsersQuery: defaultMentionAllAppUsersQuery, - }, - ) => { - if (options?.mentionAllAppUsersEnabled) { - return (queryUsersDebounced as DebouncedFunc>)( - client, - query, - (data) => { - if (onReady) { - onReady(data, query); - } - }, - { - limit: options.limit, - mentionAllAppUsersQuery: options.mentionAllAppUsersQuery, - }, - ); - } - - /** - * By default, we return maximum 100 members via queryChannels api call. - * Thus it is safe to assume, that if number of members in channel.state is < 100, - * then all the members are already available on client side and we don't need to - * make any api call to queryMembers endpoint. - */ - if (!query || Object.values(channel.state.members).length < 100) { - const users = getMembersAndWatchers(channel); - - const matchingUsers = users.filter((user) => { - if (!query) return true; - if (user.name?.toLowerCase().indexOf(query.toLowerCase()) !== -1) { - return true; - } - if (user.id.toLowerCase().indexOf(query.toLowerCase()) !== -1) { - return true; + }); + emoji.skins.forEach((tone, index) => + results.push({ + ...emoji, + name: `${emoji.name}-tone-${index + 2}`, + skins: undefined, + unicode: tone, + }), + ); + } else { + results.push(emoji); } - return false; - }); - - const data = matchingUsers.slice(0, options?.limit); - - if (onReady) { - onReady(data, query); } - - return data; } - return (queryMembersDebounced as DebouncedFunc>)( - channel, - query, - (data) => { - if (onReady) { - onReady(data, query); - } - }, - { - limit: options.limit, - }, - ); - }, - output: (entity) => ({ - caretPosition: 'next', - key: entity.id, - text: `@${entity.name || entity.id}`, - }), - type: 'mention', + return results; + } catch (error) { + console.warn('Error searching emojis:', error); + throw error; + } }, -}); +}; export const makeImageCompatibleUrl = (url: string) => (url.indexOf('//') === 0 ? `https:${url}` : url).trim(); From 680b6c8049b11eeb346867199f3102b563875fb7 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 18 Sep 2024 17:36:25 +0530 Subject: [PATCH 11/13] fix: request image access permissions for iOS only for native image picking (#2677) --- .../src/optionalDependencies/pickImage.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/package/expo-package/src/optionalDependencies/pickImage.ts b/package/expo-package/src/optionalDependencies/pickImage.ts index f2801a0395..9d2ce11533 100644 --- a/package/expo-package/src/optionalDependencies/pickImage.ts +++ b/package/expo-package/src/optionalDependencies/pickImage.ts @@ -1,3 +1,4 @@ +import { Platform } from 'react-native'; let ImagePicker; try { @@ -15,15 +16,18 @@ if (!ImagePicker) { export const pickImage = ImagePicker ? async () => { try { - const permissionCheck = await ImagePicker.getMediaLibraryPermissionsAsync(); - const canRequest = permissionCheck.canAskAgain; - let permissionGranted = permissionCheck.granted; - if (!permissionGranted) { - if (canRequest) { - const response = await ImagePicker.requestMediaLibraryPermissionsAsync(); - permissionGranted = response.granted; - } else { - return { askToOpenSettings: true, cancelled: true }; + let permissionGranted = true; + if (Platform.OS === 'ios') { + const permissionCheck = await ImagePicker.getMediaLibraryPermissionsAsync(); + const canRequest = permissionCheck.canAskAgain; + permissionGranted = permissionCheck.granted; + if (!permissionGranted) { + if (canRequest) { + const response = await ImagePicker.requestMediaLibraryPermissionsAsync(); + permissionGranted = response.granted; + } else { + return { askToOpenSettings: true, cancelled: true }; + } } } if (permissionGranted) { From 87d85c63b078bb018dd93316e1ef8374e864c23f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Wed, 18 Sep 2024 14:16:12 +0200 Subject: [PATCH 12/13] fix: properly resolve sendMessage during memoization (#2675) * fix: properly resolve sendMessage during memoization * fix: remedy change so that it does not cause performance issues * chore: revert sendMessage in the dep array --- package/src/components/Channel/Channel.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 46ea57c257..d9be2d68c9 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -2219,6 +2219,17 @@ const ChannelWithContext = < watchers, }); + // This is mainly a hack to get around an issue with sendMessage not being passed correctly as a + // useMemo() dependency. The easy fix is to add it to the dependency array, however that would mean + // that this (very used) context is essentially going to cause rerenders on pretty much every Channel + // render, since sendMessage is an inline function. Wrapping it in useCallback() is one way to fix it + // but it is definitely not trivial, especially considering it depends on other inline functions that + // are not wrapped in a useCallback() themselves hence creating a huge cascading change. Can be removed + // once our memoization issues are fixed in most places in the app or we move to a reactive state store. + const sendMessageRef = + useRef['sendMessage']>(sendMessage); + sendMessageRef.current = sendMessage; + const inputMessageInputContext = useCreateInputMessageInputContext({ additionalTextInputProps, asyncMessagesLockDistance, @@ -2269,7 +2280,7 @@ const ChannelWithContext = < quotedMessage, SendButton, sendImageAsync, - sendMessage, + sendMessage: (...args) => sendMessageRef.current(...args), SendMessageDisallowedIndicator, setInputRef, setQuotedMessageState, From b9b5cd7b487f3ea36049784eee60382027d7ddb9 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 18 Sep 2024 18:40:35 +0530 Subject: [PATCH 13/13] fix: deprecate messageReactions prop and use isMessageActionsVisible instead for messageActions (#2676) * fix: deprecate messageReactions prop and use isMessageActionsVisible instead for messageActions * docs: fix custom message actions * fix: execution logic for showMessageOverlay --- .../reactnative/guides/custom-message-actions.mdx | 2 +- package/src/components/Message/Message.tsx | 15 ++++++++++----- .../Message/MessageSimple/ReactionList.tsx | 4 ++-- .../components/Message/utils/messageActions.ts | 12 ++++++++++-- .../contexts/messageContext/MessageContext.tsx | 2 +- .../contexts/overlayContext/OverlayContext.tsx | 7 +++++++ 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/docusaurus/docs/reactnative/guides/custom-message-actions.mdx b/docusaurus/docs/reactnative/guides/custom-message-actions.mdx index 489a5bf96e..7abc635a1c 100644 --- a/docusaurus/docs/reactnative/guides/custom-message-actions.mdx +++ b/docusaurus/docs/reactnative/guides/custom-message-actions.mdx @@ -58,10 +58,10 @@ messageActions={({ editMessage, // MessageAction | null; error, // boolean; flagMessage, // MessageAction | null; + isMessageActionsVisible, // boolean; isMyMessage, // boolean; isThreadMessage, // boolean; message, // MessageType; - messageReactions, // boolean; reply, // MessageAction | null; retry, // MessageAction | null; threadReply, // MessageAction | null; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 225864ffc8..0196638e29 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -122,6 +122,10 @@ export type MessageActionHandlers< pinMessage: () => Promise; quotedReply: () => void; resendMessage: () => Promise; + /** + * @deprecated + * TODO: This seems useless for the action handlers here so can be removed. + */ showMessageOverlay: () => void; toggleBanUser: () => Promise; toggleMuteUser: () => Promise; @@ -355,7 +359,7 @@ const MessageWithContext = < setIsBounceDialogOpen(true); return; } - showMessageOverlay(false, true); + showMessageOverlay(true, true); } else if (quotedMessage) { onPressQuotedMessage(quotedMessage); } @@ -557,7 +561,7 @@ const MessageWithContext = < const { userLanguage } = useTranslationContext(); - const showMessageOverlay = async (messageReactions = false, error = errorOrFailed) => { + const showMessageOverlay = async (isMessageActionsVisible = true, error = errorOrFailed) => { await dismissKeyboard(); const isThreadMessage = threadList || !!message.parent_id; @@ -576,10 +580,11 @@ const MessageWithContext = < editMessage, error, flagMessage, + isMessageActionsVisible, isMyMessage, isThreadMessage, message, - messageReactions, + messageReactions: isMessageActionsVisible === false, muteUser, ownCapabilities, pinMessage, @@ -600,7 +605,7 @@ const MessageWithContext = < message, messageActions: messageActions?.filter(Boolean) as MessageActionListItemProps[] | undefined, messageContext: { ...messageContext, preventPress: true }, - messageReactionTitle: !error && messageReactions ? t('Message Reactions') : undefined, + messageReactionTitle: !error && !isMessageActionsVisible ? t('Message Reactions') : undefined, messagesContext: { ...messagesContext, messageContentOrder }, onlyEmojis, otherAttachments: attachments.other, @@ -659,7 +664,7 @@ const MessageWithContext = < return; } triggerHaptic('impactMedium'); - showMessageOverlay(false); + showMessageOverlay(true); } : () => null; diff --git a/package/src/components/Message/MessageSimple/ReactionList.tsx b/package/src/components/Message/MessageSimple/ReactionList.tsx index ac6ae2021a..50d5dd92f9 100644 --- a/package/src/components/Message/MessageSimple/ReactionList.tsx +++ b/package/src/components/Message/MessageSimple/ReactionList.tsx @@ -211,7 +211,7 @@ const ReactionListWithContext = < onPress={(event) => { if (onPress) { onPress({ - defaultHandler: () => showMessageOverlay(true), + defaultHandler: () => showMessageOverlay(false), emitter: 'reactionList', event, }); @@ -220,7 +220,7 @@ const ReactionListWithContext = < onPressIn={(event) => { if (onPressIn) { onPressIn({ - defaultHandler: () => showMessageOverlay(true), + defaultHandler: () => showMessageOverlay(false), emitter: 'reactionList', event, }); diff --git a/package/src/components/Message/utils/messageActions.ts b/package/src/components/Message/utils/messageActions.ts index 177ab70a69..16c1dd3f1d 100644 --- a/package/src/components/Message/utils/messageActions.ts +++ b/package/src/components/Message/utils/messageActions.ts @@ -12,7 +12,14 @@ export type MessageActionsParams< editMessage: MessageActionType; error: boolean | Error; flagMessage: MessageActionType; + /** + * Determines if the message actions are visible. + */ + isMessageActionsVisible: boolean; isThreadMessage: boolean; + /** + * @deprecated use `isMessageActionsVisible` instead. + */ messageReactions: boolean; muteUser: MessageActionType; ownCapabilities: OwnCapabilitiesContextValue; @@ -42,6 +49,7 @@ export const messageActions = < editMessage, error, flagMessage, + isMessageActionsVisible, isMyMessage, isThreadMessage, message, @@ -53,8 +61,8 @@ export const messageActions = < threadReply, unpinMessage, }: MessageActionsParams) => { - if (messageReactions) { - return undefined; + if (messageReactions || !isMessageActionsVisible) { + return []; } const actions: Array = []; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 1b3f23c477..e482e19f5d 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -125,7 +125,7 @@ export type MessageContextValue< reactions: ReactionSummary[]; /** React set state function to set the state of `isEditedMessageOpen` */ setIsEditedMessageOpen: React.Dispatch>; - showMessageOverlay: (messageReactions?: boolean, error?: boolean) => void; + showMessageOverlay: (isMessageActionsVisible?: boolean, error?: boolean) => void; showMessageStatus: boolean; /** Whether or not the Message is part of a Thread */ threadList: boolean; diff --git a/package/src/contexts/overlayContext/OverlayContext.tsx b/package/src/contexts/overlayContext/OverlayContext.tsx index 56004f8281..525e9f5061 100644 --- a/package/src/contexts/overlayContext/OverlayContext.tsx +++ b/package/src/contexts/overlayContext/OverlayContext.tsx @@ -73,6 +73,13 @@ export type OverlayProviderProps< isMyMessage?: boolean; isThreadMessage?: boolean; message?: MessageType; + /** + * @deprecated use the following instead: + * messageActions={(params) => { + * const actions = messageActions({ ...params, isMessageActionsVisible: false }); + * return actions; + * }} + */ messageReactions?: boolean; messageTextNumberOfLines?: number; numberOfImageGalleryGridColumns?: number;