diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 1128c7bbf3..a82633440a 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -3,7 +3,7 @@ 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, OverlayProvider, @@ -169,12 +169,11 @@ const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated; const DrawerNavigatorWrapper: React.FC<{ chatClient: StreamChat; }> = ({ chatClient }) => { - const { bottom } = useSafeAreaInsets(); const streamChatTheme = useStreamChatTheme(); return ( - + = (props) => { }, } = useTheme(); const insets = useSafeAreaInsets(); - const { setTopInset } = useAttachmentPickerContext(); - - useEffect(() => { - if (setTopInset) { - setTopInset(HEADER_CONTENT_HEIGHT + insets.top); - } - }, [insets.top, setTopInset]); 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/yarn.lock b/examples/SampleApp/yarn.lock index 3c1fbe91c2..0e877676c3 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -2862,6 +2862,11 @@ "@typescript-eslint/types" "8.24.1" eslint-visitor-keys "^4.2.0" +"@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== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -3894,7 +3899,7 @@ emoji-mart@^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: +emoji-regex@^10.3.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== @@ -6922,10 +6927,10 @@ react-native-haptic-feedback@^2.3.3: resolved "https://registry.yarnpkg.com/react-native-haptic-feedback/-/react-native-haptic-feedback-2.3.3.tgz#88b6876e91399a69bd1b551fe1681b2f3dc1214e" integrity sha512-svS4D5PxfNv8o68m9ahWfwje5NqukM3qLS48+WTdhbDkNUkOhP9rDfDSRHzlhk4zq+ISjyw95EhLeh8NkKX5vQ== -react-native-image-picker@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-8.2.0.tgz#d8656fdd1a0f1ad262c9c129d4f75900b685e56e" - integrity sha512-jIGllQJuJIn0YKss/JEeb0Kos1HSsnIpU+i3bYxR27sOxSyDZQyP9dKR22olssQPlfH+rGNR/Jc6xKRkhm48vw== +react-native-image-picker@^8.2.1: + version "8.2.1" + resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-8.2.1.tgz#1ac7826563cbaa5d5298d9f2acc53c69805e5393" + integrity sha512-FBeGYJGFDjMdGCcyubDJgBAPCQ4L1D3hwLXyUU91jY9ahOZMTbluceVvRmrEKqnDPFJ0gF1NVhJ0nr1nROFLdg== react-native-is-edge-to-edge@1.1.6: version "1.1.6" @@ -7595,9 +7600,9 @@ stream-chat-react-native-core@7.1.0: uid "" stream-chat@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.2.0.tgz#f3109891ca27f17b6fd0aa6ebcf66be12df1f88c" - integrity sha512-inz3CA5tuqqSrla7qjRTCKs+coRKOYROWf0wEWYgbCu0tAUuiBTRtu1PJL1isEXIaPLiWi00BuRrBEIFon9Kng== + version "9.3.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.3.0.tgz#35ca4db9e841eb92d07413ae156de0500ad77b23" + integrity sha512-S73B3HrvmQvJjq58Zjo50vh74juhsWsVRpT+OBjGAxSGxlA+ITkZ3vKs8Y/r2eDK7mBTMmX5QCruFaDJH5dRuw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -7609,10 +7614,9 @@ stream-chat@^9.2.0: linkifyjs "^4.2.0" ws "^8.18.1" -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@getstream/stream-chat-js#handle-command-injection: + version "0.0.0-development" + resolved "https://codeload.github.com/getstream/stream-chat-js/tar.gz/780c52cfc3cd7379273a9b8db34461fb935f568d" dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -7624,21 +7628,6 @@ stream-chat@^9.3.0: linkifyjs "^4.2.0" ws "^8.18.1" -stream-chat@^8.57.6: - version "8.60.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.60.0.tgz#b67d4fbb185da53fb8ac5fc5759986d6ad7e19a3" - integrity sha512-7FpO7Wno++r+n+x9aFuXtGYtNO06CIMd2Bxe3doYZLhMfS0nuaXloeFlGcMT0r4U/6bnguz1qQdDJUPNQAS8bQ== - dependencies: - "@babel/runtime" "^7.27.0" - "@types/jsonwebtoken" "~9.0.0" - "@types/ws" "^7.4.0" - axios "^1.6.0" - base64-js "^1.5.1" - form-data "^4.0.0" - isomorphic-ws "^4.0.1" - jsonwebtoken "~9.0.0" - ws "^7.5.10" - strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" diff --git a/examples/TypeScriptMessaging/App.tsx b/examples/TypeScriptMessaging/App.tsx index 3135dd4dd7..dcc6ce87d4 100644 --- a/examples/TypeScriptMessaging/App.tsx +++ b/examples/TypeScriptMessaging/App.tsx @@ -3,7 +3,7 @@ import { I18nManager, LogBox, Platform, SafeAreaView, useColorScheme, View } fro 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 +16,6 @@ import { Streami18n, Thread, ThreadContextValue, - useAttachmentPickerContext, useCreateChatClient, useOverlayContext, } from 'stream-chat-react-native'; @@ -53,11 +52,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 +95,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 +103,6 @@ const ChannelScreen: React.FC = ({ navigation }) => { }); }, [overlay, navigation]); - useEffect(() => { - setTopInset(headerHeight); - }, [headerHeight, setTopInset]); - if (channel === undefined) { return null; } @@ -193,16 +183,13 @@ 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 App = () => { - const { bottom } = useSafeAreaInsets(); const theme = useStreamChatTheme(); const { channel } = useContext(AppContext); @@ -217,11 +204,7 @@ const App = () => { } return ( - + =51.0.0", diff --git a/package/expo-package/yarn.lock b/package/expo-package/yarn.lock index f63eafd5f9..f185980bf8 100644 --- a/package/expo-package/yarn.lock +++ b/package/expo-package/yarn.lock @@ -1827,6 +1827,11 @@ dependencies: "@types/node" "*" +"@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.6", "@urql/core@^5.1.1": version "5.1.1" resolved "https://registry.yarnpkg.com/@urql/core/-/core-5.1.1.tgz#d83c405451806a5936dabbd3f10a22967199e2f5" @@ -2598,10 +2603,10 @@ electron-to-chromium@^1.5.73: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.112.tgz#8d3d95d4d5653836327890282c8eda5c6f26626d" integrity sha512-oen93kVyqSb3l+ziUgzIOlWt/oOuy4zRmpwestMn4rhFWAoFJeFuCVte9F2fASjeZZo7l/Cif9TiyrdW4CwEMA== -emoji-regex@^10.3.0: - version "10.3.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.3.0.tgz#76998b9268409eb3dae3de989254d456e70cfe23" - integrity sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw== +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== emoji-regex@^8.0.0: version "8.0.0" @@ -4778,29 +4783,13 @@ stream-buffers@2.2.x, stream-buffers@~2.2.0: 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.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-7.1.0.tgz#b5002ec967467a2ac4be54700e5e4e60bbd5fd97" - integrity sha512-Rfecu6kH2zBW0ufhVz076NlpOg6QxNgShGnK4js/ypjSZ4rGZIKMFHNuArLVr/uSuTWiVPNO1zMI/LyvljtwdQ== - dependencies: - "@gorhom/bottom-sheet" "^5.1.1" - dayjs "1.10.5" - emoji-regex "^10.3.0" - i18next "^21.6.14" - intl-pluralrules "^2.0.1" - linkifyjs "^4.1.1" - lodash-es "4.17.21" - mime-types "^2.1.34" - path "0.12.7" - react-native-markdown-package "1.8.2" - react-native-url-polyfill "^1.3.0" - stream-chat "^9.2.0" - use-sync-external-store "^1.4.0" - -stream-chat@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.2.0.tgz#f3109891ca27f17b6fd0aa6ebcf66be12df1f88c" - integrity sha512-inz3CA5tuqqSrla7qjRTCKs+coRKOYROWf0wEWYgbCu0tAUuiBTRtu1PJL1isEXIaPLiWi00BuRrBEIFon9Kng== +"stream-chat-react-native-core@link:..": + version "0.0.0" + uid "" + +stream-chat@getstream/stream-chat-js#handle-command-injection: + version "0.0.0-development" + resolved "https://codeload.github.com/getstream/stream-chat-js/tar.gz/780c52cfc3cd7379273a9b8db34461fb935f568d" dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/package/native-package/yarn.lock b/package/native-package/yarn.lock index 835b961e58..00f89168d1 100644 --- a/package/native-package/yarn.lock +++ b/package/native-package/yarn.lock @@ -1819,7 +1819,7 @@ electron-to-chromium@^1.5.73: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.112.tgz#8d3d95d4d5653836327890282c8eda5c6f26626d" integrity sha512-oen93kVyqSb3l+ziUgzIOlWt/oOuy4zRmpwestMn4rhFWAoFJeFuCVte9F2fASjeZZo7l/Cif9TiyrdW4CwEMA== -emoji-regex@^10.4.0: +emoji-regex@^10.3.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== @@ -3470,9 +3470,9 @@ stream-chat-react-native-core@7.1.0: use-sync-external-store "^1.4.0" stream-chat@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.2.0.tgz#f3109891ca27f17b6fd0aa6ebcf66be12df1f88c" - integrity sha512-inz3CA5tuqqSrla7qjRTCKs+coRKOYROWf0wEWYgbCu0tAUuiBTRtu1PJL1isEXIaPLiWi00BuRrBEIFon9Kng== + version "9.3.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.3.0.tgz#35ca4db9e841eb92d07413ae156de0500ad77b23" + integrity sha512-S73B3HrvmQvJjq58Zjo50vh74juhsWsVRpT+OBjGAxSGxlA+ITkZ3vKs8Y/r2eDK7mBTMmX5QCruFaDJH5dRuw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/package/package.json b/package/package.json index 05b78f3028..6e22d9e7cc 100644 --- a/package/package.json +++ b/package/package.json @@ -67,6 +67,7 @@ }, "dependencies": { "@gorhom/bottom-sheet": "^5.1.1", + "@ungap/structured-clone": "^1.3.0", "dayjs": "1.10.5", "emoji-regex": "^10.4.0", "i18next": "^21.6.14", @@ -77,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^1.3.0", - "stream-chat": "^9.3.0", + "stream-chat": "getstream/stream-chat-js#handle-command-injection", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { @@ -127,6 +128,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": "^0.0.6", "@types/uuid": "^10.0.0", "babel-eslint": "10.1.0", 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() ? ( ; /** * 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/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 4edf21bda9..d5933cd5f7 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -9,7 +9,7 @@ import { TextInputSelectionChangeEventData, } from 'react-native'; -import { CustomDataManagerState, TextComposerState } from 'stream-chat'; +import { MessageComposerConfig, TextComposerState } from 'stream-chat'; import { ChannelContextValue, @@ -42,11 +42,12 @@ type AutoCompleteInputPropsWithContext = TextInputProps & type AutoCompleteInputProps = Partial; const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, text: state.text, }); -const customComposerDataSelector = (state: CustomDataManagerState) => ({ - command: state.custom.command, +const configStateSelector = (state: MessageComposerConfig) => ({ + enabled: state.text.enabled, }); const MAX_NUMBER_OF_LINES = 5; @@ -56,9 +57,9 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) const [localText, setLocalText] = useState(''); const [textHeight, setTextHeight] = useState(0); const messageComposer = useMessageComposer(); - const { customDataManager, textComposer } = messageComposer; - const { text } = useStateStore(textComposer.state, textComposerStateSelector); - const { command } = useStateStore(customDataManager.state, customComposerDataSelector); + const { textComposer } = messageComposer; + const { command, text } = useStateStore(textComposer.state, textComposerStateSelector); + const { enabled } = useStateStore(messageComposer.configState, configStateSelector); const maxMessageLength = useMemo(() => { return channel.getConfig()?.max_message_length; @@ -118,6 +119,7 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) return ( & + Partial> & + Partial< + Pick< + AttachmentPickerProps, + | 'AttachmentPickerError' + | 'AttachmentPickerErrorImage' + | 'AttachmentPickerIOSSelectMorePhotos' + | 'ImageOverlaySelectedComponent' + | 'attachmentPickerErrorButtonText' + | 'attachmentPickerErrorText' + | 'numberOfAttachmentImagesToLoadPerCall' + | 'numberOfAttachmentPickerImageColumns' + > + > & Partial< Pick< ChannelContextValue, @@ -390,6 +425,7 @@ export type ChannelPropsWithContext = Pick & doSendMessageRequest?: ( channelId: string, messageData: StreamMessage, + options?: SendMessageOptions, ) => Promise; /** * Overrides the Stream default update message request (Advanced usage only) @@ -399,6 +435,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. @@ -457,6 +494,8 @@ export type ChannelPropsWithContext = Pick & >; const ChannelWithContext = (props: PropsWithChildren) => { + const { vh } = useViewport(); + const { additionalKeyboardAvoidingViewProps, additionalPressableProps, @@ -469,8 +508,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, @@ -480,7 +524,21 @@ const ChannelWithContext = (props: PropsWithChildren) = AutoCompleteSuggestionHeader = AutoCompleteSuggestionHeaderDefault, AutoCompleteSuggestionItem = AutoCompleteSuggestionItemDefault, AutoCompleteSuggestionList = AutoCompleteSuggestionListDefault, - autoCompleteSuggestionsLimit, + AttachmentPickerError = DefaultAttachmentPickerError, + AttachmentPickerErrorImage = DefaultAttachmentPickerErrorImage, + AttachmentPickerIOSSelectMorePhotos = DefaultAttachmentPickerIOSSelectMorePhotos, + ImageOverlaySelectedComponent = DefaultImageOverlaySelectedComponent, + attachmentPickerErrorButtonText, + attachmentPickerErrorText, + numberOfAttachmentImagesToLoadPerCall = 60, + numberOfAttachmentPickerImageColumns = 3, + + bottomInset = 0, + CameraSelectorIcon = DefaultCameraSelectorIcon, + FileSelectorIcon = DefaultFileSelectorIcon, + CreatePollIcon = DefaultCreatePollIcon, + ImageSelectorIcon = DefaultImageSelectorIcon, + VideoRecorderSelectorIcon = DefaultVideoRecorderSelectorIcon, Card = CardDefault, CardCover, CardFooter, @@ -497,8 +555,7 @@ const ChannelWithContext = (props: PropsWithChildren) = disableKeyboardCompatibleView = false, disableTypingIndicator, dismissKeyboardOnMessageTouch = true, - doDocUploadRequest, - doImageUploadRequest, + doFileUploadRequest, doMarkReadRequest, doSendMessageRequest, doUpdateMessageRequest, @@ -508,6 +565,7 @@ const ChannelWithContext = (props: PropsWithChildren) = enableSwipeToReply = true, enforceUniqueReaction = false, FileAttachment = FileAttachmentDefault, + FileAttachmentUploadPreview = FileAttachmentUploadPreviewDefault, FileAttachmentGroup = FileAttachmentGroupDefault, FileAttachmentIcon = FileIconDefault, FileUploadPreview = FileUploadPreviewDefault, @@ -539,12 +597,12 @@ 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, @@ -566,8 +624,6 @@ const ChannelWithContext = (props: PropsWithChildren) = markReadOnMount = true, maxNumberOfFiles = 10, maxTimeBetweenGroupedMessages, - mentionAllAppUsersEnabled = false, - mentionAllAppUsersQuery, Message = MessageDefault, MessageActionList = MessageActionListDefault, MessageActionListItem = MessageActionListItemDefault, @@ -625,7 +681,6 @@ const ChannelWithContext = (props: PropsWithChildren) = ScrollToBottomButton = ScrollToBottomButtonDefault, selectReaction, SendButton = SendButtonDefault, - sendImageAsync = false, SendMessageDisallowedIndicator = SendMessageDisallowedIndicatorDefault, setInputRef, setThreadMessages, @@ -642,11 +697,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; @@ -674,6 +731,8 @@ const ChannelWithContext = (props: PropsWithChildren) = undefined, ); + const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); + const syncingChannelRef = useRef(false); const { highlightedMessageId, setTargetedMessage, targetedMessage } = useTargetedMessage(); @@ -1181,7 +1240,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); @@ -1195,75 +1254,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); @@ -1274,8 +1281,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; @@ -1286,23 +1293,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) { @@ -1321,18 +1320,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({ @@ -1349,50 +1358,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) { @@ -1407,35 +1391,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 @@ -1443,37 +1419,41 @@ 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), + ? doUpdateMessageRequest(channel?.cid || '', localMessage, options) + : client.updateMessage(localMessage, undefined, options), ); const setEditingState: MessagesContextValue['setEditingState'] = useStableCallback((message) => { @@ -1665,6 +1645,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, @@ -1674,7 +1696,6 @@ const ChannelWithContext = (props: PropsWithChildren) = channel, channelUnreadState, disabled: !!channel?.data?.frozen, - editing, EmptyStateIndicator, enableMessageGroupingByUser, enforceUniqueReaction, @@ -1727,6 +1748,12 @@ const ChannelWithContext = (props: PropsWithChildren) = asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -1737,7 +1764,7 @@ const ChannelWithContext = (props: PropsWithChildren) = AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList, - autoCompleteSuggestionsLimit, + CameraSelectorIcon, channelId, clearEditingState, CommandInput, @@ -1745,36 +1772,38 @@ const ChannelWithContext = (props: PropsWithChildren) = compressImageQuality, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, + CreatePollIcon, + doFileUploadRequest, editing, editMessage, + FileAttachmentUploadPreview, + FileSelectorIcon, FileUploadPreview, handleAttachButtonPress, hasCameraPicker, hasCommands: hasCommands ?? (getChannelConfigSafely()?.commands ?? []).length > 0, hasFilePicker, hasImagePicker, + ImageAttachmentUploadPreview, + ImageSelectorIcon, ImageUploadPreview, - initialValue, Input, InputButtons, InputEditingStateHeader, InputReplyStateHeader, + isCommandUIEnabled, maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, MoreOptionsButton, openPollCreationDialog, SendButton, - sendImageAsync, sendMessage, SendMessageDisallowedIndicator, setInputRef, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }); const messageListContext = useCreatePaginatedMessageListContext({ @@ -1917,6 +1946,11 @@ const ChannelWithContext = (props: PropsWithChildren) = typing: channelState.typing ?? {}, }); + const messageComposerContext = useMemo( + () => ({ channel, editing, thread, threadInstance }), + [channel, editing, thread, threadInstance], + ); + // TODO: replace the null view with appropriate message. Currently this is waiting a design decision. if (deleted) { return null; @@ -1947,9 +1981,14 @@ const ChannelWithContext = (props: PropsWithChildren) = - - {children} - + + + + {children} + + + + diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index e4b4da6c4c..71556d7f20 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -6,7 +6,6 @@ export const useCreateChannelContext = ({ channel, channelUnreadState, disabled, - editing, EmptyStateIndicator, enableMessageGroupingByUser, enforceUniqueReaction, @@ -46,14 +45,12 @@ export const useCreateChannelContext = ({ const readUsersLength = readUsers.length; const readUsersLastReads = readUsers.map(({ last_read }) => last_read.toISOString()).join(); const stringifiedChannelUnreadState = JSON.stringify(channelUnreadState); - const editingDep = editing ? editing.id : ''; const channelContext: ChannelContextValue = useMemo( () => ({ channel, channelUnreadState, disabled, - editing, EmptyStateIndicator, enableMessageGroupingByUser, enforceUniqueReaction, @@ -89,7 +86,6 @@ export const useCreateChannelContext = ({ [ channelId, disabled, - editingDep, error, isChannelActive, highlightedMessageId, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index adc5704031..73fbc9a862 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -9,6 +9,12 @@ export const useCreateInputMessageInputContext = ({ asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -19,37 +25,38 @@ export const useCreateInputMessageInputContext = ({ AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList, - autoCompleteSuggestionsLimit, channelId, clearEditingState, + CameraSelectorIcon, CommandInput, CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, + CreatePollIcon, + doFileUploadRequest, editing, editMessage, + FileAttachmentUploadPreview, + FileSelectorIcon, FileUploadPreview, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, + ImageAttachmentUploadPreview, + ImageSelectorIcon, ImageUploadPreview, - initialValue, Input, InputButtons, InputEditingStateHeader, InputReplyStateHeader, + isCommandUIEnabled, maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, MoreOptionsButton, openPollCreationDialog, SendButton, - sendImageAsync, sendMessage, SendMessageDisallowedIndicator, setInputRef, @@ -57,7 +64,8 @@ export const useCreateInputMessageInputContext = ({ ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }: InputMessageInputContextValue & { /** * To ensure we allow re-render, when channel is changed @@ -74,6 +82,12 @@ export const useCreateInputMessageInputContext = ({ asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -84,36 +98,37 @@ export const useCreateInputMessageInputContext = ({ AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList, - autoCompleteSuggestionsLimit, + CameraSelectorIcon, clearEditingState, CommandInput, CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, + CreatePollIcon, + doFileUploadRequest, editing, editMessage, + FileAttachmentUploadPreview, + FileSelectorIcon, FileUploadPreview, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, + ImageAttachmentUploadPreview, + ImageSelectorIcon, ImageUploadPreview, - initialValue, Input, InputButtons, InputEditingStateHeader, InputReplyStateHeader, + isCommandUIEnabled, maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, MoreOptionsButton, openPollCreationDialog, SendButton, - sendImageAsync, sendMessage, SendMessageDisallowedIndicator, setInputRef, @@ -121,17 +136,11 @@ export const useCreateInputMessageInputContext = ({ ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [ - compressImageQuality, - channelId, - editingDep, - initialValue, - CreatePollContent, - showPollCreationDialog, - ], + [compressImageQuality, channelId, editingDep, CreatePollContent, showPollCreationDialog], ); return inputMessageInputContext; 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/ChannelPreview/hooks/useLatestMessagePreview.ts b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts index dba79b5c39..726afebb94 100644 --- a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts +++ b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts @@ -256,7 +256,7 @@ export const useLatestMessagePreview = ( const translatedLastMessage = useTranslatedMessage(lastMessage); const channelLastMessageString = translatedLastMessage - ? stringifyMessage(translatedLastMessage) + ? stringifyMessage({ message: translatedLastMessage }) : ''; const readEvents = useMemo(() => { diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index c17cf0d1dd..5faafd56d3 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -9,6 +9,7 @@ import { useMessageActions } from './hooks/useMessageActions'; import { useProcessReactions } from './hooks/useProcessReactions'; import { messageActions as defaultMessageActions } from './utils/messageActions'; +import { useMessageComposer } from '../../contexts'; import { ChannelContextValue, useChannelContext, @@ -31,9 +32,11 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; +import { useStableCallback } from '../../hooks'; import { isVideoPlayerAvailable, NativeHandlers } from '../../native'; import { FileTypes } from '../../types/types'; import { + checkMessageEquality, hasOnlyEmojis, isBlockedMessage, isBouncedMessage, @@ -140,7 +143,10 @@ export type MessagePropsWithContext = Pick< 'groupStyles' | 'handleReaction' | 'message' | 'isMessageAIGenerated' | 'readBy' > > & - Pick & + Pick< + MessageContextValue, + 'groupStyles' | 'message' | 'isMessageAIGenerated' | 'readBy' | 'setQuotedMessage' + > & Pick< MessagesContextValue, | 'sendReaction' @@ -264,6 +270,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { threadList = false, updateMessage, readBy, + setQuotedMessage, } = props; const isMessageAIGenerated = messagesContext.isMessageAIGenerated; const isAIGenerated = useMemo( @@ -499,6 +506,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { retrySendMessage, sendReaction, setEditingState, + setQuotedMessage, supportedReactions, }); @@ -543,6 +551,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { selectReaction, sendReaction, setEditingState, + setQuotedMessage, supportedReactions, t, updateMessage, @@ -691,6 +700,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { reactions, readBy, setIsEditedMessageOpen, + setQuotedMessage, showAvatar, showMessageOverlay, showMessageStatus: typeof showMessageStatus === 'boolean' ? showMessageStatus : isMyMessage, @@ -813,28 +823,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; @@ -871,6 +869,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 && @@ -938,6 +944,10 @@ export const Message = (props: MessageProps) => { const { openThread } = useThreadContext(); const { t } = useTranslationContext(); const readBy = useMemo(() => getReadState(message, read), [message, read]); + const messageComposer = useMessageComposer(); + const setQuotedMessage = useStableCallback((message: LocalMessage | null) => + messageComposer.setQuotedMessage(message), + ); return ( { messagesContext, openThread, readBy, + setQuotedMessage, t, }} {...props} diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index c73c83ea9d..c3885e99c2 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -28,6 +28,7 @@ import { import { useViewport } from '../../../hooks/useViewport'; +import { checkMessageEquality, checkQuotedMessageEquality } from '../../../utils/utils'; import { Poll } from '../../Poll/Poll'; import { useMessageData } from '../hooks/useMessageData'; @@ -412,27 +413,16 @@ const areEqual = ( return false; } - const isPrevMessageTypeDeleted = prevMessage.type === 'deleted'; - const isNextMessageTypeDeleted = nextMessage.type === 'deleted'; - - const messageEqual = - isPrevMessageTypeDeleted === isNextMessageTypeDeleted && - prevMessage.reply_count === nextMessage.reply_count && - prevMessage.status === nextMessage.status && - prevMessage.type === nextMessage.type && - prevMessage.text === nextMessage.text && - prevMessage.pinned === nextMessage.pinned && - 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 = checkQuotedMessageEquality( + prevMessage.quoted_message, + nextMessage.quoted_message, + ); - const quotedMessageEqual = - prevMessage.quoted_message?.id === nextMessage.quoted_message?.id && - isPrevQuotedMessageTypeDeleted === isNextQuotedMessageTypeDeleted; if (!quotedMessageEqual) { return false; } @@ -462,6 +452,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 && diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 0e55fc6e5c..717c15e5d0 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -18,7 +18,6 @@ import { MessageContextValue, useMessageContext, } from '../../../contexts/messageContext/MessageContext'; -import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; import { MessagesContextValue, useMessagesContext, @@ -27,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({ @@ -70,6 +70,7 @@ export type MessageSimplePropsWithContext = Pick< | 'onlyEmojis' | 'otherAttachments' | 'showMessageStatus' + | 'setQuotedMessage' > & Pick< MessagesContextValue, @@ -130,6 +131,7 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { ReactionListTop, showMessageStatus, shouldRenderSwipeableWrapper, + setQuotedMessage, } = props; const { @@ -161,7 +163,6 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { isVeryLastMessage, messageGroupedSingleOrBottom, } = useMessageData({}); - const messageComposer = useMessageComposer(); const lastMessageInMessageListStyles = [styles.lastMessageContainer, lastMessageContainer]; const messageGroupedSingleOrBottomStyles = { @@ -215,8 +216,8 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { ); const onSwipeToReply = useCallback(() => { - messageComposer.setQuotedMessage(message); - }, [messageComposer, message]); + setQuotedMessage(message); + }, [setQuotedMessage, message]); const THRESHOLD = 25; @@ -476,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; @@ -491,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; @@ -504,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; @@ -551,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 && @@ -608,6 +600,7 @@ export const MessageSimple = (props: MessageSimpleProps) => { otherAttachments, showMessageStatus, isMessageAIGenerated, + setQuotedMessage, } = useMessageContext(); const { enableMessageGroupingByUser, @@ -662,6 +655,7 @@ export const MessageSimple = (props: MessageSimpleProps) => { ReactionListBottom, reactionListPosition, ReactionListTop, + setQuotedMessage, shouldRenderSwipeableWrapper, showMessageStatus, }} 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 102abd9994..4931bb9aad 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -3,7 +3,6 @@ import { Alert } from 'react-native'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; -import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; @@ -18,6 +17,7 @@ export const useMessageActionHandlers = ({ retrySendMessage, sendReaction, setEditingState, + setQuotedMessage, }: Pick< MessagesContextValue, | 'sendReaction' @@ -29,13 +29,12 @@ export const useMessageActionHandlers = ({ > & Pick & Pick & - Pick) => { + Pick) => { const { t } = useTranslationContext(); const handleResendMessage = () => retrySendMessage(message); - const messageComposer = useMessageComposer(); const handleQuotedReplyMessage = () => { - messageComposer.setQuotedMessage(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 336b38e01b..767e89d473 100644 --- a/package/src/components/Message/hooks/useMessageActions.tsx +++ b/package/src/components/Message/hooks/useMessageActions.tsx @@ -58,7 +58,7 @@ export type MessageActionsHookProps = Pick< Pick & Pick & Pick & - Pick & + Pick & Pick & { onThreadSelect?: (message: LocalMessage) => void; }; @@ -91,6 +91,7 @@ export const useMessageActions = ({ setEditingState, supportedReactions, t, + setQuotedMessage, }: MessageActionsHookProps) => { const { theme: { @@ -119,6 +120,7 @@ export const useMessageActions = ({ retrySendMessage, sendReaction, setEditingState, + setQuotedMessage, supportedReactions, }); diff --git a/package/src/components/MessageInput/AttachButton.tsx b/package/src/components/MessageInput/AttachButton.tsx index 281387c4b4..a3dfcdb4be 100644 --- a/package/src/components/MessageInput/AttachButton.tsx +++ b/package/src/components/MessageInput/AttachButton.tsx @@ -5,14 +5,14 @@ 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 { useTheme } from '../../contexts/themeContext/ThemeContext'; import { Attach } from '../../icons/Attach'; import { isImageMediaLibraryAvailable } from '../../native'; -type AttachButtonPropsWithContext = Pick & { +type AttachButtonPropsWithContext = { + disabled?: boolean; /** Function that opens attachment options bottom sheet */ handleOnPress?: ((event: GestureResponderEvent) => void) & (() => void); selectedPicker?: 'images'; @@ -21,7 +21,7 @@ type AttachButtonPropsWithContext = Pick & { const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { const [showAttachButtonPicker, setShowAttachButtonPicker] = useState(false); const [attachButtonLayoutRectangle, setAttachButtonLayoutRectangle] = useState(); - const { disabled, handleOnPress, selectedPicker } = props; + const { disabled = false, handleOnPress, selectedPicker } = props; const { theme: { colors: { accent_blue, grey }, @@ -51,6 +51,9 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { }; const onPressHandler = () => { + if (disabled) { + return; + } if (handleOnPress) { handleOnPress(); return; @@ -71,7 +74,7 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { null : onPressHandler} + onPress={onPressHandler} style={[attachButton]} testID='attach-button' > diff --git a/package/src/components/MessageInput/CommandsButton.tsx b/package/src/components/MessageInput/CommandsButton.tsx index 7e9f035a25..b68dfd1a29 100644 --- a/package/src/components/MessageInput/CommandsButton.tsx +++ b/package/src/components/MessageInput/CommandsButton.tsx @@ -1,43 +1,24 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import type { GestureResponderEvent, PressableProps } from 'react-native'; -import { Pressable, View } from 'react-native'; - -import { SearchSourceState, TextComposerState } from 'stream-chat'; +import { Pressable } from 'react-native'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useStateStore } from '../../hooks/useStateStore'; import { Lightning } from '../../icons/Lightning'; export type CommandsButtonProps = { - /** Function that opens commands selector */ + /** Function that opens commands selector. */ handleOnPress?: PressableProps['onPress']; /** - * Determins if the text input has text + * Determines if the text input has text */ hasText?: boolean; }; -const textComposerStateSelector = (state: TextComposerState) => ({ - suggestions: state.suggestions, - text: state.text, -}); - -const searchSourceStateSelector = (nextValue: SearchSourceState) => ({ - items: nextValue.items, -}); - export const CommandsButton = (props: CommandsButtonProps) => { const { handleOnPress, hasText } = props; const messageComposer = useMessageComposer(); const { textComposer } = messageComposer; - const { suggestions } = useStateStore(textComposer.state, textComposerStateSelector); - const { items } = useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {}; - const trigger = suggestions?.trigger; - - const commandsButtonEnabled = useMemo(() => { - return items && items?.length > 0 && trigger === '/'; - }, [items, trigger]); const onPressHandler = useCallback( async (event: GestureResponderEvent) => { @@ -59,8 +40,8 @@ export const CommandsButton = (props: CommandsButtonProps) => { const { theme: { - colors: { accent_blue, grey }, - messageInput: { commandsButton, commandsButtonContainer }, + colors: { grey }, + messageInput: { commandsButton }, }, } = useTheme(); @@ -69,11 +50,9 @@ export const CommandsButton = (props: CommandsButtonProps) => { } return ( - - - - - + + + ); }; diff --git a/package/src/components/MessageInput/FileUploadPreview.tsx b/package/src/components/MessageInput/FileUploadPreview.tsx index accf612708..0b9737bdac 100644 --- a/package/src/components/MessageInput/FileUploadPreview.tsx +++ b/package/src/components/MessageInput/FileUploadPreview.tsx @@ -1,343 +1,295 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { FlatList, I18nManager, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FlatList, LayoutChangeEvent, StyleSheet } from 'react-native'; -import { UploadProgressIndicator } from './UploadProgressIndicator'; +import { + isAudioAttachment, + isLocalAudioAttachment, + isLocalFileAttachment, + isLocalImageAttachment, + isLocalVideoAttachment, + isLocalVoiceRecordingAttachment, + isVideoAttachment, + isVoiceRecordingAttachment, + LocalAudioAttachment, + LocalFileAttachment, + LocalVideoAttachment, + LocalVoiceRecordingAttachment, +} from 'stream-chat'; -import { ChatContextValue, useChatContext } from '../../contexts'; +import { useMessageComposer } from '../../contexts'; +import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; 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'; +import { AudioConfig } from '../../types/types'; 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 FileUploadPreviewPropsWithContext = Pick< + MessageInputContextValue, + 'AudioAttachmentUploadPreview' | 'FileAttachmentUploadPreview' | 'VideoAttachmentUploadPreview' +>; -export type FileUploadPreviewProps = Partial< - Pick< - MessageInputContextValue, - 'fileUploads' | 'removeFile' | 'uploadFile' | 'setFileUploads' | 'AudioAttachmentUploadPreview' - > -> & - Partial> & - Partial>; +type FileAttachmentType> = + | LocalFileAttachment + | LocalAudioAttachment + | LocalVoiceRecordingAttachment + | LocalVideoAttachment; /** * FileUploadPreview * UI Component to preview the files set for upload */ -export const FileUploadPreview = (props: FileUploadPreviewProps) => { +const UnMemoizedFileUploadPreview = (props: FileUploadPreviewPropsWithContext) => { const { - AudioAttachmentUploadPreview: propAudioAttachmentUploadPreview, - enableOfflineSupport: propEnableOfflineSupport, - FileAttachmentIcon: propFileAttachmentIcon, - fileUploads: propFileUploads, - removeFile: propRemoveFile, - uploadFile: propUploadFile, + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + VideoAttachmentUploadPreview, } = 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 { attachmentManager } = useMessageComposer(); + const { attachments } = useAttachmentManagerState(); + const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState< + Record + >({}); + const flatListRef = useRef | null>(null); const [flatListWidth, setFlatListWidth] = useState(0); + const fileUploads = useMemo(() => { + return attachments.filter( + (attachment) => + isLocalFileAttachment(attachment) || + isLocalAudioAttachment(attachment) || + isLocalVoiceRecordingAttachment(attachment) || + isLocalVideoAttachment(attachment), + ); + }, [attachments]); + useEffect(() => { - setFilesToDisplay( - fileUploads.map((file) => ({ - ...file, - duration: file.duration || filesToDisplay.find((f) => f.id === file.id)?.duration || 0, - paused: true, - progress: 0, - })), + const newAudioAttachmentsStateMap = fileUploads.reduce( + (acc, attachment) => { + if (isAudioAttachment(attachment) || isVoiceRecordingAttachment(attachment)) { + acc[attachment.localMetadata.id] = { + duration: + attachment.duration || + audioAttachmentsStateMap[attachment.localMetadata.id]?.duration || + 0, + paused: true, + progress: 0, + }; + } + return acc; + }, + {} as Record, ); + + setAudioAttachmentsStateMap(newAudioAttachmentsStateMap); // 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, - })), - ); - }; + // 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 = (index: string, progress: number) => { - setFilesToDisplay((prevFilesUploads) => - prevFilesUploads.map((fileUpload) => ({ - ...fileUpload, - progress: fileUpload.id === index ? progress : fileUpload.progress, - })), - ); - }; + // 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 = (index: string, pausedStatus?: boolean) => { + const onPlayPause = useCallback((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, - })), - ); + // 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 { - // If the status is true we simply set all the audio's paused state as true. - setFilesToDisplay((prevFileUploads) => - prevFileUploads.map((fileUpload) => ({ - ...fileUpload, + setAudioAttachmentsStateMap((prevState) => ({ + ...prevState, + [index]: { + ...prevState[index], paused: true, - })), - ); + }, + })); } - }; + }, []); const { theme: { - colors: { black, grey_dark, grey_gainsboro, grey_whisper }, messageInput: { - fileUploadPreview: { dismiss, fileContainer, filenameText, fileTextContainer, flatList }, + fileUploadPreview: { 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() ? ( + const renderItem = useCallback( + ({ item }: { item: FileAttachmentType }) => { + if (isLocalImageAttachment(item)) { + // This is already handled in the `ImageUploadPreview` component + return null; + } else if (isLocalVoiceRecordingAttachment(item)) { + return ( + + ); + } else if (isLocalAudioAttachment(item)) { + if (isSoundPackageAvailable()) { + return ( - ) : ( - - - - - - - {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; + ); + } 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 (fileUploadsLength && flatListRef.current) { + if (fileUploads.length && flatListRef.current) { setTimeout(() => flatListRef.current?.scrollToEnd(), 1); } - }, [fileUploadsLength]); + }, [fileUploads.length]); + + const onLayout = useCallback( + (event: LayoutChangeEvent) => { + if (flatListRef.current) { + setFlatListWidth(event.nativeEvent.layout.width); + } + }, + [flatListRef], + ); - return fileUploadsLength ? ( + if (fileUploads.length === 0) { + return null; + } + + return ( ({ index, length: FILE_PREVIEW_HEIGHT + 8, offset: (FILE_PREVIEW_HEIGHT + 8) * index, })} - keyExtractor={(item) => `${item.id}`} - onLayout={({ - nativeEvent: { - layout: { width }, - }, - }) => { - setFlatListWidth(width); - }} + keyExtractor={(item) => item.localMetadata.id} + onLayout={onLayout} ref={flatListRef} renderItem={renderItem} style={[styles.flatList, flatList]} + testID={'file-upload-preview'} + /> + ); +}; + +export type FileUploadPreviewProps = Partial; + +const MemoizedFileUploadPreviewWithContext = React.memo(UnMemoizedFileUploadPreview); + +/** + * FileUploadPreview + * UI Component to preview the files set for upload + */ +export const FileUploadPreview = (props: FileUploadPreviewProps) => { + const { + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + VideoAttachmentUploadPreview, + } = useMessageInputContext(); + return ( + - ) : null; + ); }; +const styles = StyleSheet.create({ + flatList: { marginBottom: 12, maxHeight: FILE_PREVIEW_HEIGHT * 2.5 + 16 }, +}); + FileUploadPreview.displayName = 'FileUploadPreview{messageInput{fileUploadPreview}}'; diff --git a/package/src/components/MessageInput/ImageUploadPreview.tsx b/package/src/components/MessageInput/ImageUploadPreview.tsx index e1046f7ba6..96d4e18b84 100644 --- a/package/src/components/MessageInput/ImageUploadPreview.tsx +++ b/package/src/components/MessageInput/ImageUploadPreview.tsx @@ -1,156 +1,68 @@ -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 React, { useCallback } from 'react'; +import { FlatList, StyleSheet } from 'react-native'; + +import { isLocalImageAttachment, LocalImageAttachment } from 'stream-chat'; + import { MessageInputContextValue, + useMessageComposer, useMessageInputContext, -} from '../../contexts/messageInputContext/MessageInputContext'; +} from '../../contexts'; +import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; 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< +export type ImageUploadPreviewPropsWithContext = Pick< MessageInputContextValue, - 'imageUploads' | 'removeImage' | 'uploadImage' -> & - Pick; + 'ImageAttachmentUploadPreview' +>; -export type ImageUploadPreviewProps = Partial; +export type ImageAttachmentPreview> = + LocalImageAttachment; -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(); +type ImageUploadPreviewItem = { index: number; item: ImageAttachmentPreview }; - const { t } = useTranslationContext(); - return indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( - - - - {t('Not supported')} - - - ) : null; -}; +/** + * UI Component to preview the images set for upload + */ +const UnmemoizedImageUploadPreview = (props: ImageUploadPreviewPropsWithContext) => { + const { ImageAttachmentUploadPreview } = props; + const { attachmentManager } = useMessageComposer(); + const { attachments } = useAttachmentManagerState(); -const ImageUploadPreviewWithContext = (props: ImageUploadPreviewPropsWithContext) => { - const { enableOfflineSupport, imageUploads, removeImage, uploadImage } = props; + const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); const { theme: { messageInput: { - imageUploadPreview: { flatList, itemContainer, upload }, + imageUploadPreview: { flatList }, }, }, } = 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); - }} + const renderItem = useCallback( + ({ item }: ImageUploadPreviewItem) => { + return ( + - - - ); - }; + ); + }, + [ + ImageAttachmentUploadPreview, + attachmentManager.removeAttachments, + attachmentManager.uploadAttachment, + ], + ); - return imageUploads.length > 0 ? ( + if (!imageUploads.length) { + return null; + } + + return ( ({ @@ -159,70 +71,41 @@ const ImageUploadPreviewWithContext = (props: ImageUploadPreviewPropsWithContext offset: (IMAGE_PREVIEW_SIZE + 8) * index, })} horizontal - keyExtractor={(item) => item.id} + keyExtractor={(item) => item.localMetadata.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(UnmemoizedImageUploadPreview); -const MemoizedImageUploadPreviewWithContext = React.memo( - ImageUploadPreviewWithContext, - areEqual, -) as typeof ImageUploadPreviewWithContext; +export type ImageUploadPreviewProps = Partial; /** * UI Component to preview the images set for upload */ export const ImageUploadPreview = (props: ImageUploadPreviewProps) => { - const { enableOfflineSupport } = useChatContext(); - const { imageUploads, removeImage, uploadImage } = useMessageInputContext(); - - return ( - - ); + const { ImageAttachmentUploadPreview } = useMessageInputContext(); + return ; }; +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, + }, +}); + ImageUploadPreview.displayName = 'ImageUploadPreview{messageInput{imageUploadPreview}}'; diff --git a/package/src/components/MessageInput/InputButtons.tsx b/package/src/components/MessageInput/InputButtons.tsx index b1d269dde9..9fbf2d95ab 100644 --- a/package/src/components/MessageInput/InputButtons.tsx +++ b/package/src/components/MessageInput/InputButtons.tsx @@ -1,8 +1,14 @@ import React, { useCallback, useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; -import { CustomDataManagerState, TextComposerState } from 'stream-chat'; +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, @@ -23,18 +29,16 @@ export type InputButtonsWithContextProps = Pick< | 'hasFilePicker' | 'hasImagePicker' | 'MoreOptionsButton' - | 'selectedPicker' | 'toggleAttachmentPicker' ->; +> & + Pick & + Pick; const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, text: state.text, }); -const customComposerDataSelector = (state: CustomDataManagerState) => ({ - command: state.custom.command, -}); - export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => { const { AttachButton, @@ -44,17 +48,20 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => hasFilePicker, hasImagePicker, MoreOptionsButton, + uploadFile: ownCapabilitiesUploadFile, } = props; - const { customDataManager, textComposer } = useMessageComposer(); - const { text } = useStateStore(textComposer.state, textComposerStateSelector); - const { command } = useStateStore(customDataManager.state, customComposerDataSelector); + const { textComposer } = useMessageComposer(); + const { command, text } = useStateStore(textComposer.state, textComposerStateSelector); + const [showMoreOptions, setShowMoreOptions] = useState(true); + const { attachments } = useAttachmentManagerState(); const hasText = !!text; + const shouldShowMoreOptions = hasText || !!attachments.length; useEffect(() => { - setShowMoreOptions(!hasText); - }, [hasText]); + setShowMoreOptions(!shouldShowMoreOptions); + }, [shouldShowMoreOptions]); const { theme: { @@ -66,24 +73,29 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => setShowMoreOptions(true); }, [setShowMoreOptions]); - const ownCapabilities = useOwnCapabilitiesContext(); + const hasAttachmentUploadCapabilities = + (hasCameraPicker || hasFilePicker || hasImagePicker) && ownCapabilitiesUploadFile; if (command) { return null; } - return !showMoreOptions && (hasCameraPicker || hasImagePicker || hasFilePicker) && hasCommands ? ( + if (!hasAttachmentUploadCapabilities && !hasCommands) { + return null; + } + + return !showMoreOptions ? ( ) : ( <> - {(hasCameraPicker || hasImagePicker || hasFilePicker) && ownCapabilities.uploadFile && ( + {hasAttachmentUploadCapabilities ? ( - )} - {hasCommands && } + ) : null} + {hasCommands ? : null} ); }; @@ -145,9 +157,10 @@ export const InputButtons = (props: InputButtonsProps) => { hasFilePicker, hasImagePicker, MoreOptionsButton, - selectedPicker, toggleAttachmentPicker, } = useMessageInputContext(); + const { selectedPicker } = useAttachmentPickerContext(); + const { uploadFile } = useOwnCapabilitiesContext(); return ( { MoreOptionsButton, selectedPicker, toggleAttachmentPicker, + uploadFile, }} {...props} /> diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index c81fbe38bf..d883f8d595 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Modal, SafeAreaView, StyleSheet, View } from 'react-native'; import { @@ -16,11 +16,11 @@ import Animated, { withSpring, } from 'react-native-reanimated'; -import type { - CustomDataManagerState, - MessageComposerState, - TextComposerState, - UserResponse, +import { + isLocalImageAttachment, + type MessageComposerState, + type TextComposerState, + type UserResponse, } from 'stream-chat'; import { useAudioController } from './hooks/useAudioController'; @@ -35,7 +35,13 @@ import { ChannelContextValue, useChannelContext, } from '../../contexts/channelContext/ChannelContext'; +import { + MessageComposerContextValue, + useMessageComposerContext, +} from '../../contexts/messageComposerContext/MessageComposerContext'; +import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useMessageComposerHasSendableData } from '../../contexts/messageInputContext/hooks/useMessageComposerHasSendableData'; import { MessageInputContextValue, useMessageInputContext, @@ -46,7 +52,6 @@ import { } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { TranslationContextValue, useTranslationContext, @@ -58,8 +63,8 @@ import { isImageMediaLibraryAvailable, NativeHandlers, } from '../../native'; -import { compressedImageURI } from '../../utils/compressImage'; import { AIStates, useAIState } from '../AITypingIndicatorView'; +import { AttachmentPickerProps } from '../AttachmentPicker/AttachmentPicker'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; @@ -101,22 +106,35 @@ const styles = StyleSheet.create({ }, }); -type MessageInputPropsWithContext = Pick< - AttachmentPickerContextValue, - 'AttachmentPickerSelectionBar' +type MessageInputPropsWithContext = Partial< + Pick< + AttachmentPickerProps, + | 'AttachmentPickerError' + | 'AttachmentPickerErrorImage' + | 'AttachmentPickerIOSSelectMorePhotos' + | 'ImageOverlaySelectedComponent' + | 'attachmentPickerErrorButtonText' + | 'attachmentPickerErrorText' + | 'numberOfAttachmentImagesToLoadPerCall' + | 'numberOfAttachmentPickerImageColumns' + > > & + Pick & Pick & Pick & Pick< MessageInputContextValue, | 'additionalTextInputProps' - | 'asyncIds' | 'audioRecordingEnabled' | 'asyncMessagesLockDistance' | 'asyncMessagesMinimumPressDuration' | 'asyncMessagesSlideToCancelDistance' | 'asyncMessagesMultiSendEnabled' - | 'asyncUploads' + | 'attachmentPickerBottomSheetHandleHeight' + | 'attachmentPickerBottomSheetHeight' + | 'AttachmentPickerBottomSheetHandle' + | 'AttachmentPickerSelectionBar' + | 'attachmentSelectionBarHeight' | 'AudioRecorder' | 'AudioRecordingInProgress' | 'AudioRecordingLockIndicator' @@ -127,30 +145,25 @@ type MessageInputPropsWithContext = Pick< | 'clearEditingState' | 'closeAttachmentPicker' | 'compressImageQuality' - | 'editing' + | 'doFileUploadRequest' | 'FileUploadPreview' - | 'fileUploads' | 'ImageUploadPreview' - | 'imageUploads' | 'Input' | 'inputBoxRef' | 'InputButtons' | 'InputEditingStateHeader' + | 'CameraSelectorIcon' + | 'CreatePollIcon' + | 'FileSelectorIcon' + | 'ImageSelectorIcon' + | 'VideoRecorderSelectorIcon' | 'CommandInput' | 'InputReplyStateHeader' - | 'isValidMessage' | 'maxNumberOfFiles' - | 'numberOfUploads' - | 'resetInput' | 'SendButton' - | 'sending' - | 'sendMessageAsync' | 'ShowThreadMessageInChannelButton' | 'StartAudioRecordingButton' - | 'removeFile' - | 'removeImage' | 'uploadNewFile' - | 'uploadNewImage' | 'openPollCreationDialog' | 'closePollCreationDialog' | 'showPollCreationDialog' @@ -159,17 +172,14 @@ type MessageInputPropsWithContext = Pick< | 'StopMessageStreamingButton' > & Pick & - Pick & - Pick; + Pick & + Pick; const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, + hasText: !!state.text, mentionedUsers: state.mentionedUsers, suggestions: state.suggestions, - text: state.text, -}); - -const customComposerDataSelector = (state: CustomDataManagerState) => ({ - command: state.custom.command, }); const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ @@ -178,14 +188,17 @@ const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { + AttachmentPickerSelectionBar, + attachmentPickerBottomSheetHeight, + attachmentSelectionBarHeight, + bottomInset, + selectedPicker, + additionalTextInputProps, - asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, - asyncUploads, - AttachmentPickerSelectionBar, AudioRecorder, audioRecordingEnabled, AudioRecordingInProgress, @@ -195,15 +208,13 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { channel, closeAttachmentPicker, closePollCreationDialog, - compressImageQuality, cooldownEndsAt, CooldownTimer, CreatePollContent, + doFileUploadRequest, editing, FileUploadPreview, - fileUploads, ImageUploadPreview, - imageUploads, Input, inputBoxRef, InputButtons, @@ -211,34 +222,31 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { CommandInput, InputReplyStateHeader, isOnline, - isValidMessage, maxNumberOfFiles, members, - numberOfUploads, - removeFile, - removeImage, Reply, - resetInput, SendButton, - sending, sendMessage, - sendMessageAsync, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, - thread, threadList, - uploadNewFile, - uploadNewImage, watchers, } = props; const messageComposer = useMessageComposer(); - const { customDataManager, textComposer } = messageComposer; - const { mentionedUsers, text } = useStateStore(textComposer.state, textComposerStateSelector); - const { command } = useStateStore(customDataManager.state, customComposerDataSelector); + const { attachmentManager, textComposer } = messageComposer; + const { command, mentionedUsers, hasText } = useStateStore( + textComposer.state, + textComposerStateSelector, + ); const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); + const { attachments, availableUploadSlots } = useAttachmentManagerState(); + const hasSendableData = useMessageComposerHasSendableData(); + + const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); + const fileUploads = attachments.filter((attachment) => !isLocalImageAttachment(attachment)); const [height, setHeight] = useState(0); @@ -262,231 +270,18 @@ 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 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)); + attachmentManager.maxNumberOfFilesPerMessage = maxNumberOfFiles; + if (doFileUploadRequest) { + attachmentManager.setCustomUploadFn(doFileUploadRequest); } - return () => setSelectedFiles([]); + return closeAttachmentPicker; // 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]); - - /** - * 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)); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageUploadsLength]); - - /** - * This is for video attachments selected from attachment picker. - */ - 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(() => { @@ -505,35 +300,13 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { */ if ( !editing && - (command || - fileUploads.length > 0 || - mentionedUsers.length > 0 || - imageUploads.length > 0 || - numberOfUploads > 0) && - resetInput + (command || attachments.length > 0 || mentionedUsers.length > 0 || availableUploadSlots) ) { - resetInput(); + messageComposer.clear(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [editingExists]); - 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]); - const getMembers = () => { const result: UserResponse[] = []; if (members && Object.values(members).length) { @@ -591,23 +364,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; - return true; - }; + const isSendingButtonVisible = useMemo(() => { + return asyncAudioEnabled && showSendingButton && !recording; + }, [asyncAudioEnabled, recording, showSendingButton]); const micPositionX = useSharedValue(0); const micPositionY = useSharedValue(0); @@ -668,37 +430,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); @@ -723,7 +483,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { {recordingStatus === 'stopped' ? ( { recording={recording} recordingDuration={recordingDuration} recordingStopped={recordingStatus === 'stopped'} - slideToCancelStyle={animatedStyles.slideToCancel} + slideToCancelStyle={slideToCancelAnimatedStyle} stopVoiceRecording={stopVoiceRecording} uploadVoiceRecording={uploadVoiceRecording} /> @@ -779,7 +539,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { )} - {imageUploads.length ? : null} + {imageUploads.length && fileUploads.length ? ( { ]} /> ) : null} - {fileUploads.length ? : null} + {command ? ( ) : ( @@ -809,25 +569,19 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { {shouldDisplayStopAIGeneration ? ( - ) : isSendingButtonVisible() ? ( + ) : isSendingButtonVisible ? ( cooldownRemainingSeconds ? ( ) : ( - + ) ) : null} {audioRecordingEnabled && isAudioRecorderAvailable() && !micLocked && ( { - - {selectedPicker && ( + {isImageMediaLibraryAvailable() && selectedPicker ? ( { > - )} + ) : null} + {showPollCreationDialog ? ( - prevAsyncUploads[key].state === nextAsyncUploads[key].state && - prevAsyncUploads[key].url === nextAsyncUploads[key].url, - ); - if (!asyncUploadsEqual) { + const cooldownEndsAtEqual = prevCooldownEndsAt === nextCooldownEndsAt; + if (!cooldownEndsAtEqual) { return false; } - const fileUploadsEqual = prevFileUploads.length === nextFileUploads.length; - if (!fileUploadsEqual) { - return false; - } - - const threadEqual = - prevThread?.id === nextThread?.id && - prevThread?.text === nextThread?.text && - prevThread?.reply_count === nextThread?.reply_count; - if (!threadEqual) { + const threadListEqual = prevThreadList === nextThreadList; + if (!threadListEqual) { return false; } - const threadListEqual = prevThreadList === nextThreadList; - if (!threadListEqual) { + const selectedPickerEqual = prevSelectedPicker === nextSelectedPicker; + if (!selectedPickerEqual) { return false; } @@ -1050,20 +769,23 @@ 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(); const { channel, members, threadList, watchers } = useChannelContext(); + const { editing } = useMessageComposerContext(); const { additionalTextInputProps, - asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, - asyncUploads, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, AudioRecorder, audioRecordingEnabled, AudioRecordingInProgress, @@ -1071,6 +793,7 @@ export const MessageInput = (props: MessageInputProps) => { AudioRecordingPreview, AudioRecordingWaveform, AutoCompleteSuggestionList, + CameraSelectorIcon, clearEditingState, closeAttachmentPicker, closePollCreationDialog, @@ -1078,41 +801,34 @@ export const MessageInput = (props: MessageInputProps) => { cooldownEndsAt, CooldownTimer, CreatePollContent, - editing, + CreatePollIcon, + doFileUploadRequest, + FileSelectorIcon, FileUploadPreview, - fileUploads, + ImageSelectorIcon, ImageUploadPreview, - imageUploads, Input, inputBoxRef, InputButtons, InputEditingStateHeader, CommandInput, InputReplyStateHeader, - isValidMessage, maxNumberOfFiles, - numberOfUploads, openPollCreationDialog, - removeFile, - removeImage, - resetInput, SendButton, - sending, sendMessage, - sendMessageAsync, SendMessageDisallowedIndicator, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, uploadNewFile, - uploadNewImage, + VideoRecorderSelectorIcon, } = useMessageInputContext(); + const { bottomInset, bottomSheetRef, selectedPicker } = useAttachmentPickerContext(); const { Reply } = useMessagesContext(); - const { thread } = useThreadContext(); - const { t } = useTranslationContext(); /** @@ -1127,13 +843,15 @@ export const MessageInput = (props: MessageInputProps) => { { AudioRecordingPreview, AudioRecordingWaveform, AutoCompleteSuggestionList, + bottomInset, + bottomSheetRef, + CameraSelectorIcon, channel, clearEditingState, closeAttachmentPicker, @@ -1150,40 +871,35 @@ export const MessageInput = (props: MessageInputProps) => { cooldownEndsAt, CooldownTimer, CreatePollContent, + CreatePollIcon, + doFileUploadRequest, editing, + FileSelectorIcon, FileUploadPreview, - fileUploads, + ImageSelectorIcon, ImageUploadPreview, - imageUploads, Input, inputBoxRef, InputButtons, InputEditingStateHeader, InputReplyStateHeader, isOnline, - isValidMessage, maxNumberOfFiles, members, - numberOfUploads, openPollCreationDialog, - removeFile, - removeImage, Reply, - resetInput, + selectedPicker, SendButton, - sending, sendMessage, - sendMessageAsync, SendMessageDisallowedIndicator, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, t, - thread, threadList, uploadNewFile, - uploadNewImage, + VideoRecorderSelectorIcon, watchers, }} {...props} diff --git a/package/src/components/MessageInput/MoreOptionsButton.tsx b/package/src/components/MessageInput/MoreOptionsButton.tsx index 5cda2d416d..f02227da61 100644 --- a/package/src/components/MessageInput/MoreOptionsButton.tsx +++ b/package/src/components/MessageInput/MoreOptionsButton.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import { TouchableOpacity } from 'react-native'; -import type { GestureResponderEvent } from 'react-native'; +import { Pressable } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { CircleRight } from '../../icons/CircleRight'; export type MoreOptionsButtonProps = { /** Function that opens attachment options bottom sheet */ - handleOnPress?: ((event: GestureResponderEvent) => 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 cce52776d6..b5b9959cf4 100644 --- a/package/src/components/MessageInput/SendButton.tsx +++ b/package/src/components/MessageInput/SendButton.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Pressable } from 'react-native'; -import { CustomDataManagerState } from 'stream-chat'; +import { TextComposerState } from 'stream-chat'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { @@ -15,19 +15,23 @@ 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 customComposerDataSelector = (state: CustomDataManagerState) => ({ - command: state.custom.command, +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, }); -const SendButtonWithContext = (props: SendButtonPropsWithContext) => { - const { disabled = false, sendMessage } = props; +export const SendButton = (props: SendButtonProps) => { + const { disabled = false, sendMessage: propsSendMessage } = props; + const { sendMessage: sendMessageFromContext } = useMessageInputContext(); + const sendMessage = propsSendMessage || sendMessageFromContext; const messageComposer = useMessageComposer(); - const { customDataManager } = messageComposer; - const { command } = useStateStore(customDataManager.state, customComposerDataSelector); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + const { theme: { colors: { accent_blue, grey_gainsboro }, @@ -35,10 +39,17 @@ const SendButtonWithContext = (props: SendButtonPropsWithContext) => { }, } = useTheme(); + const onPressHandler = useCallback(() => { + if (disabled) { + return; + } + sendMessage(); + }, [disabled, sendMessage]); + return ( null : () => sendMessage()} + onPress={onPressHandler} style={[sendButton]} testID='send-button' > @@ -53,43 +64,4 @@ const SendButtonWithContext = (props: SendButtonPropsWithContext) => { ); }; -const areEqual = (prevProps: SendButtonPropsWithContext, nextProps: SendButtonPropsWithContext) => { - const { disabled: prevDisabled, sendMessage: prevSendMessage } = prevProps; - const { disabled: nextDisabled, sendMessage: nextSendMessage } = nextProps; - - const disabledEqual = prevDisabled === nextDisabled; - if (!disabledEqual) { - 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 { sendMessage } = useMessageInputContext(); - - return ( - - ); -}; - SendButton.displayName = 'SendButton{messageInput}'; diff --git a/package/src/components/MessageInput/__tests__/AttachButton.test.js b/package/src/components/MessageInput/__tests__/AttachButton.test.js index 682a77d3db..bcaf7afe11 100644 --- a/package/src/components/MessageInput/__tests__/AttachButton.test.js +++ b/package/src/components/MessageInput/__tests__/AttachButton.test.js @@ -1,31 +1,58 @@ import React from 'react'; -import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; +import { act, render, screen, userEvent, waitFor } from '@testing-library/react-native'; -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { OverlayProvider } from '../../../contexts'; + +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import * as NativeHandler from '../../../native'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; import { AttachButton } from '../AttachButton'; -describe('AttachButton', () => { - const getComponent = (props = {}) => ( - - - +const renderComponent = ({ channelProps, client, props }) => { + return render( + + + + + + + , ); +}; + +describe('AttachButton', () => { + let client; + let channel; - it('should render an enabled AttachButton', async () => { + beforeAll(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + 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')); + await act(() => { + userEvent.press(screen.getByTestId('attach-button')); + }); - await waitFor(() => expect(handleOnPress).toHaveBeenCalledTimes(1)); + await waitFor(() => { + expect(handleOnPress).toHaveBeenCalledTimes(0); + }); const snapshot = screen.toJSON(); @@ -34,20 +61,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')); + await act(() => { + userEvent.press(screen.getByTestId('attach-button')); + }); - await waitFor(() => expect(handleOnPress).toHaveBeenCalledTimes(0)); + await waitFor(() => { + expect(handleOnPress).toHaveBeenCalledTimes(1); + }); const snapshot = screen.toJSON(); @@ -55,4 +89,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); + }); + + await act(() => { + userEvent.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(); + }); + + await act(() => { + userEvent.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(); + }); + + await act(() => { + userEvent.press(screen.getByTestId('attach-button')); + }); + + await waitFor(() => { + expect(queryByTestId('attachment-picker-list')).toBeTruthy(); + }); + }); }); diff --git a/package/src/components/MessageInput/__tests__/UploadProgressIndicator.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadProgressIndicator.test.js similarity index 66% rename from package/src/components/MessageInput/__tests__/UploadProgressIndicator.test.js rename to package/src/components/MessageInput/__tests__/AttachmentUploadProgressIndicator.test.js index 22ddaa2a0e..11f97c53a2 100644 --- a/package/src/components/MessageInput/__tests__/UploadProgressIndicator.test.js +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadProgressIndicator.test.js @@ -4,15 +4,19 @@ import { render, screen, userEvent, waitFor } from '@testing-library/react-nativ 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', () => { + it('should render an inactive AttachmentUploadProgressIndicator', async () => { const action = jest.fn(); render( - + , ); @@ -23,12 +27,15 @@ describe('UploadProgressIndicator', () => { }); }); - it('should render an active UploadProgressIndicator', async () => { + it('should render an active AttachmentUploadProgressIndicator', async () => { const action = jest.fn(); render( - + , ); @@ -39,12 +46,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 +66,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 +86,13 @@ 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( - + , ); diff --git a/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js b/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js index f0494f8392..56e5418d5f 100644 --- a/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js +++ b/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js @@ -1,30 +1,21 @@ 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 { act, 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 { 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/Libraries/Components/View/View'); @@ -33,7 +24,7 @@ jest.mock('../../../native.ts', () => { isDocumentPickerAvailable: jest.fn(() => true), isImageMediaLibraryAvailable: jest.fn(() => true), isImagePickerAvailable: jest.fn(() => true), - isSoundPackageAvailable: jest.fn(() => true), + isSoundPackageAvailable: jest.fn(() => false), NativeHandlers: { Sound: { Player: View, @@ -42,337 +33,248 @@ jest.mock('../../../native.ts', () => { }; }); -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 renderComponent = ({ client, channel, props }) => { + return render( + + + + + + + , + ); +}; + +describe("FileUploadPreview's render", () => { + let client; + let channel; + + beforeAll(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], + afterEach(() => { + act(() => { + channel.messageComposer.attachmentManager.initState(); }); + }); - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); + it('should return null when no files are uploaded', async () => { + const props = {}; - 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); - }); + renderComponent({ channel, client, props }); - user.press(screen.getAllByTestId('remove-file-upload-preview')[0]); + const { queryAllByTestId } = screen; await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(0); + expect(queryAllByTestId('file-upload-preview')).toHaveLength(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 }), + it('should return null when the file is an image', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FINISHED, + }, + }), ]; - const removeFile = jest.fn(); - const uploadFile = jest.fn(); - const user = userEvent.setup(); - - const user1 = generateUser(); + const props = {}; - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); }); - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); + renderComponent({ channel, client, props }); - 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]); + const { queryAllByTestId } = screen; await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(0); + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(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 }), + it('should render FileAttachmentUploadPreview when the sound package is unavailable', async () => { + const attachments = [ + generateAudioAttachment({ + localMetadata: { + id: 'audio-attachment', + uploadState: FileState.UPLOADING, + }, + }), ]; - const removeFile = jest.fn(); - const uploadFile = jest.fn(); - const user1 = generateUser(); - const user = userEvent.setup(); + const props = {}; - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); }); - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; 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); + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); }); + }); - user.press(screen.getAllByTestId('remove-file-upload-preview')[0]); + 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 = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); + }); - await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(0); - }); + renderComponent({ channel, client, props }); - user.press(screen.getAllByTestId('retry-upload-progress-indicator')[0]); + const { getAllByTestId, queryAllByTestId } = screen; - await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(1); - }); - }); + 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); + }); - 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(); + await act(() => { + fireEvent.press(getAllByTestId('remove-upload-preview')[0]); + }); + + await waitFor(() => { + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(1); + }); - const user1 = generateUser(); - const user = userEvent.setup(); + await act(() => { + fireEvent.press(getAllByTestId('remove-upload-preview')[0]); + }); - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], + await waitFor(() => { + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(0); + }); }); - const chatClient = await getTestClientWithUser({ id: 'testID' }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.query(); - - render( - - - - - - - , - ); + 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 = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); - 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); - }); + renderComponent({ channel, client, props }); - user.press(screen.getAllByTestId('remove-file-upload-preview')[0]); + const { queryAllByTestId } = screen; - await waitFor(() => { - expect(removeFile).toHaveBeenCalledTimes(1); - expect(uploadFile).toHaveBeenCalledTimes(0); + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(2); + }); }); - }); - 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(); + 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 = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); - const user1 = generateUser(); + renderComponent({ channel, client, props }); - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], - }); + const { getAllByTestId, queryAllByTestId } = screen; - 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(); + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(2); + }); - const user1 = generateUser(); + await act(() => { + fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); + }); - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 }), generateMessage({ user: user1 })], + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(2); + expect(uploadAttachmentSpy).toHaveBeenCalled(); + }); }); - 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'); + 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 = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); - await waitFor(() => { - fireEvent(AudioAttachmentComponent, 'onLoad'); - fireEvent(AudioAttachmentComponent, 'onProgress'); - fireEvent(AudioAttachmentComponent, 'onPlayPause'); - fireEvent(AudioAttachmentComponent, 'onPlayPause', { - status: false, + const { queryAllByText, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-attachment-upload-preview')).toHaveLength(2); + expect(queryAllByText('Not supported')).toHaveLength(2); }); }); }); diff --git a/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js b/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js index 28ec435c4a..59a5dd4c41 100644 --- a/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js +++ b/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js @@ -1,214 +1,220 @@ import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native'; -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { generateImageUploadPreview } from '../../../mock-builders/generator/attachment'; +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 { ImageUploadPreview } from '../ImageUploadPreview'; +const renderComponent = ({ client, channel, props }) => { + return render( + + + + + + + , + ); +}; + describe('ImageUploadPreview', () => { + let client; + let channel; + + beforeAll(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + afterEach(() => { + act(() => { + channel.messageComposer.clear(); + }); + }); + 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 attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.UPLOADING, + }, + }), ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { getAllByTestId, queryAllByTestId, queryAllByText } = render( - - - , - ); + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { getAllByTestId, queryAllByTestId } = screen; 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); + 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(() => { + userEvent.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 }); - fireEvent.press(getAllByTestId('remove-image-upload-preview')[0]); + const { queryAllByTestId } = screen; await waitFor(() => { - expect(removeImage).toHaveBeenCalledTimes(1); - expect(uploadImage).toHaveBeenCalledTimes(0); + expect(queryAllByTestId('file-upload-preview')).toHaveLength(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 attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FINISHED, + }, + }), ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { getAllByTestId, queryAllByTestId, queryAllByText } = render( - - - , - ); + const props = {}; - 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); + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); }); - fireEvent.press(getAllByTestId('remove-image-upload-preview')[0]); + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; await waitFor(() => { - expect(removeImage).toHaveBeenCalledTimes(1); - expect(uploadImage).toHaveBeenCalledTimes(0); + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); }); }); 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 uploadAttachmentSpy = jest.fn(); + channel.messageComposer.attachmentManager.uploadAttachment = uploadAttachmentSpy; + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FAILED, + }, + }), ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { getAllByTestId, queryAllByTestId, queryAllByText } = render( - - - , - ); + const props = {}; - 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); + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); }); - fireEvent.press(getAllByTestId('remove-image-upload-preview')[0]); + renderComponent({ channel, client, props }); + + const { getAllByTestId, queryAllByTestId } = screen; await waitFor(() => { - expect(removeImage).toHaveBeenCalledTimes(1); - expect(uploadImage).toHaveBeenCalledTimes(0); + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); }); - fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); + await act(() => { + fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); + }); await waitFor(() => { - expect(removeImage).toHaveBeenCalledTimes(1); - expect(uploadImage).toHaveBeenCalledTimes(1); + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(1); + expect(uploadAttachmentSpy).toHaveBeenCalled(); }); }); 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 attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.BLOCKED, + }, }), ]; - const removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { queryAllByTestId, queryAllByText } = render( - - - , - ); + const props = {}; - 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); + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); - expect(removeImage).toHaveBeenCalledTimes(0); - expect(uploadImage).toHaveBeenCalledTimes(0); + const { queryAllByText, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByText('Not supported')).toHaveLength(1); }); }); 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 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 removeImage = jest.fn(); - const uploadImage = jest.fn(); - - const { queryAllByTestId, queryAllByText } = render( - - - , - ); + + const props = {}; + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByTestId, queryAllByText } = screen; 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); - expect(removeImage).toHaveBeenCalledTimes(0); - expect(uploadImage).toHaveBeenCalledTimes(0); }); }); }); diff --git a/package/src/components/MessageInput/__tests__/MessageInput.test.js b/package/src/components/MessageInput/__tests__/MessageInput.test.js index 84a12885a4..87ff01f05e 100644 --- a/package/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/package/src/components/MessageInput/__tests__/MessageInput.test.js @@ -2,21 +2,17 @@ import React, { useEffect } from 'react'; import { Alert } from 'react-native'; -import { act, cleanup, fireEvent, render, userEvent, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, render, screen, userEvent, 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 { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; 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 { NativeHandlers } from '../../../native'; import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar'; import { CameraSelectorIcon } from '../../AttachmentPicker/components/CameraSelectorIcon'; @@ -27,74 +23,68 @@ 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(), + 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 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); - await initializeChannel(generateChannelResponse()); + beforeAll(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; }); afterEach(() => { - channel = null; - cleanup(); - jest.clearAllMocks(); + act(() => { + channel.messageComposer.clear(); + }); }); it('should render MessageInput', async () => { @@ -115,27 +105,17 @@ describe('MessageInput', () => { }); }); - it('trigger file size threshold limit alert when images size above the limit', async () => { - render(getComponent()); - - // 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 () => { - const userBot = userEvent.setup(); + const props = {}; + const channelProps = { audioRecordingEnabled: true, channel }; - const { queryByTestId, unmount } = render( - - - - - , - ); + renderComponent({ channelProps, client, props }); - await userBot.longPress(queryByTestId('audio-button'), { duration: 1000 }); + const { queryByTestId, unmount } = screen; + + await act(() => { + userEvent.longPress(queryByTestId('audio-button'), { duration: 1000 }); + }); await waitFor(() => { expect(NativeHandlers.Audio.startRecording).toHaveBeenCalledTimes(1); @@ -144,7 +124,9 @@ describe('MessageInput', () => { expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.'); }); - unmount(); + await act(() => { + unmount(); + }); await waitFor(() => { expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1); @@ -154,17 +136,16 @@ describe('MessageInput', () => { }); it('should trigger an alert if a normal press happened on audio recording', async () => { - const userBot = userEvent.setup(); + const props = {}; + const channelProps = { audioRecordingEnabled: true, channel }; - const { queryByTestId } = render( - - - - - , - ); + renderComponent({ channelProps, client, props }); - await userBot.press(queryByTestId('audio-button')); + const { queryByTestId } = screen; + + await act(() => { + userEvent.press(queryByTestId('audio-button')); + }); await waitFor(() => { expect(NativeHandlers.Audio.startRecording).not.toHaveBeenCalled(); @@ -177,20 +158,19 @@ describe('MessageInput', () => { }); it('should render the SendMessageDisallowedIndicator if the send-message capability is not present', async () => { - const { queryByTestId } = render( - - - - - , - ); + const props = {}; + const channelProps = { audioRecordingEnabled: true, channel }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; await waitFor(() => { expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); }); act(() => { - chatClient.dispatchEvent({ + client.dispatchEvent({ cid: channel.data.cid, own_capabilities: channel.data.own_capabilities.filter( (capability) => capability !== 'send-message', @@ -205,7 +185,12 @@ describe('MessageInput', () => { }); it('should not render the SendMessageDisallowedIndicator if the channel is frozen and the send-message capability is present', async () => { - const { queryByTestId } = render(getComponent()); + const props = {}; + const channelProps = { channel }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; await waitFor(() => { expect(queryByTestId('send-message-disallowed-indicator')).toBeNull(); @@ -213,10 +198,15 @@ describe('MessageInput', () => { }); it('should render the SendMessageDisallowedIndicator in a frozen channel only if the send-message capability is not present', async () => { - const { queryByTestId } = render(getComponent()); + const props = {}; + const channelProps = { channel }; + + renderComponent({ channelProps, client, props }); + + const { queryByTestId } = screen; act(() => { - chatClient.dispatchEvent({ + client.dispatchEvent({ channel: { ...channel.data, own_capabilities: channel.data.own_capabilities.filter( @@ -243,7 +233,7 @@ describe('MessageInput', () => { it('should not render the SendMessageDisallowedIndicator if we are editing a message, regardless of capabilities', async () => { const { queryByTestId } = render( - + @@ -255,7 +245,7 @@ describe('MessageInput', () => { }); act(() => { - chatClient.dispatchEvent({ + client.dispatchEvent({ cid: channel.data.cid, own_capabilities: channel.data.own_capabilities.filter( (capability) => capability !== 'send-message', diff --git a/package/src/components/MessageInput/__tests__/SendButton.test.js b/package/src/components/MessageInput/__tests__/SendButton.test.js index 561d126d7e..7de672de98 100644 --- a/package/src/components/MessageInput/__tests__/SendButton.test.js +++ b/package/src/components/MessageInput/__tests__/SendButton.test.js @@ -1,35 +1,58 @@ import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { act, render, screen, userEvent, 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 () => { +describe('SendButton', () => { + let client; + let channel; + + beforeAll(async () => { + const { client: chatClient, channels } = await initiateClientWithChannels(); + client = chatClient; + channel = channels[0]; + }); + + it('should render a SendButton', async () => { const sendMessage = jest.fn(); - const { getByTestId, queryByTestId, toJSON } = render( - getComponent({ 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')); + await act(() => { + userEvent.press(getByTestId('send-button')); + }); - await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); + await waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(getByTestId('send-up')).toBeDefined(); + }); const snapshot = toJSON(); @@ -38,45 +61,29 @@ describe('SendButton', () => { }); }); - it('should render a non-editing disabled SendButton', async () => { + it('should render a disabled SendButton', async () => { const sendMessage = jest.fn(); - const { getByTestId, queryByTestId, toJSON } = render( - getComponent({ disabled: true, editing: false, 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')); - - await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(0)); - - const snapshot = toJSON(); - - await waitFor(() => { - expect(snapshot).toMatchSnapshot(); + await act(() => { + userEvent.press(getByTestId('send-button')); }); - }); - - it('should render an editing enabled SendButton', async () => { - const sendMessage = jest.fn(); - - const { getByTestId, queryByTestId, toJSON } = render( - getComponent({ editing: true, sendMessage }), - ); await waitFor(() => { - expect(queryByTestId('send-button')).toBeTruthy(); expect(sendMessage).toHaveBeenCalledTimes(0); + expect(getByTestId('send-right')).toBeDefined(); }); - fireEvent.press(getByTestId('send-button')); - - await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); - const snapshot = toJSON(); await waitFor(() => { @@ -84,26 +91,23 @@ describe('SendButton', () => { }); }); - it('should render an editing disabled SendButton', async () => { + it('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 }), - ); + const props = { sendMessage }; - await waitFor(() => { - expect(queryByTestId('send-button')).toBeTruthy(); - expect(sendMessage).toHaveBeenCalledTimes(0); - }); - - 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(); + }); + + await act(() => { + channel.messageComposer.clear(); }); }); }); 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..af80124599 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -1,285 +1,637 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AttachButton should render a disabled AttachButton 1`] = ` - - - - - + - - - - - - - - - + > + + + + + + + + + + + + + , + + + + + , +] `; -exports[`AttachButton should render an 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..747fa26fd2 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,375 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SendButton should render a non-editing disabled 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 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..f37b850dc8 --- /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..701a02fd31 --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -0,0 +1,85 @@ +import React, { useCallback } 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 { enableOfflineSupport } = useChatContext(); + const indicatorType = 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]); + + 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/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/CommandInput.tsx b/package/src/components/MessageInput/components/CommandInput.tsx index e3482d1c2b..c981fbb86c 100644 --- a/package/src/components/MessageInput/components/CommandInput.tsx +++ b/package/src/components/MessageInput/components/CommandInput.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { CustomDataManagerState } from 'stream-chat'; +import { TextComposerState } from 'stream-chat'; import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; import { @@ -21,9 +21,9 @@ export type CommandInputProps = Partial< > & { disabled: boolean; }; - -const customComposerDataSelector = (state: CustomDataManagerState) => ({ - command: state.custom.command, +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, + text: state.text, }); export const CommandInput = ({ @@ -32,8 +32,8 @@ export const CommandInput = ({ }: CommandInputProps) => { const { cooldownEndsAt: contextCooldownEndsAt } = useMessageInputContext(); const messageComposer = useMessageComposer(); - const { customDataManager } = messageComposer; - const { command } = useStateStore(customDataManager.state, customComposerDataSelector); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); const cooldownEndsAt = propCooldownEndsAt || contextCooldownEndsAt; @@ -50,18 +50,21 @@ export const CommandInput = ({ } = useTheme(); const onCloseHandler = () => { - customDataManager.setCustomData({ command: null }); + textComposer.clearCommand(); + messageComposer?.restore(); }; if (!command) { return null; } + const commandName = (command.name ?? '').toUpperCase(); + return ( - {command.toUpperCase()} + {commandName} diff --git a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx b/package/src/components/MessageInput/components/InputEditingStateHeader.tsx index 5164971278..f3d4215390 100644 --- a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx +++ b/package/src/components/MessageInput/components/InputEditingStateHeader.tsx @@ -1,6 +1,7 @@ -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 { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; import { MessageInputContextValue, useMessageInputContext, @@ -10,33 +11,18 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran 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 } = useMessageInputContext(); 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')} - { - 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 fece7eec52..fb3bb905d8 100644 --- a/package/src/components/MessageInput/components/InputReplyStateHeader.tsx +++ b/package/src/components/MessageInput/components/InputReplyStateHeader.tsx @@ -2,36 +2,14 @@ import React from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../../contexts/messageInputContext/MessageInputContext'; 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: 8, - }, - replyBoxHeaderTitle: { - fontSize: 14, - fontWeight: 'bold', - }, -}); - -export type InputReplyStateHeaderProps = Partial>; - -export const InputReplyStateHeader = ({ - resetInput: propResetInput, -}: InputReplyStateHeaderProps) => { +export const InputReplyStateHeader = () => { const { t } = useTranslationContext(); const messageComposer = useMessageComposer(); - const { resetInput: contextResetInput } = useMessageInputContext(); const { theme: { colors: { black, grey, grey_gainsboro }, @@ -41,13 +19,7 @@ export const InputReplyStateHeader = ({ }, } = useTheme(); - const resetInput = propResetInput || contextResetInput; - const onCloseHandler = () => { - if (resetInput) { - resetInput(); - } - messageComposer.setQuotedMessage(null); }; @@ -59,7 +31,7 @@ export const InputReplyStateHeader = ({ [{ opacity: pressed ? 0.5 : 1 }]} + style={({ pressed }) => [{ opacity: pressed ? 0.8 : 1 }]} testID='close-button' > @@ -68,4 +40,17 @@ export const InputReplyStateHeader = ({ ); }; +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 4919a9b74e..fc43d14106 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); @@ -275,11 +281,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/Poll/CreatePollContent.tsx b/package/src/components/Poll/CreatePollContent.tsx index 6077a84e73..a36cd43593 100644 --- a/package/src/components/Poll/CreatePollContent.tsx +++ b/package/src/components/Poll/CreatePollContent.tsx @@ -162,9 +162,13 @@ export const CreatePoll = ({ const createAndSendPoll = useCallback(async () => { try { + /** + * The poll is emptied inside the createPoll method(using initState) which is why we close the dialog + * first so that it doesn't look weird. + */ + closePollCreationDialog?.(); await messageComposer.createPoll(); await sendMessage(); - closePollCreationDialog?.(); } catch (error) { console.log('Error creating a poll and sending a message:', error); } diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 001efeecad..26b4cd6be3 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -24,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'; @@ -351,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; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index ab5a57e42a..a14b4eeb98 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -132,7 +132,11 @@ 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'; 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 eb02148194..82d2eadd22 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { Channel, ChannelState, LocalMessage } from 'stream-chat'; +import type { Channel, ChannelState } from 'stream-chat'; import { MarkReadFunctionOptions } from '../../components/Channel/Channel'; import type { EmptyStateProps } from '../../components/Indicators/EmptyStateIndicator'; @@ -35,7 +35,6 @@ export type ChannelContextValue = { * @overrideType Channel */ channel: Channel; - editing?: LocalMessage; /** * Custom UI component to display empty state when channel has no messages. * diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index fa797074e3..71c32252af 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -8,9 +8,9 @@ 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 './messagesContext/MessagesContext'; export * from './paginatedMessageListContext/PaginatedMessageListContext'; export * from './overlayContext/OverlayContext'; diff --git a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx new file mode 100644 index 0000000000..e06833ad29 --- /dev/null +++ b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx @@ -0,0 +1,39 @@ +import React, { useContext } from 'react'; + +import { LocalMessage } from 'stream-chat'; + +import { ChannelProps } from '../../components'; +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']; + editing?: LocalMessage; +}; + +export const MessageComposerContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as MessageComposerContextValue, +); + +type Props = React.PropsWithChildren<{ + value: MessageComposerContextValue; +}>; + +export const MessageComposerProvider = ({ children, value }: Props) => ( + {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..78d75da95f 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { Attachment, LocalMessage } from 'stream-chat'; +import type { Attachment, LocalMessage, MessageComposer } from 'stream-chat'; import type { ActionHandler } from '../../components/Attachment/Attachment'; import { ReactionSummary } from '../../components/Message/hooks/useProcessReactions'; @@ -119,6 +119,7 @@ export type MessageContextValue = { preventPress?: boolean; /** Whether or not the avatar show show next to Message */ showAvatar?: boolean; + setQuotedMessage: MessageComposer['setQuotedMessage']; } & Pick; export const MessageContext = React.createContext( diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 174ee60afd..b40bc80be4 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -9,27 +9,25 @@ import React, { } from 'react'; import { Alert, Keyboard, Linking, TextInput, TextInputProps } from 'react-native'; +import { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; import { - Attachment, + createApplyCommandSettingsMiddleware, + createCommandInjectionMiddleware, + createDraftCommandInjectionMiddleware, LocalMessage, - logChatPromiseExecution, Message, - SendFileAPIResponse, + SendMessageOptions, StreamChat, Message as StreamMessage, TextComposerMiddleware, - TextComposerState, - UserFilters, - UserOptions, + UpdateMessageOptions, + UploadRequestFn, UserResponse, - UserSort, } from 'stream-chat'; +import { useAttachmentManagerState } from './hooks/useAttachmentManagerState'; import { useCreateMessageInputContext } from './hooks/useCreateMessageInputContext'; import { useMessageComposer } from './hooks/useMessageComposer'; -import { useMessageDetailsForState } from './hooks/useMessageDetailsForState'; - -import { isUploadAllowed, MAX_FILE_SIZE_TO_UPLOAD, prettifyFileSize } from './utils/utils'; import { AutoCompleteSuggestionHeaderProps, @@ -38,10 +36,12 @@ import { PollContentProps, StopMessageStreamingButtonProps, } from '../../components'; -import { AudioAttachmentProps } from '../../components/Attachment/AudioAttachment'; -import { parseLinksFromText } from '../../components/Message/MessageSimple/utils/parseLinks'; import type { AttachButtonProps } from '../../components/MessageInput/AttachButton'; 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'; @@ -50,7 +50,6 @@ import type { AudioRecordingPreviewProps } from '../../components/MessageInput/c 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 { 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'; @@ -59,113 +58,30 @@ import type { InputButtonsProps } from '../../components/MessageInput/InputButto 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 { useStateStore } from '../../hooks/useStateStore'; -import { - createCommandControlMiddleware, - createCommandInjectionMiddleware, - createDraftCommandInjectionMiddleware, -} from '../../middlewares/commandControl'; -import { - isDocumentPickerAvailable, - isImageMediaLibraryAvailable, - MediaTypes, - NativeHandlers, -} from '../../native'; -import { File, FileTypes, FileUpload } from '../../types/types'; +import { useStableCallback } from '../../hooks/useStableCallback'; +import { createAttachmentsCompositionMiddleware } 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 { useOwnCapabilitiesContext } from '../ownCapabilitiesContext/OwnCapabilitiesContext'; import { useThreadContext } from '../threadContext/ThreadContext'; import { useTranslationContext } from '../translationContext/TranslationContext'; 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 MentionAllAppUsersQuery = { - filters?: UserFilters; - options?: UserOptions; - sort?: UserSort; -}; - export type LocalMessageInputContext = { - asyncIds: string[]; - asyncUploads: { - [key: string]: { - state: string; - url: string; - }; - }; - isCommandUIEnabled: 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[]; - /** - * 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; - numberOfUploads: number; openAttachmentPicker: () => void; openFilePicker: () => void; /** @@ -173,88 +89,55 @@ export type LocalMessageInputContext = { */ 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; + selectedPicker?: 'images'; 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>; - setImageUploads: React.Dispatch>; /** * Ref callback to set reference on input box */ setInputBoxRef: LegacyRef | undefined; - setNumberOfUploads: React.Dispatch>; setSendThreadMessageInChannel: React.Dispatch>; /** * Function for taking a photo and uploading it */ takeAndUploadImage: (mediaType?: MediaTypes) => Promise; toggleAttachmentPicker: () => void; - 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; /** @@ -264,25 +147,29 @@ 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; @@ -290,11 +177,78 @@ export type InputMessageInputContextValue = { 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 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; + + /** + * 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; + clearEditingState: () => void; /** * 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; /** @@ -302,13 +256,18 @@ 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']; + editMessage: (params: { + localMessage: LocalMessage; + options?: UpdateMessageOptions; + }) => ReturnType; /** * 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 + * 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; @@ -323,29 +282,39 @@ export type InputMessageInputContextValue = { 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 + * 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; + /** + * Boolean value to determine if the input should show a command UI. + */ + isCommandUIEnabled?: boolean; CommandInput: React.ComponentType; - InputReplyStateHeader: React.ComponentType; + InputReplyStateHeader: React.ComponentType; /** Limit on allowed number of files to attach at a time. */ maxNumberOfFiles: number; /** * 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; /** * 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; + 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. @@ -357,24 +326,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; closePollCreationDialog?: () => void; /** * Compress image with quality (from 0 to 1, where 1 is best quality). @@ -393,28 +359,11 @@ export type InputMessageInputContextValue = { /** * 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; - - /** - * Override image upload request - * - * @param file File object - { uri: '' } - * @param channel Current channel object - * - * @overrideType Function - */ - doImageUploadRequest?: ( - file: File, - channel: ChannelContextValue['channel'], - ) => Promise; + doFileUploadRequest?: UploadRequestFn; /** * Variable that tracks the editing state. @@ -425,11 +374,11 @@ export type InputMessageInputContextValue = { * 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) + * Has access to all of + * [MessageInputContext](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/contexts/messageInputContext/MessageInputContext.tsx) */ Input?: React.ComponentType< Omit & @@ -439,7 +388,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 @@ -453,9 +403,6 @@ export type InputMessageInputContextValue = { * - toggleAttachmentPicker */ InputButtons?: React.ComponentType; - /** Object containing filters/sort/options overrides for an @mention user query */ - mentionAllAppUsersEnabled?: boolean; - mentionAllAppUsersQuery?: MentionAllAppUsersQuery; openPollCreationDialog?: ({ sendMessage }: Pick) => void; SendMessageDisallowedIndicator?: React.ComponentType; /** @@ -476,89 +423,34 @@ export const MessageInputContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as MessageInputContextValue, ); -const textComposerStateSelector = (state: TextComposerState) => ({ - mentionedUsers: state.mentionedUsers, - suggestions: state.suggestions, - text: state.text, -}); - export const MessageInputProvider = ({ children, value, }: PropsWithChildren<{ value: InputMessageInputContextValue; }>) => { - const { - closePicker, - openPicker, - selectedFiles, - selectedImages, - selectedPicker, - setSelectedFiles, - setSelectedImages, - setSelectedPicker, - } = useAttachmentPickerContext(); - const { appSettings, client, enableOfflineSupport } = useChatContext(); - const { removeMessage } = useMessagesContext(); + const { closePicker, openPicker, selectedPicker, setSelectedPicker } = + useAttachmentPickerContext(); + const { client, enableOfflineSupport } = useChatContext(); - 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, isCommandUIEnabled, uploadAbortControllerRef } = useChannelContext(); + const { isCommandUIEnabled, uploadAbortControllerRef } = useChannelContext(); 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 [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, - numberOfUploads, - setFileUploads, - setImageUploads, - setNumberOfUploads, - } = useMessageDetailsForState(editing, initialValue); const { endsAt: cooldownEndsAt, start: startCooldown } = useCooldown(); const messageComposer = useMessageComposer(); - const { textComposer } = messageComposer; - const { text } = useStateStore(textComposer.state, textComposerStateSelector); + const { attachmentManager, editedMessage } = messageComposer; + const { availableUploadSlots } = useAttachmentManagerState(); const threadId = thread?.id; useEffect(() => { @@ -566,69 +458,37 @@ export const MessageInputProvider = ({ }, [threadId]); useEffect(() => { - if (!client || !isCommandUIEnabled) { + if (!client) { return; } client.setMessageComposerSetupFunction(({ composer }) => { - composer.compositionMiddlewareExecutor.insert({ - middleware: [createCommandInjectionMiddleware(composer)], - position: { after: 'stream-io/message-composer-middleware/attachments' }, - }); - composer.draftCompositionMiddlewareExecutor.insert({ - middleware: [createDraftCommandInjectionMiddleware(composer)], - position: { after: 'stream-io/message-composer-middleware/draft-attachments' }, - }); - composer.textComposer.middlewareExecutor.insert({ - middleware: [createCommandControlMiddleware(composer) as TextComposerMiddleware], - position: { before: 'stream-io/text-composer/pre-validation-middleware' }, - }); + isCommandUIEnabled && + composer.compositionMiddlewareExecutor.insert({ + middleware: [createCommandInjectionMiddleware(composer)], + position: { after: 'stream-io/message-composer-middleware/attachments' }, + }); + enableOfflineSupport && + composer.compositionMiddlewareExecutor.replace([ + createAttachmentsCompositionMiddleware(composer), + ]); + isCommandUIEnabled && + composer.draftCompositionMiddlewareExecutor.insert({ + middleware: [createDraftCommandInjectionMiddleware(composer)], + position: { after: 'stream-io/message-composer-middleware/draft-attachments' }, + }); + isCommandUIEnabled && + composer.textComposer.middlewareExecutor.insert({ + middleware: [createApplyCommandSettingsMiddleware() as TextComposerMiddleware], + position: { after: 'stream-io/text-composer/commands-middleware' }, + }); }); - }, [client, isCommandUIEnabled]); - - /** 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; - } - - 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; - }); + }, [client, isCommandUIEnabled, enableOfflineSupport]); /** * Function for capturing a photo and uploading it */ const takeAndUploadImage = useStableCallback(async (mediaType?: MediaTypes) => { - setSelectedPicker(undefined); - closePicker(); const file = await NativeHandlers.takePhoto({ compressImageQuality: value.compressImageQuality, mediaType, @@ -643,13 +503,14 @@ export const MessageInputProvider = ({ ], ); } + + if (!availableUploadSlots) { + Alert.alert(t('Maximum number of files reached')); + return; + } + 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); - } + await uploadNewFile(file); } }); @@ -669,28 +530,38 @@ export const MessageInputProvider = ({ ); } - // RN CLI - if (numberOfUploads >= value.maxNumberOfFiles) { + 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); - } + 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; + } + + const result = await NativeHandlers.pickDocument({ + maxNumberOfFiles: availableUploadSlots, + }); + + if (!result.cancelled && result.assets) { + result.assets.forEach(async (asset) => { + await uploadNewFile(asset); }); } }); @@ -723,337 +594,40 @@ export const MessageInputProvider = ({ } }, [closeAttachmentPicker, openAttachmentPicker, selectedPicker]); - 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 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); - } - }); - } - }); - - 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); - } - }, - [imageUploads, setImageUploads, setNumberOfUploads], - ); - - const resetInput = useStableCallback(async (pendingAttachments: Attachment[] = []) => { - await messageComposer.clear(); - /** - * If the MediaLibrary is available, reset the selected files and images - */ - if (isImageMediaLibraryAvailable()) { - setSelectedFiles([]); - setSelectedImages([]); - } - - setFileUploads([]); - setImageUploads([]); - setNumberOfUploads( - (prevNumberOfUploads) => prevNumberOfUploads - (pendingAttachments?.length || 0), - ); - 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 sendMessage = useStableCallback(async () => { + startCooldown(); - 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 (inputBoxRef.current) { + inputBoxRef.current.clear(); } - }); - - // 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); - - if (!channelCapabities.sendLinks && linkInfos.length > 0) { - Alert.alert( - t('Links are disabled'), - t('Sending links is not allowed in this conversation'), - ); - - return; - } - - sending.current = true; - - startCooldown(); - - 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; - } - } + const composition = await messageComposer.compose(); + if (!composition || !composition.message) return; + const { localMessage, message, sendOptions } = composition; - // 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)); - } - } - - const composition = await messageComposer.compose(); - if (!composition || !composition.message) return; - const { localMessage } = composition; - - // Disallow sending message if its empty. - if (!localMessage.text && attachments.length === 0 && !localMessage.poll_id) { - sending.current = false; - return; - } - - const message = value.editing; - if (message && message.type !== 'error') { - const updatedMessage = { - ...message, - attachments, - mentioned_users: localMessage.mentioned_users?.map((user) => user.id), - quoted_message: undefined, - text: localMessage.text, - ...customMessageData, - } as Parameters[0]; - - // TODO: Remove this line and show an error when submit fails + if (editedMessage && editedMessage.type !== 'error') { + try { 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, - // TODO: Handle unique users - mentioned_users: localMessage.mentioned_users?.map((user) => user.id), - /** Parent message id - in case of thread */ - parent_id: thread?.id, - poll_id: localMessage.poll_id, - quoted_message_id: localMessage.quoted_message_id, - show_in_channel: sendThreadMessageInChannel || undefined, - text: localMessage.text, - ...customMessageData, - }); - - // TODO: This might not be needed. Think about it. - // value.clearQuotedMessageState(); - sending.current = false; - resetInput(attachments); - } catch (_error) { - sending.current = false; - // TODO: Test if this is really needed? - // if (value.quotedMessage && typeof value.quotedMessage !== 'boolean') { - // value.setQuotedMessageState(value.quotedMessage); - // } - console.log('Failed to send message'); - } + await value.editMessage({ localMessage, options: sendOptions }); + } catch (error) { + console.log('Failed to edit message:', error); } - }, - ); - - 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(); + } else { try { - value.sendMessage({ - attachments, - mentioned_users: [], - parent_id: thread?.id, - quoted_message_id: messageComposer.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; + messageComposer.clear(); + await value.sendMessage({ + localMessage: { + ...localMessage, + show_in_channel: sendThreadMessageInChannel || undefined, + }, + message: { + ...message, + show_in_channel: sendThreadMessageInChannel || undefined, + }, + options: sendOptions, }); - - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); - } catch (_error) { - console.log('Failed'); + } catch (error) { + console.log('Failed to send message:', error); } } }); @@ -1065,295 +639,23 @@ export const MessageInputProvider = ({ } }); - const updateMessage = useStableCallback(async () => { - try { - if (value.editing) { - await client.updateMessage({ - ...value.editing, - quoted_message: undefined, - 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); } }); @@ -1366,48 +668,26 @@ export const MessageInputProvider = ({ }); const messageInputContext = useCreateMessageInputContext({ - asyncIds, - asyncUploads, closeAttachmentPicker, cooldownEndsAt, - fileUploads, - imageUploads, inputBoxRef, - isCommandUIEnabled, - isValidMessage, - numberOfUploads, openAttachmentPicker, openFilePicker: pickFile, pickAndUploadImageFromNativePicker, pickFile, - removeFile, - removeImage, - resetInput, - selectedPicker, - sending, - sendMessageAsync, sendThreadMessageInChannel, - setAsyncIds, - setAsyncUploads, - setFileUploads, - setImageUploads, setInputBoxRef, - setNumberOfUploads, setSendThreadMessageInChannel, takeAndUploadImage, thread, toggleAttachmentPicker, - updateMessage, - uploadFile, - uploadImage, uploadNewFile, - uploadNewImage, ...value, closePollCreationDialog, 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__/__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__/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__/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/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index d0d76cb061..68386e4b30 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -5,13 +5,17 @@ import type { MessageInputContextValue } from '../MessageInputContext'; export const useCreateMessageInputContext = ({ additionalTextInputProps, - asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, - asyncUploads, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -22,7 +26,7 @@ export const useCreateMessageInputContext = ({ AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList, - autoCompleteSuggestionsLimit, + CameraSelectorIcon, clearEditingState, closeAttachmentPicker, closePollCreationDialog, @@ -32,55 +36,40 @@ export const useCreateMessageInputContext = ({ cooldownEndsAt, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, + CreatePollIcon, editing, editMessage, + FileAttachmentUploadPreview, + FileSelectorIcon, FileUploadPreview, - fileUploads, isCommandUIEnabled, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, + ImageAttachmentUploadPreview, + ImageSelectorIcon, ImageUploadPreview, - imageUploads, - initialValue, Input, inputBoxRef, InputButtons, InputEditingStateHeader, InputReplyStateHeader, - isValidMessage, maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, MoreOptionsButton, - numberOfUploads, openAttachmentPicker, openFilePicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, pickFile, - removeFile, - removeImage, - resetInput, selectedPicker, SendButton, - sendImageAsync, - sending, sendMessage, - sendMessageAsync, SendMessageDisallowedIndicator, sendThreadMessageInChannel, - setAsyncIds, - setAsyncUploads, - setFileUploads, - setImageUploads, setInputBoxRef, setInputRef, - setNumberOfUploads, setSendThreadMessageInChannel, showPollCreationDialog, ShowThreadMessageInChannelButton, @@ -89,30 +78,27 @@ export const useCreateMessageInputContext = ({ takeAndUploadImage, thread, toggleAttachmentPicker, - 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 threadId = thread?.id; - const asyncIdsLength = asyncIds.length; const messageInputContext: MessageInputContextValue = useMemo( () => ({ additionalTextInputProps, - asyncIds, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, - asyncUploads, AttachButton, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerSelectionBar, + attachmentSelectionBarHeight, + AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, audioRecordingEnabled, @@ -123,7 +109,7 @@ export const useCreateMessageInputContext = ({ AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList, - autoCompleteSuggestionsLimit, + CameraSelectorIcon, clearEditingState, closeAttachmentPicker, closePollCreationDialog, @@ -133,55 +119,40 @@ export const useCreateMessageInputContext = ({ cooldownEndsAt, CooldownTimer, CreatePollContent, - doDocUploadRequest, - doImageUploadRequest, + CreatePollIcon, editing, editMessage, + FileAttachmentUploadPreview, + FileSelectorIcon, FileUploadPreview, - fileUploads, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, + ImageAttachmentUploadPreview, + ImageSelectorIcon, ImageUploadPreview, - imageUploads, - initialValue, Input, inputBoxRef, InputButtons, InputEditingStateHeader, InputReplyStateHeader, isCommandUIEnabled, - isValidMessage, maxNumberOfFiles, - mentionAllAppUsersEnabled, - mentionAllAppUsersQuery, MoreOptionsButton, - numberOfUploads, openAttachmentPicker, openFilePicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, pickFile, - removeFile, - removeImage, - resetInput, selectedPicker, SendButton, - sendImageAsync, - sending, sendMessage, - sendMessageAsync, SendMessageDisallowedIndicator, sendThreadMessageInChannel, - setAsyncIds, - setAsyncUploads, - setFileUploads, - setImageUploads, setInputBoxRef, setInputRef, - setNumberOfUploads, setSendThreadMessageInChannel, showPollCreationDialog, ShowThreadMessageInChannelButton, @@ -189,26 +160,19 @@ export const useCreateMessageInputContext = ({ StopMessageStreamingButton, takeAndUploadImage, toggleAttachmentPicker, - updateMessage, - uploadFile, - uploadImage, uploadNewFile, - uploadNewImage, - UploadProgressIndicator, + VideoAttachmentUploadPreview, + VideoRecorderSelectorIcon, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ - asyncIdsLength, - asyncUploadsValue, cooldownEndsAt, editingdep, - fileUploadsValue, isCommandUIEnabled, - imageUploadsValue, - selectedPicker, sendThreadMessageInChannel, threadId, showPollCreationDialog, + selectedPicker, ], ); diff --git a/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts index 086ef76603..d1d46ccf1b 100644 --- a/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts +++ b/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts @@ -2,18 +2,21 @@ import { useEffect, useMemo } from 'react'; import { FixedSizeQueueCache, MessageComposer } from 'stream-chat'; -import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; +import { useMessageComposerContext } from '../../messageComposerContext/MessageComposerContext'; const queueCache = new FixedSizeQueueCache(64); export const useMessageComposer = () => { const { client } = useChatContext(); - const { channel, editing: editedMessage } = useChannelContext(); + const { + channel, + editing: editedMessage, + thread: parentMessage, + threadInstance, + } = useMessageComposerContext(); // legacy thread will receive new composer - const { thread: parentMessage, threadInstance } = useThreadContext(); const cachedEditedMessage = useMemo(() => { if (!editedMessage) return undefined; @@ -34,7 +37,7 @@ export const useMessageComposer = () => { // editedMessage ?? thread ?? parentMessage ?? channel; const messageComposer = useMemo(() => { - if (editedMessage && cachedEditedMessage) { + if (cachedEditedMessage) { const tag = MessageComposer.constructTag(cachedEditedMessage); const cachedComposer = queueCache.get(tag); @@ -65,8 +68,7 @@ export const useMessageComposer = () => { } else { return channel.messageComposer; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cachedEditedMessage, cachedParentMessage, channel, threadInstance]); + }, [cachedEditedMessage, cachedParentMessage, channel.messageComposer, client, threadInstance]); if ( (['legacy_thread', 'message'] as MessageComposer['contextType'][]).includes( 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/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 cf64e2edbf..772df72480 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/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 4ec4de54e7..10514ca7dc 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -269,6 +269,11 @@ export type Theme = { attachButtonContainer: ViewStyle; attachmentSelectionBar: ViewStyle; attachmentSeparator: ViewStyle; + attachmentUnsupportedIndicator: { + container: ViewStyle; + warningIcon: IconProps; + text: TextStyle; + }; audioRecorder: { arrowLeftIcon: IconProps; checkContainer: ViewStyle; @@ -313,13 +318,17 @@ export type Theme = { 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; @@ -327,23 +336,25 @@ 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; - - imageUploadPreview: { - dismiss: ViewStyle; - dismissIconColor: ColorValue; - flatList: ViewStyle; + imageAttachmentUploadPreview: { itemContainer: ViewStyle; upload: ImageStyle; }; + imageUploadPreview: { + flatList: ViewStyle; + }; inputBox: TextStyle; inputBoxContainer: ViewStyle; micButtonContainer: ViewStyle; @@ -407,6 +418,12 @@ export type Theme = { indicatorColor: string; overlay: ViewStyle; }; + videoAttachmentUploadPreview: { + recorderIconContainer: ViewStyle; + recorderIcon: IconProps; + itemContainer: ViewStyle; + upload: ImageStyle; + }; }; messageList: { container: ViewStyle; @@ -1049,6 +1066,11 @@ export const defaultTheme: Theme = { attachButtonContainer: {}, attachmentSelectionBar: {}, attachmentSeparator: {}, + attachmentUnsupportedIndicator: { + container: {}, + text: {}, + warningIcon: {}, + }, audioRecorder: { arrowLeftIcon: {}, checkContainer: {}, @@ -1080,13 +1102,17 @@ export const defaultTheme: Theme = { text: {}, }, commandsButton: {}, - commandsButtonContainer: {}, composerContainer: {}, container: {}, cooldownTimer: { container: {}, text: {}, }, + dismissAttachmentUpload: { + dismiss: {}, + dismissIcon: {}, + dismissIconColor: '', + }, editingBoxContainer: {}, editingBoxHeader: {}, editingBoxHeaderTitle: {}, @@ -1094,22 +1120,25 @@ export const defaultTheme: Theme = { editingBoxHeader: {}, editingBoxHeaderTitle: {}, }, - fileUploadPreview: { - dismiss: {}, + fileAttachmentUploadPreview: { fileContainer: {}, filenameText: {}, fileSizeText: {}, fileTextContainer: {}, + uploadProgressOverlay: {}, + wrapper: {}, + }, + fileUploadPreview: { flatList: {}, }, focusedInputBoxContainer: {}, - imageUploadPreview: { - dismiss: {}, - dismissIconColor: '', - flatList: {}, + imageAttachmentUploadPreview: { itemContainer: {}, upload: {}, }, + imageUploadPreview: { + flatList: {}, + }, inputBox: {}, inputBoxContainer: {}, micButtonContainer: {}, @@ -1173,6 +1202,12 @@ export const defaultTheme: Theme = { indicatorColor: '', overlay: {}, }, + videoAttachmentUploadPreview: { + itemContainer: {}, + recorderIcon: {}, + recorderIconContainer: {}, + upload: {}, + }, }, messageList: { container: {}, 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/icons/Search.tsx b/package/src/icons/Search.tsx index c5c574fa1b..060e210780 100644 --- a/package/src/icons/Search.tsx +++ b/package/src/icons/Search.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { IconProps, RootPath, RootSvg } from './utils/base'; export const Search = (props: IconProps) => ( - + ( - + ( - + { + const { localMetadata, ...attachment } = localAttachment; + + if (isLocalImageAttachment(localAttachment)) { + const isRemoteUri = !!attachment.image_url; + + if (isRemoteUri) return attachment as Attachment; + + return { + ...attachment, + image_url: localMetadata?.previewUri, + originalFile: localMetadata.file, + } as Attachment; + } else { + const isRemoteUri = !!attachment.asset_url; + if (isRemoteUri) return attachment as Attachment; + + return { + ...attachment, + asset_url: (localMetadata.file as FileReference).uri, + originalFile: localMetadata.file, + } as Attachment; + } +}; + +export const createAttachmentsCompositionMiddleware = ( + composer: MessageComposer, +): MessageCompositionMiddleware => ({ + handlers: { + compose: ({ + state, + next, + forward, + }: MiddlewareHandlerParams) => { + const { attachmentManager } = composer; + if (!attachmentManager) return forward(); + + const attachments = (state.message.attachments ?? []).concat( + attachmentManager.attachments.map(localAttachmentToAttachment), + ); + + // prevent introducing attachments array into the payload sent to the server + if (!attachments.length) return forward(); + + return next({ + ...state, + localMessage: { + ...state.localMessage, + attachments: [...attachments], + }, + message: { + ...state.message, + attachments: [...attachments], + }, + }); + }, + }, + id: 'stream-io/message-composer-middleware/attachments', +}); + +export const createDraftAttachmentsCompositionMiddleware = ( + composer: MessageComposer, +): MessageDraftCompositionMiddleware => ({ + handlers: { + compose: ({ + state, + next, + forward, + }: MiddlewareHandlerParams) => { + const { attachmentManager } = composer; + if (!attachmentManager) return forward(); + + const attachments = (state.draft.attachments ?? []).concat( + attachmentManager.attachments.map(localAttachmentToAttachment), + ); + + return next({ + ...state, + draft: { + ...state.draft, + attachments, + }, + }); + }, + }, + id: 'stream-io/message-composer-middleware/draft-attachments', +}); diff --git a/package/src/middlewares/commandControl.ts b/package/src/middlewares/commandControl.ts deleted file mode 100644 index ddea4ddbb6..0000000000 --- a/package/src/middlewares/commandControl.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - CommandSuggestion, - MessageComposer, - MessageComposerMiddlewareState, - MessageCompositionMiddleware, - MessageDraftComposerMiddlewareValueState, - MessageDraftCompositionMiddleware, - MiddlewareHandlerParams, - TextComposerMiddleware, -} from 'stream-chat'; - -export const createCommandControlMiddleware = ( - composer: MessageComposer, -): TextComposerMiddleware => { - const commands = composer.channel?.getConfig()?.commands ?? []; - const triggers = commands.map((command) => `/${command.name} `.toLowerCase()); - return { - handlers: { - onChange: ({ complete, forward, next, state }) => { - const { customDataManager } = composer; - - if (!state.text && customDataManager.customComposerData.command) { - customDataManager.setCustomData({ command: null }); - return forward(); - } - - const inputText = state.text.toLowerCase(); - - if ( - !triggers.some((t) => inputText.startsWith(t)) || - customDataManager.customComposerData.command - ) { - return next(state); - } - - // Handle the case where the text can be any command and not just giphy - const command = triggers.find((t) => inputText.startsWith(t)); - if (!command) { - return next(state); - } - const commandName = command?.slice(1, -1); - composer.customDataManager.setCustomData({ command: commandName }); - const newText = state.text.slice(command.length); - return complete({ - ...state, - selection: { - end: state.selection.end - command.length, - start: state.selection.start - command.length, - }, - suggestions: undefined, - text: newText, - }); - }, - onSuggestionItemSelect: ({ complete, forward, state }) => { - const { selectedSuggestion } = state.change ?? {}; - if (!selectedSuggestion || !commands.some((c) => c.name === selectedSuggestion.name)) { - return forward(); - } - - composer.customDataManager.setCustomData({ command: selectedSuggestion.name }); - const command = commands.find((t) => t.name === selectedSuggestion.name); - const trigger = `/${command?.name} `; - - if (!trigger) { - return forward(); - } - const newText = state.text.slice(trigger.length + 1); - return complete({ - ...state, - selection: { - end: state.selection.end - trigger.length, - start: state.selection.start - trigger.length, - }, - suggestions: undefined, - text: newText, - }); - }, - }, - id: 'stream-io/react-native-sdk/text-composer/command-control', - }; -}; - -export const createCommandInjectionMiddleware = ( - composer: MessageComposer, -): MessageCompositionMiddleware => ({ - handlers: { - compose: ({ - complete, - forward, - state, - }: MiddlewareHandlerParams) => { - const { - custom: { command }, - } = composer.customDataManager.state.getLatestValue(); - const { attachments, text } = state.localMessage; - const injection = command && `/${command}`; - if (!command || !injection || text?.startsWith(injection) || attachments?.length) { - return forward(); - } - const enrichedText = `${injection} ${text}`; - return complete({ - ...state, - localMessage: { - ...state.localMessage, - text: enrichedText, - }, - message: { - ...state.message, - text: enrichedText, - }, - }); - }, - }, - id: 'stream-io/react-native-sdk/message-composer-middleware/command-injection', -}); - -export const createDraftCommandInjectionMiddleware = ( - composer: MessageComposer, -): MessageDraftCompositionMiddleware => ({ - handlers: { - compose: ({ - forward, - state, - complete, - }: MiddlewareHandlerParams) => { - const { - custom: { command }, - } = composer.customDataManager.state.getLatestValue(); - const text = state.draft.text; - const injection = command && `/${command}`; - if (!command || !injection || text?.startsWith(injection)) { - return forward(); - } - const enrichedText = `${injection} ${text}`; - return complete({ - ...state, - draft: { - ...state.draft, - text: enrichedText, - }, - }); - }, - }, - id: 'demo-team/message-composer-middleware/draft-giphy-command-injection', -}); diff --git a/package/src/middlewares/index.ts b/package/src/middlewares/index.ts index ae542b859f..060ef459db 100644 --- a/package/src/middlewares/index.ts +++ b/package/src/middlewares/index.ts @@ -1,2 +1,2 @@ +export * from './attachments'; export * from './emojiControl'; -export * from './commandControl'; diff --git a/package/src/mock-builders/api/getOrCreateChannel.ts b/package/src/mock-builders/api/getOrCreateChannel.ts index b84c59fa67..dff2343a08 100644 --- a/package/src/mock-builders/api/getOrCreateChannel.ts +++ b/package/src/mock-builders/api/getOrCreateChannel.ts @@ -2,9 +2,12 @@ import { mockedApiResponse } from './utils'; export type GetOrCreateChannelApiParams = { + draft: Record; channel?: Record; members?: Record[]; messages?: Record[]; + pinnedMessages?: Record[]; + read?: Record[]; }; /** @@ -17,15 +20,21 @@ export type GetOrCreateChannelApiParams = { export const getOrCreateChannelApi = ( channel: GetOrCreateChannelApiParams = { channel: {}, + draft: {}, members: [], messages: [], + pinnedMessages: [], + read: [], }, ) => { const result = { channel: channel.channel, + draft: channel.draft, duration: 0.01, members: channel.members, messages: channel.messages, + pinnedMessages: channel.pinnedMessages, + read: channel.read, }; return mockedApiResponse(result, 'post'); diff --git a/package/src/mock-builders/api/initiateClientWithChannels.js b/package/src/mock-builders/api/initiateClientWithChannels.js new file mode 100644 index 0000000000..e783c012c6 --- /dev/null +++ b/package/src/mock-builders/api/initiateClientWithChannels.js @@ -0,0 +1,39 @@ +import { getOrCreateChannelApi } from './getOrCreateChannel'; +import { useMockedApis } from './useMockedApis'; + +import { generateChannel } from '../generator/channel'; +import { generateMember } from '../generator/member'; +import { generateUser } from '../generator/user'; +import { getTestClientWithUser } from '../mock'; + +const initChannelFromData = async ({ channelData, client, defaultGenerateChannelOptions }) => { + const mockedChannelData = generateChannel({ + ...defaultGenerateChannelOptions, + ...channelData, + }); + + useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); + const channel = client.channel(mockedChannelData.channel.type, mockedChannelData.channel.id); + await channel.watch(); + jest.spyOn(channel, 'getConfig').mockImplementation(() => mockedChannelData.channel.config); + // jest + // .spyOn(channel, 'getDraft') + // .mockImplementation(() => generateMessageDraft({ channel_cid: channel.cid })); + return channel; +}; + +export const initiateClientWithChannels = async ({ channelsData, customUser } = {}) => { + const user = customUser || generateUser(); + const client = await getTestClientWithUser(user); + + const defaultGenerateChannelOptions = { + members: [generateMember({ user })], + }; + const channels = await Promise.all( + (channelsData ?? [defaultGenerateChannelOptions]).map((channelData) => + initChannelFromData({ channelData, client, defaultGenerateChannelOptions }), + ), + ); + + return { channels, client }; +}; diff --git a/package/src/mock-builders/attachments.js b/package/src/mock-builders/attachments.js new file mode 100644 index 0000000000..5af36a5bcb --- /dev/null +++ b/package/src/mock-builders/attachments.js @@ -0,0 +1,39 @@ +import { generateRandomId } from '../utils/utils'; + +export const generateImageAttachment = (a) => ({ + fallback: generateRandomId() + '.png', + image_url: 'https://' + generateRandomId() + '.png', + type: 'image', + ...a, +}); + +export const generateAudioAttachment = (a) => ({ + asset_url: 'https://' + generateRandomId() + '.mp3', + fallback: generateRandomId() + '.mp3', + type: 'audio', + ...a, +}); + +export const generateFileAttachment = (a) => ({ + asset_url: 'https://' + generateRandomId() + '.xls', + fallback: generateRandomId() + '.xls', + type: 'file', + ...a, +}); + +export const generateVideoAttachment = (a) => ({ + fallback: generateRandomId() + '.mp4', + image_url: 'https://' + generateRandomId() + '.mp4', + type: 'video', + ...a, +}); + +const fileName = generateRandomId() + '.png'; + +export const generateFileReference = (a) => ({ + name: fileName, + size: 1000, + type: 'image/png', + uri: 'file://' + generateRandomId() + '.png', + ...a, +}); diff --git a/package/src/mock-builders/generator/channel.ts b/package/src/mock-builders/generator/channel.ts index 542a2f285a..8b0efad2ad 100644 --- a/package/src/mock-builders/generator/channel.ts +++ b/package/src/mock-builders/generator/channel.ts @@ -58,8 +58,7 @@ const getChannelDefaults = ( { id, type }: { [key: string]: any } = { id: uuidv4(), type: 'messaging' }, ) => ({ _client: {}, - cid: `${type}:${id}`, - data: { + channel: { cid: `${type}:${id}`, config: { ...defaultConfig, @@ -74,7 +73,9 @@ const getChannelDefaults = ( type, updated_at: '2020-04-28T11:20:48.578147Z', }, + cid: `${type}:${id}`, id, + messages: [], state: defaultState, type, }); @@ -115,7 +116,7 @@ export const generateChannelResponse = ( const defaults = getChannelDefaults(); return { channel: { - ...defaults.data, + ...defaults.channel, ...{ cid: `${type}:${id}`, ...channel, diff --git a/package/src/polyfills.ts b/package/src/polyfills.ts index 55f82860bf..c687cae293 100644 --- a/package/src/polyfills.ts +++ b/package/src/polyfills.ts @@ -1,4 +1,9 @@ +import structuredClone from '@ungap/structured-clone'; + (function () { + if (!window.structuredClone) { + window.structuredClone = structuredClone; + } if (!Array.prototype.at) { // eslint-disable-next-line no-extend-native Object.defineProperty(Array.prototype, 'at', { diff --git a/package/src/store/apis/upsertMessages.ts b/package/src/store/apis/upsertMessages.ts index 84a13e22aa..6e7d68b396 100644 --- a/package/src/store/apis/upsertMessages.ts +++ b/package/src/store/apis/upsertMessages.ts @@ -1,4 +1,4 @@ -import type { MessageResponse } from 'stream-chat'; +import type { LocalMessage, MessageResponse } from 'stream-chat'; import { mapMessageToStorable } from '../mappers/mapMessageToStorable'; import { mapPollToStorable } from '../mappers/mapPollToStorable'; @@ -11,7 +11,7 @@ export const upsertMessages = async ({ execute = true, messages, }: { - messages: MessageResponse[]; + messages: (MessageResponse | LocalMessage)[]; execute?: boolean; }) => { const storableMessages: Array> = []; @@ -19,7 +19,7 @@ export const upsertMessages = async ({ const storableReactions: Array> = []; const storablePolls: Array> = []; - messages?.forEach((message: MessageResponse) => { + messages?.forEach((message: MessageResponse | LocalMessage) => { storableMessages.push(mapMessageToStorable(message)); if (message.user) { storableUsers.push(mapUserToStorable(message.user)); diff --git a/package/src/types/stream-chat-common-custom-data.d.ts b/package/src/types/stream-chat-common-custom-data.d.ts index a99efd7fcd..7ce5447fed 100644 --- a/package/src/types/stream-chat-common-custom-data.d.ts +++ b/package/src/types/stream-chat-common-custom-data.d.ts @@ -1,4 +1,5 @@ import 'stream-chat'; + import { DefaultAttachmentData, DefaultChannelData, @@ -38,9 +39,7 @@ declare module 'stream-chat' { interface CustomThreadData extends DefaultThreadData {} - interface CustomMessageComposerData { - command: string | null; - } + interface CustomMessageComposerData {} /* eslint-enable @typescript-eslint/no-empty-object-type */ } diff --git a/package/src/types/types.ts b/package/src/types/types.ts index c7f6724eaf..68a5b8a4ec 100644 --- a/package/src/types/types.ts +++ b/package/src/types/types.ts @@ -1,6 +1,12 @@ -import type { ChannelFilters, ChannelSort, ChannelState, FileReference } from 'stream-chat'; - -import type { FileStateValue } from '../utils/utils'; +import type { + ChannelFilters, + ChannelSort, + ChannelState, + FileReference, + LocalAudioAttachment, + LocalUploadAttachment, + LocalVoiceRecordingAttachment, +} from 'stream-chat'; export enum FileTypes { Audio = 'audio', @@ -14,41 +20,29 @@ export enum FileTypes { export type File = FileReference; -/** - * This is nothing but a substitute for the attachment type prior to sending the message. - * This will change if we unify the file uploads to attachments. - */ -export type FileUpload = { - file: File; - id: string; - state: FileStateValue; - - mime_type?: string; - - type?: FileTypes; - url?: string; - - thumb_url?: string; +export type LocalAudioAttachmentType> = + | LocalAudioAttachment + | LocalVoiceRecordingAttachment; +export type AudioConfig = { duration?: number; - waveform_data?: number[]; - - height?: number; - width?: number; -}; - -export type AudioUpload = FileUpload & { progress?: number; paused?: boolean; }; +export type AudioUpload> = + LocalAudioAttachmentType & AudioConfig; + +export type UploadAttachmentPreviewProps = { + attachment: A; + handleRetry: ( + attachment: LocalUploadAttachment, + ) => void | Promise; + removeAttachments: (ids: string[]) => void; +}; + export interface DefaultAttachmentData { - duration?: number; - file_size?: number; - mime_type?: string; originalFile?: File; - originalImage?: File; - waveform_data?: number[]; } export interface DefaultUserData { diff --git a/package/src/utils/getTrimmedAttachmentTitle.ts b/package/src/utils/getTrimmedAttachmentTitle.ts index d7a9315734..c5a94ed84e 100644 --- a/package/src/utils/getTrimmedAttachmentTitle.ts +++ b/package/src/utils/getTrimmedAttachmentTitle.ts @@ -1,16 +1,17 @@ -import { lookup } from 'mime-types'; +// Add dots in between the title if it is too long and then append the remaining last characters. +// Eg: "This is a very long title" => "This is a very long ti...le" +export const getTrimmedAttachmentTitle = (title?: string, maxLength?: number) => { + const maxLengthValue = maxLength || 18; + if (!title) return ''; -export const getTrimmedAttachmentTitle = (title?: string) => { - if (!title) { - return ''; - } + const ellipsis = '...'; - const mimeType = lookup(title); - if (mimeType) { - const lastIndexOfDot = title.lastIndexOf('.'); - return title.length < 12 ? title : title.slice(0, 12) + '...' + title.slice(lastIndexOfDot); - } else { - // shorten title - return title.length < 20 ? title : title.slice(0, 20) + '...'; + if (title.length <= maxLengthValue) { + return title; } + + const start = title.slice(0, maxLengthValue / 2); + const end = title.slice(title.length - maxLengthValue / 2); + + return `${start}${ellipsis}${end}`; }; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 6551ab3275..928893f9b6 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -2,11 +2,16 @@ import type React from 'react'; import dayjs from 'dayjs'; import EmojiRegex from 'emoji-regex'; -import type { ChannelState, LocalMessage, MessageResponse } from 'stream-chat'; +import type { + AttachmentLoadingState, + ChannelState, + LocalMessage, + MessageResponse, +} from 'stream-chat'; import { IconProps } from '../../src/icons/utils/base'; import type { TableRowJoinedUser } from '../store/types'; -import { FileTypes, ValueOf } from '../types/types'; +import { ValueOf } from '../types/types'; export type ReactionData = { Icon: React.ComponentType; @@ -14,13 +19,10 @@ export type ReactionData = { }; export const FileState = Object.freeze({ - // finished and uploaded state are the same thing. First is set on frontend, - // while later is set on backend side - // TODO: Unify both of them + BLOCKED: 'blocked', + FAILED: 'failed', FINISHED: 'finished', - NOT_SUPPORTED: 'not_supported', - UPLOAD_FAILED: 'upload_failed', - UPLOADED: 'uploaded', + PENDING: 'pending', UPLOADING: 'uploading', }); @@ -28,11 +30,13 @@ export const ProgressIndicatorTypes: { IN_PROGRESS: 'in_progress'; INACTIVE: 'inactive'; NOT_SUPPORTED: 'not_supported'; + PENDING: 'pending'; RETRY: 'retry'; } = Object.freeze({ IN_PROGRESS: 'in_progress', INACTIVE: 'inactive', NOT_SUPPORTED: 'not_supported', + PENDING: 'pending', RETRY: 'retry', }); @@ -42,25 +46,22 @@ export const MessageStatusTypes = { SENDING: 'sending', }; -export type FileStateValue = (typeof FileState)[keyof typeof FileState]; - -type Progress = ValueOf; -type IndicatorStatesMap = Record, Progress | null>; +export type Progress = ValueOf; +type IndicatorStatesMap = Record; export const getIndicatorTypeForFileState = ( - fileState: FileStateValue, + fileState: AttachmentLoadingState, enableOfflineSupport: boolean, -): Progress | null => { +): Progress | undefined => { const indicatorMap: IndicatorStatesMap = { [FileState.UPLOADING]: enableOfflineSupport ? ProgressIndicatorTypes.INACTIVE : ProgressIndicatorTypes.IN_PROGRESS, - // If offline support is disabled, then there is no need - [FileState.UPLOAD_FAILED]: enableOfflineSupport + [FileState.BLOCKED]: ProgressIndicatorTypes.NOT_SUPPORTED, + [FileState.FAILED]: enableOfflineSupport ? ProgressIndicatorTypes.INACTIVE : ProgressIndicatorTypes.RETRY, - [FileState.NOT_SUPPORTED]: ProgressIndicatorTypes.NOT_SUPPORTED, - [FileState.UPLOADED]: ProgressIndicatorTypes.INACTIVE, + [FileState.PENDING]: ProgressIndicatorTypes.PENDING, [FileState.FINISHED]: ProgressIndicatorTypes.INACTIVE, }; @@ -111,7 +112,7 @@ export const getUrlWithoutParams = (url?: string) => { return url.substring(0, url.indexOf('?')); }; -export const isLocalUrl = (url: string) => url.indexOf('http') !== 0; +export const isLocalUrl = (url: string) => !url.includes('http'); export const generateRandomId = (a = ''): string => a @@ -142,8 +143,15 @@ export const hasOnlyEmojis = (text: string) => { * @param {LocalMessage} message - the message object to be stringified * @returns {string} The stringified message */ -export const stringifyMessage = (message: MessageResponse | LocalMessage): string => { +export const stringifyMessage = ({ + message, + includeReactions = true, +}: { + message: MessageResponse | LocalMessage; + includeReactions?: boolean; +}): string => { const { + attachments, deleted_at, i18n, latest_reactions, @@ -154,6 +162,10 @@ export const stringifyMessage = (message: MessageResponse | LocalMessage): strin type, updated_at, } = message; + const baseFieldsString = `${type}${deleted_at}${text}${reply_count}${status}${updated_at}${JSON.stringify(i18n)}${attachments?.length}`; + if (!includeReactions) { + return baseFieldsString; + } return `${ latest_reactions ? latest_reactions.map(({ type, user }) => `${type}${user?.id}`).join() : '' }${ @@ -165,7 +177,7 @@ export const stringifyMessage = (message: MessageResponse | LocalMessage): strin ) .join() : '' - }${type}${deleted_at}${text}${reply_count}${status}${updated_at}${JSON.stringify(i18n)}`; + }${baseFieldsString}`; }; /** @@ -174,7 +186,13 @@ export const stringifyMessage = (message: MessageResponse | LocalMessage): strin * @returns {string} The mapped message string */ export const reduceMessagesToString = (messages: LocalMessage[]): string => - messages.map(stringifyMessage).join(); + messages + .map((message) => + message?.quoted_message + ? `${stringifyMessage({ message })}_${message.quoted_message.type}_${message.quoted_message.deleted_at}_${message.quoted_message.text}_${message.quoted_message.updated_at}` + : stringifyMessage({ message }), + ) + .join(); /** * Utility to get the file name from the path using regex. @@ -190,18 +208,6 @@ export const getFileNameFromPath = (path: string) => { return match ? match[0] : ''; }; -export const getFileTypeFromMimeType = (mimeType: string) => { - const fileType = mimeType.split('/')[0]; - if (fileType === 'image') { - return FileTypes.Image; - } else if (fileType === 'video') { - return FileTypes.Video; - } else if (fileType === 'audio') { - return FileTypes.Audio; - } - return FileTypes.File; -}; - /** * Utility to get the duration label from the duration in seconds. * @param duration number @@ -286,3 +292,63 @@ export const findInMessagesByDate = ( return { index: -1 }; }; + +/** + * The purpose of this function is to compare two messages and determine if they are equal. + * It checks various properties of the messages, such as status, type, text, pinned state, updated_at timestamp, i18n data, and reply count. + * If all these properties match, it returns true, indicating that the messages are considered equal. + * If any of the properties differ, it returns false, indicating that the messages are not equal. + * Useful for the `areEqual` logic in the React.memo of the Message component/sub-components. + */ +export const checkMessageEquality = ( + prevMessage?: LocalMessage, + nextMessage?: LocalMessage, +): boolean => { + const prevMessageExists = !!prevMessage; + const nextMessageExists = !!nextMessage; + if (!prevMessageExists && !nextMessageExists) { + return true; + } + if (prevMessageExists !== nextMessageExists) { + return false; + } + const messageEqual = + prevMessage?.status === nextMessage?.status && + prevMessage?.type === nextMessage?.type && + prevMessage?.text === nextMessage?.text && + prevMessage?.pinned === nextMessage?.pinned && + prevMessage?.i18n === nextMessage?.i18n && + prevMessage?.reply_count === nextMessage?.reply_count && + prevMessage?.updated_at?.getTime?.() === nextMessage?.updated_at?.getTime?.() && + prevMessage?.deleted_at?.getTime?.() === nextMessage?.deleted_at?.getTime?.(); + + return messageEqual; +}; + +/** + * The purpose of this function is to compare two quoted messages and determine if they are equal. + * It checks various properties of the messages, such as status, type, text, updated_at timestamp, and deleted_at. + * If all these properties match, it returns true, indicating that the messages are considered equal. + * If any of the properties differ, it returns false, indicating that the messages are not equal. + * Useful for the `areEqual` logic in the React.memo of the Message component/sub-components. + */ +export const checkQuotedMessageEquality = ( + prevQuotedMessage?: LocalMessage, + nextQuotedMessage?: LocalMessage, +): boolean => { + const prevQuotedMessageExists = !!prevQuotedMessage; + const nextQuotedMessageExists = !!nextQuotedMessage; + if (!prevQuotedMessageExists && !nextQuotedMessageExists) { + return true; + } + if (prevQuotedMessageExists !== nextQuotedMessageExists) { + return false; + } + const quotedMessageEqual = + prevQuotedMessage?.type === nextQuotedMessage?.type && + prevQuotedMessage?.text === nextQuotedMessage?.text && + prevQuotedMessage?.updated_at?.getTime?.() === nextQuotedMessage?.updated_at?.getTime?.() && + prevQuotedMessage?.deleted_at?.getTime?.() === nextQuotedMessage?.deleted_at?.getTime?.(); + + return quotedMessageEqual; +}; diff --git a/package/yarn.lock b/package/yarn.lock index a8319251dd..5683b3eeee 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -2078,6 +2078,11 @@ resolved "https://registry.yarnpkg.com/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz#51b1c00b516a5774ada5d611e65eb123f988ef8d" integrity sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA== +"@types/ungap__structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/ungap__structured-clone/-/ungap__structured-clone-1.2.0.tgz#12b9fd4ab3e6a82292d60048492b05eb75b4a48f" + integrity sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA== + "@types/unist@^2", "@types/unist@^2.0.2": version "2.0.11" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" @@ -2322,6 +2327,11 @@ "@typescript-eslint/types" "8.29.0" eslint-visitor-keys "^4.2.0" +"@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== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -7817,10 +7827,9 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat@^9.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@getstream/stream-chat-js#handle-command-injection: + version "0.0.0-development" + resolved "https://codeload.github.com/getstream/stream-chat-js/tar.gz/780c52cfc3cd7379273a9b8db34461fb935f568d" dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"