From bb4012475221cbc0cd21602d63405ff79f9e1280 Mon Sep 17 00:00:00 2001 From: Stream SDK Bot <60655709+Stream-SDK-Bot@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:41:42 +0100 Subject: [PATCH 01/13] chore: update sdk size (#3336) This PR was created automatically by CI. Co-authored-by: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Co-authored-by: Stream Bot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a93c62eca..973ffc78b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![NPM](https://img.shields.io/npm/v/stream-chat-react-native.svg)](https://www.npmjs.com/package/stream-chat-react-native) [![Build Status](https://github.com/GetStream/stream-chat-react-native/actions/workflows/release.yml/badge.svg)](https://github.com/GetStream/stream-chat-react-native/actions) [![Component Reference](https://img.shields.io/badge/docs-component%20reference-blue.svg)](https://getstream.io/chat/docs/sdk/reactnative) -![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-302%20KB-blue) +![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-304%20KB-blue) From 40539513af51033200930bd95fc8b53bdb9c167d Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 18 Dec 2025 18:32:37 +0530 Subject: [PATCH 02/13] feat!: remove deprecated APIs and props for the central audio player change (#3319) This PR removes the deprecate API after the centralized audio change: - useAudioPlayerControl API changes to useAudioControl api - useAudioController is changed to useAudioRecorder hook as it just handles the audio recording part henceforth. - Removed the props with @deprecated label --- .../components/Attachment/AudioAttachment.tsx | 21 +- .../Attachment/FileAttachmentGroup.tsx | 87 +--- .../components/ImageGalleryVideoControl.tsx | 1 - .../Message/MessageSimple/MessageContent.tsx | 5 +- .../AttachmentUploadPreviewList.tsx | 23 -- .../components/MessageInput/MessageInput.tsx | 12 +- .../__tests__/MessageInput.test.js | 2 - .../AudioAttachmentUploadPreview.tsx | 36 +- .../AudioRecorder/AudioRecordingPreview.tsx | 32 +- .../MessageInput/hooks/useAudioController.tsx | 390 ------------------ .../hooks/useAudioPreviewManager.tsx | 121 ------ .../MessageInput/hooks/useAudioRecorder.tsx | 205 +++++++++ .../ProgressControl/ProgressControl.tsx | 15 - .../ProgressControl/WaveProgressBar.tsx | 5 - package/src/components/index.ts | 2 +- package/src/hooks/useAudioPlayer.ts | 133 +++--- 16 files changed, 281 insertions(+), 809 deletions(-) delete mode 100644 package/src/components/MessageInput/hooks/useAudioController.tsx delete mode 100644 package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx create mode 100644 package/src/components/MessageInput/hooks/useAudioRecorder.tsx diff --git a/package/src/components/Attachment/AudioAttachment.tsx b/package/src/components/Attachment/AudioAttachment.tsx index 016c78705..cd3f2670c 100644 --- a/package/src/components/Attachment/AudioAttachment.tsx +++ b/package/src/components/Attachment/AudioAttachment.tsx @@ -13,7 +13,7 @@ import { import { useTheme } from '../../contexts'; import { useStateStore } from '../../hooks'; -import { useAudioPlayerControl } from '../../hooks/useAudioPlayerControl'; +import { useAudioPlayer } from '../../hooks/useAudioPlayer'; import { Audio, Pause, Play } from '../../icons'; import { NativeHandlers, @@ -56,21 +56,6 @@ export type AudioAttachmentProps = { * If true, the audio attachment is in preview mode in the message input. */ isPreview?: boolean; - /** - * Callback to be called when the audio is loaded - * @deprecated This is deprecated and will be removed in the future. - */ - onLoad?: (index: string, duration: number) => void; - /** - * Callback to be called when the audio is played or paused - * @deprecated This is deprecated and will be removed in the future. - */ - onPlayPause?: (index: string, pausedStatus?: boolean) => void; - /** - * Callback to be called when the audio progresses - * @deprecated This is deprecated and will be removed in the future. - */ - onProgress?: (index: string, progress: number) => void; }; const audioPlayerSelector = (state: AudioPlayerState) => ({ @@ -99,7 +84,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { } = props; const isVoiceRecording = isVoiceRecordingAttachment(item); - const audioPlayer = useAudioPlayerControl({ + const audioPlayer = useAudioPlayer({ duration: item.duration ?? 0, mimeType: item.mime_type ?? '', requester: isPreview @@ -269,10 +254,8 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { /> ) : ( & Pick & { - /** - * @deprecated Use message instead - * The unique id for the message with file attachments - */ - messageId: string; styles?: Partial<{ attachmentContainer: StyleProp; container: StyleProp; @@ -49,68 +44,6 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte ); }, [files]); - /** - * Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here and the duration is set. - * @param index - The index of the audio - * @param duration - The duration of the audio - * - * @deprecated This is deprecated and will be removed in the future. - * FIXME: Remove this in the next major version. - */ - const onLoad = (index: string, duration: number) => { - setFilesToDisplay((prevFilesToDisplay) => - prevFilesToDisplay.map((fileToDisplay, id) => ({ - ...fileToDisplay, - duration: id.toString() === index ? duration : fileToDisplay.duration, - })), - ); - }; - - /** - * Handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The progressed duration is set here. - * @param index - The index of the audio - * @param progress - The progress of the audio - * - * @deprecated This is deprecated and will be removed in the future. - * FIXME: Remove this in the next major version. - */ - const onProgress = (index: string, progress: number) => { - setFilesToDisplay((prevFilesToDisplay) => - prevFilesToDisplay.map((filesToDisplay, id) => ({ - ...filesToDisplay, - progress: id.toString() === index ? progress : filesToDisplay.progress, - })), - ); - }; - - /** - * Handler which controls or sets the paused/played state of the audio. - * @param index - The index of the audio - * @param pausedStatus - The paused status of the audio - * - * @deprecated This is deprecated and will be removed in the future. - * FIXME: Remove this in the next major version. - */ - const onPlayPause = (index: string, pausedStatus?: boolean) => { - if (pausedStatus === false) { - // If the status is false we set the audio with the index as playing and the others as paused. - setFilesToDisplay((prevFilesToDisplay) => - prevFilesToDisplay.map((fileToDisplay, id) => ({ - ...fileToDisplay, - paused: id.toString() !== index, - })), - ); - } else { - // If the status is true we simply set all the audio's paused state as true. - setFilesToDisplay((prevFilesToDisplay) => - prevFilesToDisplay.map((fileToDisplay) => ({ - ...fileToDisplay, - paused: true, - })), - ); - } - }; - const { theme: { messageSimple: { @@ -135,9 +68,6 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte ) : ( @@ -153,8 +83,13 @@ const areEqual = ( prevProps: FileAttachmentGroupPropsWithContext, nextProps: FileAttachmentGroupPropsWithContext, ) => { - const { files: prevFiles } = prevProps; - const { files: nextFiles } = nextProps; + const { files: prevFiles, message: prevMessage } = prevProps; + const { files: nextFiles, message: nextMessage } = nextProps; + + const messageEqual = prevMessage?.id === nextMessage?.id; + if (!messageEqual) { + return false; + } return prevFiles.length === nextFiles.length; }; @@ -164,13 +99,10 @@ const MemoizedFileAttachmentGroup = React.memo( areEqual, ) as typeof FileAttachmentGroupWithContext; -export type FileAttachmentGroupProps = Partial< - Omit -> & - Pick; +export type FileAttachmentGroupProps = Partial; export const FileAttachmentGroup = (props: FileAttachmentGroupProps) => { - const { files: propFiles, messageId } = props; + const { files: propFiles } = props; const { files: contextFiles, message } = useMessageContext(); @@ -189,7 +121,6 @@ export const FileAttachmentGroup = (props: FileAttachmentGroupProps) => { AudioAttachment, files, message, - messageId, }} /> ); diff --git a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx index 18ff62f08..a73ddf45a 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx @@ -94,7 +94,6 @@ export const ImageGalleryVideoControl = React.memo( { )); case 'files': return ( - + ); case 'gallery': return ; diff --git a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx index ed225bbab..d14f51a2b 100644 --- a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx @@ -11,8 +11,6 @@ import { LocalImageAttachment, } from 'stream-chat'; -import { useAudioPreviewManager } from './hooks/useAudioPreviewManager'; - import { useMessageComposer } from '../../contexts'; import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; import { @@ -64,15 +62,6 @@ const UnMemoizedAttachmentUploadListPreview = ( const fileUploads = useMemo(() => { return attachments.filter((attachment) => !isLocalImageAttachment(attachment)); }, [attachments]); - const audioUploads = useMemo(() => { - return fileUploads.filter( - (attachment) => - isLocalAudioAttachment(attachment) || isLocalVoiceRecordingAttachment(attachment), - ); - }, [fileUploads]); - - const { audioAttachmentsStateMap, onLoad, onProgress, onPlayPause } = - useAudioPreviewManager(audioUploads); const renderImageItem = useCallback( ({ item }: { item: LocalImageAttachment }) => { @@ -100,11 +89,7 @@ const UnMemoizedAttachmentUploadListPreview = ( return ( ); @@ -113,11 +98,7 @@ const UnMemoizedAttachmentUploadListPreview = ( return ( ); @@ -157,11 +138,7 @@ const UnMemoizedAttachmentUploadListPreview = ( VideoAttachmentUploadPreview, attachmentManager.removeAttachments, attachmentManager.uploadAttachment, - audioAttachmentsStateMap, flatListWidth, - onLoad, - onPlayPause, - onProgress, ], ); diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 7ced90ea0..37e594f47 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -18,7 +18,7 @@ import Animated, { import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; -import { useAudioController } from './hooks/useAudioController'; +import { useAudioRecorder } from './hooks/useAudioRecorder'; import { useCountdown } from './hooks/useCountdown'; import { ChatContextValue, useChatContext, useOwnCapabilitiesContext } from '../../contexts'; @@ -311,11 +311,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { deleteVoiceRecording, micLocked, - onVoicePlayerPlayPause, - paused, permissionsGranted, - position, - progress, recording, recordingDuration, recordingStatus, @@ -324,7 +320,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { stopVoiceRecording, uploadVoiceRecording, waveformData, - } = useAudioController(); + } = useAudioRecorder(); const asyncAudioEnabled = audioRecordingEnabled && isAudioRecorderAvailable(); const showSendingButton = hasText || attachments.length || command; @@ -449,10 +445,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { /> {recordingStatus === 'stopped' ? ( { await waitFor(() => { expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1); - // once when starting the recording, once on unmount - expect(NativeHandlers.Audio.stopPlayer).toHaveBeenCalledTimes(2); }); }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index d1de2afb5..3b094f121 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -11,44 +11,18 @@ import { DismissAttachmentUpload } from './DismissAttachmentUpload'; import { AudioAttachment } from '../../../../components/Attachment/AudioAttachment'; import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { AudioConfig, UploadAttachmentPreviewProps } from '../../../../types/types'; +import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; export type AudioAttachmentUploadPreviewProps> = UploadAttachmentPreviewProps< LocalAudioAttachment | LocalVoiceRecordingAttachment - > & { - /** - * The audio attachment config - * - * @deprecated This is deprecated and will be removed in the future. - */ - audioAttachmentConfig: AudioConfig; - /** - * Callback to be called when the audio is loaded - * @deprecated This is deprecated and will be removed in the future. - */ - onLoad: (index: string, duration: number) => void; - /** - * Callback to be called when the audio is played or paused - * @deprecated This is deprecated and will be removed in the future. - */ - onPlayPause: (index: string, pausedStatus?: boolean) => void; - /** - * Callback to be called when the audio progresses - * @deprecated This is deprecated and will be removed in the future. - */ - onProgress: (index: string, progress: number) => void; - }; + >; export const AudioAttachmentUploadPreview = ({ attachment, - audioAttachmentConfig, handleRetry, removeAttachments, - onLoad, - onPlayPause, - onProgress, }: AudioAttachmentUploadPreviewProps) => { const { enableOfflineSupport } = useChatContext(); const indicatorType = getIndicatorTypeForFileState( @@ -69,9 +43,8 @@ export const AudioAttachmentUploadPreview = ({ ...attachment, asset_url: assetUrl, id: attachment.localMetadata.id, - ...audioAttachmentConfig, }), - [attachment, assetUrl, audioAttachmentConfig], + [attachment, assetUrl], ); const onRetryHandler = useCallback(() => { @@ -93,9 +66,6 @@ export const AudioAttachmentUploadPreview = ({ hideProgressBar={true} isPreview={true} item={finalAttachment} - onLoad={onLoad} - onPlayPause={onPlayPause} - onProgress={onProgress} showSpeedSettings={false} titleMaxLength={12} /> diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx index 1e5f37820..4bea5b394 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx @@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; -import { useAudioPlayerControl } from '../../../../hooks/useAudioPlayerControl'; +import { useAudioPlayer } from '../../../../hooks/useAudioPlayer'; import { useStateStore } from '../../../../hooks/useStateStore'; import { Pause, Play } from '../../../../icons'; @@ -22,34 +22,6 @@ export type AudioRecordingPreviewProps = { * The waveform data to be presented to show the audio levels. */ waveformData: number[]; - /** - * Boolean used to show the paused state of the player. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * FIXME: Remove this in the next major version. - */ - paused: boolean; - /** - * Number used to show the current position of the audio being played. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * FIXME: Remove this in the next major version. - */ - position: number; - /** - * Number used to show the percentage of progress of the audio being played. It should be in 0-1 range. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * FIXME: Remove this in the next major version. - */ - progress: number; - /** - * Function to play or pause the audio player. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * FIXME: Remove this in the next major version. - */ - onVoicePlayerPlayPause?: () => Promise; }; const audioPlayerSelector = (state: AudioPlayerState) => ({ @@ -65,7 +37,7 @@ const audioPlayerSelector = (state: AudioPlayerState) => ({ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { const { recordingDuration, uri, waveformData } = props; - const audioPlayer = useAudioPlayerControl({ + const audioPlayer = useAudioPlayer({ duration: recordingDuration / ONE_SECOND_IN_MILLISECONDS, mimeType: 'audio/aac', // This is a temporary flag to manage audio player for voice recording in preview as the one in message list uses react-native-video. diff --git a/package/src/components/MessageInput/hooks/useAudioController.tsx b/package/src/components/MessageInput/hooks/useAudioController.tsx deleted file mode 100644 index f845176ce..000000000 --- a/package/src/components/MessageInput/hooks/useAudioController.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import { Alert, Platform } from 'react-native'; - -import { LocalVoiceRecordingAttachment } from 'stream-chat'; - -import { useActiveAudioPlayer } from '../../../contexts/audioPlayerContext/AudioPlayerContext'; -import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; - -import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; -import { - AudioRecordingReturnType, - NativeHandlers, - PlaybackStatus, - RecordingStatus, - SoundReturnType, -} 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'; - -export type RecordingStatusStates = 'idle' | 'recording' | 'stopped'; - -/** - * The hook that controls all the async audio core features including start/stop or recording, player, upload/delete of the recorded audio. - * - * FIXME: Change the name to `useAudioRecorder` in the next major version as the hook will only be used for audio recording. - */ -export const useAudioController = () => { - const [micLocked, setMicLocked] = useState(false); - const [permissionsGranted, setPermissionsGranted] = useState(true); - /** - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. - * FIXME: Remove this in the next major version. - */ - const [paused, setPaused] = useState(true); - /** - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. - * FIXME: Remove this in the next major version. - */ - const [position, setPosition] = useState(0); - /** - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. - * FIXME: Remove this in the next major version. - */ - const [progress, setProgress] = useState(0); - const [waveformData, setWaveformData] = useState([]); - const [isScheduledForSubmit, setIsScheduleForSubmit] = useState(false); - const [recording, setRecording] = useState(undefined); - const [recordingDuration, setRecordingDuration] = useState(0); - const [recordingStatus, setRecordingStatus] = useState('idle'); - const { attachmentManager } = useMessageComposer(); - const activeAudioPlayer = useActiveAudioPlayer(); - - const { sendMessage } = useMessageInputContext(); - - /** - * Reference to the sound object for playback support in Expo CLI apps - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. - * - * FIXME: Remove this in the next major version. - */ - const soundRef = useRef(null); - - // This effect stop the player from playing and stops audio recording on - // the audio SDK side on unmount. - useEffect( - () => () => { - stopVoicePlayer(); - stopSDKVoiceRecording(); - }, - [], - ); - - useEffect(() => { - if (isScheduledForSubmit) { - sendMessage(); - setIsScheduleForSubmit(false); - } - }, [isScheduledForSubmit, sendMessage]); - - /** - * Function to update the progress of the voice recording. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * FIXME: Remove this in the next major version. - */ - const onVoicePlayerProgressHandler = (currentPosition: number, playbackDuration: number) => { - const currentProgress = currentPosition / playbackDuration; - if (currentProgress === 1) { - setPaused(true); - setProgress(0); - } else { - setProgress(currentProgress); - } - }; - - /** - * Function to update the playback status of the voice recording. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. - * - * FIXME: Remove this in the next major version. - */ - const onVoicePlayerPlaybackStatusUpdate = (status: PlaybackStatus) => { - if (status.shouldPlay === undefined || status.shouldPlay === true) { - setPosition(status?.currentPosition || status?.positionMillis); - setRecordingDuration(status.duration || status.durationMillis); - - if (status.didJustFinish) { - onVoicePlayerProgressHandler(status.durationMillis, status.durationMillis); - } else { - // For Native CLI - if (status.currentPosition && status.duration) { - onVoicePlayerProgressHandler(status.currentPosition, status.duration); - } - // For Expo CLI - else if (status.positionMillis && status.durationMillis) { - onVoicePlayerProgressHandler(status.positionMillis, status.durationMillis); - } - } - } - }; - - /** - * Function to play or pause voice recording. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. - * - * FIXME: Remove this in the next major version. - */ - const onVoicePlayerPlayPause = async () => { - if (paused) { - if (progress === 0) { - await startVoicePlayer(); - } else { - // For Native CLI - if (NativeHandlers.Audio?.resumePlayer) { - await NativeHandlers.Audio.resumePlayer(); - } - // For Expo CLI - if (soundRef.current?.playAsync) { - await soundRef.current.playAsync(); - } - } - } else { - // For Native CLI - if (NativeHandlers.Audio?.pausePlayer) { - await NativeHandlers.Audio.pausePlayer(); - } - // For Expo CLI - if (soundRef.current?.pauseAsync) { - await soundRef.current.pauseAsync(); - } - } - setPaused(!paused); - }; - - /** - * Function to start playing voice recording to preview it after recording. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. - * - * FIXME: Remove this in the next major version. - */ - const startVoicePlayer = async () => { - if (!recording) { - return; - } - // For Native CLI - if (NativeHandlers.Audio?.startPlayer) { - await NativeHandlers.Audio.startPlayer(recording, {}, onVoicePlayerPlaybackStatusUpdate); - } - // For Expo CLI - if (recording && typeof recording !== 'string') { - const uri = recording.getURI(); - if (uri && NativeHandlers.Sound?.initializeSound) { - if (soundRef.current?.replayAsync) { - await soundRef.current.replayAsync({}); - } else { - soundRef.current = await NativeHandlers.Sound.initializeSound( - { uri }, - { progressUpdateIntervalMillis: Platform.OS === 'android' ? 100 : 60 }, - onVoicePlayerPlaybackStatusUpdate, - ); - if (soundRef.current?.playAsync) { - await soundRef.current.playAsync(); - } - } - } - } - }; - - /** - * Function to stop playing voice recording. - * - * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. - * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. - * - * FIXME: Remove this in the next major version. - */ - const stopVoicePlayer = async () => { - // For Native CLI - if (NativeHandlers.Audio?.stopPlayer) { - await NativeHandlers.Audio.stopPlayer(); - } - // For Expo CLI - if (soundRef.current?.stopAsync && soundRef.current?.unloadAsync) { - await soundRef.current?.stopAsync(); - await soundRef.current?.unloadAsync(); - soundRef.current = null; - } - }; - - const onRecordingStatusUpdate = (status: RecordingStatus) => { - if (status.isDoneRecording === true) { - return; - } - setRecordingDuration(status?.currentPosition || status.durationMillis); - // For expo android the lower bound is -120 so we need to normalize according to it. The `status.currentMetering` is undefined for Expo CLI apps, so we can use it. - const lowerBound = Platform.OS === 'ios' || status.currentMetering ? -60 : -120; - const normalizedAudioLevel = normalizeAudioLevel( - status.currentMetering || status.metering, - lowerBound, - ); - setWaveformData((prev) => [...prev, normalizedAudioLevel]); - }; - - /** - * Function to start voice recording. - */ - const startVoiceRecording = async () => { - if (!NativeHandlers.Audio) { - return; - } - const recordingInfo = await NativeHandlers.Audio.startRecording( - { - isMeteringEnabled: true, - }, - onRecordingStatusUpdate, - ); - const accessGranted = recordingInfo.accessGranted; - if (accessGranted) { - setPermissionsGranted(true); - const recording = recordingInfo.recording; - if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) { - recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60); - } - setRecording(recording); - setRecordingStatus('recording'); - if (activeAudioPlayer?.isPlaying) { - await activeAudioPlayer?.pause(); - } - await stopVoicePlayer(); - } else { - setPermissionsGranted(false); - resetState(); - Alert.alert('Please allow Audio permissions in settings.'); - } - }; - - /** - * A function that takes care of stopping the voice recording from the library's - * side only. Meant to be used as a pure function (during unmounting for instance) - * hence this approach. - */ - const stopSDKVoiceRecording = async () => { - if (!NativeHandlers.Audio) { - return; - } - await NativeHandlers.Audio.stopRecording(); - }; - - /** - * Function to stop voice recording. - */ - const stopVoiceRecording = async () => { - await stopSDKVoiceRecording(); - setRecordingStatus('stopped'); - }; - - /** - * Function to reset the state of the message input for async voice messages. - */ - const resetState = () => { - setRecording(undefined); - setRecordingStatus('idle'); - setMicLocked(false); - setWaveformData([]); - setPaused(true); - setPosition(0); - setProgress(0); - }; - - /** - * Function to delete voice recording. - */ - const deleteVoiceRecording = async () => { - if (recordingStatus === 'recording') { - await stopVoiceRecording(); - } - await stopVoicePlayer(); - resetState(); - NativeHandlers.triggerHaptic('impactMedium'); - }; - - /** - * Function to upload or send voice recording. - * @param multiSendEnabled boolean - */ - const uploadVoiceRecording = async (multiSendEnabled: boolean) => { - if (!paused) { - await stopVoicePlayer(); - } - if (recordingStatus === 'recording') { - await stopVoiceRecording(); - } - - const durationInSeconds = parseFloat((recordingDuration / 1000).toFixed(3)); - - const resampledWaveformData = resampleWaveformData(waveformData, 100); - - const clearFilter = new RegExp('[.:]', 'g'); - const date = new Date().toISOString().replace(clearFilter, '_'); - - const file: File = { - duration: durationInSeconds, - name: `audio_recording_${date}.aac`, - size: 0, - type: 'audio/aac', - uri: typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string), - 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 attachmentManager.uploadAttachment(audioFile); - } else { - // FIXME: cannot call handleSubmit() directly as the function has stale reference to file uploads - await attachmentManager.uploadAttachment(audioFile); - setIsScheduleForSubmit(true); - } - resetState(); - }; - - return { - deleteVoiceRecording, - micLocked, - onVoicePlayerPlayPause, - paused, - permissionsGranted, - position, - progress, - recording, - recordingDuration, - recordingStatus, - setMicLocked, - startVoicePlayer, - startVoiceRecording, - stopVoicePlayer, - stopVoiceRecording, - uploadVoiceRecording, - waveformData, - }; -}; diff --git a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx deleted file mode 100644 index 31af18f47..000000000 --- a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -import { LocalAttachment } from 'stream-chat'; - -import { AudioConfig } from '../../../types/types'; - -/** - * Manages the state of audio attachments for preview and playback. - * @param files The audio files to manage. - * @returns An object containing the state and handlers for audio attachments. - * - * @deprecated This is deprecated and will be removed in the future. - */ -export const useAudioPreviewManager = (files: LocalAttachment[]) => { - const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState< - Record - >({}); - - useEffect(() => { - setAudioAttachmentsStateMap((prevState) => { - const updatedStateMap = Object.fromEntries( - files.map((attachment) => { - const id = attachment.localMetadata.id; - - const config: AudioConfig = { - duration: attachment.duration ?? prevState[id]?.duration ?? 0, - paused: prevState[id]?.paused ?? true, - progress: prevState[id]?.progress ?? 0, - }; - - return [id, config]; - }), - ); - - return updatedStateMap; - }); - }, [files]); - - /** - * Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here - * and the duration is set. - * @param index - The index of the audio - * @param duration - The duration of the audio - * - * @deprecated This is deprecated and will be removed in the future. - * FIXME: Remove this in the next major version. - */ - const onLoad = useCallback((index: string, duration: number) => { - setAudioAttachmentsStateMap((prevState) => ({ - ...prevState, - [index]: { - ...prevState[index], - duration, - }, - })); - }, []); - - /** - * Handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The - * progressed duration is set here. - * @param index - The index of the audio - * @param progress - The progress of the audio - * - * @deprecated This is deprecated and will be removed in the future. - * FIXME: Remove this in the next major version. - */ - const onProgress = useCallback((index: string, progress: number) => { - setAudioAttachmentsStateMap((prevState) => ({ - ...prevState, - [index]: { - ...prevState[index], - progress, - }, - })); - }, []); - - /** - * Handler which controls or sets the paused/played state of the audio. - * @param index - The index of the audio - * @param pausedStatus - The paused status of the audio - * - * @deprecated This is deprecated and will be removed in the future. - * FIXME: Remove this in the next major version. - */ - const onPlayPause = useCallback((index: string, pausedStatus?: boolean) => { - if (pausedStatus === false) { - // In this case, all others except the index are set to paused. - setAudioAttachmentsStateMap((prevState) => { - const newState = { ...prevState }; - Object.keys(newState).forEach((key) => { - if (key !== index) { - newState[key].paused = true; - } - }); - return { - ...newState, - [index]: { - ...newState[index], - paused: false, - }, - }; - }); - } else { - setAudioAttachmentsStateMap((prevState) => ({ - ...prevState, - [index]: { - ...prevState[index], - paused: true, - }, - })); - } - }, []); - - return { - audioAttachmentsStateMap, - onLoad, - onPlayPause, - onProgress, - setAudioAttachmentsStateMap, - }; -}; diff --git a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx new file mode 100644 index 000000000..86ca1c486 --- /dev/null +++ b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from 'react'; + +import { Alert, Platform } from 'react-native'; + +import { LocalVoiceRecordingAttachment } from 'stream-chat'; + +import { useActiveAudioPlayer } from '../../../contexts/audioPlayerContext/AudioPlayerContext'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; + +import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; +import { AudioRecordingReturnType, NativeHandlers, RecordingStatus } 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'; + +export type RecordingStatusStates = 'idle' | 'recording' | 'stopped'; + +/** + * The hook that controls all the async audio core features including start/stop or recording, player, upload/delete of the recorded audio. + * + * FIXME: Change the name to `useAudioRecorder` in the next major version as the hook will only be used for audio recording. + */ +export const useAudioRecorder = () => { + const [micLocked, setMicLocked] = useState(false); + const [permissionsGranted, setPermissionsGranted] = useState(true); + const [waveformData, setWaveformData] = useState([]); + const [isScheduledForSubmit, setIsScheduleForSubmit] = useState(false); + const [recording, setRecording] = useState(undefined); + const [recordingDuration, setRecordingDuration] = useState(0); + const [recordingStatus, setRecordingStatus] = useState('idle'); + const { attachmentManager } = useMessageComposer(); + const activeAudioPlayer = useActiveAudioPlayer(); + + const { sendMessage } = useMessageInputContext(); + + // This effect stop the player from playing and stops audio recording on + // the audio SDK side on unmount. + useEffect( + () => () => { + stopSDKVoiceRecording(); + }, + [], + ); + + useEffect(() => { + if (isScheduledForSubmit) { + sendMessage(); + setIsScheduleForSubmit(false); + } + }, [isScheduledForSubmit, sendMessage]); + + const onRecordingStatusUpdate = (status: RecordingStatus) => { + if (status.isDoneRecording === true) { + return; + } + setRecordingDuration(status?.currentPosition || status.durationMillis); + // For expo android the lower bound is -120 so we need to normalize according to it. The `status.currentMetering` is undefined for Expo CLI apps, so we can use it. + const lowerBound = Platform.OS === 'ios' || status.currentMetering ? -60 : -120; + const normalizedAudioLevel = normalizeAudioLevel( + status.currentMetering || status.metering, + lowerBound, + ); + setWaveformData((prev) => [...prev, normalizedAudioLevel]); + }; + + /** + * Function to start voice recording. + */ + const startVoiceRecording = async () => { + if (!NativeHandlers.Audio) { + return; + } + const recordingInfo = await NativeHandlers.Audio.startRecording( + { + isMeteringEnabled: true, + }, + onRecordingStatusUpdate, + ); + const accessGranted = recordingInfo.accessGranted; + if (accessGranted) { + setPermissionsGranted(true); + const recording = recordingInfo.recording; + if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) { + recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60); + } + setRecording(recording); + setRecordingStatus('recording'); + if (activeAudioPlayer?.isPlaying) { + await activeAudioPlayer?.pause(); + } + } else { + setPermissionsGranted(false); + resetState(); + Alert.alert('Please allow Audio permissions in settings.'); + } + }; + + /** + * A function that takes care of stopping the voice recording from the library's + * side only. Meant to be used as a pure function (during unmounting for instance) + * hence this approach. + */ + const stopSDKVoiceRecording = async () => { + if (!NativeHandlers.Audio) { + return; + } + await NativeHandlers.Audio.stopRecording(); + }; + + /** + * Function to stop voice recording. + */ + const stopVoiceRecording = async () => { + await stopSDKVoiceRecording(); + setRecordingStatus('stopped'); + }; + + /** + * Function to reset the state of the message input for async voice messages. + */ + const resetState = () => { + setRecording(undefined); + setRecordingStatus('idle'); + setMicLocked(false); + setWaveformData([]); + }; + + /** + * Function to delete voice recording. + */ + const deleteVoiceRecording = async () => { + if (recordingStatus === 'recording') { + await stopVoiceRecording(); + } + resetState(); + NativeHandlers.triggerHaptic('impactMedium'); + }; + + /** + * Function to upload or send voice recording. + * @param multiSendEnabled boolean + */ + const uploadVoiceRecording = async (multiSendEnabled: boolean) => { + if (recordingStatus === 'recording') { + await stopVoiceRecording(); + } + + const durationInSeconds = parseFloat((recordingDuration / 1000).toFixed(3)); + + const resampledWaveformData = resampleWaveformData(waveformData, 100); + + const clearFilter = new RegExp('[.:]', 'g'); + const date = new Date().toISOString().replace(clearFilter, '_'); + + const file: File = { + duration: durationInSeconds, + name: `audio_recording_${date}.aac`, + size: 0, + type: 'audio/aac', + uri: typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string), + 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 attachmentManager.uploadAttachment(audioFile); + } else { + // FIXME: cannot call handleSubmit() directly as the function has stale reference to file uploads + await attachmentManager.uploadAttachment(audioFile); + setIsScheduleForSubmit(true); + } + resetState(); + }; + + return { + deleteVoiceRecording, + micLocked, + permissionsGranted, + recording, + recordingDuration, + recordingStatus, + setMicLocked, + startVoiceRecording, + stopVoiceRecording, + uploadVoiceRecording, + waveformData, + }; +}; diff --git a/package/src/components/ProgressControl/ProgressControl.tsx b/package/src/components/ProgressControl/ProgressControl.tsx index e4c53c813..0c7ee0c49 100644 --- a/package/src/components/ProgressControl/ProgressControl.tsx +++ b/package/src/components/ProgressControl/ProgressControl.tsx @@ -11,11 +11,6 @@ import Animated, { import { useTheme } from '../../contexts/themeContext/ThemeContext'; export type ProgressControlProps = { - /** - * @deprecated unused prop. - * The duration of the audio in seconds - */ - duration: number; /** * The color of the filled progress bar */ @@ -32,16 +27,6 @@ export type ProgressControlProps = { * The function to be called when the user ends dragging the progress bar */ onEndDrag?: (progress: number) => void; - /** - * The function to be called when the user plays or pauses the audio - * @deprecated Use onStartDrag and onEndDrag instead - */ - onPlayPause?: (status?: boolean) => void; - /** - * The function to be called when the user is dragging the progress bar - * @deprecated This is not used anymore and is handled locally - */ - onProgressDrag?: (progress: number) => void; /** * The function to be called when the user starts dragging the progress bar */ diff --git a/package/src/components/ProgressControl/WaveProgressBar.tsx b/package/src/components/ProgressControl/WaveProgressBar.tsx index d258eb6ee..3a509a906 100644 --- a/package/src/components/ProgressControl/WaveProgressBar.tsx +++ b/package/src/components/ProgressControl/WaveProgressBar.tsx @@ -32,11 +32,6 @@ export type WaveProgressBarProps = { * The function to be called when the user ends dragging the waveform */ onEndDrag?: (progress: number) => void; - /** - * The function to be called when the user plays or pauses the audio - * @deprecated Use onStartDrag and onEndDrag instead - */ - onPlayPause?: (status?: boolean) => void; /** * The function to be called when the user is dragging the waveform */ diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 344c7e144..f58d6de86 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -142,7 +142,7 @@ export * from './MessageInput/components/AttachmentPreview/AttachmentUploadProgr export * from './MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; export * from './MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; export * from './MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview'; -export * from './MessageInput/hooks/useAudioController'; +export * from './MessageInput/hooks/useAudioRecorder'; export * from './MessageList/DateHeader'; export * from './MessageList/hooks/useMessageList'; diff --git a/package/src/hooks/useAudioPlayer.ts b/package/src/hooks/useAudioPlayer.ts index bb8dd5ccc..c3eed6f53 100644 --- a/package/src/hooks/useAudioPlayer.ts +++ b/package/src/hooks/useAudioPlayer.ts @@ -1,80 +1,59 @@ -import React, { useCallback } from 'react'; - -import { NativeHandlers, SoundReturnType } from '../native'; - -export type UseSoundPlayerProps = { - soundRef: React.MutableRefObject; -}; - -/** - * This hook is used to play, pause, seek and change audio speed. - * It handles both Expo CLI and Native CLI. - * - * @deprecated This is deprecated and will be removed in the future. - */ -export const useAudioPlayer = (props: UseSoundPlayerProps) => { - const { soundRef } = props; - - const isExpoCLI = NativeHandlers.SDK === 'stream-chat-expo'; - - const playAudio = useCallback(async () => { - if (isExpoCLI) { - if (soundRef.current?.playAsync) { - await soundRef.current.playAsync(); - } - } else { - if (soundRef.current?.resume) { - soundRef.current.resume(); - } - } - }, [isExpoCLI, soundRef]); - - const pauseAudio = useCallback(async () => { - if (isExpoCLI) { - if (soundRef.current?.pauseAsync) { - await soundRef.current.pauseAsync(); - } - } else { - if (soundRef.current?.pause) { - soundRef.current.pause(); - } - } - }, [isExpoCLI, soundRef]); - - const seekAudio = useCallback( - async (currentTimeInSeconds: number = 0) => { - if (isExpoCLI) { - if (currentTimeInSeconds === 0) { - // If currentTime is 0, we should replay the video from 0th position. - if (soundRef.current?.replayAsync) { - await soundRef.current.replayAsync({}); - } - } else { - if (soundRef.current?.setPositionAsync) { - await soundRef.current.setPositionAsync(currentTimeInSeconds); - } - } - } else { - if (soundRef.current?.seek) { - soundRef.current.seek(currentTimeInSeconds); - } - } - }, - [isExpoCLI, soundRef], - ); - - const changeAudioSpeed = useCallback( - async (speed: number) => { - // Handled through prop `rate` in `Sound.Player` - if (!isExpoCLI) { - return; - } - if (soundRef.current?.setRateAsync) { - await soundRef.current.setRateAsync(speed, true, 'high'); - } - }, - [isExpoCLI, soundRef], +import { useMemo } from 'react'; + +import { useAudioPlayerContext } from '../contexts/audioPlayerContext/AudioPlayerContext'; +import { AudioPlayerOptions } from '../state-store/audio-player'; + +export type UseAudioPlayerProps = { + /** + * Identifier of the entity that requested the audio playback, e.g. message ID. + * Asset to specific audio player is a many-to-many relationship + * - one URL can be associated with multiple UI elements, + * - one UI element can display multiple audio sources. + * Therefore, the AudioPlayer ID is a combination of request:src. + * + * The requester string can take into consideration whether there are multiple instances of + * the same URL requested by the same requester (message has multiple attachments with the same asset URL). + * In reality the fact that one message has multiple attachments with the same asset URL + * could be considered a bad practice or a bug. + */ + requester?: string; +} & Partial; + +const makeAudioPlayerId = ({ + requester, + src, + id, +}: { + src: string; + requester?: string; + id?: string; +}) => `${requester ?? 'requester-unknown'}:${src}:${id ?? ''}`; + +export const useAudioPlayer = ({ + duration, + mimeType, + playbackRates, + previewVoiceRecording, + requester = '', + type, + uri, + id: fileId, +}: UseAudioPlayerProps) => { + const { audioPlayerPool } = useAudioPlayerContext(); + const id = makeAudioPlayerId({ id: fileId, requester, src: uri ?? '' }); + const audioPlayer = useMemo( + () => + audioPlayerPool?.getOrAddPlayer({ + duration: duration ?? 0, + id, + mimeType: mimeType ?? '', + playbackRates, + previewVoiceRecording, + type: type ?? 'audio', + uri: uri ?? '', + }), + [audioPlayerPool, duration, id, mimeType, playbackRates, previewVoiceRecording, type, uri], ); - return { changeAudioSpeed, pauseAudio, playAudio, seekAudio }; + return audioPlayer; }; From 0afc592237298da7f9fd3904a3ce1dd101992e33 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 18 Dec 2025 14:26:51 +0100 Subject: [PATCH 03/13] chore: disable beta releases from develop --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e6f1a772..c524c794a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: push: branches: - main - - develop +# - develop permissions: id-token: write # for OIDC / npm provenance if you use it From 59e62b4748fdd0ad4f212cf52ea2d5a1984a108e Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 18 Dec 2025 19:25:18 +0530 Subject: [PATCH 04/13] feat!: remove deprecated APIs and props from useMessageList and channel unread state (#3318) Removed deprecated stuff from the message list improvement PR --- package/src/components/Channel/Channel.tsx | 5 +- .../Channel/hooks/useCreateChannelContext.ts | 2 - .../Channel/hooks/useCreateMessagesContext.ts | 2 - .../MessageList/MessageFlashList.tsx | 4 -- .../components/MessageList/MessageList.tsx | 4 -- .../MessageList/hooks/useMessageList.ts | 67 +------------------ .../MessageList/utils/getDateSeparators.ts | 52 -------------- .../MessageList/utils/getGroupStyles.ts | 55 --------------- package/src/components/index.ts | 1 - .../channelContext/ChannelContext.tsx | 6 -- .../messageContext/MessageContext.tsx | 5 -- .../messagesContext/MessagesContext.tsx | 6 -- 12 files changed, 4 insertions(+), 205 deletions(-) delete mode 100644 package/src/components/MessageList/utils/getDateSeparators.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 723cd9b90..bc4dc9af2 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -329,7 +329,6 @@ export type ChannelPropsWithContext = Pick & | 'FlatList' | 'forceAlignMessages' | 'Gallery' - | 'getMessagesGroupStyles' | 'getMessageGroupStyle' | 'Giphy' | 'giphyVersion' @@ -626,7 +625,6 @@ const ChannelWithContext = (props: PropsWithChildren) = FlatList = NativeHandlers.FlatList, forceAlignMessages, Gallery = GalleryDefault, - getMessagesGroupStyles, getMessageGroupStyle, Giphy = GiphyDefault, giphyVersion = 'fixed_height', @@ -785,6 +783,7 @@ const ChannelWithContext = (props: PropsWithChildren) = const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore()); + // TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere. const setChannelUnreadState = useCallback( (data: ChannelUnreadStateStoreType['channelUnreadState']) => { channelUnreadStateStore.channelUnreadState = data; @@ -1780,7 +1779,6 @@ const ChannelWithContext = (props: PropsWithChildren) = const channelContext = useCreateChannelContext({ channel, - channelUnreadState: channelUnreadStateStore.channelUnreadState, channelUnreadStateStore, disabled: !!channel?.data?.frozen, EmptyStateIndicator, @@ -1930,7 +1928,6 @@ const ChannelWithContext = (props: PropsWithChildren) = forceAlignMessages, Gallery, getMessageGroupStyle, - getMessagesGroupStyles, Giphy, giphyVersion, handleBan, diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index d47c70fc5..824f30cab 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -4,7 +4,6 @@ import type { ChannelContextValue } from '../../../contexts/channelContext/Chann export const useCreateChannelContext = ({ channel, - channelUnreadState, channelUnreadStateStore, disabled, EmptyStateIndicator, @@ -51,7 +50,6 @@ export const useCreateChannelContext = ({ const channelContext: ChannelContextValue = useMemo( () => ({ channel, - channelUnreadState, channelUnreadStateStore, disabled, EmptyStateIndicator, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 690a34a23..0d469486b 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -28,7 +28,6 @@ export const useCreateMessagesContext = ({ forceAlignMessages, Gallery, getMessageGroupStyle, - getMessagesGroupStyles, Giphy, giphyVersion, handleBan, @@ -148,7 +147,6 @@ export const useCreateMessagesContext = ({ forceAlignMessages, Gallery, getMessageGroupStyle, - getMessagesGroupStyles, Giphy, giphyVersion, handleBan, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 842a3f355..31f31185c 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -104,7 +104,6 @@ type MessageFlashListPropsWithContext = Pick< Pick< ChannelContextValue, | 'channel' - | 'channelUnreadState' | 'channelUnreadStateStore' | 'disabled' | 'EmptyStateIndicator' @@ -358,7 +357,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } = useMessageList({ isFlashList: true, isLiveStreaming, - noGroupByUser, threadList, }); @@ -1129,7 +1127,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); const { channel, - channelUnreadState, channelUnreadStateStore, disabled, EmptyStateIndicator, @@ -1178,7 +1175,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { { viewabilityChangedCallback, } = useMessageList({ isLiveStreaming, - noGroupByUser, threadList, }); const messageListLengthBeforeUpdate = useRef(0); @@ -1214,7 +1212,6 @@ export const MessageList = (props: MessageListProps) => { const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); const { channel, - channelUnreadState, channelUnreadStateStore, disabled, EmptyStateIndicator, @@ -1263,7 +1260,6 @@ export const MessageList = (props: MessageListProps) => { { - const { noGroupByUser, threadList, isLiveStreaming, isFlashList = false } = params; + const { threadList, isLiveStreaming, isFlashList = false } = params; const { client } = useChatContext(); - const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext(); - const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } = - useMessagesContext(); + const { deletedMessagesVisibilityType } = useMessagesContext(); const { messages, viewabilityChangedCallback } = usePaginatedMessageListContext(); const { threadMessages } = useThreadContext(); const messageList = threadList ? threadMessages : messages; @@ -101,61 +91,10 @@ export const useMessageList = (params: UseMessageListParams) => { }); }, [processedMessageList, messageListPreviousAndNextMessageStore, isFlashList]); - /** - * @deprecated use `useDateSeparator` hook instead directly in the Message. - */ - const dateSeparators = useMemo( - () => - getDateSeparators({ - hideDateSeparators, - messages: processedMessageList, - }), - [hideDateSeparators, processedMessageList], - ); - - /** - * @deprecated use `useDateSeparator` hook instead directly in the Message. - */ - const dateSeparatorsRef = useRef(dateSeparators); - dateSeparatorsRef.current = dateSeparators; - - /** - * @deprecated use `useMessageGroupStyles` hook instead directly in the Message. - */ - const messageGroupStyles = useMemo( - () => - getMessagesGroupStyles({ - dateSeparators: dateSeparatorsRef.current, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - messages: processedMessageList, - noGroupByUser, - userId: client.userID, - }), - [ - getMessagesGroupStyles, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - processedMessageList, - noGroupByUser, - client.userID, - ], - ); - - /** - * @deprecated use `useMessageGroupStyles` hook instead directly in the Message. - */ - const messageGroupStylesRef = useRef(messageGroupStyles); - messageGroupStylesRef.current = messageGroupStyles; - const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming); return useMemo( () => ({ - /** Date separators */ - dateSeparatorsRef, - /** Message group styles */ - messageGroupStylesRef, messageListPreviousAndNextMessageStore, /** Messages enriched with dates/readby/groups and also reversed in order */ processedMessageList: data, diff --git a/package/src/components/MessageList/utils/getDateSeparators.ts b/package/src/components/MessageList/utils/getDateSeparators.ts deleted file mode 100644 index 575debf95..000000000 --- a/package/src/components/MessageList/utils/getDateSeparators.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { DeletedMessagesVisibilityType } from '../../../contexts/messagesContext/MessagesContext'; -import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; - -/** - * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. - */ -export type GetDateSeparatorsParams = { - messages: PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages']; - /** - * @deprecated The computations are done ahead of time in the useMessageList hook so this parameter is no longer needed. - */ - deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; - hideDateSeparators?: boolean; - /** - * @deprecated The computations are done ahead of time in the useMessageList hook so this parameter is no longer needed. - */ - userId?: string; -}; - -export type DateSeparators = { - [key: string]: Date; -}; - -/** - * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. - */ -export const getDateSeparators = (params: GetDateSeparatorsParams) => { - const { hideDateSeparators, messages } = params; - const dateSeparators: DateSeparators = {}; - - if (hideDateSeparators) { - return dateSeparators; - } - - for (let i = 0; i < messages.length; i++) { - const previousMessage = messages[i - 1]; - const message = messages[i]; - - const messageDate = message.created_at.toDateString(); - - const prevMessageDate = previousMessage - ? previousMessage.created_at.toDateString() - : messageDate; - - if (i === 0 || messageDate !== prevMessageDate) { - dateSeparators[message.id] = message.created_at; - } - } - - return dateSeparators; -}; diff --git a/package/src/components/MessageList/utils/getGroupStyles.ts b/package/src/components/MessageList/utils/getGroupStyles.ts index 55630bd6f..e3dec9e16 100644 --- a/package/src/components/MessageList/utils/getGroupStyles.ts +++ b/package/src/components/MessageList/utils/getGroupStyles.ts @@ -1,12 +1,6 @@ import { LocalMessage } from 'stream-chat'; -import type { DateSeparators } from './getDateSeparators'; - -import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; - import { isEditedMessage } from '../../../utils/utils'; -import type { GroupType } from '../hooks/useMessageList'; export type MessageGroupStylesParams = { message: LocalMessage; @@ -17,24 +11,6 @@ export type MessageGroupStylesParams = { nextMessageDateSeparatorDate?: Date; }; -/** - * @deprecated in favor of `useMessageGroupStyles` hook instead directly in the Message. - */ -export type GetGroupStylesParams = { - dateSeparators: DateSeparators; - messages: PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages']; - /** - * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. - */ - hideDateSeparators?: boolean; - maxTimeBetweenGroupedMessages?: number; - noGroupByUser?: boolean; - /** - * @deprecated - */ - userId?: string; -}; - export type GroupStyle = '' | 'middle' | 'top' | 'bottom' | 'single'; /** @@ -118,34 +94,3 @@ export const getGroupStyle = ({ return groupStyles; }; - -/** - * @deprecated in favor of `useMessageGroupStyles` hook instead directly in the Message. - */ -export const getGroupStyles = (params: GetGroupStylesParams) => { - const { dateSeparators, maxTimeBetweenGroupedMessages, messages, noGroupByUser } = params; - - if (noGroupByUser) { - return {}; - } - - const messageGroupStyles: { [key: string]: GroupType[] } = {}; - - for (let i = 0; i < messages.length; i++) { - const previousMessage = messages[i - 1]; - const message = messages[i]; - const nextMessage = messages[i + 1]; - - if (message.id) { - messageGroupStyles[message.id] = getGroupStyle({ - dateSeparatorDate: dateSeparators[message.id], - maxTimeBetweenGroupedMessages, - message, - nextMessage, - previousMessage, - }); - } - } - - return messageGroupStyles; -}; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index f58d6de86..6c0d40a33 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -158,7 +158,6 @@ export * from './MessageList/NetworkDownIndicator'; export * from './MessageList/ScrollToBottomButton'; export * from './MessageList/TypingIndicator'; export * from './MessageList/TypingIndicatorContainer'; -export * from './MessageList/utils/getDateSeparators'; export * from './MessageList/utils/getGroupStyles'; export * from './MessageList/utils/getLastReceivedMessage'; export * from './Message/hooks/useMessageDeliveryData'; diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index a41c90e31..36e3c63ed 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -10,7 +10,6 @@ import { ChannelUnreadStateStore, ChannelUnreadStateStoreType, } from '../../state-store/channel-unread-state'; -import { ChannelUnreadState } from '../../types/types'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -135,11 +134,6 @@ export type ChannelContextValue = { * Its a map of filename and AbortController */ uploadAbortControllerRef: React.MutableRefObject>; - /** - * Channel unread data - * @deprecated Use channelUnreadStateStore instead - */ - channelUnreadState?: ChannelUnreadState; channelUnreadStateStore: ChannelUnreadStateStore; disabled?: boolean; enableMessageGroupingByUser?: boolean; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 45e7f0e51..f1851a50d 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -112,11 +112,6 @@ export type MessageContextValue = { * @returns */ handleReaction?: (reactionType: string) => Promise; - /** - * Latest message id on current channel - * @deprecated and will be removed in the future. This is pretty much accessible through the message-list itself. - */ - lastReceivedId?: string; /** * Theme provided only to messages that are the current users */ diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index f0158ef0a..fdfa6a27d 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -57,7 +57,6 @@ import type { ScrollToBottomButtonProps } from '../../components/MessageList/Scr import { TypingIndicatorContainerProps } from '../../components/MessageList/TypingIndicatorContainer'; import { UnreadMessagesNotificationProps } from '../../components/MessageList/UnreadMessagesNotification'; import type { - getGroupStyles, GroupStyle, MessageGroupStylesParams, } from '../../components/MessageList/utils/getGroupStyles'; @@ -411,11 +410,6 @@ export type MessagesContextValue = Pick GroupStyle[]; /** * Handler to access when a ban user action is invoked. From a05e3fcea6ef17f03cbef6fa72511685817a6240 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 26 Dec 2025 14:51:44 +0530 Subject: [PATCH 05/13] feat: optional support for react-native-keyboard-controller (#3338) Add optional support for the package react-native-keyboard-controller so as to make the keyboard interactions smoother in the SDK. --- examples/SampleApp/ios/Podfile.lock | 63 +++++++++++++++ examples/SampleApp/package.json | 1 + .../SampleApp/src/screens/ChannelScreen.tsx | 6 +- examples/SampleApp/yarn.lock | 7 ++ package/package.json | 5 ++ .../AttachmentPicker/AttachmentPicker.tsx | 27 ++++--- package/src/components/Channel/Channel.tsx | 13 ++-- .../components/ImageGallery/ImageGallery.tsx | 6 +- .../KeyboardControllerAvoidingView.tsx | 77 +++++++++++++++++++ package/src/components/Message/Message.tsx | 10 +-- .../UIComponents/BottomSheetModal.tsx | 27 ++++++- .../MessageInputContext.tsx | 5 +- package/yarn.lock | 12 +++ 13 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 171a4084a..16f46a1c0 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2036,6 +2036,65 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - react-native-keyboard-controller (1.20.2): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - react-native-keyboard-controller/common (= 1.20.2) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - react-native-keyboard-controller/common (1.20.2): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - react-native-maps (1.20.1): - React-Core - react-native-netinfo (11.4.1): @@ -3220,6 +3279,7 @@ DEPENDENCIES: - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" - "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) + - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-maps (from `../node_modules/react-native-maps`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) @@ -3394,6 +3454,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-community/geolocation" react-native-image-picker: :path: "../node_modules/react-native-image-picker" + react-native-keyboard-controller: + :path: "../node_modules/react-native-keyboard-controller" react-native-maps: :path: "../node_modules/react-native-maps" react-native-netinfo: @@ -3561,6 +3623,7 @@ SPEC CHECKSUMS: react-native-document-picker: c5fa18e9fc47b34cfbab3b0a4447d0df918a5621 react-native-geolocation: eb39c815c9b58ddc3efb552cafdd4b035e4cf682 react-native-image-picker: e479ec8884df9d99a62c1f53f2307055ad43ea85 + react-native-keyboard-controller: 6fe65d5d011d88e651d5279396e95e9c1f9458ca react-native-maps: ee1e65647460c3d41e778071be5eda10e3da6225 react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac react-native-safe-area-context: 7fd4c2c8023da8e18eaa3424cb49d52f626debee diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 5da8d63f2..105ff7f23 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -47,6 +47,7 @@ "react-native-gesture-handler": "^2.26.0", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^8.2.1", + "react-native-keyboard-controller": "^1.20.2", "react-native-maps": "1.20.1", "react-native-nitro-modules": "^0.31.3", "react-native-nitro-sound": "^0.2.9", diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 406938e40..ba4898e13 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import type { LocalMessage, Channel as StreamChatChannel } from 'stream-chat'; +import { useHeaderHeight } from '@react-navigation/elements'; import { RouteProp, useFocusEffect, useNavigation } from '@react-navigation/native'; import { Channel, @@ -17,7 +18,7 @@ import { useTranslationContext, MessageActionsParams, } from 'stream-chat-react-native'; -import { Platform, Pressable, StyleSheet, View } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -210,6 +211,7 @@ export const ChannelScreen: React.FC = ({ }, [chatClient, colors, t, handleMessageInfo], ); + const headerHeight = useHeaderHeight(); if (!channel || !chatClient) { return null; @@ -225,7 +227,7 @@ export const ChannelScreen: React.FC = ({ disableTypingIndicator enforceUniqueReaction initialScrollToFirstUnreadMessage - keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -300} + keyboardVerticalOffset={headerHeight} messageActions={messageActions} MessageHeader={MessageReminderHeader} MessageLocation={MessageLocation} diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 5d203517a..c119e101b 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7658,6 +7658,13 @@ react-native-is-edge-to-edge@^1.2.1: resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz#64e10851abd9d176cbf2b40562f751622bde3358" integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q== +react-native-keyboard-controller@^1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.20.2.tgz#2953341f48e25fec20dd732241cb8152251fd4d1" + integrity sha512-3xvPTIfasAbosDxT3Mc6b5Xr/M+yq99ECCM4iGnSAngziIVUZsZuPpfYL7nN1UiN9rQjWKvjdul/jq9E0V1s2w== + dependencies: + react-native-is-edge-to-edge "^1.2.1" + react-native-lightbox@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/react-native-lightbox/-/react-native-lightbox-0.7.0.tgz#e52b4d7fcc141f59d7b23f0180de535e35b20ec9" diff --git a/package/package.json b/package/package.json index 0f4028fa8..71ad526cc 100644 --- a/package/package.json +++ b/package/package.json @@ -91,6 +91,7 @@ "emoji-mart": ">=5.4.0", "react-native": ">=0.73.0", "react-native-gesture-handler": ">=2.18.0", + "react-native-keyboard-controller": ">=1.20.2", "react-native-reanimated": ">=3.16.0", "react-native-safe-area-context": ">=5.4.1", "react-native-svg": ">=15.8.0" @@ -107,6 +108,9 @@ }, "@emoji-mart/data": { "optional": true + }, + "react-native-keyboard-controller": { + "optional": true } }, "devDependencies": { @@ -154,6 +158,7 @@ "react-native": "0.80.2", "react-native-builder-bob": "0.40.11", "react-native-gesture-handler": "^2.26.0", + "react-native-keyboard-controller": "^1.20.2", "react-native-reanimated": "3.18.0", "react-native-safe-area-context": "^5.6.1", "react-native-svg": "15.12.0", diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index c23af98dc..59669fd70 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { BackHandler, Keyboard, Platform, StyleSheet } from 'react-native'; +import { BackHandler, EmitterSubscription, Keyboard, Platform, StyleSheet } from 'react-native'; import BottomSheetOriginal from '@gorhom/bottom-sheet'; import type { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; @@ -19,6 +19,7 @@ import { NativeHandlers } from '../../native'; import type { File } from '../../types/types'; import { BottomSheet } from '../BottomSheetCompatibility/BottomSheet'; import { BottomSheetFlatList } from '../BottomSheetCompatibility/BottomSheetFlatList'; +import { KeyboardControllerPackage } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; dayjs.extend(duration); @@ -185,20 +186,18 @@ export const AttachmentPicker = React.forwardRef( } closePicker(); }; - const keyboardShowEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; - const keyboardSubscription = Keyboard.addListener(keyboardShowEvent, onKeyboardOpenHandler); - + let keyboardSubscription: EmitterSubscription | null = null; + if (KeyboardControllerPackage?.KeyboardEvents) { + keyboardSubscription = KeyboardControllerPackage.KeyboardEvents.addListener( + 'keyboardWillShow', + onKeyboardOpenHandler, + ); + } else { + const keyboardShowEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + keyboardSubscription = Keyboard.addListener(keyboardShowEvent, onKeyboardOpenHandler); + } return () => { - // Following if-else condition to avoid deprecated warning coming RN 0.65 - if (keyboardSubscription?.remove) { - keyboardSubscription.remove(); - return; - } - // @ts-ignore - else if (Keyboard.removeListener) { - // @ts-ignore - Keyboard.removeListener(keyboardShowEvent, onKeyboardOpenHandler); - } + keyboardSubscription?.remove(); }; }, [closePicker, selectedPicker, setSelectedPicker]); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index bc4dc9af2..60edac220 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1,5 +1,5 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { KeyboardAvoidingViewProps, StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; @@ -150,7 +150,10 @@ import { LoadingErrorProps, } from '../Indicators/LoadingErrorIndicator'; import { LoadingIndicator as LoadingIndicatorDefault } from '../Indicators/LoadingIndicator'; -import { KeyboardCompatibleView as KeyboardCompatibleViewDefault } from '../KeyboardCompatibleView/KeyboardCompatibleView'; +import { + KeyboardCompatibleView as KeyboardCompatibleViewDefault, + KeyboardCompatibleViewProps, +} from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { Message as MessageDefault } from '../Message/Message'; import { MessageAvatar as MessageAvatarDefault } from '../Message/MessageSimple/MessageAvatar'; import { MessageBlocked as MessageBlockedDefault } from '../Message/MessageSimple/MessageBlocked'; @@ -414,7 +417,7 @@ export type ChannelPropsWithContext = Pick & /** * Additional props passed to keyboard avoiding view */ - additionalKeyboardAvoidingViewProps?: Partial; + additionalKeyboardAvoidingViewProps?: Partial; /** * When true, disables the KeyboardCompatibleView wrapper * @@ -470,7 +473,7 @@ export type ChannelPropsWithContext = Pick & * When true, messageList will be scrolled at first unread message, when opened. */ initialScrollToFirstUnreadMessage?: boolean; - keyboardBehavior?: KeyboardAvoidingViewProps['behavior']; + keyboardBehavior?: KeyboardCompatibleViewProps['behavior']; /** * Custom wrapper component that handles height adjustment of Channel component when keyboard is opened or dismissed * Default component (accepts the same props): [KeyboardCompatibleView](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx) @@ -490,7 +493,7 @@ export type ChannelPropsWithContext = Pick & * /> * ``` */ - KeyboardCompatibleView?: React.ComponentType; + KeyboardCompatibleView?: React.ComponentType; keyboardVerticalOffset?: number; /** * Custom loading error indicator to override the Stream default diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index ccd2193b9..866973e78 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -1,6 +1,5 @@ import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Image, ImageStyle, Keyboard, StyleSheet, ViewStyle } from 'react-native'; - +import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -50,6 +49,7 @@ import { BottomSheetModal, BottomSheetModalProvider, } from '../BottomSheetCompatibility/BottomSheetModal'; +import { dismissKeyboard } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; const MARGIN = 32; @@ -177,7 +177,7 @@ export const ImageGallery = (props: Props) => { * Run the fade animation on visible change */ useEffect(() => { - Keyboard.dismiss(); + dismissKeyboard(); showScreen(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx b/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx new file mode 100644 index 000000000..f75e5853b --- /dev/null +++ b/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import { + Keyboard, + Platform, + KeyboardAvoidingViewProps as ReactNativeKeyboardAvoidingViewProps, +} from 'react-native'; + +import { + KeyboardAvoidingView as KeyboardControllerPackageKeyboardAvoidingView, + KeyboardController as KeyboardControllerPackageKeyboardController, + KeyboardEvents, + KeyboardProvider, +} from 'react-native-keyboard-controller'; + +import { KeyboardCompatibleView as KeyboardCompatibleViewDefault } from './KeyboardCompatibleView'; + +type ExtraKeyboardControllerProps = { + behavior?: 'translate-with-padding'; +}; + +export type KeyboardCompatibleViewProps = ReactNativeKeyboardAvoidingViewProps & + ExtraKeyboardControllerProps; + +let KeyboardControllerPackage: + | { + KeyboardAvoidingView: typeof KeyboardControllerPackageKeyboardAvoidingView; + KeyboardController: typeof KeyboardControllerPackageKeyboardController; + KeyboardProvider: typeof KeyboardProvider; + KeyboardEvents: typeof KeyboardEvents; + } + | undefined; + +try { + KeyboardControllerPackage = require('react-native-keyboard-controller'); +} catch (e) { + KeyboardControllerPackage = undefined; +} + +export const KeyboardCompatibleView = (props: KeyboardCompatibleViewProps) => { + const { behavior = 'translate-with-padding', children, ...rest } = props; + + const KeyboardProvider = KeyboardControllerPackage?.KeyboardProvider; + const KeyboardAvoidingView = KeyboardControllerPackage?.KeyboardAvoidingView; + + if (KeyboardProvider && KeyboardAvoidingView) { + return ( + + {/* @ts-expect-error - The reason is that react-native-keyboard-controller's KeyboardAvoidingViewProps is a discriminated union, not a simple behavior union so it complains about the `position` value passed. */} + + {children} + + + ); + } + const compatibleBehavior = + behavior === 'translate-with-padding' + ? Platform.OS === 'ios' + ? 'padding' + : 'position' + : behavior; + + return ( + + {children} + + ); +}; + +export const dismissKeyboard = () => { + if (KeyboardControllerPackage?.KeyboardController) { + KeyboardControllerPackage?.KeyboardController.dismiss(); + } + Keyboard.dismiss(); +}; + +export { KeyboardControllerPackage }; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 1949d3d6b..fb95d80bc 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { GestureResponderEvent, Keyboard, StyleProp, View, ViewStyle } from 'react-native'; +import { GestureResponderEvent, StyleProp, View, ViewStyle } from 'react-native'; import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; @@ -48,6 +48,7 @@ import { MessageStatusTypes, } from '../../utils/utils'; import type { Thumbnail } from '../Attachment/utils/buildGallery/types'; +import { dismissKeyboard } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; export type TouchableEmitter = | 'fileAttachment' @@ -232,7 +233,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { deleteMessage: deleteMessageFromContext, deleteReaction, deliveredToCount, - dismissKeyboard, dismissKeyboardOnMessageTouch, enableLongPress = true, enforceUniqueReaction, @@ -299,8 +299,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }, } = useTheme(); - const showMessageOverlay = async (showMessageReactions = false, selectedReaction?: string) => { - await dismissKeyboard(); + const showMessageOverlay = (showMessageReactions = false, selectedReaction?: string) => { + dismissKeyboard(); setShowMessageReactions(showMessageReactions); setMessageOverlayVisible(true); setSelectedReaction(selectedReaction); @@ -341,7 +341,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const onPress = (error = errorOrFailed) => { if (dismissKeyboardOnMessageTouch) { - Keyboard.dismiss(); + dismissKeyboard(); } if (isEditedMessage(message)) { setIsEditedMessageOpen((prevState) => !prevState); diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 4176eb86d..3d8e31975 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -1,6 +1,7 @@ import React, { PropsWithChildren, useEffect, useMemo } from 'react'; import { Animated, + EventSubscription, Keyboard, KeyboardEvent, Modal, @@ -17,9 +18,11 @@ import { PanGestureHandlerEventPayload, } from 'react-native-gesture-handler'; +import type { KeyboardEventData } from 'react-native-keyboard-controller'; import { runOnJS } from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { KeyboardControllerPackage } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; export type BottomSheetModalProps = { /** @@ -79,12 +82,28 @@ export const BottomSheetModal = (props: PropsWithChildren }, [visible, openAnimation]); useEffect(() => { - const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', keyboardDidShow); - const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', keyboardDidHide); + const listeners: EventSubscription[] = []; + + if (KeyboardControllerPackage?.KeyboardEvents) { + const keyboardDidShow = (e: KeyboardEventData) => { + Animated.timing(translateY, { + duration: 250, + toValue: -e.height, + useNativeDriver: true, + }).start(); + }; + + listeners.push( + KeyboardControllerPackage.KeyboardEvents.addListener('keyboardDidShow', keyboardDidShow), + KeyboardControllerPackage.KeyboardEvents.addListener('keyboardDidHide', keyboardDidHide), + ); + } else { + listeners.push(Keyboard.addListener('keyboardDidShow', keyboardDidShow)); + listeners.push(Keyboard.addListener('keyboardDidHide', keyboardDidHide)); + } return () => { - keyboardDidShowListener.remove(); - keyboardDidHideListener.remove(); + listeners.forEach((listener) => listener.remove()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index dca13d332..05a556a80 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -7,7 +7,7 @@ import React, { useRef, useState, } from 'react'; -import { Alert, Keyboard, Linking, TextInput, TextInputProps } from 'react-native'; +import { Alert, Linking, TextInput, TextInputProps } from 'react-native'; import { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; import { @@ -32,6 +32,7 @@ import { PollContentProps, StopMessageStreamingButtonProps, } from '../../components'; +import { dismissKeyboard } from '../../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { parseLinksFromText } from '../../components/Message/MessageSimple/utils/parseLinks'; import type { AttachButtonProps } from '../../components/MessageInput/AttachButton'; import { AttachmentUploadPreviewListProps } from '../../components/MessageInput/AttachmentUploadPreviewList'; @@ -555,7 +556,7 @@ export const MessageInputProvider = ({ * Function to open the attachment picker if the MediaLibary is installed. */ const openAttachmentPicker = useCallback(() => { - Keyboard.dismiss(); + dismissKeyboard(); setSelectedPicker('images'); openPicker(); }, [openPicker, setSelectedPicker]); diff --git a/package/yarn.lock b/package/yarn.lock index 100f186f7..336dfb336 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -7706,6 +7706,18 @@ react-native-is-edge-to-edge@1.1.7: resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz#28947688f9fafd584e73a4f935ea9603bd9b1939" integrity sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w== +react-native-is-edge-to-edge@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz#64e10851abd9d176cbf2b40562f751622bde3358" + integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q== + +react-native-keyboard-controller@^1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.20.2.tgz#2953341f48e25fec20dd732241cb8152251fd4d1" + integrity sha512-3xvPTIfasAbosDxT3Mc6b5Xr/M+yq99ECCM4iGnSAngziIVUZsZuPpfYL7nN1UiN9rQjWKvjdul/jq9E0V1s2w== + dependencies: + react-native-is-edge-to-edge "^1.2.1" + react-native-lightbox@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/react-native-lightbox/-/react-native-lightbox-0.7.0.tgz#e52b4d7fcc141f59d7b23f0180de535e35b20ec9" From 6560ccd8eff221d7963f81bb1859cb8c01af4d43 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Sat, 27 Dec 2025 07:51:16 +0100 Subject: [PATCH 06/13] feat: message context menu (#3339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎯 Goal Resolves [this linear issue](https://linear.app/stream/issue/RN-328/message-context-menu). ## 🛠 Implementation details ## 🎨 UI Changes
iOS
Before After
Android
Before After
## 🧪 Testing ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- examples/SampleApp/App.tsx | 116 +- .../main/java/com/sampleapp/MainActivity.kt | 77 +- examples/SampleApp/ios/Podfile.lock | 65 +- examples/SampleApp/package.json | 1 + examples/SampleApp/yarn.lock | 5 + package/jest-setup.js | 34 +- package/package.json | 4 +- package/src/components/Channel/Channel.tsx | 11 +- .../Channel/__tests__/ownCapabilities.test.js | 20 +- package/src/components/Message/Message.tsx | 170 +- .../Message/MessageSimple/MessageContent.tsx | 23 +- .../Message/MessageSimple/MessageSimple.tsx | 184 +- .../MessageSimple/__tests__/Message.test.js | 21 +- .../Message/hooks/useCreateMessageContext.ts | 1 + .../Message/utils/measureInWindow.ts | 20 + .../__snapshots__/AttachButton.test.js.snap | 1329 +++++++---- .../__snapshots__/SendButton.test.js.snap | 830 ++++--- .../MessageList/MessageFlashList.tsx | 10 + .../components/MessageList/MessageList.tsx | 8 + .../MessageMenu/MessageActionList.tsx | 20 +- .../MessageMenu/MessageActionListItem.tsx | 9 +- .../components/MessageMenu/MessageMenu.tsx | 187 +- .../MessageMenu/MessageReactionPicker.tsx | 118 +- .../MessageMenu/MessageUserReactions.tsx | 105 +- .../MessageMenu/MessageUserReactionsItem.tsx | 3 +- .../components/MessageMenu/ReactionButton.tsx | 15 +- .../__tests__/MessageUserReactions.test.tsx | 51 +- package/src/components/MessageMenu/emojis.ts | 163 ++ .../MessageMenu/hooks/useFetchReactions.ts | 41 +- .../components/Reply/__tests__/Reply.test.tsx | 18 +- .../__snapshots__/Thread.test.js.snap | 2010 +++++++++-------- .../UIComponents/BottomSheetModal.tsx | 246 +- .../MessageListItemContext.tsx | 1 + .../messagesContext/MessagesContext.tsx | 4 +- .../MessageOverlayHostLayer.tsx | 272 +++ .../overlayContext/OverlayProvider.tsx | 30 +- package/src/state-store/index.ts | 1 + .../src/state-store/message-overlay-store.ts | 85 + package/src/utils/utils.ts | 1 + package/yarn.lock | 5 + 40 files changed, 4001 insertions(+), 2313 deletions(-) create mode 100644 package/src/components/Message/utils/measureInWindow.ts create mode 100644 package/src/components/MessageMenu/emojis.ts create mode 100644 package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx create mode 100644 package/src/state-store/message-overlay-store.ts diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 771d8db35..b71b8c6a6 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -94,6 +94,7 @@ notifee.onBackgroundEvent(async ({ detail, type }) => { const Drawer = createDrawerNavigator(); const Stack = createNativeStackNavigator(); const UserSelectorStack = createNativeStackNavigator(); + const App = () => { const { chatClient, isConnecting, loginUser, logout, switchUser } = useChatClient(); const [messageListImplementation, setMessageListImplementation] = useState< @@ -107,6 +108,7 @@ const App = () => { >(undefined); const colorScheme = useColorScheme(); const streamChatTheme = useStreamChatTheme(); + const streami18n = new Streami18n(); useEffect(() => { const messaging = getMessaging(); @@ -209,39 +211,43 @@ const App = () => { backgroundColor: streamChatTheme.colors?.white_snow || '#FCFCFC', }} > - - - - {isConnecting && !chatClient ? ( - - ) : chatClient ? ( - - ) : ( - - )} - - - + + + + + + {isConnecting && !chatClient ? ( + + ) : chatClient ? ( + + ) : ( + + )} + + + + + ); }; @@ -265,32 +271,26 @@ const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated; const DrawerNavigatorWrapper: React.FC<{ chatClient: StreamChat; -}> = ({ chatClient }) => { - const streamChatTheme = useStreamChatTheme(); - const streami18n = new Streami18n(); - + i18nInstance: Streami18n; +}> = ({ chatClient, i18nInstance }) => { return ( - - - - - - - - - - - - - - + + + + + + + + + + ); }; diff --git a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt index f3ca98b78..79546eb2e 100644 --- a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt +++ b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt @@ -1,52 +1,49 @@ package com.sampleapp -import com.facebook.react.ReactActivity -import com.facebook.react.ReactActivityDelegate -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled -import com.facebook.react.defaults.DefaultReactActivityDelegate - -import android.os.Bundle import android.os.Build +import android.os.Bundle import android.view.View +import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate class MainActivity : ReactActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(null) - - if (Build.VERSION.SDK_INT >= 35) { - val rootView = findViewById(android.R.id.content) - - - ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> - val bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() - or WindowInsetsCompat.Type.ime() // adding the ime's height - ) - rootView.updatePadding( - left = bars.left, - top = bars.top, - right = bars.right, - bottom = bars.bottom - ) - WindowInsetsCompat.CONSUMED - } - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(null) + + if (Build.VERSION.SDK_INT >= 35) { + val rootView = findViewById(android.R.id.content) + + val initial = Insets.of( + rootView.paddingLeft, + rootView.paddingTop, + rootView.paddingRight, + rootView.paddingBottom + ) + + ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets -> + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + + v.updatePadding( + left = initial.left, + top = initial.top, + right = initial.right, + bottom = initial.bottom + ime.bottom + ) + + insets } - /** - * Returns the name of the main component registered from JavaScript. This is used to schedule - * rendering of the component. - */ - override fun getMainComponentName(): String = "SampleApp" - - /** - * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] - * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] - */ - override fun createReactActivityDelegate(): ReactActivityDelegate = - DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + } + } + + override fun getMainComponentName(): String = "SampleApp" + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) } diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 16f46a1c0..acfbd0474 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3224,6 +3224,65 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - Teleport (0.5.4): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Teleport/common (= 0.5.4) + - Yoga + - Teleport/common (0.5.4): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - Yoga (0.0.0) DEPENDENCIES: @@ -3329,6 +3388,7 @@ DEPENDENCIES: - RNWorklets (from `../node_modules/react-native-worklets`) - SocketRocket (~> 0.7.1) - stream-chat-react-native (from `../node_modules/stream-chat-react-native`) + - Teleport (from `../node_modules/react-native-teleport`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -3552,6 +3612,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-worklets" stream-chat-react-native: :path: "../node_modules/stream-chat-react-native" + Teleport: + :path: "../node_modules/react-native-teleport" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -3584,7 +3646,7 @@ SPEC CHECKSUMS: op-sqlite: b9a4028bef60145d7b98fbbc4341c83420cdcfd5 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f @@ -3675,6 +3737,7 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a + Teleport: 9ae328b3384204b23236e47bd0d8e994ce6bdb48 Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 105ff7f23..c1171deb1 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -56,6 +56,7 @@ "react-native-screens": "^4.11.1", "react-native-share": "^12.0.11", "react-native-svg": "^15.12.0", + "react-native-teleport": "^0.5.4", "react-native-video": "^6.16.1", "react-native-worklets": "^0.4.1", "stream-chat-react-native": "link:../../package/native-package", diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index c119e101b..a93301a6f 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7736,6 +7736,11 @@ react-native-svg@^15.12.0: css-tree "^1.1.3" warn-once "0.1.1" +react-native-teleport@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.4.tgz#6af16e3984ee0fc002e5d6c6dae0ad40f22699c2" + integrity sha512-kiNbB27lJHAO7DdG7LlPi6DaFnYusVQV9rqp0q5WoRpnqKNckIvzXULox6QpZBstaTF/jkO+xmlkU+pnQqKSAQ== + react-native-url-polyfill@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589" diff --git a/package/jest-setup.js b/package/jest-setup.js index 5b2987f3d..85760c08a 100644 --- a/package/jest-setup.js +++ b/package/jest-setup.js @@ -1,7 +1,8 @@ /* global require */ -import { FlatList, View } from 'react-native'; +import rn, { FlatList, View } from 'react-native'; import mockRNCNetInfo from '@react-native-community/netinfo/jest/netinfo-mock.js'; +import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; import { registerNativeHandlers } from './src/native'; @@ -65,3 +66,34 @@ jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () jest.mock('@shopify/flash-list', () => ({ FlashList: undefined, })); + +jest.mock('react-native-teleport', () => { + const rn = require('react-native'); + return { + Portal: rn.View, + PortalHost: rn.View, + PortalProvider: rn.View, + usePortal: jest.fn().mockReturnValue({ removePortal: jest.fn() }), + }; +}); + +jest.mock('react-native-teleport', () => { + const rn = require('react-native'); + return { + Portal: rn.View, + PortalHost: rn.View, + PortalProvider: rn.View, + usePortal: jest.fn().mockReturnValue({ removePortal: jest.fn() }), + }; +}); + +jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); + +jest.mock('./src/components/Message/utils/measureInWindow', () => ({ + measureInWindow: jest.fn(async () => ({ + x: 10, + y: 100, + w: 250, + h: 60, + })), +})); diff --git a/package/package.json b/package/package.json index 71ad526cc..4126ff35e 100644 --- a/package/package.json +++ b/package/package.json @@ -94,7 +94,8 @@ "react-native-keyboard-controller": ">=1.20.2", "react-native-reanimated": ">=3.16.0", "react-native-safe-area-context": ">=5.4.1", - "react-native-svg": ">=15.8.0" + "react-native-svg": ">=15.8.0", + "react-native-teleport": ">=0.5.4" }, "peerDependenciesMeta": { "@op-engineering/op-sqlite": { @@ -162,6 +163,7 @@ "react-native-reanimated": "3.18.0", "react-native-safe-area-context": "^5.6.1", "react-native-svg": "15.12.0", + "react-native-teleport": "^0.5.4", "react-test-renderer": "19.1.0", "rimraf": "^6.0.1", "typescript": "5.8.3", diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 60edac220..6afeff2d7 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -207,10 +207,14 @@ import { StickyHeader as StickyHeaderDefault } from '../MessageList/StickyHeader import { TypingIndicator as TypingIndicatorDefault } from '../MessageList/TypingIndicator'; import { TypingIndicatorContainer as TypingIndicatorContainerDefault } from '../MessageList/TypingIndicatorContainer'; import { UnreadMessagesNotification as UnreadMessagesNotificationDefault } from '../MessageList/UnreadMessagesNotification'; +import { emojis } from '../MessageMenu/emojis'; import { MessageActionList as MessageActionListDefault } from '../MessageMenu/MessageActionList'; import { MessageActionListItem as MessageActionListItemDefault } from '../MessageMenu/MessageActionListItem'; import { MessageMenu as MessageMenuDefault } from '../MessageMenu/MessageMenu'; -import { MessageReactionPicker as MessageReactionPickerDefault } from '../MessageMenu/MessageReactionPicker'; +import { + MessageReactionPicker as MessageReactionPickerDefault, + toUnicodeScalarString, +} from '../MessageMenu/MessageReactionPicker'; import { MessageUserReactions as MessageUserReactionsDefault } from '../MessageMenu/MessageUserReactions'; import { MessageUserReactionsAvatar as MessageUserReactionsAvatarDefault } from '../MessageMenu/MessageUserReactionsAvatar'; import { MessageUserReactionsItem as MessageUserReactionsItemDefault } from '../MessageMenu/MessageUserReactionsItem'; @@ -250,6 +254,11 @@ export const reactionData: ReactionData[] = [ Icon: WutReaction, type: 'wow', }, + ...emojis.map((emoji) => ({ + Icon: () => {emoji}, + isUnicode: true, + type: toUnicodeScalarString(emoji), + })), ]; /** diff --git a/package/src/components/Channel/__tests__/ownCapabilities.test.js b/package/src/components/Channel/__tests__/ownCapabilities.test.js index 876bb5b48..b12e2f874 100644 --- a/package/src/components/Channel/__tests__/ownCapabilities.test.js +++ b/package/src/components/Channel/__tests__/ownCapabilities.test.js @@ -1,6 +1,8 @@ import React from 'react'; import { FlatList } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -47,14 +49,16 @@ describe('Own capabilities', () => { }); const getComponent = (props = {}) => ( - - - - - - - - + + + + + + + + + + ); const generateChannelWithCapabilities = async (capabilities = []) => { diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index fb95d80bc..e9dfa4737 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,5 +1,15 @@ -import React, { useMemo, useState } from 'react'; -import { GestureResponderEvent, StyleProp, View, ViewStyle } from 'react-native'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + GestureResponderEvent, + StyleProp, + useWindowDimensions, + View, + ViewStyle, +} from 'react-native'; + +import { useSharedValue } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Portal } from 'react-native-teleport'; import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; @@ -9,6 +19,7 @@ import { useMessageActions } from './hooks/useMessageActions'; import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; +import { measureInWindow } from './utils/measureInWindow'; import { messageActions as defaultMessageActions } from './utils/messageActions'; import { @@ -25,6 +36,7 @@ import { useMessageComposerAPIContext, } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; import { MessageContextValue, MessageProvider } from '../../contexts/messageContext/MessageContext'; +import { useMessageListItemContext } from '../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -38,6 +50,7 @@ import { } from '../../contexts/translationContext/TranslationContext'; import { isVideoPlayerAvailable, NativeHandlers } from '../../native'; +import { closeOverlay, openOverlay, useIsOverlayActive } from '../../state-store'; import { FileTypes } from '../../types/types'; import { checkMessageEquality, @@ -193,6 +206,13 @@ export type MessagePropsWithContext = Pick< | 'supportedReactions' | 'updateMessage' | 'PollContent' + // TODO: remove this comment later, using it as a pragma mark + | 'MessageUserReactions' + | 'MessageUserReactionsAvatar' + | 'MessageUserReactionsItem' + | 'MessageReactionPicker' + | 'MessageActionList' + | 'MessageActionListItem' > & Pick & Pick & { @@ -220,12 +240,11 @@ export type MessagePropsWithContext = Pick< * each individual Message component. */ const MessageWithContext = (props: MessagePropsWithContext) => { - const [messageOverlayVisible, setMessageOverlayVisible] = useState(false); const [isErrorInMessage, setIsErrorInMessage] = useState(false); const [showMessageReactions, setShowMessageReactions] = useState(true); const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); const [isEditedMessageOpen, setIsEditedMessageOpen] = useState(false); - const [selectedReaction, setSelectedReaction] = useState(undefined); + // const [selectedReaction, setSelectedReaction] = useState(undefined); const { channel, @@ -259,7 +278,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { MessageBlocked, MessageBounce, messageContentOrder: messageContentOrderProp, - MessageMenu, messagesContext, MessageSimple, onLongPressMessage: onLongPressMessageProp, @@ -283,7 +301,15 @@ const MessageWithContext = (props: MessagePropsWithContext) => { updateMessage, readBy, setQuotedMessage, + MessageUserReactions, + MessageUserReactionsAvatar, + MessageUserReactionsItem, + MessageReactionPicker, + MessageActionList, + MessageActionListItem, } = props; + // TODO: V9: Reconsider using safe area insets in every message. + const insets = useSafeAreaInsets(); const isMessageAIGenerated = messagesContext.isMessageAIGenerated; const isAIGenerated = useMemo( () => isMessageAIGenerated(message), @@ -299,15 +325,37 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }, } = useTheme(); - const showMessageOverlay = (showMessageReactions = false, selectedReaction?: string) => { + const topH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); + const bottomH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); + const messageH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); + const [rect, setRect] = useState<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); + const { width: screenW } = useWindowDimensions(); + + const showMessageOverlay = async (showMessageReactions = false) => { dismissKeyboard(); - setShowMessageReactions(showMessageReactions); - setMessageOverlayVisible(true); - setSelectedReaction(selectedReaction); + try { + const layout = await measureInWindow(messageWrapperRef, insets); + setRect(layout); + setShowMessageReactions(showMessageReactions); + messageH.value = layout; + openOverlay(message.id, { bottomH, messageH, topH }); + } catch (e) { + console.error(e); + } }; + const { setNativeScrollability } = useMessageListItemContext(); + const dismissOverlay = () => { - setMessageOverlayVisible(false); + closeOverlay(); }; const actionsEnabled = @@ -620,7 +668,10 @@ const MessageWithContext = (props: MessagePropsWithContext) => { unpinMessage: handleTogglePinMessage, }; + const messageWrapperRef = useRef(null); + const onLongPress = () => { + setNativeScrollability(false); if (hasAttachmentActions || isBlockedMessage(message) || !enableLongPress) { return; } @@ -633,6 +684,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { showMessageOverlay(); }; + const frozenMessage = useRef(message); + const { active: overlayActive } = useIsOverlayActive(message.id); + const messageContext = useCreateMessageContext({ actionsEnabled, alignment, @@ -652,7 +706,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { isMyMessage, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', members, - message, + message: overlayActive ? frozenMessage.current : message, messageContentOrder, myMessageTheme: messagesContext.myMessageTheme, onLongPress: (payload) => { @@ -716,7 +770,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } : null, otherAttachments: attachments.other, - preventPress, + preventPress: overlayActive ? true : preventPress, reactions, readBy, setIsEditedMessageOpen, @@ -728,6 +782,21 @@ const MessageWithContext = (props: MessagePropsWithContext) => { videos: attachments.videos, }); + const prevActive = useRef(overlayActive); + + useEffect(() => { + if (!overlayActive && prevActive.current && setNativeScrollability) { + setNativeScrollability(true); + } + prevActive.current = overlayActive; + }, [setNativeScrollability, overlayActive]); + + useEffect(() => { + if (!overlayActive) { + frozenMessage.current = message; + } + }, [overlayActive, message]); + if (!(isMessageTypeDeleted || messageContentOrder.length)) { return null; } @@ -759,20 +828,75 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ]} testID='message-wrapper' > - + {overlayActive && rect ? ( + + ) : null} + {/*TODO: V9: Find a way to separate these in a dedicated file*/} + + {overlayActive && rect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + + topH.value = { + h, + w, + x: isMyMessage ? screenW - rect.x - w : rect.x, + y: rect.y - h, + }; + }} + > + reaction.type) || []} + /> + + ) : null} + + + + + + {overlayActive && rect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + bottomH.value = { + h, + w, + x: isMyMessage ? screenW - rect.x - w : rect.x, + y: rect.y + rect.h, + }; + }} + > + {showMessageReactions ? ( + + ) : ( + + )} + + ) : null} + {isBounceDialogOpen ? ( ) : null} - {messageOverlayVisible ? ( - - ) : null} diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index 425ebb4d8..bddd71b58 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { AnimatableNumericValue, ColorValue, @@ -16,6 +16,7 @@ import { MessageContextValue, useMessageContext, } from '../../../contexts/messageContext/MessageContext'; +import { useMessageListItemContext } from '../../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -121,6 +122,7 @@ export type MessageContentPropsWithContext = Pick< * Child of MessageSimple that displays a message's content */ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { + const [longPressFired, setLongPressFired] = useState(false); const { additionalPressableProps, alignment, @@ -239,10 +241,13 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { return bordersFromTheme; }; + const { setNativeScrollability } = useMessageListItemContext(); + return ( { + setLongPressFired(true); if (onLongPress) { onLongPress({ emitter: 'messageContent', @@ -266,8 +271,16 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { }); } }} - style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }, container]} + style={({ pressed }) => [{ opacity: pressed && !longPressFired ? 0.5 : 1 }, container]} {...additionalPressableProps} + onPressOut={(event) => { + setLongPressFired(false); + setNativeScrollability(true); + + if (additionalPressableProps?.onPressOut) { + additionalPressableProps.onPressOut(event); + } + }} > {hasThreadReplies && !threadList && !noBorder && ( @@ -373,6 +386,7 @@ const areEqual = ( nextProps: MessageContentPropsWithContext, ) => { const { + preventPress: prevPreventPress, goToMessage: prevGoToMessage, groupStyles: prevGroupStyles, isAttachmentEqual, @@ -384,6 +398,7 @@ const areEqual = ( t: prevT, } = prevProps; const { + preventPress: nextPreventPress, goToMessage: nextGoToMessage, groupStyles: nextGroupStyles, isEditedMessageOpen: nextIsEditedMessageOpen, @@ -394,6 +409,10 @@ const areEqual = ( t: nextT, } = nextProps; + if (prevPreventPress !== nextPreventPress) { + return false; + } + const goToMessageChangedAndMatters = nextMessage.quoted_message_id && prevGoToMessage !== nextGoToMessage; if (goToMessageChangedAndMatters) { diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 472d4c47b..db9e5b4f7 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { forwardRef, useMemo, useState } from 'react'; import { Dimensions, LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { MessageBubble, SwipableMessageBubble } from './MessageBubble'; @@ -84,7 +84,7 @@ export type MessageSimplePropsWithContext = Pick< shouldRenderSwipeableWrapper: boolean; }; -const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { +const MessageSimpleWithContext = forwardRef((props, ref) => { const [messageContentWidth, setMessageContentWidth] = useState(0); const { width } = Dimensions.get('screen'); const { @@ -200,100 +200,105 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { }); return ( - - {alignment === 'left' ? : null} - {isMessageTypeDeleted ? ( - - ) : ( - + + + {alignment === 'left' ? : null} + {isMessageTypeDeleted ? ( + + ) : ( - {MessageHeader && ( - + {MessageHeader && ( + + )} + {message.pinned ? : null} + + {enableSwipeToReply ? ( + + ) : ( + )} - {message.pinned ? : null} + {reactionListPosition === 'bottom' && ReactionListBottom ? ( + + ) : null} + + - {enableSwipeToReply ? ( - - ) : ( - - )} - {reactionListPosition === 'bottom' && ReactionListBottom ? : null} - - - - )} + )} + ); -}; +}); const areEqual = ( prevProps: MessageSimplePropsWithContext, @@ -431,7 +436,7 @@ export type MessageSimpleProps = Partial; * * Message UI component */ -export const MessageSimple = (props: MessageSimpleProps) => { +export const MessageSimple = forwardRef((props, ref) => { const { alignment, channel, @@ -506,9 +511,10 @@ export const MessageSimple = (props: MessageSimpleProps) => { shouldRenderSwipeableWrapper, showMessageStatus, }} + ref={ref} {...props} /> ); -}; +}); MessageSimple.displayName = 'MessageSimple{messageSimple{container}}'; diff --git a/package/src/components/Message/MessageSimple/__tests__/Message.test.js b/package/src/components/Message/MessageSimple/__tests__/Message.test.js index 69779d57f..1ea946732 100644 --- a/package/src/components/Message/MessageSimple/__tests__/Message.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/Message.test.js @@ -1,5 +1,7 @@ import React from 'react'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider'; @@ -37,15 +39,16 @@ describe('Message', () => { renderMessage = (options) => render( - - - - - - - - , - , + + + + + + + + + + , ); }); diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index b44fa768e..67b5ce394 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -113,6 +113,7 @@ export const useCreateMessageContext = ({ showAvatar, showMessageStatus, threadList, + preventPress, ], ); diff --git a/package/src/components/Message/utils/measureInWindow.ts b/package/src/components/Message/utils/measureInWindow.ts new file mode 100644 index 000000000..fb22c07e4 --- /dev/null +++ b/package/src/components/Message/utils/measureInWindow.ts @@ -0,0 +1,20 @@ +import React from 'react'; +import { Platform, View } from 'react-native'; +import { EdgeInsets } from 'react-native-safe-area-context'; + +export const measureInWindow = ( + node: React.RefObject, + insets: EdgeInsets, +): Promise<{ x: number; y: number; w: number; h: number }> => { + return new Promise((resolve, reject) => { + const handle = node.current; + if (!handle) + return reject( + new Error('The native handle could not be found while invoking measureInWindow.'), + ); + + handle.measureInWindow((x, y, w, h) => + resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }), + ); + }); +}; 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 c88aacebc..ce8d93e13 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -1,610 +1,1051 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AttachButton should call handleAttachButtonPress when the button is clicked if passed 1`] = ` - + - - - - - - - - - - - + + + + + + + + + - - - - - + + + + - -`; - -exports[`AttachButton should render a enabled AttachButton 1`] = ` - - + + + + + + - + + + + +`; + +exports[`AttachButton should render a enabled AttachButton 1`] = ` + + + + + - - - - - - - - - + + + + + + + + + - - - - - + + + + - -`; - -exports[`AttachButton should render an disabled 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 ca6b9d765..193309cc7 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap @@ -1,103 +1,89 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`SendButton should render a SendButton 1`] = ` - + - - - - + - - + propList={ + [ + "fill", + ] + } + r={16} + /> + + + + - - - - - + + + + - -`; - -exports[`SendButton should render a disabled SendButton 1`] = ` - - - + + + + + + + + + + +`; + +exports[`SendButton should render a disabled SendButton 1`] = ` + + + + + - - + - - + propList={ + [ + "fill", + ] + } + r={16} + /> + + + + + + + + + - - - + + + + + + + + + + `; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 31f31185c..bac31e544 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -316,6 +316,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); + const [scrollEnabled, setScrollEnabled] = useState(true); const stickyHeaderDateRef = useRef(undefined); /** @@ -717,6 +718,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [], ); + const setNativeScrollability = useStableCallback((value: boolean) => { + // FlashList does not have setNativeProps exposed, hence we cannot use that. + // Instead, we resort to state. + setScrollEnabled(value); + }); + const messageListItemContextValue: MessageListItemContextValue = useMemo( () => ({ goToMessage, @@ -724,6 +731,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, }), [ goToMessage, @@ -731,6 +739,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, ], ); @@ -1083,6 +1092,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onViewableItemsChanged={stableOnViewableItemsChanged} ref={refCallback} renderItem={renderItem} + scrollEnabled={scrollEnabled} scrollEventThrottle={isLiveStreaming ? 16 : undefined} showsVerticalScrollIndicator={false} style={flatListStyle} diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 45618918f..ac6693a39 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -766,6 +766,12 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetedMessage]); + const setNativeScrollability = useStableCallback((value: boolean) => { + if (flatListRef.current) { + flatListRef.current.setNativeProps({ scrollEnabled: value }); + } + }); + const messageListItemContextValue: MessageListItemContextValue = useMemo( () => ({ goToMessage, @@ -773,6 +779,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, }), [ goToMessage, @@ -780,6 +787,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, ], ); diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index 6fc69abfe..14dbd0502 100644 --- a/package/src/components/MessageMenu/MessageActionList.tsx +++ b/package/src/components/MessageMenu/MessageActionList.tsx @@ -24,6 +24,7 @@ export const MessageActionList = (props: MessageActionListProps) => { const { MessageActionListItem, messageActions } = props; const { theme: { + colors: { white }, messageMenu: { actionList: { container, contentContainer }, }, @@ -37,8 +38,12 @@ export const MessageActionList = (props: MessageActionListProps) => { return ( {messageActions?.map((messageAction, index) => ( { }; const styles = StyleSheet.create({ - container: {}, + container: { + borderRadius: 16, + marginTop: 6, + }, contentContainer: { - paddingHorizontal: 16, + borderRadius: 16, + flexGrow: 1, + minWidth: 250, + paddingHorizontal: 12, + paddingVertical: 4, }, }); diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx index 47f8c26cf..332dd0e6b 100644 --- a/package/src/components/MessageMenu/MessageActionListItem.tsx +++ b/package/src/components/MessageMenu/MessageActionListItem.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { Pressable, StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; +import { closeOverlay, scheduleActionOnClose } from '../../state-store'; export type ActionType = | 'banUser' @@ -62,8 +64,13 @@ export const MessageActionListItem = (props: MessageActionListItemProps) => { }, } = useTheme(); + const onActionPress = useStableCallback(() => { + closeOverlay(); + scheduleActionOnClose(() => action()); + }); + return ( - [{ opacity: pressed ? 0.5 : 1 }]}> + [{ opacity: pressed ? 0.5 : 1 }]}> -> & - Partial> & { - /** - * Function to close the message actions bottom sheet - * @returns void - */ - dismissOverlay: () => void; - /** - * An array of message actions to render - */ - messageActions: MessageActionType[]; - /** - * Boolean to determine if there are message actions - */ - showMessageReactions: boolean; - /** - * Boolean to determine if the overlay is visible. - */ - visible: boolean; - /** - * Function to handle reaction on press - * @param reactionType - * @returns - */ - handleReaction?: (reactionType: string) => Promise; - /** - * The selected reaction - */ - selectedReaction?: string; - }; +export type MessageMenuProps = PropsWithChildren< + Partial< + Pick< + MessagesContextValue, + | 'MessageActionList' + | 'MessageActionListItem' + | 'MessageReactionPicker' + | 'MessageUserReactions' + | 'MessageUserReactionsAvatar' + | 'MessageUserReactionsItem' + > + > & + Partial> & { + /** + * Function to close the message actions bottom sheet + * @returns void + */ + dismissOverlay: () => void; + /** + * An array of message actions to render + */ + messageActions: MessageActionType[]; + /** + * Boolean to determine if there are message actions + */ + showMessageReactions: boolean; + /** + * Boolean to determine if the overlay is visible. + */ + visible: boolean; + /** + * Function to handle reaction on press + * @param reactionType + * @returns + */ + handleReaction?: (reactionType: string) => Promise; + /** + * The selected reaction + */ + selectedReaction?: string; + + layout: { + x: number; + y: number; + w: number; + h: number; + }; + } +>; +// TODO: V9: Either remove this or refactor it so that it's useful again, as its logic +// is offloaded to other components now. export const MessageMenu = (props: MessageMenuProps) => { const { dismissOverlay, - handleReaction, - message: propMessage, - MessageActionList: propMessageActionList, - MessageActionListItem: propMessageActionListItem, - messageActions, - MessageReactionPicker: propMessageReactionPicker, - MessageUserReactions: propMessageUserReactions, - MessageUserReactionsAvatar: propMessageUserReactionsAvatar, - MessageUserReactionsItem: propMessageUserReactionsItem, - selectedReaction, + // handleReaction, + // message: propMessage, + // MessageActionList: propMessageActionList, + // MessageActionListItem: propMessageActionListItem, + // messageActions, + // MessageReactionPicker: propMessageReactionPicker, + // MessageUserReactions: propMessageUserReactions, + // MessageUserReactionsAvatar: propMessageUserReactionsAvatar, + // MessageUserReactionsItem: propMessageUserReactionsItem, + // selectedReaction, showMessageReactions, visible, + // layout, + children, } = props; const { height } = useWindowDimensions(); - const { - MessageActionList: contextMessageActionList, - MessageActionListItem: contextMessageActionListItem, - MessageReactionPicker: contextMessageReactionPicker, - MessageUserReactions: contextMessageUserReactions, - MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, - MessageUserReactionsItem: contextMessageUserReactionsItem, - } = useMessagesContext(); - const { message: contextMessage } = useMessageContext(); - const MessageActionList = propMessageActionList ?? contextMessageActionList; - const MessageActionListItem = propMessageActionListItem ?? contextMessageActionListItem; - const MessageReactionPicker = propMessageReactionPicker ?? contextMessageReactionPicker; - const MessageUserReactions = propMessageUserReactions ?? contextMessageUserReactions; - const MessageUserReactionsAvatar = - propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar; - const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem; - const message = propMessage ?? contextMessage; + // const { + // MessageActionList: contextMessageActionList, + // MessageActionListItem: contextMessageActionListItem, + // MessageReactionPicker: contextMessageReactionPicker, + // MessageUserReactions: contextMessageUserReactions, + // MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, + // MessageUserReactionsItem: contextMessageUserReactionsItem, + // } = useMessagesContext(); + // const { message: contextMessage } = useMessageContext(); + // const MessageActionList = propMessageActionList ?? contextMessageActionList; + // const MessageActionListItem = propMessageActionListItem ?? contextMessageActionListItem; + // const MessageReactionPicker = propMessageReactionPicker ?? contextMessageReactionPicker; + // const MessageUserReactions = propMessageUserReactions ?? contextMessageUserReactions; + // const MessageUserReactionsAvatar = + // propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar; + // const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem; + // const message = propMessage ?? contextMessage; const { theme: { messageMenu: { @@ -101,36 +108,12 @@ export const MessageMenu = (props: MessageMenuProps) => { return ( - {showMessageReactions ? ( - - ) : ( - <> - reaction.type) || []} - /> - - - )} + {children} ); }; diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index 72131b94b..bb8c55211 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -1,6 +1,8 @@ -import React from 'react'; -import { FlatList, StyleSheet, View } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; +import { emojis } from './emojis'; import { ReactionButton } from './ReactionButton'; import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; @@ -11,9 +13,13 @@ import { import { useOwnCapabilitiesContext } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; +import { Attach } from '../../icons'; import { NativeHandlers } from '../../native'; +import { scheduleActionOnClose } from '../../state-store'; import { ReactionData } from '../../utils/utils'; +import { BottomSheetModal } from '../UIComponents'; export type MessageReactionPickerProps = Pick & Pick & { @@ -28,6 +34,8 @@ export type ReactionPickerItemType = ReactionData & { ownReactionTypes: string[]; }; +const keyExtractor = (item: ReactionPickerItemType) => item.type; + const renderItem = ({ index, item }: { index: number; item: ReactionPickerItemType }) => ( ); +const emojiKeyExtractor = (item: string) => `unicode-${item}`; + +// TODO: V9: Move this to utils and also clean it up a bit. +// This was done quickly and in a bit of a hurry. +export const toUnicodeScalarString = (emoji: string): string => { + const out: number[] = []; + for (const ch of emoji) out.push(ch.codePointAt(0)!); + return out.map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`).join('-'); +}; + /** * MessageReactionPicker - A high level component which implements all the logic required for a message overlay reaction list */ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { + const [emojiViewerOpened, setEmojiViewerOpened] = React.useState(null); const { dismissOverlay, handleReaction, @@ -51,6 +70,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { const { supportedReactions: contextSupportedReactions } = useMessagesContext(); const { theme: { + colors: { white, grey }, messageMenu: { reactionPicker: { container, contentContainer }, }, @@ -60,48 +80,116 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { const supportedReactions = propSupportedReactions || contextSupportedReactions; - const onSelectReaction = (type: string) => { + const onSelectReaction = useStableCallback((type: string) => { NativeHandlers.triggerHaptic('impactLight'); + setEmojiViewerOpened(false); + dismissOverlay(); if (handleReaction) { - handleReaction(type); + scheduleActionOnClose(() => handleReaction(type)); } - dismissOverlay(); - }; + }); + + const onOpenEmojiViewer = useStableCallback(() => { + NativeHandlers.triggerHaptic('impactLight'); + setEmojiViewerOpened(true); + }); + + const EmojiViewerButton = useCallback( + () => ( + + + + ), + [grey, onOpenEmojiViewer], + ); + + const reactions: ReactionPickerItemType[] = useMemo( + () => + supportedReactions + ?.filter((reaction) => !reaction.isUnicode) + ?.map((reaction) => ({ + ...reaction, + onSelectReaction, + ownReactionTypes, + })) ?? [], + [onSelectReaction, ownReactionTypes, supportedReactions], + ); + + const selectEmoji = useStableCallback((emoji: string) => { + const scalarString = toUnicodeScalarString(emoji); + onSelectReaction(scalarString); + }); + + const closeModal = useStableCallback(() => setEmojiViewerOpened(false)); + + const renderEmoji = useCallback( + ({ item }: { item: string }) => { + return ( + selectEmoji(item)} style={styles.emojiContainer}> + {item} + + ); + }, + [selectEmoji], + ); if (!own_capabilities.sendReaction) { return null; } - const reactions: ReactionPickerItemType[] = - supportedReactions?.map((reaction) => ({ - ...reaction, - onSelectReaction, - ownReactionTypes, - })) ?? []; - return ( item.type} + keyExtractor={keyExtractor} + ListFooterComponent={EmojiViewerButton} renderItem={renderItem} /> + {emojiViewerOpened ? ( + + + + ) : null} ); }; const styles = StyleSheet.create({ + bottomSheet: { height: 300 }, + bottomSheetColumnWrapper: { + alignItems: 'center', + justifyContent: 'space-evenly', + width: '100%', + }, + bottomSheetContentContainer: { paddingVertical: 16 }, container: { alignSelf: 'stretch', }, contentContainer: { + borderRadius: 20, flexGrow: 1, justifyContent: 'space-around', marginVertical: 8, + paddingHorizontal: 5, }, + emojiContainer: { height: 30 }, + emojiText: { fontSize: 20, padding: 2 }, + emojiViewerButton: { alignItems: 'flex-start', justifyContent: 'flex-start', paddingTop: 4 }, }); diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index ee7dfe5da..c8e3c3c74 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { Dimensions, StyleSheet, Text, View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { ReactionSortBase } from 'stream-chat'; import { useFetchReactions } from './hooks/useFetchReactions'; -import { ReactionButton } from './ReactionButton'; import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { @@ -15,7 +14,6 @@ import { import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { Reaction } from '../../types/types'; -import { ReactionData } from '../../utils/utils'; export type MessageUserReactionsProps = Partial< Pick< @@ -32,26 +30,14 @@ export type MessageUserReactionsProps = Partial< * The selected reaction */ selectedReaction?: string; + reactionFilterEnabled?: boolean; }; const sort: ReactionSortBase = { created_at: -1, }; -export type ReactionSelectorItemType = ReactionData & { - onSelectReaction: (type: string) => void; - selectedReaction?: string; -}; - -const renderSelectorItem = ({ index, item }: { index: number; item: ReactionSelectorItemType }) => ( - -); +const keyExtractor = (item: Reaction) => item.id; export const MessageUserReactions = (props: MessageUserReactionsProps) => { const { @@ -59,13 +45,8 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { MessageUserReactionsAvatar: propMessageUserReactionsAvatar, MessageUserReactionsItem: propMessageUserReactionsItem, reactions: propReactions, - selectedReaction: propSelectedReaction, supportedReactions: propSupportedReactions, } = props; - const reactionTypes = Object.keys(message?.reaction_groups ?? {}); - const [selectedReaction, setSelectedReaction] = React.useState( - propSelectedReaction ?? reactionTypes[0], - ); const { MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, MessageUserReactionsItem: contextMessageUserReactionsItem, @@ -76,51 +57,21 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar; const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem; - const onSelectReaction = (reactionType: string) => { - setSelectedReaction(reactionType); - }; - - useEffect(() => { - if (selectedReaction && reactionTypes.length > 0 && !reactionTypes.includes(selectedReaction)) { - setSelectedReaction(reactionTypes[0]); - } - }, [reactionTypes, selectedReaction]); - - const messageReactions = useMemo( - () => - reactionTypes.reduce((acc, reaction) => { - const reactionData = supportedReactions?.find( - (supportedReaction) => supportedReaction.type === reaction, - ); - if (reactionData) { - acc.push(reactionData); - } - return acc; - }, []), - [reactionTypes, supportedReactions], - ); - const { loading, loadNextPage, reactions: fetchedReactions, } = useFetchReactions({ message, - reactionType: selectedReaction, + reactionType: undefined, sort, }); const { theme: { + colors: { white }, messageMenu: { - userReactions: { - container, - contentContainer, - flatlistColumnContainer, - flatlistContainer, - reactionSelectorContainer, - reactionsText, - }, + userReactions: { container, flatlistColumnContainer, flatlistContainer, reactionsText }, }, }, } = useTheme(); @@ -149,52 +100,34 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { [MessageUserReactionsAvatar, MessageUserReactionsItem, supportedReactions], ); - const renderHeader = useCallback( - () => {t('Message Reactions')}, - [t, reactionsText], - ); - - const selectorReactions: ReactionSelectorItemType[] = messageReactions.map((reaction) => ({ - ...reaction, - onSelectReaction, - selectedReaction, - })); - - return ( + return !loading ? ( - - item.type} - renderItem={renderSelectorItem} - /> - - - {!loading ? ( + <> + {t('Message Reactions')} item.id} - ListHeaderComponent={renderHeader} + keyExtractor={keyExtractor} numColumns={4} onEndReached={loadNextPage} renderItem={renderItem} /> - ) : null} + - ); + ) : null; }; const styles = StyleSheet.create({ container: { - flex: 1, + borderRadius: 16, + marginTop: 16, + maxHeight: 256, + width: Dimensions.get('window').width * 0.9, }, contentContainer: { flexGrow: 1, @@ -206,6 +139,7 @@ const styles = StyleSheet.create({ }, flatListContainer: { justifyContent: 'center', + paddingHorizontal: 8, }, reactionSelectorContainer: { flexDirection: 'row', @@ -215,6 +149,7 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: 'bold', marginVertical: 16, + paddingHorizontal: 8, textAlign: 'center', }, }); diff --git a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx index 3a3ca771c..181f4cef1 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx @@ -108,7 +108,8 @@ export const MessageUserReactionsItem = ({ const styles = StyleSheet.create({ avatarContainer: { - marginBottom: 8, + marginBottom: 24, + marginHorizontal: 8, }, avatarInnerContainer: { alignSelf: 'center', diff --git a/package/src/components/MessageMenu/ReactionButton.tsx b/package/src/components/MessageMenu/ReactionButton.tsx index b3d4ad8f9..63fb815d1 100644 --- a/package/src/components/MessageMenu/ReactionButton.tsx +++ b/package/src/components/MessageMenu/ReactionButton.tsx @@ -29,14 +29,9 @@ export const ReactionButton = (props: ReactionButtonProps) => { const { Icon, onPress, selected, type } = props; const { theme: { - colors: { light_blue, accent_blue, white, grey }, + colors: { accent_blue, grey }, messageMenu: { - reactionButton: { - filledBackgroundColor = light_blue, - filledColor = accent_blue, - unfilledBackgroundColor = white, - unfilledColor = grey, - }, + reactionButton: { filledColor = accent_blue, unfilledColor = grey }, reactionPicker: { buttonContainer, reactionIconSize }, }, }, @@ -54,7 +49,7 @@ export const ReactionButton = (props: ReactionButtonProps) => { onPress={onPressHandler} style={({ pressed }) => [ styles.reactionButton, - { backgroundColor: pressed || selected ? filledBackgroundColor : unfilledBackgroundColor }, + { backgroundColor: 'transparent', opacity: pressed ? 0.5 : 1 }, buttonContainer, ]} > @@ -72,6 +67,8 @@ const styles = StyleSheet.create({ alignItems: 'center', borderRadius: 8, justifyContent: 'center', - padding: 8, + overflow: 'hidden', + paddingHorizontal: 3, + paddingVertical: 8, }, }); diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx index e93cefa59..db623849b 100644 --- a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Text } from 'react-native'; -import { fireEvent, render } from '@testing-library/react-native'; +import { render } from '@testing-library/react-native'; import { LocalMessage, ReactionResponse } from 'stream-chat'; @@ -85,30 +85,31 @@ describe('MessageUserReactions when the supportedReactions are defined', () => { expect(getByText('Message Reactions')).toBeTruthy(); }); - it('renders reaction buttons', () => { - const { getByLabelText } = renderComponent(); - const likeReactionButton = getByLabelText('reaction-button-like-selected'); - expect(likeReactionButton).toBeDefined(); - const loveReactionButton = getByLabelText('reaction-button-love-unselected'); - expect(loveReactionButton).toBeDefined(); - }); - - it('selects the first reaction by default', () => { - const { getAllByLabelText } = renderComponent(); - const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); - expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-selected'); - expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-unselected'); - }); - - it('changes selected reaction when a reaction button is pressed', () => { - const { getAllByLabelText } = renderComponent(); - const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); - - fireEvent.press(reactionButtons[1]); - - expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-unselected'); - expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-selected'); - }); + // TODO: V9: Remove these with V9, they are no longer relevant tests. + // it('renders reaction buttons', () => { + // const { getByLabelText } = renderComponent(); + // const likeReactionButton = getByLabelText('reaction-button-like-selected'); + // expect(likeReactionButton).toBeDefined(); + // const loveReactionButton = getByLabelText('reaction-button-love-unselected'); + // expect(loveReactionButton).toBeDefined(); + // }); + // + // it('selects the first reaction by default', () => { + // const { getAllByLabelText } = renderComponent(); + // const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); + // expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-selected'); + // expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-unselected'); + // }); + // + // it('changes selected reaction when a reaction button is pressed', () => { + // const { getAllByLabelText } = renderComponent(); + // const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); + // + // fireEvent.press(reactionButtons[1]); + // + // expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-unselected'); + // expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-selected'); + // }); it('renders reactions list', () => { const { getByText } = renderComponent(); diff --git a/package/src/components/MessageMenu/emojis.ts b/package/src/components/MessageMenu/emojis.ts new file mode 100644 index 000000000..7693c0c47 --- /dev/null +++ b/package/src/components/MessageMenu/emojis.ts @@ -0,0 +1,163 @@ +// TODO: V9: This should really come from emoji mart or something else. +// No reason to pollute the SDK like this. It'll have to do for now though, +// as for the purposes of a PoC it's fine. +export const emojis = [ + '😀', + '😃', + '😄', + '😁', + '😆', + '😅', + '🤣', + '😂', + '🙂', + '🙃', + '😉', + '😊', + '😇', + '🥰', + '😍', + '🤩', + '😘', + '😗', + '😚', + '😙', + '😋', + '😛', + '😜', + '🤪', + '😝', + '🤑', + '🤗', + '🤭', + '🤫', + '🤔', + '🤐', + '🤨', + '😐', + '😑', + '😶', + '😶‍🌫️', + '😏', + '😒', + '🙄', + '😬', + '🤥', + '😌', + '😔', + '😪', + '🤤', + '😴', + '😷', + '🤒', + '🤕', + '🤢', + '🤮', + '🤧', + '🥵', + '🥶', + '🥴', + '😵‍💫', + '🤯', + '🤠', + '🥳', + '😎', + '🤓', + '🧐', + '😕', + '😟', + '🙁', + '☹️', + '😮', + '😯', + '😲', + '😳', + '🥺', + '😦', + '😧', + '😨', + '😰', + '😥', + '😢', + '😭', + '😱', + '😖', + '😣', + '😞', + '😓', + '😩', + '😫', + '🥱', + '😤', + '😡', + '😠', + '🤬', + '😈', + '👿', + '💀', + '☠️', + '💩', + '🤡', + '👹', + '👺', + '👻', + '👽', + '👾', + '🤖', + '🎃', + '😺', + '😸', + '😹', + '😻', + '😼', + '😽', + '🙀', + '😿', + '😾', + '👍', + '👎', + '👌', + '🤌', + '🤏', + '✌️', + '🤞', + '🤟', + '🤘', + '🤙', + '👈', + '👉', + '👆', + '👇', + '☝️', + '✋', + '🤚', + '🖐️', + '🖖', + '👋', + '🤝', + '🙏', + '💪', + '👣', + '👀', + '🧠', + '🫶', + '💋', + '❤️', + '🧡', + '💛', + '💚', + '💙', + '💜', + '🖤', + '🤍', + '🤎', + '💔', + '❣️', + '💕', + '💞', + '💓', + '💗', + '💖', + '💘', + '💝', +]; diff --git a/package/src/components/MessageMenu/hooks/useFetchReactions.ts b/package/src/components/MessageMenu/hooks/useFetchReactions.ts index f67630a75..96683a101 100644 --- a/package/src/components/MessageMenu/hooks/useFetchReactions.ts +++ b/package/src/components/MessageMenu/hooks/useFetchReactions.ts @@ -41,9 +41,19 @@ export const useFetchReactions = ({ if (response) { setNext(response.next); - setReactions((prevReactions) => - next ? [...prevReactions, ...response.reactions] : response.reactions, - ); + setReactions((prevReactions) => { + if ( + prevReactions.length !== response.reactions.length || + !prevReactions.every( + (r, index) => + r.user_id === response.reactions[index].user_id && + r.type === response.reactions[index].type, + ) + ) { + return next ? [...prevReactions, ...response.reactions] : response.reactions; + } + return prevReactions; + }); setLoading(false); } } catch (error) { @@ -82,7 +92,7 @@ export const useFetchReactions = ({ client.on('reaction.new', (event) => { const { reaction } = event; - if (reaction && reaction.type === reactionType) { + if (reaction && (reactionType ? reactionType === reaction.type : true)) { setReactions((prevReactions) => [reaction, ...prevReactions]); } }), @@ -92,14 +102,11 @@ export const useFetchReactions = ({ client.on('reaction.updated', (event) => { const { reaction } = event; - if (reaction) { - if (reaction.type === reactionType) { - setReactions((prevReactions) => [reaction, ...prevReactions]); - } else { - setReactions((prevReactions) => - prevReactions.filter((r) => r.user_id !== reaction.user_id), - ); - } + if (reaction && (reactionType ? reactionType === reaction.type : true)) { + setReactions((prevReactions) => [ + reaction, + ...prevReactions.filter((r) => r.user_id !== reaction.user_id), + ]); } }), ); @@ -108,10 +115,12 @@ export const useFetchReactions = ({ client.on('reaction.deleted', (event) => { const { reaction } = event; - if (reaction && reaction.type === reactionType) { - setReactions((prevReactions) => - prevReactions.filter((r) => r.user_id !== reaction.user_id), - ); + if (reaction && (reactionType ? reactionType === reaction.type : true)) { + setReactions((prevReactions) => { + return prevReactions.filter( + (r) => r.user_id !== reaction.user_id && r.type !== reaction.type, + ); + }); } }), ); diff --git a/package/src/components/Reply/__tests__/Reply.test.tsx b/package/src/components/Reply/__tests__/Reply.test.tsx index 7a20c757d..ed31d5e16 100644 --- a/package/src/components/Reply/__tests__/Reply.test.tsx +++ b/package/src/components/Reply/__tests__/Reply.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + import { render, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -24,13 +26,15 @@ describe('', () => { await channel.watch(); const TestComponent = () => ( - - - - - - - + + + + + + + + + ); try { diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index a917135ec..b9c34b762 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -262,322 +262,329 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-wrapper" > - - - - - - - - - + + + - - + + + + + + + + - - - Message6 - - + + + Message6 + + + + + + + 2:50 PM + + + ⦁ + + + Edited + + - - - 2:50 PM - - - ⦁ - - - Edited - - + @@ -632,322 +639,329 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-wrapper" > - - - - - - - - - + + + - - + + + + + + + + - - - Message5 - - + + + Message5 + + + + + + + 2:50 PM + + + ⦁ + + + Edited + + - - - 2:50 PM - - - ⦁ - - - Edited - - + @@ -1040,322 +1054,329 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-wrapper" > - - - - - - - - - + + + - - + + + + + + + + - - - Message4 - - + + + Message4 + + + + + + + 2:50 PM + + + ⦁ + + + Edited + + - - - 2:50 PM - - - ⦁ - - - Edited - - + @@ -1411,325 +1432,332 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-wrapper" > - - - - - - - - - + + + - - + + + + + + + + - - - Message3 - - + + + Message3 + + + + + + + 2:50 PM + + + ⦁ + + + Edited + + - - - 2:50 PM - - - ⦁ - - - Edited - - + diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 3d8e31975..5de007e7e 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -1,6 +1,5 @@ -import React, { PropsWithChildren, useEffect, useMemo } from 'react'; +import React, { PropsWithChildren, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { - Animated, EventSubscription, Keyboard, KeyboardEvent, @@ -10,42 +9,32 @@ import { useWindowDimensions, View, } from 'react-native'; -import { - Gesture, - GestureDetector, - GestureHandlerRootView, - GestureUpdateEvent, - PanGestureHandlerEventPayload, -} from 'react-native-gesture-handler'; - +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; import type { KeyboardEventData } from 'react-native-keyboard-controller'; -import { runOnJS } from 'react-native-reanimated'; +import Animated, { + cancelAnimation, + Easing, + FadeIn, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; import { KeyboardControllerPackage } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; export type BottomSheetModalProps = { - /** - * Function to call when the modal is closed. - * @returns void - */ onClose: () => void; - /** - * Whether the modal is visible. - */ visible: boolean; - /** - * The height of the modal. - */ height?: number; }; -/** - * A modal that slides up from the bottom of the screen. - */ export const BottomSheetModal = (props: PropsWithChildren) => { const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const { children, height = windowHeight / 2, onClose, visible } = props; + const { theme: { bottomSheetModal: { container, contentContainer, handle, overlay: overlayTheme, wrapper }, @@ -53,44 +42,102 @@ export const BottomSheetModal = (props: PropsWithChildren }, } = useTheme(); - const translateY = useMemo(() => new Animated.Value(height), [height]); + const translateY = useSharedValue(height); + const keyboardOffset = useSharedValue(0); + const isOpen = useSharedValue(false); - const openAnimation = useMemo( - () => - Animated.timing(translateY, { - duration: 200, - toValue: 0, - useNativeDriver: true, - }), - [translateY], - ); + const panStartY = useSharedValue(0); - const closeAnimation = Animated.timing(translateY, { - duration: 50, - toValue: height, - useNativeDriver: true, - }); + const [renderContent, setRenderContent] = useState(false); - const handleDismiss = () => { - closeAnimation.start(() => onClose()); - }; + const close = useStableCallback(() => { + // close always goes fully off-screen and only then notifies JS + setRenderContent(false); + isOpen.value = false; + cancelAnimation(translateY); + translateY.value = withTiming(height, { duration: 200 }, (finished) => { + if (finished) runOnJS(onClose)(); + }); + }); + + // Open animation: keep it simple (setting shared values from JS still runs on UI) + useLayoutEffect(() => { + if (!visible) return; + + isOpen.value = true; + keyboardOffset.value = 0; + + // clean up any leftover animations + cancelAnimation(translateY); + // kick animation on UI thread so JS congestion can't delay the start; only render content + // once the animation finishes + translateY.value = height; + + translateY.value = withTiming( + keyboardOffset.value, + { duration: 200, easing: Easing.inOut(Easing.ease) }, + (finished) => { + if (finished) runOnJS(setRenderContent)(true); + }, + ); + }, [visible, height, isOpen, keyboardOffset, translateY]); + + // if `visible` gets hard changed, we force a cleanup useEffect(() => { - if (visible) { - openAnimation.start(); + if (visible) return; + + setRenderContent(false); + + isOpen.value = false; + keyboardOffset.value = 0; + + cancelAnimation(translateY); + translateY.value = height; + }, [visible, height, isOpen, keyboardOffset, translateY]); + + const keyboardDidShow = useStableCallback((event: KeyboardEvent) => { + const offset = -event.endCoordinates.height; + keyboardOffset.value = offset; + + if (isOpen.value) { + cancelAnimation(translateY); + translateY.value = withTiming(offset, { + duration: 250, + easing: Easing.inOut(Easing.ease), + }); } - }, [visible, openAnimation]); + }); + + const keyboardDidHide = useStableCallback(() => { + keyboardOffset.value = 0; + + if (isOpen.value) { + cancelAnimation(translateY); + translateY.value = withTiming(0, { + duration: 250, + easing: Easing.inOut(Easing.ease), + }); + } + }); useEffect(() => { + if (!visible) return; + const listeners: EventSubscription[] = []; if (KeyboardControllerPackage?.KeyboardEvents) { - const keyboardDidShow = (e: KeyboardEventData) => { - Animated.timing(translateY, { - duration: 250, - toValue: -e.height, - useNativeDriver: true, - }).start(); + const keyboardDidShow = (event: KeyboardEventData) => { + const offset = -event.height; + keyboardOffset.value = offset; + + if (isOpen.value) { + cancelAnimation(translateY); + translateY.value = withTiming(offset, { + duration: 250, + easing: Easing.inOut(Easing.ease), + }); + } }; listeners.push( @@ -101,45 +148,48 @@ export const BottomSheetModal = (props: PropsWithChildren listeners.push(Keyboard.addListener('keyboardDidShow', keyboardDidShow)); listeners.push(Keyboard.addListener('keyboardDidHide', keyboardDidHide)); } - return () => { listeners.forEach((listener) => listener.remove()); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const keyboardDidShow = (event: KeyboardEvent) => { - Animated.timing(translateY, { - duration: 250, - toValue: -event.endCoordinates.height, - useNativeDriver: true, - }).start(); - }; - - const keyboardDidHide = () => { - Animated.timing(translateY, { - duration: 250, - toValue: 0, - useNativeDriver: true, - }).start(); - }; - - const handleUpdate = (event: GestureUpdateEvent) => { - const translationY = Math.max(event.translationY, 0); - translateY.setValue(translationY); - }; - - const gesture = Gesture.Pan() - .onUpdate((event) => { - runOnJS(handleUpdate)(event); - }) - .onEnd((event) => { - if (event.velocityY > 500 || event.translationY > height / 2) { - runOnJS(handleDismiss)(); - } else { - runOnJS(openAnimation.start)(); - } - }); + }, [visible, keyboardDidHide, keyboardDidShow, keyboardOffset, isOpen, translateY]); + + const sheetAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + const gesture = useMemo( + () => + Gesture.Pan() + .onBegin(() => { + cancelAnimation(translateY); + panStartY.value = translateY.value; + }) + .onUpdate((event) => { + const minY = keyboardOffset.value; + translateY.value = Math.max(panStartY.value + event.translationY, minY); + }) + .onEnd((event) => { + const openY = keyboardOffset.value; + const draggedDown = Math.max(translateY.value - openY, 0); + const shouldClose = event.velocityY > 500 || draggedDown > height / 2; + + cancelAnimation(translateY); + + if (shouldClose) { + isOpen.value = false; + translateY.value = withTiming(height, { duration: 100 }, (finished) => { + if (finished) runOnJS(onClose)(); + }); + } else { + isOpen.value = true; + translateY.value = withTiming(openY, { + duration: 200, + easing: Easing.inOut(Easing.ease), + }); + } + }), + [height, isOpen, keyboardOffset, onClose, panStartY, translateY], + ); return ( @@ -147,24 +197,28 @@ export const BottomSheetModal = (props: PropsWithChildren - + + - {children} + + {renderContent ? ( + + {children} + + ) : null} + @@ -179,10 +233,6 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 16, borderTopRightRadius: 16, }, - content: { - flex: 1, - padding: 16, - }, contentContainer: { flex: 1, marginTop: 8, diff --git a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx index 90fb5d487..71245fbf1 100644 --- a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx +++ b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx @@ -33,6 +33,7 @@ export type MessageListItemContextValue = { * @param message A message object to open the thread upon. */ onThreadSelect: MessageListProps['onThreadSelect']; + setNativeScrollability: (value: boolean) => void; }; export const MessageListItemContext = createContext( diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index fdfa6a27d..aa4e4d36b 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import { PressableProps, ViewProps } from 'react-native'; +import { PressableProps, View, ViewProps } from 'react-native'; import type { Attachment, @@ -272,7 +272,7 @@ export type MessagesContextValue = Pick; + MessageSimple: React.ComponentType }>; /** * UI component for MessageStatus (delivered/read) * Defaults to: [MessageStatus](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/Message/MessageSimple/MessageStatus.tsx) diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx new file mode 100644 index 000000000..cf1226712 --- /dev/null +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useMemo } from 'react'; +import { Platform, Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + cancelAnimation, + clamp, + runOnJS, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withDecay, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PortalHost } from 'react-native-teleport'; + +import { closeOverlay, useOverlayController } from '../../state-store'; +import { finalizeCloseOverlay } from '../../state-store'; + +export const MessageOverlayHostLayer = () => { + const { messageH, topH, bottomH, id, closing } = useOverlayController(); + const insets = useSafeAreaInsets(); + const { height: screenH } = useWindowDimensions(); + + const topInset = insets.top; + // Due to edge-to-edge in combination with various libraries, Android sometimes reports + // the insets to be 0. If that's the case, we use this as an escape hatch to offset the bottom + // of the overlay so that it doesn't collide with the navigation bar. Worst case scenario, + // if the navigation bar is actually 0 - we end up animating a little bit further. + const bottomInset = insets.bottom === 0 && Platform.OS === 'android' ? 60 : insets.bottom; + + const isActive = !!id; + + const padding = 8; + const minY = topInset + padding; + const maxY = screenH - bottomInset - padding; + + const backdrop = useSharedValue(0); + + useEffect(() => { + const target = isActive && !closing ? 1 : 0; + backdrop.value = withTiming(target, { duration: 150 }); + }, [isActive, closing, backdrop]); + + const backdropStyle = useAnimatedStyle(() => ({ + opacity: backdrop.value, + })); + + const shiftY = useDerivedValue(() => { + if (!messageH?.value || !topH?.value || !bottomH?.value) return 0; + + const anchorY = messageH.value.y; + const msgH = messageH.value.h; + + const minTop = minY + topH.value.h; + const maxTop = maxY - (msgH + bottomH.value.h); + + const solvedTop = clamp(anchorY, minTop, maxTop); + return solvedTop - anchorY; + }); + + const viewportH = useSharedValue(screenH); + useEffect(() => { + viewportH.value = screenH; + }, [screenH, viewportH]); + + const scrollY = useSharedValue(0); + const initialScrollOffset = useSharedValue(0); + + useEffect(() => { + if (isActive) scrollY.value = 0; + }, [isActive, scrollY]); + + const contentH = useDerivedValue(() => + topH?.value && bottomH?.value && messageH?.value + ? Math.max( + screenH, + topH.value.h + messageH.value.h + bottomH.value.h + topInset + bottomInset + 20, + ) + : 0, + ); + + const maxScroll = useDerivedValue(() => Math.max(0, contentH.value - viewportH.value)); + + const pan = useMemo( + () => + Gesture.Pan() + .activeOffsetY([-8, 8]) + .failOffsetX([-12, 12]) + .onBegin(() => { + cancelAnimation(scrollY); + initialScrollOffset.value = scrollY.value; + }) + .onUpdate((e) => { + scrollY.value = clamp(initialScrollOffset.value + e.translationY, 0, maxScroll.value); + }) + .onEnd((e) => { + scrollY.value = withDecay({ clamp: [0, maxScroll.value], velocity: e.velocityY }); + }), + [initialScrollOffset, maxScroll, scrollY], + ); + + const scrollAtClose = useSharedValue(0); + + useDerivedValue(() => { + if (closing) { + scrollAtClose.value = scrollY.value; + cancelAnimation(scrollY); + } + }, [closing]); + + const closeCompStyle = useAnimatedStyle(() => { + const target = closing ? -scrollAtClose.value : 0; + return { + transform: [{ translateY: withTiming(target, { duration: 150 }) }], + }; + }, [closing]); + + const topItemStyle = useAnimatedStyle(() => { + if (!topH?.value) return { height: 0 }; + return { + height: topH.value.h, + left: topH.value.x, + position: 'absolute', + top: topH.value.y + scrollY.value, + width: topH.value.w, + }; + }); + + const topItemTranslateStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + return { + transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], + }; + }, [isActive, closing]); + + const bottomItemStyle = useAnimatedStyle(() => { + if (!bottomH?.value) return { height: 0 }; + return { + height: bottomH.value.h, + left: bottomH.value.x, + position: 'absolute', + top: bottomH.value.y + scrollY.value, + width: bottomH.value.w, + }; + }); + + const bottomItemTranslateStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + return { + transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], + }; + }, [isActive, closing]); + + const hostStyle = useAnimatedStyle(() => { + if (!messageH?.value) return { height: 0 }; + return { + height: messageH.value.h, + left: messageH.value.x, + position: 'absolute', + top: messageH.value.y + scrollY.value, // layout scroll (no special msg-only compensation) + width: messageH.value.w, + }; + }); + + const hostTranslateStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + + return { + transform: [ + { + translateY: withTiming(target, { duration: 150 }, (finished) => { + if (finished && closing) { + runOnJS(finalizeCloseOverlay)(); + } + }), + }, + ], + }; + }, [isActive, closing]); + + const contentStyle = useAnimatedStyle(() => ({ + height: contentH.value, + })); + + const tap = Gesture.Tap() + .onTouchesDown((e, state) => { + const t = e.allTouches[0]; + if (!t) return; + + const x = t.x; + const y = t.y; + + const yShift = shiftY.value; // overlay shift + const yParent = scrollY.value; // parent content + + const top = topH?.value; + if (top) { + // top rectangle's final screen Y + // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) + const topY = top.y + yParent + yShift; + if (x >= top.x && x <= top.x + top.w && y >= topY && y <= topY + top.h) { + state.fail(); + return; + } + } + + const bot = bottomH?.value; + if (bot) { + // bottom rectangle's final screen Y + // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) + const botY = bot.y + yParent + yShift; + if (x >= bot.x && x <= bot.x + bot.w && y >= botY && y <= botY + bot.h) { + state.fail(); + return; + } + } + }) + .onEnd(() => { + runOnJS(closeOverlay)(); + }); + + return ( + + + {isActive ? ( + + ) : null} + + + {isActive ? ( + + ) : null} + + + + + + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + shadow3: { + overflow: 'visible', + ...Platform.select({ + android: { + elevation: 3, + // helps on newer Android (API 28+) to tint elevation shadow + shadowColor: '#000000', + }, + ios: { + shadowColor: 'white', + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.4, + shadowRadius: 10, + }, + }), + }, +}); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index e4397032f..35d03fba4 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -4,6 +4,9 @@ import { BackHandler } from 'react-native'; import { cancelAnimation, useSharedValue, withTiming } from 'react-native-reanimated'; +import { PortalProvider } from 'react-native-teleport'; + +import { MessageOverlayHostLayer } from './MessageOverlayHostLayer'; import { OverlayContext, OverlayProviderProps } from './OverlayContext'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; @@ -93,18 +96,21 @@ export const OverlayProvider = (props: PropsWithChildren) - {children} - {overlay === 'gallery' && ( - - )} + + {children} + {overlay === 'gallery' && ( + + )} + + diff --git a/package/src/state-store/index.ts b/package/src/state-store/index.ts index 642110a9c..4d896c62a 100644 --- a/package/src/state-store/index.ts +++ b/package/src/state-store/index.ts @@ -1,3 +1,4 @@ export * from './audio-player'; export * from './in-app-notifications-store'; export * from './audio-player-pool'; +export * from './message-overlay-store'; diff --git a/package/src/state-store/message-overlay-store.ts b/package/src/state-store/message-overlay-store.ts new file mode 100644 index 000000000..898f70555 --- /dev/null +++ b/package/src/state-store/message-overlay-store.ts @@ -0,0 +1,85 @@ +import { useCallback } from 'react'; +import Animated from 'react-native-reanimated'; + +import { StateStore } from 'stream-chat'; + +import { useStateStore } from '../hooks'; + +type OverlayState = { + topH: Animated.SharedValue | undefined; + bottomH: Animated.SharedValue | undefined; + messageH: Animated.SharedValue | undefined; + id: string | undefined; + closing: boolean; +}; + +type Rect = { x: number; y: number; w: number; h: number } | undefined; + +const DefaultState = { + bottomH: undefined, + closing: false, + id: undefined, + messageH: undefined, + topH: undefined, +}; + +export const openOverlay = (id: string, { messageH, topH, bottomH }: Partial) => + overlayStore.partialNext({ bottomH, closing: false, id, messageH, topH }); + +export const closeOverlay = () => { + requestAnimationFrame(() => overlayStore.partialNext({ closing: true })); +}; + +let actionQueue: Array<() => void | Promise> = []; + +export const scheduleActionOnClose = (action: () => void | Promise) => { + const { id } = overlayStore.getLatestValue(); + if (id) { + actionQueue.push(action); + return; + } + action(); +}; + +export const finalizeCloseOverlay = () => overlayStore.partialNext(DefaultState); + +export const overlayStore = new StateStore(DefaultState); + +const actionQueueSelector = (nextState: OverlayState) => ({ active: !!nextState.id }); + +// TODO: V9: Consider having a store per `MessageOverlayHostLayer` to prevent multi-instance +// integrations causing UI issues. +overlayStore.subscribeWithSelector(actionQueueSelector, async ({ active }) => { + if (!active) { + // flush the queue + for (const action of actionQueue) { + await action(); + } + + actionQueue = []; + } +}); + +const selector = (nextState: OverlayState) => ({ + bottomH: nextState.bottomH, + closing: nextState.closing, + id: nextState.id, + messageH: nextState.messageH, + topH: nextState.topH, +}); + +export const useOverlayController = () => { + return useStateStore(overlayStore, selector); +}; + +const noOpObject = { active: false, closing: false }; + +export const useIsOverlayActive = (messageId: string) => { + const messageOverlaySelector = useCallback( + (nextState: OverlayState) => + nextState.id === messageId ? { active: true, closing: nextState.closing } : noOpObject, + [messageId], + ); + + return useStateStore(overlayStore, messageOverlaySelector); +}; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 9d26951c2..ebab5759f 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -15,6 +15,7 @@ import { ValueOf } from '../types/types'; export type ReactionData = { Icon: React.ComponentType; type: string; + isUnicode?: boolean; }; export const FileState = Object.freeze({ diff --git a/package/yarn.lock b/package/yarn.lock index 336dfb336..5d968e079 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -7774,6 +7774,11 @@ react-native-svg@15.12.0: css-tree "^1.1.3" warn-once "0.1.1" +react-native-teleport@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.4.tgz#6af16e3984ee0fc002e5d6c6dae0ad40f22699c2" + integrity sha512-kiNbB27lJHAO7DdG7LlPi6DaFnYusVQV9rqp0q5WoRpnqKNckIvzXULox6QpZBstaTF/jkO+xmlkU+pnQqKSAQ== + react-native-url-polyfill@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589" From 60d115e1d35ace98bc5ddd365b31b601ea6aa166 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Sun, 28 Dec 2025 00:24:48 +0530 Subject: [PATCH 07/13] fix: thread scrreen issue with keyboard controller --- examples/SampleApp/src/screens/ThreadScreen.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index ddf81d1ec..1cd305ff1 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -64,7 +64,6 @@ const ThreadHeader: React.FC = ({ thread }) => { return ( @@ -116,7 +115,7 @@ export const ThreadScreen: React.FC = ({ }, [setThread]); return ( - + = ({ thread={thread} threadList > - - - - + + ); From e6b1525eabed2762b0c9d30eb57bef713d5397ba Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Sat, 27 Dec 2025 20:37:00 +0100 Subject: [PATCH 08/13] fix: bottom sheet android content issues (#3340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎯 Goal I unfortunately noticed today that there was a serious concurrency issue introduced on Android with [this PR](https://github.com/GetStream/stream-chat-react-native/pull/3339), where the bottom sheet content would simply not load 90% of the time. The issue was that the animation got cancelled in-flight and so the animation callback of `withTiming` fired with `finished: false` every time, causing the content to never really be shown. These changes should address that now. I also introduced optionality for the lazy loading so that the sheet can properly be reused in the future. ## 🛠 Implementation details ## 🎨 UI Changes
iOS
Before After
Android
Before After
## 🧪 Testing ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- .../SampleApp/src/screens/ThreadScreen.tsx | 2 +- .../MessageMenu/MessageReactionPicker.tsx | 2 +- .../UIComponents/BottomSheetModal.tsx | 176 ++++++++++++------ 3 files changed, 123 insertions(+), 57 deletions(-) diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index 1cd305ff1..a06d0f1a5 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Channel, diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index bb8c55211..700ff4b50 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -155,7 +155,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { renderItem={renderItem} /> {emojiViewerOpened ? ( - + void; + /** + * Whether the modal is visible. + */ visible: boolean; + /** + * The height of the modal. + */ height?: number; + /** + * Whether the sheet content should be lazy loaded or not. Particularly + * useful when the content is something heavy and we don't want to disrupt + * the animations while this is happening. + */ + lazy?: boolean; }; +// TODO: V9: Animate the backdrop as well. export const BottomSheetModal = (props: PropsWithChildren) => { const { height: windowHeight, width: windowWidth } = useWindowDimensions(); - const { children, height = windowHeight / 2, onClose, visible } = props; + const { children, height = windowHeight / 2, onClose, visible, lazy = false } = props; const { theme: { @@ -44,81 +61,119 @@ export const BottomSheetModal = (props: PropsWithChildren const translateY = useSharedValue(height); const keyboardOffset = useSharedValue(0); + const isOpen = useSharedValue(false); + const isOpening = useSharedValue(false); const panStartY = useSharedValue(0); - const [renderContent, setRenderContent] = useState(false); + const [renderContent, setRenderContent] = useState(!lazy); + + const showContent = useStableCallback(() => { + if (lazy) { + setRenderContent(true); + } + }); + + const hideContent = useStableCallback(() => { + if (lazy) { + setRenderContent(false); + } + }); const close = useStableCallback(() => { - // close always goes fully off-screen and only then notifies JS - setRenderContent(false); + // hide content immediately + hideContent(); isOpen.value = false; + isOpening.value = false; + cancelAnimation(translateY); - translateY.value = withTiming(height, { duration: 200 }, (finished) => { - if (finished) runOnJS(onClose)(); - }); + + translateY.value = withTiming( + height, + { duration: 180, easing: Easing.out(Easing.cubic) }, + (finished) => { + if (finished) runOnJS(onClose)(); + }, + ); }); - // Open animation: keep it simple (setting shared values from JS still runs on UI) + // modal opening layout effect - we make sure to only show the content + // after the animation has finished if `lazy` has been set to true useLayoutEffect(() => { if (!visible) return; isOpen.value = true; - keyboardOffset.value = 0; + isOpening.value = true; - // clean up any leftover animations cancelAnimation(translateY); - // kick animation on UI thread so JS congestion can't delay the start; only render content - // once the animation finishes + + // start from closed translateY.value = height; + // Snapshot current keyboard offset as the open target. + // If keyboard changes during opening, we’ll adjust after. + const initialTarget = keyboardOffset.value; + translateY.value = withTiming( - keyboardOffset.value, - { duration: 200, easing: Easing.inOut(Easing.ease) }, + initialTarget, + { duration: 220, easing: Easing.out(Easing.cubic) }, (finished) => { - if (finished) runOnJS(setRenderContent)(true); + if (!finished) return; + + // opening the modal has now truly finished + isOpening.value = false; + + // reveal the content if we want to load it lazily + runOnJS(showContent)(); + + // if keyboard offset changed while we were opening, we do a + // follow-up adjustment (we do not gate the content however) + const latestTarget = keyboardOffset.value; + if (latestTarget !== initialTarget && isOpen.value) { + cancelAnimation(translateY); + translateY.value = withTiming(latestTarget, { + duration: 200, + easing: Easing.inOut(Easing.ease), + }); + } }, ); - }, [visible, height, isOpen, keyboardOffset, translateY]); + }, [visible, height, hideContent, isOpen, isOpening, keyboardOffset, showContent, translateY]); // if `visible` gets hard changed, we force a cleanup useEffect(() => { if (visible) return; - setRenderContent(false); - isOpen.value = false; + isOpening.value = false; keyboardOffset.value = 0; cancelAnimation(translateY); translateY.value = height; - }, [visible, height, isOpen, keyboardOffset, translateY]); + }, [visible, height, isOpen, isOpening, keyboardOffset, translateY]); - const keyboardDidShow = useStableCallback((event: KeyboardEvent) => { + const keyboardDidShowRN = useStableCallback((event: KeyboardEvent) => { const offset = -event.endCoordinates.height; keyboardOffset.value = offset; - if (isOpen.value) { - cancelAnimation(translateY); - translateY.value = withTiming(offset, { - duration: 250, - easing: Easing.inOut(Easing.ease), - }); - } + // We just record the offset, but we avoid cancelling the animation + // if it's in the process of opening. The same logic applies to all + // other keyboard related callbacks in this specific conditional. + if (!isOpen.value || isOpening.value) return; + + cancelAnimation(translateY); + translateY.value = withTiming(offset, { duration: 250, easing: Easing.inOut(Easing.ease) }); }); const keyboardDidHide = useStableCallback(() => { keyboardOffset.value = 0; - if (isOpen.value) { - cancelAnimation(translateY); - translateY.value = withTiming(0, { - duration: 250, - easing: Easing.inOut(Easing.ease), - }); - } + if (!isOpen.value || isOpening.value) return; + + cancelAnimation(translateY); + translateY.value = withTiming(0, { duration: 250, easing: Easing.inOut(Easing.ease) }); }); useEffect(() => { @@ -127,31 +182,27 @@ export const BottomSheetModal = (props: PropsWithChildren const listeners: EventSubscription[] = []; if (KeyboardControllerPackage?.KeyboardEvents) { - const keyboardDidShow = (event: KeyboardEventData) => { + const keyboardDidShowKC = (event: KeyboardEventData) => { const offset = -event.height; keyboardOffset.value = offset; - if (isOpen.value) { - cancelAnimation(translateY); - translateY.value = withTiming(offset, { - duration: 250, - easing: Easing.inOut(Easing.ease), - }); - } + if (!isOpen.value || isOpening.value) return; + + cancelAnimation(translateY); + translateY.value = withTiming(offset, { duration: 250, easing: Easing.inOut(Easing.ease) }); }; listeners.push( - KeyboardControllerPackage.KeyboardEvents.addListener('keyboardDidShow', keyboardDidShow), + KeyboardControllerPackage.KeyboardEvents.addListener('keyboardDidShow', keyboardDidShowKC), KeyboardControllerPackage.KeyboardEvents.addListener('keyboardDidHide', keyboardDidHide), ); } else { - listeners.push(Keyboard.addListener('keyboardDidShow', keyboardDidShow)); + listeners.push(Keyboard.addListener('keyboardDidShow', keyboardDidShowRN)); listeners.push(Keyboard.addListener('keyboardDidHide', keyboardDidHide)); } - return () => { - listeners.forEach((listener) => listener.remove()); - }; - }, [visible, keyboardDidHide, keyboardDidShow, keyboardOffset, isOpen, translateY]); + + return () => listeners.forEach((l) => l.remove()); + }, [visible, keyboardDidHide, keyboardDidShowRN, keyboardOffset, isOpen, isOpening, translateY]); const sheetAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], @@ -160,13 +211,16 @@ export const BottomSheetModal = (props: PropsWithChildren const gesture = useMemo( () => Gesture.Pan() + // disable pan until content is rendered (prevents canceling the opening timing). + .enabled(renderContent) .onBegin(() => { cancelAnimation(translateY); panStartY.value = translateY.value; }) .onUpdate((event) => { const minY = keyboardOffset.value; - translateY.value = Math.max(panStartY.value + event.translationY, minY); + const next = panStartY.value + event.translationY; + translateY.value = Math.max(next, minY); }) .onEnd((event) => { const openY = keyboardOffset.value; @@ -177,9 +231,15 @@ export const BottomSheetModal = (props: PropsWithChildren if (shouldClose) { isOpen.value = false; - translateY.value = withTiming(height, { duration: 100 }, (finished) => { - if (finished) runOnJS(onClose)(); - }); + isOpening.value = false; + + translateY.value = withTiming( + height, + { duration: 140, easing: Easing.out(Easing.cubic) }, + (finished) => { + if (finished) runOnJS(onClose)(); + }, + ); } else { isOpen.value = true; translateY.value = withTiming(openY, { @@ -188,13 +248,13 @@ export const BottomSheetModal = (props: PropsWithChildren }); } }), - [height, isOpen, keyboardOffset, onClose, panStartY, translateY], + [height, isOpen, isOpening, keyboardOffset, onClose, panStartY, renderContent, translateY], ); return ( - + @@ -214,7 +274,10 @@ export const BottomSheetModal = (props: PropsWithChildren /> {renderContent ? ( - + {children} ) : null} @@ -247,6 +310,9 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'flex-end', }, + sheetContentContainer: { + flex: 1, + }, wrapper: { alignItems: 'center', flex: 1, From 2218f1e4f9b7518b8e50986f3462a9be54f04daf Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 9 Jan 2026 18:49:35 +0530 Subject: [PATCH 09/13] fix: remove sort keys and import eslint config --- package/eslint.config.mjs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/package/eslint.config.mjs b/package/eslint.config.mjs index 5ea72ef94..a8066671b 100644 --- a/package/eslint.config.mjs +++ b/package/eslint.config.mjs @@ -141,17 +141,6 @@ export default tsEslint.config( 'react/prop-types': 0, 'require-await': 2, semi: [1, 'always'], - 'sort-imports': [ - 'error', - { - allowSeparatedGroups: true, - ignoreCase: true, - ignoreDeclarationSort: true, - ignoreMemberSort: false, - memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], - }, - ], - 'sort-keys': ['error', 'asc', { caseSensitive: false, minKeys: 2, natural: false }], 'valid-typeof': 2, '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/no-empty-interface': 0, From 7b5f9d6bcd2b87aa8e12b3117088a489b7c15cc3 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 14 Jan 2026 18:21:58 +0530 Subject: [PATCH 10/13] feat: redesign of the Message Input component (#3342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 📝 Changelog — Message Composer & Input Refactor ## ✨ Design & UX Improvements - Introduced a **new unified icon set** across the Message Composer. - Added **smoother and more contextual animations** throughout the component. - Message Composer now internally handles **bottom spacing**, removing the need for extra padding in the sample app’s `ChannelScreen`. - Added a new **Edit button** with a tick icon for clearer editing actions. - Reply UI received **theming updates** due to internal component refactor. --- ## ⬇️ Scroll to Bottom Enhancements - Refactored **Scroll to Bottom button** with: - Dedicated wrapper - Configurable `chevronColor` - Touchable support - Button visibility logic improved: - Now appears when scroll distance **exceeds the composer height** - Replaces the previous hardcoded `150px` threshold --- ## 🧱 Architectural & State Improvements - Message Composer height is now stored in a **detached internal state**. - Introduced `messageInputFloating` **config prop at the Channel level**. - Introduced **OutputButtons** as a new dedicated component to manage: - Send - Cooldown - Edit - Audio recording buttons --- ## 🎨 Theme Updates ### MessageList **New theme properties:** - `scrollToBottomButtonContainer` - `stickyHeaderContainer` - `unreadMessagesNotificationContainer` --- ### Removed / Deprecated Themes - `audioRecordingButton` theme is no longer relevant. - **SendButton** theme props removed: - `sendUpIcon` - `sendRightIcon` - `searchIcon` - `CooldownTimer.container` theme removed. - **InputButtons** - `MoreOptionsButton` - `CommandsButton` are no longer used. --- ## 📎 Attachment & Preview Changes ### ImageAttachmentUploadPreview - `itemContainer` theme removed. - New unified `container` theme introduced. ### AttachmentUploadListPreview - Removed: - `imagesFlatList` - `filesFlatList` - `wrapper` - Now uses: - Single unified `flatList` - `itemSeparator` as the only theme prop. ### FileAttachmentUploadPreview - `wrapper` theme removed. - `flatListWidth` prop removed. - Title rendering logic simplified and made more consistent. --- ## ⌨️ AutoComplete & Cooldown Updates - **AutoCompleteInput** - `coolDownActive` → `cooldownRemainingSeconds` - **CooldownTimer** - `container` theme removed. --- ## ✂️ Removed Components The following components are no longer part of the Message Input flow: - `InputEditingStateHeader` - `InputReplyStateHeader` - `CommandButton` - `MoreOptionsButton` --- ## 🧩 MessageInput — Breaking Changes ### ❌ Removed Props - `InputEditingStateHeader` - `InputReplyStateHeader` - `StopMessageStreamingButton` - `SendButton` - `CooldownTimer` - `channel` ### ➕ Added Props - `isKeyboardVisible` - `hasAttachments` --- ## 🎨 MessageInput Theme Changes ### Removed Theme Keys - `editingBoxContainer` - `editingBoxHeader` - `editingBoxHeaderTitle` - `editingStateHeader.editingBoxHeader` - `editingStateHeader.editingBoxHeaderTitle` - `imageUploadPreview.flatList` - `moreOptionsButton` - `autoCompleteInputContainer` - `optionsContainer` - `composerContainer` - `inputBox` ### Added Theme Keys - `wrapper` - `contentContainer` - `inputBoxWrapper` - `inputButtonsContainer` - `inputContainer` - `inputFloatingContainer` - `floatingWrapper` - `editButton` - `cooldownButtonContainer` - `outputButtonsContainer` --- ## ⚠️ Migration Notes - Custom themes targeting removed keys will need updates. - Remove manual bottom padding from `ChannelScreen`. - Update `AutoCompleteInput` usage to `cooldownRemainingSeconds`. - Consumers using removed MessageInput props must migrate to the new API. --- --- examples/SampleApp/App.tsx | 12 + .../SampleApp/src/components/SecretMenu.tsx | 53 ++ examples/SampleApp/src/context/AppContext.ts | 2 + .../src/screens/ChannelListScreen.tsx | 3 +- .../SampleApp/src/screens/ChannelScreen.tsx | 16 +- .../SampleApp/src/screens/ThreadScreen.tsx | 17 +- .../AutoCompleteInput/AutoCompleteInput.tsx | 47 +- .../__tests__/AutoCompleteInput.test.js | 29 +- package/src/components/Channel/Channel.tsx | 24 +- .../useCreateInputMessageInputContext.ts | 10 +- .../Message/MessageSimple/MessageContent.tsx | 5 +- .../AttachmentUploadPreviewList.tsx | 254 ------- .../MessageInput/CommandsButton.tsx | 51 -- .../components/MessageInput/CooldownTimer.tsx | 67 -- .../components/MessageInput/MessageInput.tsx | 322 +++++--- .../MessageInput/MoreOptionsButton.tsx | 34 - .../components/MessageInput/SendButton.tsx | 67 -- .../__tests__/AttachButton.test.js | 2 +- .../AttachmentUploadPreviewList.test.js | 2 +- .../AudioAttachmentUploadPreview.test.js | 2 +- .../__tests__/CommandsButton.test.js | 70 -- .../__tests__/InputButtons.test.js | 118 +-- .../__tests__/MessageInput.test.js | 112 +-- .../MessageInput/__tests__/SendButton.test.js | 21 +- .../__snapshots__/AttachButton.test.js.snap | 363 +++++---- .../__snapshots__/SendButton.test.js.snap | 172 +++-- .../AttachmentUnsupportedIndicator.tsx | 23 +- .../AttachmentUploadPreviewList.tsx | 245 ++++++ .../DismissAttachmentUpload.tsx | 19 +- .../FileAttachmentUploadPreview.tsx | 50 +- .../ImageAttachmentUploadPreview.tsx | 36 +- .../AudioRecorder/AudioRecordingButton.tsx | 45 +- .../MessageInput/components/CommandInput.tsx | 18 +- .../InputButtons}/AttachButton.tsx | 27 +- .../InputButtons/index.tsx} | 75 +- .../components/InputEditingStateHeader.tsx | 73 -- .../components/InputReplyStateHeader.tsx | 56 -- .../OutputButtons/CooldownTimer.tsx | 49 ++ .../components/OutputButtons/EditButton.tsx | 47 ++ .../components/OutputButtons/SendButton.tsx | 47 ++ .../components/OutputButtons/index.tsx | 202 +++++ .../MessageList/MessageFlashList.tsx | 87 ++- .../components/MessageList/MessageList.tsx | 89 ++- .../MessageList/ScrollToBottomButton.tsx | 99 +-- .../UnreadMessagesNotification.tsx | 7 +- .../ScrollToBottomButton.test.js.snap | 203 ++--- .../WritingDirectionAwareText.tsx | 4 +- package/src/components/Reply/Reply.tsx | 711 ++++++++++-------- .../__snapshots__/Thread.test.js.snap | 573 +++++++------- package/src/components/index.ts | 12 +- package/src/components/ui/IconButton.tsx | 109 +++ .../MessageInputContext.tsx | 38 +- .../hooks/useCreateMessageInputContext.ts | 10 +- .../src/contexts/themeContext/utils/theme.ts | 149 ++-- package/src/hooks/useKeyboardVisibility.ts | 35 + package/src/icons/ErrorCircle.tsx | 16 + package/src/icons/NewClose.tsx | 17 + package/src/icons/NewDown.tsx | 17 + package/src/icons/NewFile.tsx | 16 + package/src/icons/NewLink.tsx | 16 + package/src/icons/NewMapPin.tsx | 22 + package/src/icons/NewMic.tsx | 17 + package/src/icons/NewPhoto.tsx | 24 + package/src/icons/NewPlayIcon.tsx | 14 + package/src/icons/NewPlus.tsx | 11 + package/src/icons/NewPoll.tsx | 17 + package/src/icons/NewTick.tsx | 17 + package/src/icons/NewVideo.tsx | 22 + package/src/icons/Search.tsx | 12 +- package/src/icons/SendRight.tsx | 24 +- .../state-store/message-input-height-store.ts | 15 + 71 files changed, 2830 insertions(+), 2460 deletions(-) delete mode 100644 package/src/components/MessageInput/AttachmentUploadPreviewList.tsx delete mode 100644 package/src/components/MessageInput/CommandsButton.tsx delete mode 100644 package/src/components/MessageInput/CooldownTimer.tsx delete mode 100644 package/src/components/MessageInput/MoreOptionsButton.tsx delete mode 100644 package/src/components/MessageInput/SendButton.tsx delete mode 100644 package/src/components/MessageInput/__tests__/CommandsButton.test.js create mode 100644 package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx rename package/src/components/MessageInput/{ => components/InputButtons}/AttachButton.tsx (85%) rename package/src/components/MessageInput/{InputButtons.tsx => components/InputButtons/index.tsx} (56%) delete mode 100644 package/src/components/MessageInput/components/InputEditingStateHeader.tsx delete mode 100644 package/src/components/MessageInput/components/InputReplyStateHeader.tsx create mode 100644 package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx create mode 100644 package/src/components/MessageInput/components/OutputButtons/EditButton.tsx create mode 100644 package/src/components/MessageInput/components/OutputButtons/SendButton.tsx create mode 100644 package/src/components/MessageInput/components/OutputButtons/index.tsx create mode 100644 package/src/components/ui/IconButton.tsx create mode 100644 package/src/hooks/useKeyboardVisibility.ts create mode 100644 package/src/icons/ErrorCircle.tsx create mode 100644 package/src/icons/NewClose.tsx create mode 100644 package/src/icons/NewDown.tsx create mode 100644 package/src/icons/NewFile.tsx create mode 100644 package/src/icons/NewLink.tsx create mode 100644 package/src/icons/NewMapPin.tsx create mode 100644 package/src/icons/NewMic.tsx create mode 100644 package/src/icons/NewPhoto.tsx create mode 100644 package/src/icons/NewPlayIcon.tsx create mode 100644 package/src/icons/NewPlus.tsx create mode 100644 package/src/icons/NewPoll.tsx create mode 100644 package/src/icons/NewTick.tsx create mode 100644 package/src/icons/NewVideo.tsx create mode 100644 package/src/state-store/message-input-height-store.ts diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index b71b8c6a6..4bdc24765 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -60,6 +60,7 @@ import { Toast } from './src/components/ToastComponent/Toast'; import { useClientNotificationsToastHandler } from './src/hooks/useClientNotificationsToastHandler'; import AsyncStore from './src/utils/AsyncStore.ts'; import { + MessageInputFloatingConfigItem, MessageListImplementationConfigItem, MessageListModeConfigItem, MessageListPruningConfigItem, @@ -106,6 +107,9 @@ const App = () => { const [messageListPruning, setMessageListPruning] = useState< MessageListPruningConfigItem['value'] | undefined >(undefined); + const [messageInputFloating, setMessageInputFloating] = useState< + MessageInputFloatingConfigItem['value'] | undefined + >(undefined); const colorScheme = useColorScheme(); const streamChatTheme = useStreamChatTheme(); const streami18n = new Streami18n(); @@ -161,6 +165,10 @@ const App = () => { '@stream-rn-sampleapp-messagelist-pruning', { value: undefined }, ); + const messageInputFloatingStoredValue = await AsyncStore.getItem( + '@stream-rn-sampleapp-messageinput-floating', + { value: false }, + ); setMessageListImplementation( messageListImplementationStoredValue?.id as MessageListImplementationConfigItem['id'], ); @@ -168,6 +176,9 @@ const App = () => { setMessageListPruning( messageListPruningStoredValue?.value as MessageListPruningConfigItem['value'], ); + setMessageInputFloating( + messageInputFloatingStoredValue?.value as MessageInputFloatingConfigItem['value'], + ); }; getMessageListConfig(); return () => { @@ -232,6 +243,7 @@ const App = () => { logout, switchUser, messageListImplementation, + messageInputFloating: messageInputFloating ?? false, messageListMode, messageListPruning, }} diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index 0037645b8..aff708538 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -26,6 +26,7 @@ export type NotificationConfigItem = { label: string; name: string; id: string } export type MessageListImplementationConfigItem = { label: string; id: 'flatlist' | 'flashlist' }; export type MessageListModeConfigItem = { label: string; mode: 'default' | 'livestream' }; export type MessageListPruningConfigItem = { label: string; value: 100 | 500 | 1000 | undefined }; +export type MessageInputFloatingConfigItem = { label: string; value: boolean }; const messageListImplementationConfigItems: MessageListImplementationConfigItem[] = [ { label: 'FlatList', id: 'flatlist' }, @@ -44,6 +45,11 @@ const messageListPruningConfigItems: MessageListPruningConfigItem[] = [ { label: '1000 Messages', value: 1000 }, ]; +const messageInputFloatingConfigItems: MessageInputFloatingConfigItem[] = [ + { label: 'Normal', value: false }, + { label: 'Floating', value: true }, +]; + export const SlideInView = ({ visible, children, @@ -161,6 +167,23 @@ const SecretMenuMessageListImplementationConfigItem = ({ ); +const SecretMenuMessageInputFloatingConfigItem = ({ + messageInputFloatingConfigItem, + storeMessageInputFloating, + isSelected, +}: { + messageInputFloatingConfigItem: MessageInputFloatingConfigItem; + storeMessageInputFloating: (item: MessageInputFloatingConfigItem) => void; + isSelected: boolean; +}) => ( + storeMessageInputFloating(messageInputFloatingConfigItem)} + > + {messageInputFloatingConfigItem.label} + +); + const SecretMenuMessageListModeConfigItem = ({ messageListModeConfigItem, storeMessageListMode, @@ -218,6 +241,8 @@ export const SecretMenu = ({ const [selectedMessageListPruning, setSelectedMessageListPruning] = useState< MessageListPruningConfigItem['value'] | null >(null); + const [selectedMessageInputFloating, setSelectedMessageInputFloating] = + useState(false); const { theme: { colors: { black, grey }, @@ -250,12 +275,19 @@ export const SecretMenu = ({ '@stream-rn-sampleapp-messagelist-pruning', messageListPruningConfigItems[0], ); + const messageInputFloating = await AsyncStore.getItem( + '@stream-rn-sampleapp-messageinput-floating', + messageInputFloatingConfigItems[0], + ); setSelectedProvider(notificationProvider?.id ?? notificationConfigItems[0].id); setSelectedMessageListImplementation( messageListImplementation?.id ?? messageListImplementationConfigItems[0].id, ); setSelectedMessageListMode(messageListMode?.mode ?? messageListModeConfigItems[0].mode); setSelectedMessageListPruning(messageListPruning?.value); + setSelectedMessageInputFloating( + messageInputFloating?.value ?? messageInputFloatingConfigItems[0].value, + ); }; getSelectedConfig(); }, [notificationConfigItems]); @@ -283,6 +315,11 @@ export const SecretMenu = ({ setSelectedMessageListPruning(item.value); }, []); + const storeMessageInputFloating = useCallback(async (item: MessageInputFloatingConfigItem) => { + await AsyncStore.setItem('@stream-rn-sampleapp-messageinput-floating', item); + setSelectedMessageInputFloating(item.value); + }, []); + const removeAllDevices = useCallback(async () => { const { devices } = await chatClient.getDevices(chatClient.userID); for (const device of devices ?? []) { @@ -335,6 +372,22 @@ export const SecretMenu = ({ + + + + Message Input Floating + + {messageInputFloatingConfigItems.map((item) => ( + + ))} + + + diff --git a/examples/SampleApp/src/context/AppContext.ts b/examples/SampleApp/src/context/AppContext.ts index 9ec7c8099..b0e7921d6 100644 --- a/examples/SampleApp/src/context/AppContext.ts +++ b/examples/SampleApp/src/context/AppContext.ts @@ -4,6 +4,7 @@ import type { StreamChat } from 'stream-chat'; import type { LoginConfig } from '../types'; import { + MessageInputFloatingConfigItem, MessageListImplementationConfigItem, MessageListModeConfigItem, MessageListPruningConfigItem, @@ -15,6 +16,7 @@ type AppContextType = { logout: () => void; switchUser: (userId?: string) => void; messageListImplementation: MessageListImplementationConfigItem['id']; + messageInputFloating: MessageInputFloatingConfigItem['value']; messageListMode: MessageListModeConfigItem['mode']; messageListPruning: MessageListPruningConfigItem['value']; }; diff --git a/examples/SampleApp/src/screens/ChannelListScreen.tsx b/examples/SampleApp/src/screens/ChannelListScreen.tsx index 796406673..8bc0d965c 100644 --- a/examples/SampleApp/src/screens/ChannelListScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelListScreen.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native'; import { useNavigation, useScrollToTop } from '@react-navigation/native'; -import { ChannelList, CircleClose, Search, useTheme } from 'stream-chat-react-native'; +import { ChannelList, CircleClose, useTheme } from 'stream-chat-react-native'; import { Channel } from 'stream-chat'; import { ChannelPreview } from '../components/ChannelPreview'; import { ChatScreenHeader } from '../components/ChatScreenHeader'; @@ -19,6 +19,7 @@ import { usePaginatedSearchedMessages } from '../hooks/usePaginatedSearchedMessa import type { ChannelSort } from 'stream-chat'; import { useStreamChatContext } from '../context/StreamChatContext'; +import { Search } from '../icons/Search'; const styles = StyleSheet.create({ channelListContainer: { diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index ba4898e13..70b489cca 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -20,7 +20,6 @@ import { } from 'stream-chat-react-native'; import { Pressable, StyleSheet, View } from 'react-native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAppContext } from '../context/AppContext'; import { ScreenHeader } from '../components/ScreenHeader'; @@ -122,9 +121,13 @@ export const ChannelScreen: React.FC = ({ params: { channel: channelFromProp, channelId, messageId }, }, }) => { - const { chatClient, messageListImplementation, messageListMode, messageListPruning } = - useAppContext(); - const { bottom } = useSafeAreaInsets(); + const { + chatClient, + messageListImplementation, + messageListMode, + messageListPruning, + messageInputFloating, + } = useAppContext(); const { theme: { colors }, } = useTheme(); @@ -218,11 +221,12 @@ export const ChannelScreen: React.FC = ({ } return ( - + ({ parentMessage: nextValue.parentMessage }) as const; @@ -84,6 +86,8 @@ export const ThreadScreen: React.FC = ({ const { client: chatClient } = useChatContext(); const { t } = useTranslationContext(); const { setThread } = useStreamChatContext(); + const { messageInputFloating } = useAppContext(); + const headerHeight = useHeaderHeight(); const onPressMessage: NonNullable['onPressMessage']> = ( payload, @@ -115,14 +119,15 @@ export const ThreadScreen: React.FC = ({ }, [setThread]); return ( - + = ({ - + ); }; diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index a49bfe7c7..ac23dd105 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -3,7 +3,6 @@ import { I18nManager, TextInput as RNTextInput, StyleSheet, - TextInputContentSizeChangeEvent, TextInputProps, TextInputSelectionChangeEvent, } from 'react-native'; @@ -35,7 +34,7 @@ type AutoCompleteInputPropsWithContext = TextInputProps & * This is currently passed in from MessageInput to avoid rerenders * that would happen if we put this in the MessageInputContext */ - cooldownActive?: boolean; + cooldownRemainingSeconds?: number; TextInputComponent?: React.ComponentType< TextInputProps & { ref: React.Ref | undefined; @@ -55,18 +54,19 @@ const configStateSelector = (state: MessageComposerConfig) => ({ }); const MAX_NUMBER_OF_LINES = 5; +const LINE_HEIGHT = 20; +const PADDING_VERTICAL = 12; const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => { const { channel, - cooldownActive = false, + cooldownRemainingSeconds, setInputBoxRef, t, TextInputComponent = RNTextInput, ...rest } = props; const [localText, setLocalText] = useState(''); - const [textHeight, setTextHeight] = useState(0); const messageComposer = useMessageComposer(); const { textComposer } = messageComposer; const { command, text } = useStateStore(textComposer.state, textComposerStateSelector); @@ -115,15 +115,12 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) } = useTheme(); const placeholderText = useMemo(() => { - return command ? t('Search') : cooldownActive ? t('Slow mode ON') : t('Send a message'); - }, [command, cooldownActive, t]); - - const handleContentSizeChange = useCallback( - ({ nativeEvent: { contentSize } }: TextInputContentSizeChangeEvent) => { - setTextHeight(contentSize.height); - }, - [], - ); + return command + ? t('Search') + : cooldownRemainingSeconds + ? `Slow mode, wait ${cooldownRemainingSeconds}s...` + : t('Send a message'); + }, [command, cooldownRemainingSeconds, t]); return ( { - const { channel: prevChannel, cooldownActive: prevCooldownActive, t: prevT } = prevProps; - const { channel: nextChannel, cooldownActive: nextCooldownActive, t: nextT } = nextProps; + const { + channel: prevChannel, + cooldownRemainingSeconds: prevCooldownRemainingSeconds, + t: prevT, + } = prevProps; + const { + channel: nextChannel, + cooldownRemainingSeconds: nextCooldownRemainingSeconds, + t: nextT, + } = nextProps; const tEqual = prevT === nextT; if (!tEqual) { return false; } - const cooldownActiveEqual = prevCooldownActive === nextCooldownActive; - if (!cooldownActiveEqual) { + const cooldownRemainingSecondsEqual = + prevCooldownRemainingSeconds === nextCooldownRemainingSeconds; + if (!cooldownRemainingSecondsEqual) { return false; } @@ -206,6 +211,8 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 16, includeFontPadding: false, // for android vertical text centering + lineHeight: 20, + paddingLeft: 16, paddingVertical: 12, textAlignVertical: 'center', // for android vertical text centering }, diff --git a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js index c3591fc02..945581876 100644 --- a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js +++ b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js @@ -113,29 +113,6 @@ describe('AutoCompleteInput', () => { }); }); - it('should style the text input with maxHeight that is set by the layout', async () => { - const channelProps = { channel }; - const props = { numberOfLines: 10 }; - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - const input = queryByTestId('auto-complete-text-input'); - - act(() => { - fireEvent(input, 'contentSizeChange', { - nativeEvent: { - contentSize: { height: 100 }, - }, - }); - }); - - await waitFor(() => { - expect(input.props.style[1].maxHeight).toBe(1000); - }); - }); - it('should call the textComposer setSelection when the onSelectionChange is triggered', async () => { const { textComposer } = channel.messageComposer; @@ -166,12 +143,12 @@ describe('AutoCompleteInput', () => { // TODO: Add a test for command it.each([ - { cooldownActive: false, result: 'Send a message' }, - { cooldownActive: true, result: 'Slow mode ON' }, + { cooldownRemainingSeconds: undefined, result: 'Send a message' }, + { cooldownRemainingSeconds: 10, result: 'Slow mode, wait 10s...' }, ])('should have the placeholderText as Slow mode ON when cooldown is active', async (data) => { const channelProps = { channel }; const props = { - cooldownActive: data.cooldownActive, + cooldownRemainingSeconds: data.cooldownRemainingSeconds, }; renderComponent({ channelProps, client, props }); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 6afeff2d7..fe0469bd8 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -173,9 +173,7 @@ import { MessageTimestamp as MessageTimestampDefault } from '../Message/MessageS import { ReactionListBottom as ReactionListBottomDefault } from '../Message/MessageSimple/ReactionList/ReactionListBottom'; import { ReactionListTop as ReactionListTopDefault } from '../Message/MessageSimple/ReactionList/ReactionListTop'; import { StreamingMessageView as DefaultStreamingMessageView } from '../Message/MessageSimple/StreamingMessageView'; -import { AttachButton as AttachButtonDefault } from '../MessageInput/AttachButton'; -import { AttachmentUploadPreviewList as AttachmentUploadPreviewDefault } from '../MessageInput/AttachmentUploadPreviewList'; -import { CommandsButton as CommandsButtonDefault } from '../MessageInput/CommandsButton'; +import { AttachmentUploadPreviewList as AttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList'; import { AttachmentUploadProgressIndicator as AttachmentUploadProgressIndicatorDefault } from '../MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; import { AudioAttachmentUploadPreview as AudioAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; import { FileAttachmentUploadPreview as FileAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; @@ -187,12 +185,10 @@ import { AudioRecordingLockIndicator as AudioRecordingLockIndicatorDefault } fro import { AudioRecordingPreview as AudioRecordingPreviewDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingPreview'; import { AudioRecordingWaveform as AudioRecordingWaveformDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingWaveform'; import { CommandInput as CommandInputDefault } from '../MessageInput/components/CommandInput'; -import { InputEditingStateHeader as InputEditingStateHeaderDefault } from '../MessageInput/components/InputEditingStateHeader'; -import { InputReplyStateHeader as InputReplyStateHeaderDefault } from '../MessageInput/components/InputReplyStateHeader'; -import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/CooldownTimer'; -import { InputButtons as InputButtonsDefault } from '../MessageInput/InputButtons'; -import { MoreOptionsButton as MoreOptionsButtonDefault } from '../MessageInput/MoreOptionsButton'; -import { SendButton as SendButtonDefault } from '../MessageInput/SendButton'; +import { InputButtons as InputButtonsDefault } from '../MessageInput/components/InputButtons'; +import { AttachButton as AttachButtonDefault } from '../MessageInput/components/InputButtons/AttachButton'; +import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/components/OutputButtons/CooldownTimer'; +import { SendButton as SendButtonDefault } from '../MessageInput/components/OutputButtons/SendButton'; import { SendMessageDisallowedIndicator as SendMessageDisallowedIndicatorDefault } from '../MessageInput/SendMessageDisallowedIndicator'; import { ShowThreadMessageInChannelButton as ShowThreadMessageInChannelButtonDefault } from '../MessageInput/ShowThreadMessageInChannelButton'; import { StopMessageStreamingButton as DefaultStopMessageStreamingButton } from '../MessageInput/StopMessageStreamingButton'; @@ -608,7 +604,6 @@ const ChannelWithContext = (props: PropsWithChildren) = channel, children, client, - CommandsButton = CommandsButtonDefault, compressImageQuality, CooldownTimer = CooldownTimerDefault, CreatePollContent, @@ -671,9 +666,7 @@ const ChannelWithContext = (props: PropsWithChildren) = InlineUnreadIndicator = InlineUnreadIndicatorDefault, Input, InputButtons = InputButtonsDefault, - InputEditingStateHeader = InputEditingStateHeaderDefault, CommandInput = CommandInputDefault, - InputReplyStateHeader = InputReplyStateHeaderDefault, isAttachmentEqual, isMessageAIGenerated = () => false, keyboardBehavior, @@ -708,6 +701,7 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageDeleted = MessageDeletedDefault, MessageEditedTimestamp = MessageEditedTimestampDefault, MessageError = MessageErrorDefault, + messageInputFloating = false, MessageFooter = MessageFooterDefault, MessageHeader, messageId, @@ -729,7 +723,6 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageUserReactions = MessageUserReactionsDefault, MessageUserReactionsAvatar = MessageUserReactionsAvatarDefault, MessageUserReactionsItem = MessageUserReactionsItemDefault, - MoreOptionsButton = MoreOptionsButtonDefault, myMessageTheme, NetworkDownIndicator = NetworkDownIndicatorDefault, // TODO: Think about this one @@ -1866,7 +1859,6 @@ const ChannelWithContext = (props: PropsWithChildren) = CameraSelectorIcon, channelId, CommandInput, - CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, @@ -1884,9 +1876,7 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageSelectorIcon, Input, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openPollCreationDialog, SendButton, sendMessage, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 5c5d4a060..197d1419b 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -30,7 +30,6 @@ export const useCreateInputMessageInputContext = ({ channelId, CameraSelectorIcon, CommandInput, - CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, @@ -48,9 +47,7 @@ export const useCreateInputMessageInputContext = ({ ImageSelectorIcon, Input, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openPollCreationDialog, SendButton, sendMessage, @@ -96,7 +93,6 @@ export const useCreateInputMessageInputContext = ({ AutoCompleteSuggestionList, CameraSelectorIcon, CommandInput, - CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, @@ -114,9 +110,7 @@ export const useCreateInputMessageInputContext = ({ ImageSelectorIcon, Input, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openPollCreationDialog, SendButton, sendMessage, diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index bddd71b58..7f02d2731 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -27,8 +27,6 @@ import { useTranslationContext, } from '../../../contexts/translationContext/TranslationContext'; -import { useViewport } from '../../../hooks/useViewport'; - import { checkMessageEquality, checkQuotedMessageEquality } from '../../../utils/utils'; import { Poll } from '../../Poll/Poll'; import { useMessageData } from '../hooks/useMessageData'; @@ -180,7 +178,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { }, }, } = useTheme(); - const { vw } = useViewport(); const onLayout: (event: LayoutChangeEvent) => void = ({ nativeEvent: { @@ -325,7 +322,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { key={`quoted_reply_${messageContentOrderIndex}`} style={[styles.replyContainer, replyContainer]} > - + ) ); diff --git a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx deleted file mode 100644 index d14f51a2b..000000000 --- a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FlatList, LayoutChangeEvent, StyleSheet, View } from 'react-native'; - -import { - isLocalAudioAttachment, - isLocalFileAttachment, - isLocalImageAttachment, - isLocalVoiceRecordingAttachment, - isVideoAttachment, - LocalAttachment, - LocalImageAttachment, -} from 'stream-chat'; - -import { useMessageComposer } from '../../contexts'; -import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { isSoundPackageAvailable } from '../../native'; - -const IMAGE_PREVIEW_SIZE = 100; -const FILE_PREVIEW_HEIGHT = 60; - -export type AttachmentUploadPreviewListPropsWithContext = Pick< - MessageInputContextValue, - | 'AudioAttachmentUploadPreview' - | 'FileAttachmentUploadPreview' - | 'ImageAttachmentUploadPreview' - | 'VideoAttachmentUploadPreview' ->; - -/** - * AttachmentUploadPreviewList - * UI Component to preview the files set for upload - */ -const UnMemoizedAttachmentUploadListPreview = ( - props: AttachmentUploadPreviewListPropsWithContext, -) => { - const [flatListWidth, setFlatListWidth] = useState(0); - const flatListRef = useRef | null>(null); - const { - AudioAttachmentUploadPreview, - FileAttachmentUploadPreview, - ImageAttachmentUploadPreview, - VideoAttachmentUploadPreview, - } = props; - const { attachmentManager } = useMessageComposer(); - const { attachments } = useAttachmentManagerState(); - const { - theme: { - colors: { grey_whisper }, - messageInput: { - attachmentSeparator, - attachmentUploadPreviewList: { filesFlatList, imagesFlatList, wrapper }, - }, - }, - } = useTheme(); - - const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); - const fileUploads = useMemo(() => { - return attachments.filter((attachment) => !isLocalImageAttachment(attachment)); - }, [attachments]); - - const renderImageItem = useCallback( - ({ item }: { item: LocalImageAttachment }) => { - return ( - - ); - }, - [ - ImageAttachmentUploadPreview, - attachmentManager.removeAttachments, - attachmentManager.uploadAttachment, - ], - ); - - const renderFileItem = useCallback( - ({ item }: { item: LocalAttachment }) => { - if (isLocalImageAttachment(item)) { - // This is already handled in the `renderImageItem` above, so we return null here to avoid duplication. - return null; - } else if (isLocalVoiceRecordingAttachment(item)) { - return ( - - ); - } else if (isLocalAudioAttachment(item)) { - if (isSoundPackageAvailable()) { - return ( - - ); - } else { - return ( - - ); - } - } else if (isVideoAttachment(item)) { - return ( - - ); - } else if (isLocalFileAttachment(item)) { - return ( - - ); - } else return null; - }, - [ - AudioAttachmentUploadPreview, - FileAttachmentUploadPreview, - VideoAttachmentUploadPreview, - attachmentManager.removeAttachments, - attachmentManager.uploadAttachment, - flatListWidth, - ], - ); - - useEffect(() => { - if (fileUploads.length && flatListRef.current) { - setTimeout(() => flatListRef.current?.scrollToEnd(), 1); - } - }, [fileUploads.length]); - - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - if (flatListRef.current) { - setFlatListWidth(event.nativeEvent.layout.width); - } - }, - [flatListRef], - ); - - if (!attachments.length) { - return null; - } - - return ( - - {imageUploads.length ? ( - ({ - index, - length: IMAGE_PREVIEW_SIZE + 8, - offset: (IMAGE_PREVIEW_SIZE + 8) * index, - })} - horizontal - keyExtractor={(item) => item.localMetadata.id} - renderItem={renderImageItem} - style={[styles.imagesFlatList, imagesFlatList]} - /> - ) : null} - {imageUploads.length && fileUploads.length ? ( - - ) : null} - {fileUploads.length ? ( - ({ - index, - length: FILE_PREVIEW_HEIGHT + 8, - offset: (FILE_PREVIEW_HEIGHT + 8) * index, - })} - keyExtractor={(item) => item.localMetadata.id} - onLayout={onLayout} - ref={flatListRef} - renderItem={renderFileItem} - style={[styles.filesFlatList, filesFlatList]} - testID={'file-upload-preview'} - /> - ) : null} - - ); -}; - -export type AttachmentUploadPreviewListProps = Partial; - -const MemoizedAttachmentUploadPreviewListWithContext = React.memo( - UnMemoizedAttachmentUploadListPreview, -); - -/** - * AttachmentUploadPreviewList - * UI Component to preview the files set for upload - */ -export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListProps) => { - const { - AudioAttachmentUploadPreview, - FileAttachmentUploadPreview, - ImageAttachmentUploadPreview, - VideoAttachmentUploadPreview, - } = useMessageInputContext(); - return ( - - ); -}; - -const styles = StyleSheet.create({ - attachmentSeparator: { - borderBottomWidth: 1, - marginVertical: 8, - }, - filesFlatList: { maxHeight: FILE_PREVIEW_HEIGHT * 2.5 + 16 }, - imagesFlatList: {}, - wrapper: { - paddingTop: 12, - }, -}); - -AttachmentUploadPreviewList.displayName = - 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}'; diff --git a/package/src/components/MessageInput/CommandsButton.tsx b/package/src/components/MessageInput/CommandsButton.tsx deleted file mode 100644 index 7ca093dec..000000000 --- a/package/src/components/MessageInput/CommandsButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useCallback } from 'react'; -import type { GestureResponderEvent, PressableProps } from 'react-native'; -import { Pressable } from 'react-native'; - -import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { Lightning } from '../../icons/Lightning'; - -export type CommandsButtonProps = { - /** Function that opens commands selector. */ - handleOnPress?: PressableProps['onPress']; -}; - -export const CommandsButton = (props: CommandsButtonProps) => { - const { handleOnPress } = props; - const messageComposer = useMessageComposer(); - const { textComposer } = messageComposer; - - const onPressHandler = useCallback( - async (event: GestureResponderEvent) => { - if (handleOnPress) { - handleOnPress(event); - return; - } - - await textComposer.handleChange({ - selection: { - end: 1, - start: 1, - }, - text: '/', - }); - }, - [handleOnPress, textComposer], - ); - - const { - theme: { - colors: { grey }, - messageInput: { commandsButton }, - }, - } = useTheme(); - - return ( - - - - ); -}; - -CommandsButton.displayName = 'CommandsButton{messageInput}'; diff --git a/package/src/components/MessageInput/CooldownTimer.tsx b/package/src/components/MessageInput/CooldownTimer.tsx deleted file mode 100644 index 36c524d2d..000000000 --- a/package/src/components/MessageInput/CooldownTimer.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; - -import { useTheme } from '../../contexts/themeContext/ThemeContext'; - -export type CooldownTimerProps = { - seconds: number; -}; - -const CONTAINER_SIZE = 24; -const CONTAINER_HORIZONTAL_PADDING = 6; -const EXTRA_CHARACTER_PADDING = CONTAINER_SIZE - CONTAINER_HORIZONTAL_PADDING * 2; - -/** - * To avoid the container jumping between sizes when there are more - * than one character in the width of the container since we aren't - * using a monospaced font. - */ -const normalizeWidth = (seconds: number) => - CONTAINER_SIZE + EXTRA_CHARACTER_PADDING * (`${seconds}`.length - 1); - -/** - * Renders an amount of seconds left for a cooldown to finish. - * - * See `useCountdown` for an example of how to set a countdown - * to use as the source of `seconds`. - **/ -export const CooldownTimer = (props: CooldownTimerProps) => { - const { seconds } = props; - const { - theme: { - colors: { black, grey_gainsboro }, - messageInput: { - cooldownTimer: { container, text }, - }, - }, - } = useTheme(); - - return ( - - - {seconds} - - - ); -}; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - borderRadius: CONTAINER_SIZE / 2, - height: CONTAINER_SIZE, - justifyContent: 'center', - minWidth: CONTAINER_SIZE, - paddingHorizontal: CONTAINER_HORIZONTAL_PADDING, - }, - text: { fontSize: 16, fontWeight: '600' }, -}); diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 37e594f47..5c6080e79 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Modal, StyleSheet, TextInput, TextInputProps, View } from 'react-native'; +import React, { useEffect } from 'react'; +import { Modal, Platform, StyleSheet, TextInput, TextInputProps, View } from 'react-native'; import { Gesture, @@ -9,19 +9,30 @@ import { } from 'react-native-gesture-handler'; import Animated, { Extrapolation, + FadeIn, + FadeOut, interpolate, + LinearTransition, runOnJS, useAnimatedStyle, useSharedValue, withSpring, + ZoomIn, + ZoomOut, } from 'react-native-reanimated'; import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; +import { OutputButtons } from './components/OutputButtons'; import { useAudioRecorder } from './hooks/useAudioRecorder'; import { useCountdown } from './hooks/useCountdown'; -import { ChatContextValue, useChatContext, useOwnCapabilitiesContext } from '../../contexts'; +import { + ChatContextValue, + useAttachmentManagerState, + useChatContext, + useOwnCapabilitiesContext, +} from '../../contexts'; import { AttachmentPickerContextValue, useAttachmentPickerContext, @@ -34,9 +45,7 @@ import { MessageComposerAPIContextValue, useMessageComposerAPIContext, } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; -import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useMessageComposerHasSendableData } from '../../contexts/messageInputContext/hooks/useMessageComposerHasSendableData'; import { MessageInputContextValue, useMessageInputContext, @@ -52,47 +61,75 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; +import { useKeyboardVisibility } from '../../hooks/useKeyboardVisibility'; import { useStateStore } from '../../hooks/useStateStore'; import { isAudioRecorderAvailable, NativeHandlers } from '../../native'; -import { AIStates, useAIState } from '../AITypingIndicatorView'; +import { + MessageInputHeightState, + messageInputHeightStore, + setMessageInputHeight, +} from '../../state-store/message-input-height-store'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; const styles = StyleSheet.create({ - attachmentSeparator: { - borderBottomWidth: 1, - marginBottom: 10, - }, - autoCompleteInputContainer: { - alignItems: 'center', - flexDirection: 'row', - }, - composerContainer: { + container: { alignItems: 'center', flexDirection: 'row', + gap: 8, justifyContent: 'space-between', }, - container: { - borderTopWidth: 1, - padding: 10, + contentContainer: { + gap: 4, + overflow: 'hidden', + paddingHorizontal: 8, + }, + floatingWrapper: { + left: 0, + paddingHorizontal: 24, + position: 'absolute', + right: 0, }, inputBoxContainer: { - borderRadius: 20, + flex: 1, + }, + inputBoxWrapper: { + borderRadius: 24, borderWidth: 1, flex: 1, - marginHorizontal: 10, + flexDirection: 'row', }, - micButtonContainer: {}, - optionsContainer: { + inputButtonsContainer: { + alignSelf: 'flex-end', + }, + inputContainer: { + alignItems: 'center', flexDirection: 'row', + justifyContent: 'space-between', + }, + micButtonContainer: {}, + outputButtonsContainer: { + alignSelf: 'flex-end', + padding: 8, + }, + shadow: { + elevation: 6, + + shadowColor: 'hsla(0, 0%, 0%, 0.24)', + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.24, + shadowRadius: 12, }, - replyContainer: { paddingBottom: 0, paddingHorizontal: 8, paddingTop: 8 }, - sendButtonContainer: {}, suggestionsListContainer: { position: 'absolute', width: '100%', }, + wrapper: { + borderTopWidth: 1, + paddingHorizontal: 24, + paddingTop: 24, + }, }); type MessageInputPropsWithContext = Pick< @@ -125,14 +162,13 @@ type MessageInputPropsWithContext = Pick< | 'Input' | 'inputBoxRef' | 'InputButtons' - | 'InputEditingStateHeader' | 'CameraSelectorIcon' | 'CreatePollIcon' | 'FileSelectorIcon' + | 'messageInputFloating' | 'ImageSelectorIcon' | 'VideoRecorderSelectorIcon' | 'CommandInput' - | 'InputReplyStateHeader' | 'SendButton' | 'ShowThreadMessageInChannelButton' | 'StartAudioRecordingButton' @@ -148,6 +184,8 @@ type MessageInputPropsWithContext = Pick< Pick & Pick & { editing: boolean; + hasAttachments: boolean; + isKeyboardVisible: boolean; TextInputComponent?: React.ComponentType< TextInputProps & { ref: React.Ref | undefined; @@ -157,7 +195,6 @@ type MessageInputPropsWithContext = Pick< const textComposerStateSelector = (state: TextComposerState) => ({ command: state.command, - hasText: !!state.text, mentionedUsers: state.mentionedUsers, suggestions: state.suggestions, }); @@ -166,6 +203,10 @@ const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ quotedMessage: state.quotedMessage, }); +const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ + height: state.height, +}); + const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { AttachmentPickerSelectionBar, @@ -186,58 +227,59 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { AudioRecordingLockIndicator, AudioRecordingPreview, AutoCompleteSuggestionList, - channel, closeAttachmentPicker, closePollCreationDialog, cooldownEndsAt, - CooldownTimer, CreatePollContent, disableAttachmentPicker, editing, + hasAttachments, + messageInputFloating, Input, inputBoxRef, InputButtons, - InputEditingStateHeader, CommandInput, - InputReplyStateHeader, + isKeyboardVisible, isOnline, members, Reply, threadList, - SendButton, sendMessage, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, - StopMessageStreamingButton, TextInputComponent, watchers, } = props; const messageComposer = useMessageComposer(); + const { clearEditingState } = useMessageComposerAPIContext(); + const onDismissEditMessage = () => { + clearEditingState(); + }; const { textComposer } = messageComposer; - const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); + const { command } = useStateStore(textComposer.state, textComposerStateSelector); const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); - const { attachments } = useAttachmentManagerState(); - const hasSendableData = useMessageComposerHasSendableData(); - - const [height, setHeight] = useState(0); + const { height } = useStateStore(messageInputHeightStore, messageInputHeightStoreSelector); const { theme: { colors: { border, grey_whisper, white, white_smoke }, messageInput: { attachmentSelectionBar, - autoCompleteInputContainer, - composerContainer, container, + contentContainer, + floatingWrapper, focusedInputBoxContainer, inputBoxContainer, + inputBoxWrapper, + inputContainer, + inputButtonsContainer, + inputFloatingContainer, micButtonContainer, - optionsContainer, - replyContainer, - sendButtonContainer, + outputButtonsContainer, suggestionsListContainer: { container: suggestionListContainer }, + wrapper, }, }, } = useTheme(); @@ -323,11 +365,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { } = useAudioRecorder(); const asyncAudioEnabled = audioRecordingEnabled && isAudioRecorderAvailable(); - const showSendingButton = hasText || attachments.length || command; - - const isSendingButtonVisible = useMemo(() => { - return asyncAudioEnabled ? showSendingButton && !recording : true; - }, [asyncAudioEnabled, recording, showSendingButton]); const micPositionX = useSharedValue(0); const micPositionY = useSharedValue(0); @@ -418,24 +455,31 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { ], })); - const { aiState } = useAIState(channel); - - const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); - const shouldDisplayStopAIGeneration = - [AIStates.Thinking, AIStates.Generating].includes(aiState) && !!StopMessageStreamingButton; + const BOTTOM_OFFSET = isKeyboardVisible ? 24 : Platform.OS === 'ios' ? 32 : 24; return ( <> - setHeight(newHeight)} - style={[styles.container, { backgroundColor: white, borderColor: border }, container]} + }) => setMessageInputHeight(messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight)} // 24 is the position of the input from the bottom of the screen + style={ + messageInputFloating + ? [styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] + : [ + styles.wrapper, + { + backgroundColor: white, + borderColor: border, + paddingBottom: BOTTOM_OFFSET, + }, + wrapper, + ] + } > - {editing && } - {quotedMessage && !editing && } {recording && ( <> { )} - + {Input ? ( ) : ( @@ -480,76 +524,118 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { /> ) : ( <> - + {InputButtons && } - - {quotedMessage && ( - - + + + {editing ? ( + + + + ) : null} + {quotedMessage ? ( + + + + ) : null} + - )} - - - {command ? ( - - ) : ( - - + + + {command ? ( + + ) : ( + + )} + + + + - )} - + + )} - {shouldDisplayStopAIGeneration ? ( - - ) : isSendingButtonVisible ? ( - cooldownRemainingSeconds ? ( - - ) : ( - - - - ) - ) : null} - {audioRecordingEnabled && isAudioRecorderAvailable() && !micLocked && ( + {asyncAudioEnabled && !micLocked ? ( - - + + + + - )} + ) : null} )} - + - + - + {!disableAttachmentPicker && selectedPicker ? ( - { ]} > - + ) : null} {showPollCreationDialog ? ( @@ -600,6 +686,8 @@ const areEqual = ( closePollCreationDialog: prevClosePollCreationDialog, cooldownEndsAt: prevCooldownEndsAt, editing: prevEditing, + hasAttachments: prevHasAttachments, + isKeyboardVisible: prevIsKeyboardVisible, isOnline: prevIsOnline, openPollCreationDialog: prevOpenPollCreationDialog, selectedPicker: prevSelectedPicker, @@ -617,7 +705,9 @@ const areEqual = ( closePollCreationDialog: nextClosePollCreationDialog, cooldownEndsAt: nextCooldownEndsAt, editing: nextEditing, + isKeyboardVisible: nextIsKeyboardVisible, isOnline: nextIsOnline, + hasAttachments: nextHasAttachments, openPollCreationDialog: nextOpenPollCreationDialog, selectedPicker: nextSelectedPicker, showPollCreationDialog: nextShowPollCreationDialog, @@ -677,6 +767,16 @@ const areEqual = ( return false; } + const hasAttachmentsEqual = prevHasAttachments === nextHasAttachments; + if (!hasAttachmentsEqual) { + return false; + } + + const isKeyboardVisibleEqual = prevIsKeyboardVisible === nextIsKeyboardVisible; + if (!isKeyboardVisibleEqual) { + return false; + } + const isOnlineEqual = prevIsOnline === nextIsOnline; if (!isOnlineEqual) { return false; @@ -754,9 +854,8 @@ export const MessageInput = (props: MessageInputProps) => { Input, inputBoxRef, InputButtons, - InputEditingStateHeader, CommandInput, - InputReplyStateHeader, + messageInputFloating, openPollCreationDialog, SendButton, sendMessage, @@ -775,6 +874,8 @@ export const MessageInput = (props: MessageInputProps) => { const { clearEditingState } = useMessageComposerAPIContext(); const { Reply } = useMessagesContext(); + const { attachments } = useAttachmentManagerState(); + const isKeyboardVisible = useKeyboardVisibility(); const { t } = useTranslationContext(); @@ -823,14 +924,15 @@ export const MessageInput = (props: MessageInputProps) => { disableAttachmentPicker, editing, FileSelectorIcon, + hasAttachments: attachments.length > 0, ImageSelectorIcon, Input, inputBoxRef, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, + isKeyboardVisible, isOnline, members, + messageInputFloating, openPollCreationDialog, Reply, selectedPicker, diff --git a/package/src/components/MessageInput/MoreOptionsButton.tsx b/package/src/components/MessageInput/MoreOptionsButton.tsx deleted file mode 100644 index f02227da6..000000000 --- a/package/src/components/MessageInput/MoreOptionsButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -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?: () => void; -}; - -export const MoreOptionsButton = (props: MoreOptionsButtonProps) => { - const { handleOnPress } = props; - - const { - theme: { - colors: { accent_blue }, - messageInput: { moreOptionsButton }, - }, - } = useTheme(); - - return ( - [moreOptionsButton, { opacity: pressed ? 0.8 : 1 }]} - testID='more-options-button' - > - - - ); -}; - -MoreOptionsButton.displayName = 'MoreOptionsButton{messageInput}'; diff --git a/package/src/components/MessageInput/SendButton.tsx b/package/src/components/MessageInput/SendButton.tsx deleted file mode 100644 index b5b9959cf..000000000 --- a/package/src/components/MessageInput/SendButton.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useCallback } from 'react'; - -import { Pressable } from 'react-native'; - -import { TextComposerState } from 'stream-chat'; - -import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useStateStore } from '../../hooks/useStateStore'; -import { Search } from '../../icons/Search'; -import { SendRight } from '../../icons/SendRight'; -import { SendUp } from '../../icons/SendUp'; - -export type SendButtonProps = Partial> & { - /** Disables the button */ - disabled: boolean; -}; - -const textComposerStateSelector = (state: TextComposerState) => ({ - command: state.command, -}); - -export const SendButton = (props: SendButtonProps) => { - const { disabled = false, sendMessage: propsSendMessage } = props; - const { sendMessage: sendMessageFromContext } = useMessageInputContext(); - const sendMessage = propsSendMessage || sendMessageFromContext; - const messageComposer = useMessageComposer(); - const { textComposer } = messageComposer; - const { command } = useStateStore(textComposer.state, textComposerStateSelector); - - const { - theme: { - colors: { accent_blue, grey_gainsboro }, - messageInput: { searchIcon, sendButton, sendRightIcon, sendUpIcon }, - }, - } = useTheme(); - - const onPressHandler = useCallback(() => { - if (disabled) { - return; - } - sendMessage(); - }, [disabled, sendMessage]); - - return ( - - {command ? ( - - ) : disabled ? ( - - ) : ( - - )} - - ); -}; - -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 d0201770a..dbd9ae730 100644 --- a/package/src/components/MessageInput/__tests__/AttachButton.test.js +++ b/package/src/components/MessageInput/__tests__/AttachButton.test.js @@ -8,7 +8,7 @@ import { initiateClientWithChannels } from '../../../mock-builders/api/initiateC import * as NativeHandler from '../../../native'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { AttachButton } from '../AttachButton'; +import { AttachButton } from '../components/InputButtons/AttachButton'; const renderComponent = ({ channelProps, client, props }) => { return render( diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js index 553298948..91c86b386 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js @@ -14,7 +14,7 @@ import { import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList'; +import { AttachmentUploadPreviewList } from '../components/AttachmentPreview/AttachmentUploadPreviewList'; jest.mock('../../../native.ts', () => { const { View } = require('react-native'); diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js index 00a93050c..4aebfdb77 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js @@ -9,7 +9,7 @@ import { generateAudioAttachment } from '../../../mock-builders/attachments'; import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList'; +import { AttachmentUploadPreviewList } from '../components/AttachmentPreview/AttachmentUploadPreviewList'; jest.mock('../../../native.ts', () => { const View = require('react-native').View; diff --git a/package/src/components/MessageInput/__tests__/CommandsButton.test.js b/package/src/components/MessageInput/__tests__/CommandsButton.test.js deleted file mode 100644 index cd226cb65..000000000 --- a/package/src/components/MessageInput/__tests__/CommandsButton.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; - -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; - -import { OverlayProvider } from '../../../contexts'; - -import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; -import { Channel } from '../../Channel/Channel'; -import { Chat } from '../../Chat/Chat'; -import { CommandsButton } from '../CommandsButton'; - -const renderComponent = ({ client, channel, props }) => { - return render( - - - - - - - , - ); -}; - -describe('CommandsButton', () => { - let client; - let channel; - - beforeEach(async () => { - const { client: chatClient, channels } = await initiateClientWithChannels(); - client = chatClient; - channel = channels[0]; - }); - - afterEach(() => { - jest.clearAllMocks(); - cleanup(); - }); - - it('should render component', async () => { - const props = {}; - renderComponent({ channel, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - }); - }); - - it('should call handleOnPress callback when the button is clicked if passed', async () => { - const handleOnPress = jest.fn(); - const props = { handleOnPress }; - - renderComponent({ channel, client, props }); - - const { getByTestId, queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - }); - - act(() => { - fireEvent.press(getByTestId('commands-button')); - }); - - await waitFor(() => { - expect(handleOnPress).toHaveBeenCalled(); - }); - }); -}); diff --git a/package/src/components/MessageInput/__tests__/InputButtons.test.js b/package/src/components/MessageInput/__tests__/InputButtons.test.js index 501fb72db..d25e38492 100644 --- a/package/src/components/MessageInput/__tests__/InputButtons.test.js +++ b/package/src/components/MessageInput/__tests__/InputButtons.test.js @@ -1,15 +1,13 @@ import React from 'react'; -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; -import { generateImageAttachment } from '../../../mock-builders/attachments'; -import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { InputButtons } from '../InputButtons'; +import { InputButtons } from '../components/InputButtons/index'; const renderComponent = ({ channelProps, client, props }) => { return render( @@ -42,24 +40,6 @@ describe('InputButtons', () => { }); }); - // TODO: Add it back once the command inject PR is merged - it.skip('should return null if the commands are set on the textComposer', async () => { - const props = {}; - const channelProps = { channel }; - - channel.messageComposer.textComposer.setCommand({ description: 'Ban a user', name: 'ban' }); - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('more-options-button')).toBeFalsy(); - expect(queryByTestId('commands-button')).toBeFalsy(); - expect(queryByTestId('attach-button')).toBeFalsy(); - }); - }); - it('should return null if hasCommands is false and hasAttachmentUploadCapabilities is false', async () => { const props = {}; const channelProps = { @@ -75,28 +55,11 @@ describe('InputButtons', () => { const { queryByTestId } = screen; await waitFor(() => { - expect(queryByTestId('more-options-button')).toBeFalsy(); expect(queryByTestId('commands-button')).toBeFalsy(); expect(queryByTestId('attach-button')).toBeFalsy(); }); }); - it('should show more options when the hasCommand is true and the hasAttachmentUploadCapabilities is true', async () => { - const props = {}; - const channelProps = { - channel, - }; - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - expect(queryByTestId('attach-button')).toBeTruthy(); - }); - }); - it('should show only attach button when the hasCommand is false and the hasAttachmentUploadCapabilities is true', async () => { const props = {}; const channelProps = { @@ -114,25 +77,6 @@ describe('InputButtons', () => { }); }); - it('should show only commands button when the hasCommand is true and the hasAttachmentUploadCapabilities is false', async () => { - const props = {}; - const channelProps = { - channel, - overrideOwnCapabilities: { - uploadFile: false, - }, - }; - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - expect(queryByTestId('attach-button')).toBeFalsy(); - }); - }); - it('should not show commands buttons when there is text in the textComposer', async () => { const props = {}; const channelProps = { @@ -146,62 +90,4 @@ describe('InputButtons', () => { expect(queryByTestId('commands-button')).toBeFalsy(); }); }); - - it('should show more options button when there is text in the textComposer', async () => { - const props = {}; - const channelProps = { - channel, - }; - channel.messageComposer.textComposer.setText('hello'); - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('more-options-button')).toBeTruthy(); - }); - - act(() => { - fireEvent.press(queryByTestId('more-options-button')); - }); - - await waitFor(() => { - // Falsy, because the textComposer has text. This is a good test. - expect(queryByTestId('commands-button')).toBeFalsy(); - expect(queryByTestId('attach-button')).toBeTruthy(); - }); - }); - - it('should show more options button when there is attachments', async () => { - const props = {}; - const channelProps = { - channel, - }; - channel.messageComposer.attachmentManager.upsertAttachments([ - generateImageAttachment({ - localMetadata: { - id: 'image-attachment', - uploadState: FileState.UPLOADING, - }, - }), - ]); - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('more-options-button')).toBeTruthy(); - }); - - act(() => { - fireEvent.press(queryByTestId('more-options-button')); - }); - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - expect(queryByTestId('attach-button')).toBeTruthy(); - }); - }); }); diff --git a/package/src/components/MessageInput/__tests__/MessageInput.test.js b/package/src/components/MessageInput/__tests__/MessageInput.test.js index 634472fb1..8cea3bef4 100644 --- a/package/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/package/src/components/MessageInput/__tests__/MessageInput.test.js @@ -9,7 +9,6 @@ import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvide import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; -import { NativeHandlers } from '../../../native'; import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar'; import { CameraSelectorIcon } from '../../AttachmentPicker/components/CameraSelectorIcon'; import { FileSelectorIcon } from '../../AttachmentPicker/components/FileSelectorIcon'; @@ -90,59 +89,60 @@ describe('MessageInput', () => { }); }); - it('should start the audio recorder on long press and cleanup on unmount', async () => { - renderComponent({ - channelProps: { audioRecordingEnabled: true, channel }, - client, - props: {}, - }); - - const { queryByTestId, unmount } = screen; - - const audioButton = queryByTestId('audio-button'); - - act(() => { - fireEvent(audioButton, 'longPress'); - }); - - await waitFor(() => { - expect(NativeHandlers.Audio.startRecording).toHaveBeenCalledTimes(1); - expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled(); - expect(queryByTestId('recording-active-container')).toBeTruthy(); - expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.'); - }); - - await act(() => { - unmount(); - }); - - await waitFor(() => { - expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1); - }); - }); - - it('should trigger an alert if a normal press happened on audio recording', async () => { - renderComponent({ - channelProps: { audioRecordingEnabled: true, channel }, - client, - props: {}, - }); - - const { queryByTestId } = screen; - - const audioButton = queryByTestId('audio-button'); - - act(() => { - fireEvent.press(audioButton); - }); - - await waitFor(() => { - expect(NativeHandlers.Audio.startRecording).not.toHaveBeenCalled(); - expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled(); - expect(queryByTestId('recording-active-container')).not.toBeTruthy(); - // This is sort of a brittle test, but there doesn't seem to be another way - // to target alerts. The reason why it's here is because we had a bug with it. - expect(Alert.alert).toHaveBeenCalledWith('Hold to start recording.'); - }); - }); + // TODO: Once the async audio design is done, fix it + // it('should start the audio recorder on long press and cleanup on unmount', async () => { + // renderComponent({ + // channelProps: { audioRecordingEnabled: false, channel }, // TODO: Once the async audio design is done, fix it + // client, + // props: {}, + // }); + + // const { getByLabelText, unmount } = screen; + + // const audioButton = getByLabelText('Start recording'); + + // act(() => { + // fireEvent(audioButton, 'longPress'); + // }); + + // await waitFor(() => { + // expect(NativeHandlers.Audio.startRecording).toHaveBeenCalledTimes(1); + // expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled(); + // expect(queryByTestId('recording-active-container')).toBeTruthy(); + // expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.'); + // }); + + // await act(() => { + // unmount(); + // }); + + // await waitFor(() => { + // expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1); + // }); + // }); + + // it('should trigger an alert if a normal press happened on audio recording', async () => { + // renderComponent({ + // channelProps: { audioRecordingEnabled: false, channel }, + // client, + // props: {}, + // }); + + // const { getByLabelText, queryByTestId } = screen; + + // const audioButton = getByLabelText('Start recording'); + + // act(() => { + // fireEvent.press(audioButton); + // }); + + // await waitFor(() => { + // expect(NativeHandlers.Audio.startRecording).not.toHaveBeenCalled(); + // expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled(); + // expect(queryByTestId('recording-active-container')).not.toBeTruthy(); + // // This is sort of a brittle test, but there doesn't seem to be another way + // // to target alerts. The reason why it's here is because we had a bug with it. + // expect(Alert.alert).toHaveBeenCalledWith('Hold to start recording.'); + // }); + // }); }); diff --git a/package/src/components/MessageInput/__tests__/SendButton.test.js b/package/src/components/MessageInput/__tests__/SendButton.test.js index e501588eb..f237aad82 100644 --- a/package/src/components/MessageInput/__tests__/SendButton.test.js +++ b/package/src/components/MessageInput/__tests__/SendButton.test.js @@ -7,7 +7,7 @@ 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'; +import { SendButton } from '../components/OutputButtons/SendButton'; const renderComponent = ({ client, channel, props }) => { return render( @@ -60,7 +60,6 @@ describe('SendButton', () => { await waitFor(() => { expect(sendMessage).toHaveBeenCalledTimes(1); - expect(getByTestId('send-up')).toBeDefined(); }); const snapshot = toJSON(); @@ -90,7 +89,6 @@ describe('SendButton', () => { await waitFor(() => { expect(sendMessage).toHaveBeenCalledTimes(0); - expect(getByTestId('send-right')).toBeDefined(); }); const snapshot = toJSON(); @@ -99,21 +97,4 @@ describe('SendButton', () => { expect(snapshot).toMatchSnapshot(); }); }); - - // TODO: Add it back once the command inject PR is merged - it.skip('should show search button if the command is enabled', async () => { - const sendMessage = jest.fn(); - - const props = { sendMessage }; - - channel.messageComposer.textComposer.setCommand({ description: 'Ban a user', name: 'ban' }); - - renderComponent({ channel, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('search-icon')).toBeTruthy(); - }); - }); }); 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 ce8d93e13..8c62fffa5 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -51,6 +51,20 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 24, + "height": 48, + "width": 48, + }, + { + "backgroundColor": "#FFFFFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -58,14 +72,17 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli > - - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> @@ -401,6 +398,20 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 24, + "height": 48, + "width": 48, + }, + { + "backgroundColor": "#FFFFFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -408,14 +419,17 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` > - - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> @@ -751,6 +745,20 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 24, + "height": 48, + "width": 48, + }, + { + "backgroundColor": "#FFFFFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -758,14 +766,17 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` > - - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> 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 193309cc7..9ba722b90 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap @@ -50,6 +50,20 @@ exports[`SendButton should render a SendButton 1`] = ` onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 16, + "height": 32, + "width": 32, + }, + { + "backgroundColor": "#005FFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -57,14 +71,18 @@ exports[`SendButton should render a SendButton 1`] = ` > - - @@ -375,6 +394,20 @@ exports[`SendButton should render a disabled SendButton 1`] = ` onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 16, + "height": 32, + "width": 32, + }, + { + "backgroundColor": "#005FFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -382,14 +415,18 @@ exports[`SendButton should render a disabled SendButton 1`] = ` > - - diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx index 6ff419c2f..096d29382 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx @@ -3,11 +3,9 @@ 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 { ErrorCircle } from '../../../../icons/ErrorCircle'; import { Progress, ProgressIndicatorTypes } from '../../../../utils/utils'; -const WARNING_ICON_SIZE = 16; - export type AttachmentUnsupportedIndicatorProps = { /** Type of active indicator */ indicatorType?: Progress; @@ -21,7 +19,7 @@ export const AttachmentUnsupportedIndicator = ({ }: AttachmentUnsupportedIndicatorProps) => { const { theme: { - colors: { accent_red, grey_dark, overlay, white }, + colors: { accent_error, overlay }, messageInput: { attachmentUnsupportedIndicator: { container, text, warningIcon }, }, @@ -42,16 +40,14 @@ export const AttachmentUnsupportedIndicator = ({ container, ]} > - - - {t('Not supported')} - + {t('Not supported')} ); }; @@ -61,7 +57,6 @@ const styles = StyleSheet.create({ alignItems: 'center', flexDirection: 'row', marginTop: 4, - paddingHorizontal: 2, }, imageStyle: { borderRadius: 16, @@ -70,13 +65,11 @@ const styles = StyleSheet.create({ }, warningIconStyle: { borderRadius: 24, - marginTop: 6, }, warningText: { alignItems: 'center', color: 'black', - fontSize: 10, - justifyContent: 'center', + fontSize: 12, marginHorizontal: 4, }, }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx new file mode 100644 index 000000000..c10db5e1e --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -0,0 +1,245 @@ +import React, { useCallback } from 'react'; +import { FlatList, StyleSheet, View } from 'react-native'; + +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; + +import { + isLocalAudioAttachment, + isLocalFileAttachment, + isLocalImageAttachment, + isLocalVoiceRecordingAttachment, + isVideoAttachment, + LocalAttachment, +} from 'stream-chat'; + +import { useMessageComposer } from '../../../../contexts'; +import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { isSoundPackageAvailable } from '../../../../native'; + +const IMAGE_PREVIEW_SIZE = 100; +const FILE_PREVIEW_HEIGHT = 60; + +export type AttachmentUploadListPreviewPropsWithContext = Pick< + MessageInputContextValue, + | 'AudioAttachmentUploadPreview' + | 'FileAttachmentUploadPreview' + | 'ImageAttachmentUploadPreview' + | 'VideoAttachmentUploadPreview' +>; + +const ItemSeparatorComponent = () => { + const { + theme: { + messageInput: { + attachmentUploadPreviewList: { itemSeparator }, + }, + }, + } = useTheme(); + return ; +}; + +const getItemLayout = (data: ArrayLike | null | undefined, index: number) => { + const item = data?.[index]; + if (item && isLocalImageAttachment(item as LocalAttachment)) { + return { + index, + length: IMAGE_PREVIEW_SIZE + 8, + offset: (IMAGE_PREVIEW_SIZE + 8) * index, + }; + } + return { + index, + length: FILE_PREVIEW_HEIGHT + 8, + offset: (FILE_PREVIEW_HEIGHT + 8) * index, + }; +}; + +/** + * AttachmentUploadPreviewList + * UI Component to preview the files set for upload + */ +const UnMemoizedAttachmentUploadPreviewList = ( + props: AttachmentUploadListPreviewPropsWithContext, +) => { + const { + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, + VideoAttachmentUploadPreview, + } = props; + const { attachmentManager } = useMessageComposer(); + const { attachments } = useAttachmentManagerState(); + const { + theme: { + messageInput: { + attachmentUploadPreviewList: { flatList }, + }, + }, + } = useTheme(); + + const renderItem = useCallback( + ({ item }: { item: LocalAttachment }) => { + if (isLocalImageAttachment(item)) { + return ( + + + + ); + } else if (isLocalVoiceRecordingAttachment(item)) { + return ( + + + + ); + } else if (isLocalAudioAttachment(item)) { + if (isSoundPackageAvailable()) { + return ( + + + + ); + } else { + return ( + + + + ); + } + } else if (isVideoAttachment(item)) { + return ( + + + + ); + } else if (isLocalFileAttachment(item)) { + return ( + + + + ); + } else return null; + }, + [ + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, + VideoAttachmentUploadPreview, + attachmentManager.removeAttachments, + attachmentManager.uploadAttachment, + ], + ); + + if (!attachments.length) { + return null; + } + + return ( + item.localMetadata.id} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + style={[styles.flatList, flatList]} + testID={'attachment-upload-preview-list'} + /> + ); +}; + +export type AttachmentUploadPreviewListProps = Partial; + +const MemoizedAttachmentUploadPreviewListWithContext = React.memo( + UnMemoizedAttachmentUploadPreviewList, +); + +/** + * AttachmentUploadPreviewList + * UI Component to preview the files set for upload + */ +export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListProps) => { + const { + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, + VideoAttachmentUploadPreview, + } = useMessageInputContext(); + return ( + + ); +}; + +const styles = StyleSheet.create({ + flatList: { + overflow: 'visible', + }, + itemSeparator: { + width: 8, + }, + wrapper: {}, +}); + +AttachmentUploadPreviewList.displayName = + 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}'; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx b/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx index 43158d4b9..631bd8efd 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx @@ -3,14 +3,14 @@ import React from 'react'; import { Pressable, PressableProps, StyleSheet } from 'react-native'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; -import { Close } from '../../../../icons'; +import { NewClose } from '../../../../icons/NewClose'; type DismissAttachmentUploadProps = PressableProps; export const DismissAttachmentUpload = ({ onPress }: DismissAttachmentUploadProps) => { const { theme: { - colors: { overlay, white }, + colors: { white }, messageInput: { dismissAttachmentUpload: { dismiss, dismissIcon, dismissIconColor }, }, @@ -22,21 +22,24 @@ export const DismissAttachmentUpload = ({ onPress }: DismissAttachmentUploadProp onPress={onPress} style={({ pressed }) => [ styles.dismiss, - { backgroundColor: overlay, opacity: pressed ? 0.8 : 1 }, + { + borderColor: white, + opacity: pressed ? 0.8 : 1, + }, dismiss, ]} testID='remove-upload-preview' > - +
); }; const styles = StyleSheet.create({ dismiss: { - borderRadius: 24, - position: 'absolute', - right: 8, - top: 8, + backgroundColor: '#384047', + borderRadius: 16, + borderWidth: 2, + overflow: 'hidden', }, }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 856bc31e8..9e913ad69 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -14,7 +14,6 @@ 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, @@ -26,13 +25,10 @@ export type FileAttachmentUploadPreviewProps | LocalVideoAttachment | LocalAudioAttachment - > & { - flatListWidth: number; - }; + >; export const FileAttachmentUploadPreview = ({ attachment, - flatListWidth, handleRetry, removeAttachments, }: FileAttachmentUploadPreviewProps) => { @@ -71,7 +67,7 @@ export const FileAttachmentUploadPreview = ({ - + - + - {getTrimmedAttachmentTitle(attachment.title)} + {attachment.title} {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( @@ -120,17 +110,26 @@ export const FileAttachmentUploadPreview = ({ - + + + ); }; const styles = StyleSheet.create({ + dismissWrapper: { position: 'absolute', right: 0, top: 0 }, fileContainer: { borderRadius: 12, borderWidth: 1, flexDirection: 'row', - paddingHorizontal: 8, + gap: 12, + maxWidth: 224, // TODO: Not sure how to omit this + padding: 16, + }, + fileContent: { + flexShrink: 1, + justifyContent: 'space-between', }, fileIcon: { alignItems: 'center', @@ -138,24 +137,19 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, filenameText: { - fontSize: 14, - fontWeight: 'bold', + fontSize: 12, + fontWeight: '600', + }, + fileNameTextContainer: { + flexShrink: 1, }, fileSizeText: { fontSize: 12, - marginTop: 10, - }, - fileTextContainer: { - justifyContent: 'space-around', - marginVertical: 10, - paddingHorizontal: 10, }, overlay: { borderRadius: 12, - marginTop: 2, }, wrapper: { - flexDirection: 'row', - marginHorizontal: 8, + padding: 4, }, }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index 7c29cc571..c476a9b33 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -13,7 +13,7 @@ import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; -const IMAGE_PREVIEW_SIZE = 100; +const IMAGE_PREVIEW_SIZE = 72; export type ImageAttachmentUploadPreviewProps> = UploadAttachmentPreviewProps>; @@ -32,7 +32,7 @@ export const ImageAttachmentUploadPreview = ({ const { theme: { messageInput: { - imageAttachmentUploadPreview: { itemContainer, upload }, + imageAttachmentUploadPreview: { container, upload, wrapper }, }, }, } = useTheme(); @@ -54,10 +54,10 @@ export const ImageAttachmentUploadPreview = ({ }, []); return ( - + + {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( + + ) : null} - - {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( - - ) : null} + + + ); }; const styles = StyleSheet.create({ + container: { + borderColor: '#E2E6EA', + borderRadius: 12, + borderWidth: 1, + flexDirection: 'row', + overflow: 'hidden', + }, + dismissWrapper: { position: 'absolute', right: 0, top: 0 }, 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, }, + wrapper: { + padding: 4, + }, }); diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 0186f0d57..39782431d 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { Alert, Linking, Pressable, StyleSheet } from 'react-native'; +import { Alert, Linking } from 'react-native'; +import { IconButton } from '../../../../components/ui/IconButton'; import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; -import { Mic } from '../../../../icons/Mic'; +import { NewMic } from '../../../../icons/NewMic'; import { AudioRecordingReturnType, NativeHandlers } from '../../../../native'; export type AudioRecordingButtonProps = Partial< @@ -45,7 +45,6 @@ export type AudioRecordingButtonProps = Partial< export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { const { asyncMessagesMinimumPressDuration: propAsyncMessagesMinimumPressDuration, - buttonSize, handleLongPress, handlePress, permissionsGranted, @@ -58,14 +57,6 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { const asyncMessagesMinimumPressDuration = propAsyncMessagesMinimumPressDuration || contextAsyncMessagesMinimumPressDuration; - const { - theme: { - colors: { grey, light_gray, white }, - messageInput: { - audioRecordingButton: { container, micIcon }, - }, - }, - } = useTheme(); const { t } = useTranslationContext(); const onPressHandler = () => { @@ -103,33 +94,17 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { }; return ( - [ - styles.container, - { - backgroundColor: pressed ? light_gray : white, - height: buttonSize || 40, - width: buttonSize || 40, - }, - container, - ]} - testID='audio-button' - > - - + size='sm' + type='secondary' + /> ); }; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - borderRadius: 50, - justifyContent: 'center', - marginLeft: 8, - }, -}); - AudioRecordingButton.displayName = 'AudioRecordingButton{messageInput}'; diff --git a/package/src/components/MessageInput/components/CommandInput.tsx b/package/src/components/MessageInput/components/CommandInput.tsx index c789f657f..f8c6c58f8 100644 --- a/package/src/components/MessageInput/components/CommandInput.tsx +++ b/package/src/components/MessageInput/components/CommandInput.tsx @@ -43,8 +43,8 @@ export const CommandInput = ({ theme: { colors: { accent_blue, grey, white }, messageInput: { - autoCompleteInputContainer, commandInput: { closeButton, container, text }, + inputContainer, }, }, } = useTheme(); @@ -61,13 +61,13 @@ export const CommandInput = ({ const commandName = (command.name ?? '').toUpperCase(); return ( - + {commandName} - + { } = props; const { theme: { - colors: { accent_blue, grey }, messageInput: { attachButton }, }, } = useTheme(); @@ -84,15 +82,16 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { return ( <> - - - + type='secondary' + /> {showAttachButtonPicker ? ( ; export type InputButtonsWithContextProps = Pick< MessageInputContextValue, | 'AttachButton' - | 'CommandsButton' | 'hasCameraPicker' | 'hasCommands' | 'hasFilePicker' | 'hasImagePicker' - | 'MoreOptionsButton' | 'toggleAttachmentPicker' > & Pick & Pick; -const textComposerStateSelector = (state: TextComposerState) => ({ - command: state.command, - hasText: !!state.text, -}); - export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => { const { AttachButton, - CommandsButton, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - MoreOptionsButton, uploadFile: ownCapabilitiesUploadFile, } = props; - const { textComposer } = useMessageComposer(); - const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); - - const [showMoreOptions, setShowMoreOptions] = useState(true); - const { attachments } = useAttachmentManagerState(); - - const shouldShowMoreOptions = hasText || attachments.length; - - useEffect(() => { - setShowMoreOptions(!shouldShowMoreOptions); - }, [shouldShowMoreOptions]); const { theme: { @@ -68,36 +43,18 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => }, } = useTheme(); - const handleShowMoreOptions = useCallback(() => { - setShowMoreOptions(true); - }, [setShowMoreOptions]); - const hasAttachmentUploadCapabilities = (hasCameraPicker || hasFilePicker || hasImagePicker) && ownCapabilitiesUploadFile; - const showCommandsButton = hasCommands && !hasText; - - if (command) { - return null; - } if (!hasAttachmentUploadCapabilities && !hasCommands) { return null; } - return !showMoreOptions ? ( - - ) : ( - <> - {hasAttachmentUploadCapabilities ? ( - - - - ) : null} - {showCommandsButton ? : null} - - ); + return hasAttachmentUploadCapabilities ? ( + + + + ) : null; }; const areEqual = ( @@ -106,7 +63,6 @@ const areEqual = ( ) => { const { hasCameraPicker: prevHasCameraPicker, - hasCommands: prevHasCommands, hasFilePicker: prevHasFilePicker, hasImagePicker: prevHasImagePicker, selectedPicker: prevSelectedPicker, @@ -114,7 +70,6 @@ const areEqual = ( const { hasCameraPicker: nextHasCameraPicker, - hasCommands: nextHasCommands, hasFilePicker: nextHasFilePicker, hasImagePicker: nextHasImagePicker, selectedPicker: nextSelectedPicker, @@ -132,10 +87,6 @@ const areEqual = ( return false; } - if (prevHasCommands !== nextHasCommands) { - return false; - } - if (prevSelectedPicker !== nextSelectedPicker) { return false; } @@ -151,12 +102,10 @@ const MemoizedInputButtonsWithContext = React.memo( export const InputButtons = (props: InputButtonsProps) => { const { AttachButton, - CommandsButton, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - MoreOptionsButton, toggleAttachmentPicker, } = useMessageInputContext(); const { selectedPicker } = useAttachmentPickerContext(); @@ -166,12 +115,10 @@ export const InputButtons = (props: InputButtonsProps) => { { }; const styles = StyleSheet.create({ - attachButtonContainer: { paddingRight: 5 }, + attachButtonContainer: {}, }); diff --git a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx b/package/src/components/MessageInput/components/InputEditingStateHeader.tsx deleted file mode 100644 index cb0559989..000000000 --- a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useCallback } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { - MessageComposerAPIContextValue, - useMessageComposerAPIContext, -} from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; -import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; - -import { CircleClose, Edit } from '../../../icons'; - -export type InputEditingStateHeaderProps = Partial< - Pick ->; - -export const InputEditingStateHeader = ({ - clearEditingState: propClearEditingState, -}: InputEditingStateHeaderProps) => { - const messageComposer = useMessageComposer(); - const { t } = useTranslationContext(); - const { clearEditingState: contextClearEditingState } = useMessageComposerAPIContext(); - - const clearEditingState = propClearEditingState || contextClearEditingState; - - const { - theme: { - colors: { black, grey, grey_gainsboro }, - messageInput: { - editingStateHeader: { editingBoxHeader, editingBoxHeaderTitle }, - }, - }, - } = useTheme(); - - const onCloseHandler = useCallback(() => { - if (clearEditingState) { - clearEditingState(); - } - messageComposer.restore(); - }, [clearEditingState, messageComposer]); - - return ( - - - - {t('Editing Message')} - - [{ 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 deleted file mode 100644 index 8fd09bf49..000000000 --- a/package/src/components/MessageInput/components/InputReplyStateHeader.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; - -import { CircleClose, CurveLineLeftUp } from '../../../icons'; - -export const InputReplyStateHeader = () => { - const { t } = useTranslationContext(); - const messageComposer = useMessageComposer(); - const { - theme: { - colors: { black, grey, grey_gainsboro }, - messageInput: { - editingStateHeader: { editingBoxHeader, editingBoxHeaderTitle }, - }, - }, - } = useTheme(); - - const onCloseHandler = () => { - messageComposer.setQuotedMessage(null); - }; - - return ( - - - - {t('Reply to Message')} - - [{ opacity: pressed ? 0.8 : 1 }]} - testID='close-button' - > - - - - ); -}; - -const styles = StyleSheet.create({ - replyBoxHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingBottom: 8, - }, - replyBoxHeaderTitle: { - fontSize: 14, - fontWeight: 'bold', - }, -}); - -InputReplyStateHeader.displayName = 'ReplyStateHeader{messageInput}'; diff --git a/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx new file mode 100644 index 000000000..ecf6105a2 --- /dev/null +++ b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { StyleSheet, Text } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { IconButton } from '../../../ui/IconButton'; + +export type CooldownTimerProps = { + seconds: number; +}; + +/** + * Renders an amount of seconds left for a cooldown to finish. + * + * See `useCountdown` for an example of how to set a countdown + * to use as the source of `seconds`. + **/ +export const CooldownTimer = (props: CooldownTimerProps) => { + const { seconds } = props; + const { + theme: { + messageInput: { + cooldownTimer: { text }, + }, + }, + } = useTheme(); + + const icon = useCallback(() => { + return ( + + {seconds} + + ); + }, [seconds, text]); + + return ( + + ); +}; + +const styles = StyleSheet.create({ + text: { color: '#B8BEC4', fontSize: 16, fontWeight: '600' }, +}); diff --git a/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx new file mode 100644 index 000000000..48adc924f --- /dev/null +++ b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; + +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { NewTick } from '../../../../icons/NewTick'; +import { IconButton } from '../../../ui/IconButton'; + +export type EditButtonProps = Partial> & { + /** Disables the button */ + disabled: boolean; +}; + +export const EditButton = (props: EditButtonProps) => { + const { disabled = false, sendMessage: propsSendMessage } = props; + const { sendMessage: sendMessageFromContext } = useMessageInputContext(); + const sendMessage = propsSendMessage || sendMessageFromContext; + + const { + theme: { + messageInput: { editButton }, + }, + } = useTheme(); + + const onPressHandler = useCallback(() => { + if (disabled) { + return; + } + sendMessage(); + }, [disabled, sendMessage]); + + return ( + + ); +}; + +EditButton.displayName = 'EditButton{messageInput}'; diff --git a/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx new file mode 100644 index 000000000..b0ccabad2 --- /dev/null +++ b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; + +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { SendRight } from '../../../../icons/SendRight'; +import { IconButton } from '../../../ui/IconButton'; + +export type SendButtonProps = Partial> & { + /** Disables the button */ + disabled: boolean; +}; + +export const SendButton = (props: SendButtonProps) => { + const { disabled = false, sendMessage: propsSendMessage } = props; + const { sendMessage: sendMessageFromContext } = useMessageInputContext(); + const sendMessage = propsSendMessage || sendMessageFromContext; + + const { + theme: { + messageInput: { sendButton }, + }, + } = useTheme(); + + const onPressHandler = useCallback(() => { + if (disabled) { + return; + } + sendMessage(); + }, [disabled, sendMessage]); + + return ( + + ); +}; + +SendButton.displayName = 'SendButton{messageInput}'; diff --git a/package/src/components/MessageInput/components/OutputButtons/index.tsx b/package/src/components/MessageInput/components/OutputButtons/index.tsx new file mode 100644 index 000000000..dda8a74d5 --- /dev/null +++ b/package/src/components/MessageInput/components/OutputButtons/index.tsx @@ -0,0 +1,202 @@ +import React, { useCallback } from 'react'; + +import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; + +import { TextComposerState } from 'stream-chat'; + +import { EditButton } from './EditButton'; + +import { + ChannelContextValue, + ChatContextValue, + useChannelContext, + useChatContext, + useMessageComposerHasSendableData, + useTheme, +} from '../../../../contexts'; +import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState'; +import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { AIStates, useAIState } from '../../../AITypingIndicatorView'; +import { AudioRecordingButton } from '../../components/AudioRecorder/AudioRecordingButton'; +import { useCountdown } from '../../hooks/useCountdown'; + +export type OutputButtonsProps = Partial; + +export type OutputButtonsWithContextProps = Pick & + Pick & + Pick< + MessageInputContextValue, + | 'asyncMessagesMinimumPressDuration' + | 'asyncMessagesSlideToCancelDistance' + | 'asyncMessagesLockDistance' + | 'asyncMessagesMultiSendEnabled' + | 'audioRecordingEnabled' + | 'cooldownEndsAt' + | 'CooldownTimer' + | 'SendButton' + | 'StopMessageStreamingButton' + | 'StartAudioRecordingButton' + >; + +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, + hasText: !!state.text, +}); + +export const OutputButtonsWithContext = (props: OutputButtonsWithContextProps) => { + const { + channel, + cooldownEndsAt, + CooldownTimer, + isOnline, + SendButton, + StopMessageStreamingButton, + } = props; + const { + theme: { + messageInput: { + audioRecordingButtonContainer, + cooldownButtonContainer, + editButtonContainer, + sendButtonContainer, + }, + }, + } = useTheme(); + + const messageComposer = useMessageComposer(); + const editing = !!messageComposer.editedMessage; + const { textComposer } = messageComposer; + const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); + const { attachments } = useAttachmentManagerState(); + const hasSendableData = useMessageComposerHasSendableData(); + + const showSendingButton = hasText || attachments.length || command; + + const { seconds: cooldownRemainingSeconds } = useCountdown(cooldownEndsAt); + + const { aiState } = useAIState(channel); + const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); + const shouldDisplayStopAIGeneration = + [AIStates.Thinking, AIStates.Generating].includes(aiState) && !!StopMessageStreamingButton; + + if (shouldDisplayStopAIGeneration) { + return ; + } + + if (editing) { + return ( + + + + ); + } + + if (cooldownRemainingSeconds) { + return ( + + + + ); + } + + if (showSendingButton) { + return ( + + + + ); + } + + return ( + + + + ); +}; + +const areEqual = ( + prevProps: OutputButtonsWithContextProps, + nextProps: OutputButtonsWithContextProps, +) => { + const { channel: prevChannel, cooldownEndsAt: prevCooldownEndsAt } = prevProps; + + const { channel: nextChannel, cooldownEndsAt: nextCooldownEndsAt } = nextProps; + + if (prevChannel?.cid !== nextChannel?.cid) { + return false; + } + + const cooldownEndsAtEqual = prevCooldownEndsAt === nextCooldownEndsAt; + if (!cooldownEndsAtEqual) { + return false; + } + + return true; +}; + +const MemoizedOutputButtonsWithContext = React.memo( + OutputButtonsWithContext, + areEqual, +) as typeof OutputButtonsWithContext; + +export const OutputButtons = (props: OutputButtonsProps) => { + const { isOnline } = useChatContext(); + const { channel } = useChannelContext(); + const { + audioRecordingEnabled, + asyncMessagesMinimumPressDuration, + asyncMessagesSlideToCancelDistance, + asyncMessagesLockDistance, + asyncMessagesMultiSendEnabled, + cooldownEndsAt, + CooldownTimer, + SendButton, + StopMessageStreamingButton, + StartAudioRecordingButton, + } = useMessageInputContext(); + + return ( + + ); +}; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index bac31e544..02c618be8 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -8,6 +8,8 @@ import { ViewToken, } from 'react-native'; +import Animated, { LinearTransition } from 'react-native-reanimated'; + import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; @@ -30,6 +32,10 @@ import { ImageGalleryContextValue, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../contexts/messageInputContext/MessageInputContext'; import { MessageListItemContextValue, MessageListItemProvider, @@ -49,7 +55,11 @@ import { import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback, useStateStore } from '../../hooks'; +import { + MessageInputHeightState, + messageInputHeightStore, +} from '../../state-store/message-input-height-store'; import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; @@ -96,6 +106,10 @@ const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageRe return previousLastMessage; }; +const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ + height: state.height, +}); + type MessageFlashListPropsWithContext = Pick< AttachmentPickerContextValue, 'closePicker' | 'selectedPicker' | 'setSelectedPicker' @@ -124,6 +138,7 @@ type MessageFlashListPropsWithContext = Pick< | 'maximumMessageLimit' > & Pick & + Pick & Pick & Pick & Pick< @@ -287,6 +302,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => loadMoreThread, markRead, maximumMessageLimit, + messageInputFloating, myMessageTheme, readEvents, NetworkDownIndicator, @@ -312,6 +328,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } = props; const flashListRef = useRef | null>(null); + const { height: messageInputHeight } = useStateStore( + messageInputHeightStore, + messageInputHeightStoreSelector, + ); + const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); @@ -339,7 +360,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const { colors: { white_snow }, - messageList: { container, contentContainer, listContainer }, + messageList: { + container, + contentContainer, + listContainer, + scrollToBottomButtonContainer, + stickyHeaderContainer, + unreadMessagesNotificationContainer, + }, } = theme; const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); @@ -935,8 +963,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const visibleLength = nativeEvent.layoutMeasurement.height; const contentLength = nativeEvent.contentSize.height; - // Show scrollToBottom button once scroll position goes beyond 150. - const isScrollAtStart = contentLength - visibleLength - offset < 150; + const isScrollAtStart = contentLength - visibleLength - offset < messageInputHeight; const notLatestSet = channel.state.messages !== channel.state.latestMessages; @@ -1025,8 +1052,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); const flatListContentContainerStyle = useMemo( - () => [styles.contentContainer, contentContainer], - [contentContainer], + () => [ + styles.contentContainer, + { paddingBottom: messageInputFloating ? messageInputHeight : 0 }, + contentContainer, + ], + [contentContainer, messageInputFloating, messageInputHeight], ); const currentListHeightRef = useRef(undefined); @@ -1102,7 +1133,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => /> )} - + {messageListLengthAfterUpdate && StickyHeader ? ( ) : null} @@ -1112,14 +1143,27 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => )} - + + + {isUnreadNotificationOpen && !threadList ? ( - + + + ) : null} ); @@ -1180,6 +1224,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); + const { messageInputFloating } = useMessageInputContext(); return ( { markRead, maximumMessageLimit, Message, + messageInputFloating, MessageSystem, myMessageTheme, NetworkDownIndicator, @@ -1241,7 +1287,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const styles = StyleSheet.create({ container: { - alignItems: 'center', flex: 1, width: '100%', }, @@ -1259,8 +1304,20 @@ const styles = StyleSheet.create({ flex: 1, width: '100%', }, - stickyHeader: { + scrollToBottomButtonContainer: { + bottom: 8, position: 'absolute', + right: 24, + }, + stickyHeaderContainer: { + left: 0, + position: 'absolute', + right: 0, top: 0, }, + unreadMessagesNotificationContainer: { + alignSelf: 'center', + position: 'absolute', + top: 8, + }, }); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index ac6693a39..7960e1173 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -9,6 +9,8 @@ import { ViewToken, } from 'react-native'; +import Animated, { LinearTransition } from 'react-native-reanimated'; + import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -32,6 +34,10 @@ import { ImageGalleryContextValue, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../contexts/messageInputContext/MessageInputContext'; import { MessageListItemContextValue, MessageListItemProvider, @@ -52,6 +58,11 @@ import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext' import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; +import { useStateStore } from '../../hooks/useStateStore'; +import { + MessageInputHeightState, + messageInputHeightStore, +} from '../../state-store/message-input-height-store'; import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; @@ -61,7 +72,6 @@ const WAIT_FOR_SCROLL_TIMEOUT = 0; const MAX_RETRIES_AFTER_SCROLL_FAILURE = 10; const styles = StyleSheet.create({ container: { - alignItems: 'center', flex: 1, width: '100%', }, @@ -79,10 +89,22 @@ const styles = StyleSheet.create({ flex: 1, width: '100%', }, - stickyHeader: { + scrollToBottomButtonContainer: { + bottom: 8, + position: 'absolute', + right: 24, + }, + stickyHeaderContainer: { + left: 0, position: 'absolute', + right: 0, top: 0, }, + unreadMessagesNotificationContainer: { + alignSelf: 'center', + position: 'absolute', + top: 8, + }, }); const keyExtractor = (item: LocalMessage) => { @@ -160,6 +182,7 @@ type MessageListPropsWithContext = Pick< | 'TypingIndicatorContainer' | 'UnreadMessagesNotification' > & + Pick & Pick< ThreadContextValue, 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' @@ -236,6 +259,10 @@ const renderItem = ({ item: message }: { item: LocalMessage }) => { return ; }; +const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ + height: state.height, +}); + /** * The message list component renders a list of messages. It consumes the following contexts: * @@ -276,6 +303,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { loadMoreThread, markRead, maximumMessageLimit, + messageInputFloating, myMessageTheme, NetworkDownIndicator, noGroupByUser, @@ -301,10 +329,21 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } = props; const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const { theme } = useTheme(); + const { height: messageInputHeight } = useStateStore( + messageInputHeightStore, + messageInputHeightStoreSelector, + ); const { colors: { white_snow }, - messageList: { container, contentContainer, listContainer }, + messageList: { + container, + contentContainer, + listContainer, + stickyHeaderContainer, + scrollToBottomButtonContainer, + unreadMessagesNotificationContainer, + }, } = theme; const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); @@ -915,8 +954,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const handleScroll: ScrollViewProps['onScroll'] = useStableCallback((event) => { const messageListHasMessages = processedMessageList.length > 0; const offset = event.nativeEvent.contentOffset.y; - // Show scrollToBottom button once scroll position goes beyond 150. - const isScrollAtBottom = offset <= 150; + const isScrollAtBottom = offset <= messageInputHeight; const notLatestSet = channel.state.messages !== channel.state.latestMessages; @@ -1122,11 +1160,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const flatListContentContainerStyle = useMemo( () => [ - styles.contentContainer, + { paddingTop: messageInputFloating ? messageInputHeight : 0 }, + additionalFlatListProps?.contentContainerStyle, + contentContainer, + ], + [ additionalFlatListProps?.contentContainerStyle, contentContainer, + messageInputHeight, + messageInputFloating, ], - [additionalFlatListProps?.contentContainerStyle, contentContainer], ); if (!FlatList) { @@ -1191,7 +1234,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { /> )} - + {messageListLengthAfterUpdate && StickyHeader ? ( ) : null} @@ -1201,14 +1244,30 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { )} - + {scrollToBottomButtonVisible ? ( + + + + ) : null} + {isUnreadNotificationOpen && !threadList ? ( - + + + ) : null} ); @@ -1261,6 +1320,7 @@ export const MessageList = (props: MessageListProps) => { TypingIndicatorContainer, UnreadMessagesNotification, } = useMessagesContext(); + const { messageInputFloating } = useMessageInputContext(); const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); @@ -1294,6 +1354,7 @@ export const MessageList = (props: MessageListProps) => { markRead, maximumMessageLimit, Message, + messageInputFloating, MessageSystem, myMessageTheme, NetworkDownIndicator, diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 8c15cb851..5e7c732a7 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -1,29 +1,13 @@ import React from 'react'; -import { GestureResponderEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; + +import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { Down } from '../../icons'; +import { NewDown } from '../../icons/NewDown'; +import { IconButton } from '../ui/IconButton'; const styles = StyleSheet.create({ - container: { - alignItems: 'center', - borderRadius: 20, - elevation: 5, - height: 40, - justifyContent: 'center', - shadowOffset: { - height: 2, - width: 0, - }, - shadowOpacity: 0.25, - shadowRadius: 4, - width: 40, - }, - touchable: { - bottom: 20, - position: 'absolute', - right: 20, - }, unreadCountNotificationContainer: { alignItems: 'center', borderRadius: 10, @@ -40,16 +24,11 @@ const styles = StyleSheet.create({ textAlign: 'center', textAlignVertical: 'center', }, - wrapper: { - alignItems: 'center', - height: 50, - justifyContent: 'flex-end', - }, }); export type ScrollToBottomButtonProps = { /** onPress handler */ - onPress: (event: GestureResponderEvent) => void; + onPress: () => void; /** If we should show the notification or not */ showNotification?: boolean; unreadCount?: number; @@ -60,15 +39,12 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { const { theme: { - colors: { accent_blue, black, white }, + colors: { accent_blue, white }, messageList: { scrollToBottomButton: { - chevronColor, container, - touchable, unreadCountNotificationContainer, unreadCountNotificationText, - wrapper, }, }, }, @@ -79,37 +55,42 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { } return ( - - - - - - {!!unreadCount && ( - + + + + {!!unreadCount && ( + + - - {unreadCount} - - - )} - - + {unreadCount} + + + )} + ); }; diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index 47192ee19..0872d8be4 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text } from 'react-native'; import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; -import { Close } from '../../icons'; +import { NewClose } from '../../icons/NewClose'; export type UnreadMessagesNotificationProps = { /** @@ -76,7 +76,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp closeButtonContainer, ]} > - +
); @@ -84,13 +84,11 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp const styles = StyleSheet.create({ container: { - alignItems: 'center', borderRadius: 20, elevation: 4, flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 8, - position: 'absolute', shadowColor: '#000', shadowOffset: { height: 2, @@ -98,7 +96,6 @@ const styles = StyleSheet.create({ }, shadowOpacity: 0.23, shadowRadius: 2.62, - top: 8, }, text: { fontWeight: '500', diff --git a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap index 286fdbf9a..eddcde61d 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap @@ -1,136 +1,141 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`ScrollToBottomButton should render the message notification and match snapshot 1`] = ` - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> + + `; diff --git a/package/src/components/RTLComponents/WritingDirectionAwareText.tsx b/package/src/components/RTLComponents/WritingDirectionAwareText.tsx index 0e7e0fa17..e34ff46d3 100644 --- a/package/src/components/RTLComponents/WritingDirectionAwareText.tsx +++ b/package/src/components/RTLComponents/WritingDirectionAwareText.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { I18nManager, StyleSheet, Text, ViewProps } from 'react-native'; +import { I18nManager, StyleSheet, Text, TextProps } from 'react-native'; const styles = StyleSheet.create({ defaultStyle: { writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr' }, }); -export type WritingDirectionAwareTextProps = ViewProps; +export type WritingDirectionAwareTextProps = TextProps; export const WritingDirectionAwareText = (props: WritingDirectionAwareTextProps) => { const { children, style, ...rest } = props; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 1ad146941..764345705 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -1,405 +1,474 @@ -import React, { useMemo, useState } from 'react'; - -import { Image, ImageStyle, StyleSheet, Text, View, ViewStyle } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; +import { LocalMessage, MessageComposerState, PollState } from 'stream-chat'; -import merge from 'lodash/merge'; - -import type { Attachment, MessageComposerState, PollState } from 'stream-chat'; - -import { useChatContext, useMessageComposer } from '../../contexts'; -import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext'; -import { useMessageContext } from '../../contexts/messageContext/MessageContext'; +import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { - MessagesContextValue, - useMessagesContext, -} from '../../contexts/messagesContext/MessagesContext'; + MessageContextValue, + useMessageContext, +} from '../../contexts/messageContext/MessageContext'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { MessagesContextValue } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { - TranslationContextValue, - useTranslationContext, -} from '../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../hooks'; +import { NewFile } from '../../icons/NewFile'; +import { NewLink } from '../../icons/NewLink'; +import { NewMapPin } from '../../icons/NewMapPin'; +import { NewMic } from '../../icons/NewMic'; +import { NewPhoto } from '../../icons/NewPhoto'; +import { NewPlayIcon } from '../../icons/NewPlayIcon'; +import { NewPoll } from '../../icons/NewPoll'; +import { NewVideo } from '../../icons/NewVideo'; import { FileTypes } from '../../types/types'; -import { getResizedImageUrl } from '../../utils/getResizedImageUrl'; -import { getTrimmedAttachmentTitle } from '../../utils/getTrimmedAttachmentTitle'; -import { checkQuotedMessageEquality, hasOnlyEmojis } from '../../utils/utils'; +import { checkQuotedMessageEquality } from '../../utils/utils'; +import { FileIcon } from '../Attachment/FileIcon'; +import { DismissAttachmentUpload } from '../MessageInput/components/AttachmentPreview/DismissAttachmentUpload'; -import { FileIcon as FileIconDefault } from '../Attachment/FileIcon'; -import { VideoThumbnail } from '../Attachment/VideoThumbnail'; -import { MessageAvatar as MessageAvatarDefault } from '../Message/MessageSimple/MessageAvatar'; -import { MessageTextContainer } from '../Message/MessageSimple/MessageTextContainer'; - -const styles = StyleSheet.create({ - container: { - alignItems: 'flex-end', - flexDirection: 'row', - }, - fileAttachmentContainer: { paddingLeft: 8, paddingVertical: 8 }, - imageAttachment: { - borderRadius: 8, - height: 32, - marginLeft: 8, - marginVertical: 8, - width: 32, - }, - messageContainer: { - alignItems: 'flex-start', - borderBottomLeftRadius: 0, - borderBottomRightRadius: 12, - borderTopLeftRadius: 12, - borderTopRightRadius: 12, - flexDirection: 'row', - flexGrow: 1, - flexShrink: 1, - }, - secondaryText: { - paddingHorizontal: 8, - }, - text: { fontSize: 12, fontWeight: 'bold', overflow: 'hidden' }, - textContainer: { maxWidth: undefined, paddingHorizontal: 8 }, - videoThumbnailContainerStyle: { - borderRadius: 8, - height: 50, - marginLeft: 8, - marginVertical: 8, - width: 50, - }, - videoThumbnailImageStyle: { - borderRadius: 10, - }, +const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ + quotedMessage: state.quotedMessage, }); -export type ReplySelectorReturnType = { - name?: string; -}; - -const selector = (nextValue: PollState): ReplySelectorReturnType => ({ +const selector = (nextValue: PollState) => ({ name: nextValue.name, }); -const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ - quotedMessage: state.quotedMessage, +const RightContent = React.memo((props: { message: LocalMessage }) => { + const { message } = props; + const attachments = message?.attachments; + + if (!attachments || attachments.length > 1) { + return null; + } + + const attachment = attachments?.[0]; + + if (attachment?.type === FileTypes.Image) { + return ( + + + + ); + } + if (attachment?.type === FileTypes.Video) { + return ( + + + + + + + + + ); + } + + if (attachment?.type === FileTypes.File) { + return ; + } + + return null; }); -type ReplyPropsWithContext = Pick< - MessagesContextValue, - 'FileAttachmentIcon' | 'MessageAvatar' | 'quotedMessage' -> & - Pick & { - attachmentSize?: number; - styles?: Partial<{ - container: ViewStyle; - fileAttachmentContainer: ViewStyle; - imageAttachment: ImageStyle; - messageContainer: ViewStyle; - textContainer: ViewStyle; - }>; - }; +const SubtitleText = React.memo(({ message }: { message?: LocalMessage | null }) => { + const { client } = useChatContext(); + const poll = client.polls.fromState(message?.poll_id ?? ''); + const { name: pollName } = useStateStore(poll?.state, selector) ?? {}; + const { + theme: { + reply: { subtitle: subtitleStyle }, + }, + } = useTheme(); -const getMessageType = (lastAttachment: Attachment) => { - let messageType; + const subtitle = useMemo(() => { + const attachments = message?.attachments; + const audioAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.Audio, + ); + const imageAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.Image, + ); + const videoAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.Video, + ); + const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File); + const voiceRecordingAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.VoiceRecording, + ); + const onlyImages = imageAttachments?.length && imageAttachments?.length === attachments?.length; + const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length; + const onlyFiles = fileAttachments?.length && fileAttachments?.length === attachments?.length; + const onlyAudio = audioAttachments?.length === attachments?.length; + const onlyVoiceRecordings = + voiceRecordingAttachments?.length && + voiceRecordingAttachments?.length === attachments?.length; + + if (pollName) { + return pollName; + } - const isLastAttachmentFile = lastAttachment.type === FileTypes.File; + if (message?.shared_location) { + if ( + message?.shared_location?.end_at && + new Date(message?.shared_location?.end_at) > new Date() + ) { + return 'Live Location'; + } + return 'Location'; + } - const isLastAttachmentAudio = lastAttachment.type === FileTypes.Audio; + if (message?.text) { + return message?.text; + } - const isLastAttachmentVoiceRecording = lastAttachment.type === FileTypes.VoiceRecording; + if (imageAttachments?.length && videoAttachments?.length) { + return `${imageAttachments?.length + videoAttachments.length} Media`; + } - const isLastAttachmentVideo = lastAttachment.type === FileTypes.Video; + if (onlyImages) { + if (imageAttachments?.length === 1) { + return 'Photo'; + } else { + return `${imageAttachments?.length} Photos`; + } + } - const isLastAttachmentGiphy = - lastAttachment?.type === FileTypes.Giphy || lastAttachment?.type === FileTypes.Imgur; + if (onlyVideos) { + if (videoAttachments?.length === 1) { + return 'Video'; + } else { + return `${videoAttachments?.length} Videos`; + } + } - const isLastAttachmentImageOrGiphy = - lastAttachment?.type === FileTypes.Image && - !lastAttachment?.title_link && - !lastAttachment?.og_scrape_url; + if (onlyAudio) { + if (audioAttachments?.length === 1) { + return 'Audio'; + } else { + return `${audioAttachments?.length} Audios`; + } + } - const isLastAttachmentImage = lastAttachment?.image_url || lastAttachment?.thumb_url; + if (onlyVoiceRecordings) { + if (voiceRecordingAttachments?.length === 1) { + return `Voice message (${dayjs.duration(voiceRecordingAttachments?.[0]?.duration ?? 0, 'seconds').format('m:ss')})`; + } else { + return `${voiceRecordingAttachments?.length} Voice messages`; + } + } - if (isLastAttachmentFile) { - messageType = FileTypes.File; - } else if (isLastAttachmentVideo) { - messageType = FileTypes.Video; - } else if (isLastAttachmentAudio) { - messageType = FileTypes.Audio; - } else if (isLastAttachmentVoiceRecording) { - messageType = FileTypes.VoiceRecording; - } else if (isLastAttachmentImageOrGiphy) { - if (isLastAttachmentImage) { - messageType = FileTypes.Image; - } else { - messageType = undefined; + if (onlyFiles && fileAttachments?.length === 1) { + return fileAttachments?.[0]?.title; } - } else if (isLastAttachmentGiphy) { - messageType = FileTypes.Giphy; - } else { - messageType = 'other'; + + return `${attachments?.length} Files`; + }, [message?.attachments, message?.shared_location, message?.text, pollName]); + + if (!subtitle) { + return null; } - return messageType; -}; + return ( + + {subtitle} + + ); +}); -const ReplyWithContext = (props: ReplyPropsWithContext) => { - const { client } = useChatContext(); +const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { + const { message } = props; const { - attachmentSize = 40, - FileAttachmentIcon, - MessageAvatar, - quotedMessage, - styles: stylesProp = {}, - t, - } = props; + theme: { + reply: { pollIcon, locationIcon, linkIcon, audioIcon, fileIcon, videoIcon, photoIcon }, + }, + } = useTheme(); + if (!message) { + return null; + } + + const attachments = message?.attachments; + const audioAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Audio); + const imageAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Image); + const videoAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Video); + const voiceRecordingAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.VoiceRecording, + ); + const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File); + const onlyAudio = audioAttachments?.length && audioAttachments?.length === attachments?.length; + const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length; + const onlyVoiceRecordings = + voiceRecordingAttachments?.length && voiceRecordingAttachments?.length === attachments?.length; + const hasLink = attachments?.some( + (attachment) => attachment.type === FileTypes.Image && attachment.og_scrape_url, + ); + + if (message.poll_id) { + return ( + + ); + } + + if (message.shared_location) { + return ( + + ); + } + + if (hasLink) { + return ( + + ); + } + + if (onlyAudio || onlyVoiceRecordings) { + return ( + + ); + } + + if (fileAttachments?.length) { + return ( + + ); + } + + if (onlyVideos) { + return ( + + ); + } + + if (imageAttachments?.length) { + return ( + + ); + } - const { resizableCDNHosts } = useChatConfigContext(); + return null; +}); - const [error, setError] = useState(false); +export type ReplyPropsWithContext = Pick & + Pick & { + isMyMessage: boolean; + onDismiss: () => void; + mode: 'reply' | 'edit'; + }; +export const ReplyWithContext = (props: ReplyPropsWithContext) => { + const { isMyMessage, message: messageFromContext, mode, onDismiss, quotedMessage } = props; const { theme: { - colors: { blue_alice, border, grey, transparent, white }, - messageSimple: { - content: { deletedText }, - }, + colors: { grey_whisper }, reply: { + wrapper, container, - fileAttachmentContainer, - imageAttachment, - markdownStyles, - messageContainer, - secondaryText, - textContainer, - videoThumbnail: { - container: videoThumbnailContainerStyle, - image: videoThumbnailImageStyle, - }, + leftContainer, + rightContainer, + title: titleStyle, + subtitleContainer, + dismissWrapper, }, }, } = useTheme(); - const poll = client.polls.fromState(quotedMessage?.poll_id ?? ''); - const { name: pollName }: ReplySelectorReturnType = useStateStore(poll?.state, selector) ?? {}; - - const messageText = quotedMessage ? quotedMessage.text : ''; - - const emojiOnlyText = useMemo(() => { - if (!messageText) { - return false; - } - return hasOnlyEmojis(messageText); - }, [messageText]); + const title = useMemo( + () => + mode === 'edit' + ? 'Edit Message' + : isMyMessage + ? 'You' + : `Reply to ${quotedMessage?.user?.name}`, + [mode, isMyMessage, quotedMessage?.user?.name], + ); if (!quotedMessage) { return null; } - const lastAttachment = quotedMessage.attachments?.slice(-1)[0] as Attachment; - const messageType = lastAttachment && getMessageType(lastAttachment); - - const trimmedLastAttachmentTitle = getTrimmedAttachmentTitle(lastAttachment?.title); - - const hasImage = - !error && - lastAttachment && - messageType !== FileTypes.File && - messageType !== FileTypes.Video && - messageType !== FileTypes.Audio && - messageType !== FileTypes.VoiceRecording && - (lastAttachment.image_url || lastAttachment.thumb_url || lastAttachment.og_scrape_url); - - const onlyEmojis = !lastAttachment && emojiOnlyText; - return ( - - + - {!error && lastAttachment ? ( - messageType === FileTypes.File || - messageType === FileTypes.Audio || - messageType === FileTypes.VoiceRecording ? ( - - - - ) : hasImage ? ( - setError(true)} - source={{ - uri: getResizedImageUrl({ - height: - (stylesProp.imageAttachment?.height as number) || - (imageAttachment?.height as number) || - styles.imageAttachment.height, - resizableCDNHosts, - url: (lastAttachment.image_url || - lastAttachment.thumb_url || - lastAttachment.og_scrape_url) as string, - width: - (stylesProp.imageAttachment?.width as number) || - (imageAttachment?.width as number) || - styles.imageAttachment.width, - }), - }} - style={[styles.imageAttachment, imageAttachment, stylesProp.imageAttachment]} - /> - ) : null - ) : null} - {messageType === FileTypes.Video && !lastAttachment.og_scrape_url ? ( - - ) : null} - - 170 - ? `${quotedMessage.text.slice(0, 170)}...` - : quotedMessage.text - : messageType === FileTypes.Image - ? t('Photo') - : messageType === FileTypes.Video - ? t('Video') - : messageType === FileTypes.File || - messageType === FileTypes.Audio || - messageType === FileTypes.VoiceRecording - ? trimmedLastAttachmentTitle || '' - : '', - }} - onlyEmojis={onlyEmojis} - styles={{ - textContainer: [ - { - marginRight: - hasImage || messageType === FileTypes.Video - ? Number( - stylesProp.imageAttachment?.height || - imageAttachment.height || - styles.imageAttachment.height, - ) + - Number( - stylesProp.imageAttachment?.marginLeft || - imageAttachment.marginLeft || - styles.imageAttachment.marginLeft, - ) - : messageType === FileTypes.File || - messageType === FileTypes.Audio || - messageType === FileTypes.VoiceRecording - ? attachmentSize + - Number( - stylesProp.fileAttachmentContainer?.paddingLeft || - fileAttachmentContainer.paddingLeft || - styles.fileAttachmentContainer.paddingLeft, - ) - : undefined, - }, - styles.textContainer, - textContainer, - stylesProp.textContainer, - ], - }} - /> - {messageType === FileTypes.Audio || messageType === FileTypes.VoiceRecording ? ( - - {lastAttachment.duration - ? dayjs.duration(lastAttachment.duration, 'second').format('mm:ss') - : ''} - - ) : null} + + + {title} + + + + + + + + + {!messageFromContext?.quoted_message ? ( + + + + ) : null} ); }; const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithContext) => { - const { quotedMessage: prevQuotedMessage } = prevProps; - const { quotedMessage: nextQuotedMessage } = nextProps; + const { + isMyMessage: prevIsMyMessage, + mode: prevMode, + quotedMessage: prevQuotedMessage, + } = prevProps; + const { + isMyMessage: nextIsMyMessage, + mode: nextMode, + quotedMessage: nextQuotedMessage, + } = nextProps; - const quotedMessageEqual = - !!prevQuotedMessage && - !!nextQuotedMessage && - checkQuotedMessageEquality(prevQuotedMessage, nextQuotedMessage); + const isMyMessageEqual = prevIsMyMessage === nextIsMyMessage; - const quotedMessageAttachmentsEqual = - prevQuotedMessage?.attachments?.length === nextQuotedMessage?.attachments?.length; + if (!isMyMessageEqual) { + return false; + } - if (!quotedMessageAttachmentsEqual) { + const modeEqual = prevMode === nextMode; + if (!modeEqual) { return false; } - if (!quotedMessageEqual) { + const messageEqual = + prevQuotedMessage && + nextQuotedMessage && + checkQuotedMessageEquality(prevQuotedMessage, nextQuotedMessage); + if (!messageEqual) { return false; } return true; }; -const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof ReplyWithContext; +export const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof ReplyWithContext; export type ReplyProps = Partial; -/** - * UI Component for reply - */ export const Reply = (props: ReplyProps) => { - const { message } = useMessageContext(); - - const { FileAttachmentIcon = FileIconDefault, MessageAvatar = MessageAvatarDefault } = - useMessagesContext(); + const { message: messageFromContext } = useMessageContext(); + const { client } = useChatContext(); const messageComposer = useMessageComposer(); - const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); + const { quotedMessage: quotedMessageFromComposer } = useStateStore( + messageComposer.state, + messageComposerStateStoreSelector, + ); + + const onDismiss = useCallback(() => { + messageComposer.setQuotedMessage(null); + }, [messageComposer]); + + const quotedMessage = messageFromContext + ? (messageFromContext.quoted_message as MessagesContextValue['quotedMessage']) + : quotedMessageFromComposer; + + const isMyMessage = client.user?.id === quotedMessage?.user?.id; - const { t } = useTranslationContext(); + const mode = messageComposer.editedMessage ? 'edit' : 'reply'; return ( ); }; -Reply.displayName = 'Reply{reply}'; +const styles = StyleSheet.create({ + attachmentContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + container: { + borderRadius: 12, + flexDirection: 'row', + padding: 8, + }, + contentWrapper: { + backgroundColor: 'white', + borderColor: '#E2E6EA', + borderRadius: 8, + borderWidth: 1, + height: 40, + overflow: 'hidden', + width: 40, + }, + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + iconStyle: {}, + imageAttachment: {}, + leftContainer: { + borderLeftColor: '#B8BEC4', + borderLeftWidth: 2, + flex: 1, + justifyContent: 'center', + paddingHorizontal: 8, + paddingVertical: 2, + }, + playIconContainer: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 10, + height: 20, + justifyContent: 'center', + width: 20, + }, + rightContainer: {}, + subtitle: { + color: '#384047', + flexShrink: 1, + fontSize: 12, + includeFontPadding: false, + lineHeight: 16, + }, + subtitleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: 4, + paddingTop: 4, + }, + title: { + color: '#384047', + fontSize: 12, + fontWeight: 'bold', + includeFontPadding: false, + lineHeight: 16, + }, + wrapper: { + padding: 4, + }, +}); diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index b9c34b762..36267f673 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -21,7 +21,6 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { - "alignItems": "center", "flex": 1, "width": "100%", }, @@ -39,7 +38,7 @@ exports[`Thread should match thread snapshot 1`] = ` contentContainerStyle={ [ { - "paddingBottom": 4, + "paddingTop": 0, }, undefined, {}, @@ -1866,24 +1865,32 @@ exports[`Thread should match thread snapshot 1`] = ` - - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> - - - - - - - - - -
- - - - + - + - - - + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 16, + "height": 32, + "width": 32, + }, + { + "backgroundColor": "#FFFFFF", + "borderColor": "#E2E6EA", + "borderWidth": 0, + }, + undefined, + ] + } + > + + + + + + + +
+
@@ -2438,6 +2406,9 @@ exports[`Thread should match thread snapshot 1`] = `
| React.ReactNode; + iconColor?: string; + onPress?: () => void; + size?: 'sm' | 'md' | 'lg'; + status?: 'disabled' | 'pressed' | 'selected' | 'enabled'; + type?: 'primary' | 'secondary' | 'destructive'; + category?: 'ghost' | 'filled' | 'outline'; +}; + +const sizes = { + lg: { borderRadius: 24, height: 48, width: 48 }, + md: { borderRadius: 20, height: 40, width: 40 }, + sm: { + borderRadius: 16, + height: 32, + width: 32, + }, +}; + +const getBackgroundColor = ({ + type, + status, +}: { + type: IconButtonProps['type']; + status: IconButtonProps['status']; +}) => { + if (type === 'primary') { + if (status === 'disabled') { + return '#E2E6EA'; + } else { + return '#005FFF'; + } + } else if (type === 'secondary') { + return '#FFFFFF'; + } + return { + destructive: '#D92F26', + primary: '#005FFF', + secondary: '#FFFFFF', + }[type ?? 'primary']; +}; + +export const IconButton = (props: IconButtonProps) => { + const { + category = 'filled', + status = 'enabled', + Icon, + iconColor, + onPress, + size = 'md', + style, + type = 'primary', + ...rest + } = props; + const { + theme: { + colors: { selected: selectedColor }, + }, + } = useTheme(); + return ( + [ + styles.container, + sizes[size], + { + backgroundColor: + status === 'selected' + ? selectedColor + : pressed + ? '#F5F6F7' + : getBackgroundColor({ status, type }), + borderColor: '#E2E6EA', + borderWidth: category === 'outline' || category === 'filled' ? 1 : 0, + }, + style as StyleProp, + ]} + {...rest} + > + {typeof Icon === 'function' ? ( + + ) : ( + {Icon} + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 05a556a80..e076b7ea7 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -34,9 +34,7 @@ import { } from '../../components'; import { dismissKeyboard } from '../../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { parseLinksFromText } from '../../components/Message/MessageSimple/utils/parseLinks'; -import type { AttachButtonProps } from '../../components/MessageInput/AttachButton'; -import { AttachmentUploadPreviewListProps } from '../../components/MessageInput/AttachmentUploadPreviewList'; -import type { CommandsButtonProps } from '../../components/MessageInput/CommandsButton'; +import { AttachmentUploadPreviewListProps } from '../../components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList'; 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'; @@ -48,13 +46,12 @@ import type { AudioRecordingLockIndicatorProps } from '../../components/MessageI import type { AudioRecordingPreviewProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingPreview'; import type { AudioRecordingWaveformProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingWaveform'; import type { CommandInputProps } from '../../components/MessageInput/components/CommandInput'; -import type { InputEditingStateHeaderProps } from '../../components/MessageInput/components/InputEditingStateHeader'; -import type { CooldownTimerProps } from '../../components/MessageInput/CooldownTimer'; +import type { AttachButtonProps } from '../../components/MessageInput/components/InputButtons/AttachButton'; +import type { InputButtonsProps } from '../../components/MessageInput/components/InputButtons/index'; +import type { CooldownTimerProps } from '../../components/MessageInput/components/OutputButtons/CooldownTimer'; +import type { SendButtonProps } from '../../components/MessageInput/components/OutputButtons/SendButton'; import { useCooldown } from '../../components/MessageInput/hooks/useCooldown'; -import type { InputButtonsProps } from '../../components/MessageInput/InputButtons'; import type { MessageInputProps } from '../../components/MessageInput/MessageInput'; -import type { MoreOptionsButtonProps } from '../../components/MessageInput/MoreOptionsButton'; -import type { SendButtonProps } from '../../components/MessageInput/SendButton'; import { useStableCallback } from '../../hooks/useStableCallback'; import { createAttachmentsCompositionMiddleware, @@ -249,13 +246,7 @@ export type InputMessageInputContextValue = { ImageAttachmentUploadPreview: React.ComponentType; FileAttachmentUploadPreview: React.ComponentType; VideoAttachmentUploadPreview: React.ComponentType; - /** - * Custom UI component for commands button. - * - * Defaults to and accepts same props as: - * [CommandsButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/commands-button/) - */ - CommandsButton: React.ComponentType; + /** * Custom UI component to display the remaining cooldown a user will have to wait before * being allowed to send another message. This component is displayed in place of the @@ -280,17 +271,7 @@ export type InputMessageInputContextValue = { /** When false, ImageSelectorIcon will be hidden */ hasImagePicker: boolean; - InputEditingStateHeader: React.ComponentType; CommandInput: React.ComponentType; - InputReplyStateHeader: React.ComponentType; - /** - * Custom UI component for more options button. - * - * Defaults to and accepts same props as: - * [MoreOptionsButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/more-options-button/) - */ - MoreOptionsButton: React.ComponentType; - /** * Custom UI component for send button. * @@ -358,6 +339,13 @@ export type InputMessageInputContextValue = { */ handleAttachButtonPress?: () => void; + /** + * Whether the message input is floating or not. + * @type boolean + * @default false + */ + messageInputFloating: boolean; + /** * 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) diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 7ad8d8f71..9d966bac5 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -31,7 +31,6 @@ export const useCreateMessageInputContext = ({ closeAttachmentPicker, closePollCreationDialog, CommandInput, - CommandsButton, compressImageQuality, cooldownEndsAt, CooldownTimer, @@ -50,9 +49,7 @@ export const useCreateMessageInputContext = ({ Input, inputBoxRef, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openAttachmentPicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, @@ -105,7 +102,6 @@ export const useCreateMessageInputContext = ({ closeAttachmentPicker, closePollCreationDialog, CommandInput, - CommandsButton, compressImageQuality, cooldownEndsAt, CooldownTimer, @@ -124,9 +120,7 @@ export const useCreateMessageInputContext = ({ Input, inputBoxRef, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openAttachmentPicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index f1b26969d..6795d52f0 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -9,7 +9,7 @@ export const BASE_AVATAR_SIZE = 32; export const Colors = { accent_blue: '#005FFF', accent_dark_blue: '#005DFF', - accent_error: '#FF3842', + accent_error: '#D92F26', accent_green: '#20E070', accent_info: '#1FE06F', accent_red: '#FF3742', @@ -31,6 +31,7 @@ export const Colors = { light_gray: '#E9EAED', modal_shadow: '#00000099', // 99 = 60% opacity; x=0, y= 1, radius=4 overlay: '#000000CC', // CC = 80% opacity + selected: 'hsla(0, 0%, 0%, 0.15)', shadow_icon: '#00000040', // 40 = 25% opacity; x=0, y=0, radius=4 static_black: '#000000', static_white: '#ffffff', @@ -272,17 +273,16 @@ export type Theme = { attachButton: ViewStyle; attachButtonContainer: ViewStyle; attachmentSelectionBar: ViewStyle; - attachmentSeparator: ViewStyle; attachmentUnsupportedIndicator: { container: ViewStyle; warningIcon: IconProps; text: TextStyle; }; attachmentUploadPreviewList: { - filesFlatList: ViewStyle; - imagesFlatList: ViewStyle; - wrapper: ViewStyle; + flatList: ViewStyle; + itemSeparator: ViewStyle; }; + audioRecordingButtonContainer: ViewStyle; audioRecorder: { arrowLeftIcon: IconProps; checkContainer: ViewStyle; @@ -295,10 +295,6 @@ export type Theme = { sendCheckIcon: IconProps; slideToCancelContainer: ViewStyle; }; - audioRecordingButton: { - container: ViewStyle; - micIcon: IconProps; - }; audioRecordingInProgress: { container: ViewStyle; durationText: TextStyle; @@ -320,17 +316,15 @@ export type Theme = { container: ViewStyle; waveform: ViewStyle; }; - autoCompleteInputContainer: ViewStyle; commandInput: { closeButton: ViewStyle; container: ViewStyle; text: TextStyle; }; - commandsButton: ViewStyle; - composerContainer: ViewStyle; container: ViewStyle; + contentContainer: ViewStyle; + cooldownButtonContainer: ViewStyle; cooldownTimer: { - container: ViewStyle; text: TextStyle; }; dismissAttachmentUpload: { @@ -338,13 +332,8 @@ export type Theme = { dismissIcon: IconProps; dismissIconColor: ColorValue; }; - editingBoxContainer: ViewStyle; - editingBoxHeader: ViewStyle; - editingBoxHeaderTitle: TextStyle; - editingStateHeader: { - editingBoxHeader: ViewStyle; - editingBoxHeaderTitle: TextStyle; - }; + editButton: ViewStyle; + editButtonContainer: ViewStyle; fileAttachmentUploadPreview: { fileContainer: ViewStyle; filenameText: TextStyle; @@ -356,34 +345,32 @@ export type Theme = { fileUploadPreview: { flatList: ViewStyle; }; + floatingWrapper: ViewStyle; focusedInputBoxContainer: ViewStyle; imageAttachmentUploadPreview: { - itemContainer: ViewStyle; + container: ViewStyle; upload: ImageStyle; + wrapper: ViewStyle; }; - imageUploadPreview: { - flatList: ViewStyle; - }; + inputContainer: ViewStyle; inputBox: TextStyle; inputBoxContainer: ViewStyle; + inputBoxWrapper: ViewStyle; + inputButtonsContainer: ViewStyle; + inputFloatingContainer: ViewStyle; micButtonContainer: ViewStyle; - moreOptionsButton: ViewStyle; nativeAttachmentPicker: { buttonContainer: ViewStyle; buttonDimmerStyle: ViewStyle; container: ViewStyle; }; - optionsContainer: ViewStyle; - replyContainer: ViewStyle; - searchIcon: IconProps; + outputButtonsContainer: ViewStyle; sendButton: ViewStyle; sendButtonContainer: ViewStyle; sendMessageDisallowedIndicator: { container: ViewStyle; text: TextStyle; }; - sendRightIcon: IconProps; - sendUpIcon: IconProps; showThreadMessageInChannelButton: { check: IconProps; checkBoxActive: ViewStyle; @@ -433,6 +420,7 @@ export type Theme = { itemContainer: ViewStyle; upload: ImageStyle; }; + wrapper: ViewStyle; }; messageList: { container: ViewStyle; @@ -452,15 +440,15 @@ export type Theme = { text: TextStyle; textContainer: ViewStyle; }; + scrollToBottomButtonContainer: ViewStyle; scrollToBottomButton: { container: ViewStyle; - touchable: ViewStyle; unreadCountNotificationContainer: ViewStyle; unreadCountNotificationText: TextStyle; - wrapper: ViewStyle; - chevronColor?: ColorValue; }; + stickyHeaderContainer: ViewStyle; typingIndicatorContainer: ViewStyle; + unreadMessagesNotificationContainer: ViewStyle; unreadMessagesNotification: { closeButtonContainer: ViewStyle; closeIcon: IconProps; @@ -842,17 +830,21 @@ export type Theme = { thumb: ViewStyle; }; reply: { + audioIcon: IconProps; container: ViewStyle; - fileAttachmentContainer: ViewStyle; - imageAttachment: ImageStyle; - markdownStyles: MarkdownStyle; - messageContainer: ViewStyle; - secondaryText: ViewStyle; - textContainer: ViewStyle; - videoThumbnail: { - container: ViewStyle; - image: ImageStyle; - }; + dismissWrapper: ViewStyle; + fileIcon: IconProps; + leftContainer: ViewStyle; + locationIcon: IconProps; + linkIcon: IconProps; + photoIcon: IconProps; + pollIcon: IconProps; + rightContainer: ViewStyle; + title: TextStyle; + subtitle: TextStyle; + subtitleContainer: ViewStyle; + videoIcon: IconProps; + wrapper: ViewStyle; }; screenPadding: number; spinner: ViewStyle; @@ -1096,16 +1088,14 @@ export const defaultTheme: Theme = { attachButton: {}, attachButtonContainer: {}, attachmentSelectionBar: {}, - attachmentSeparator: {}, attachmentUnsupportedIndicator: { container: {}, text: {}, warningIcon: {}, }, attachmentUploadPreviewList: { - filesFlatList: {}, - imagesFlatList: {}, - wrapper: {}, + flatList: {}, + itemSeparator: {}, }, audioRecorder: { arrowLeftIcon: {}, @@ -1119,7 +1109,7 @@ export const defaultTheme: Theme = { sendCheckIcon: {}, slideToCancelContainer: {}, }, - audioRecordingButton: { container: {}, micIcon: {} }, + audioRecordingButtonContainer: {}, audioRecordingInProgress: { container: {}, durationText: {} }, audioRecordingLockIndicator: { arrowUpIcon: {}, container: {}, lockIcon: {} }, audioRecordingPreview: { @@ -1131,17 +1121,15 @@ export const defaultTheme: Theme = { progressBar: {}, }, audioRecordingWaveform: { container: {}, waveform: {} }, - autoCompleteInputContainer: {}, commandInput: { closeButton: {}, container: {}, text: {}, }, - commandsButton: {}, - composerContainer: {}, container: {}, + contentContainer: {}, + cooldownButtonContainer: {}, cooldownTimer: { - container: {}, text: {}, }, dismissAttachmentUpload: { @@ -1149,13 +1137,8 @@ export const defaultTheme: Theme = { dismissIcon: {}, dismissIconColor: '', }, - editingBoxContainer: {}, - editingBoxHeader: {}, - editingBoxHeaderTitle: {}, - editingStateHeader: { - editingBoxHeader: {}, - editingBoxHeaderTitle: {}, - }, + editButton: {}, + editButtonContainer: {}, fileAttachmentUploadPreview: { fileContainer: {}, filenameText: {}, @@ -1167,34 +1150,32 @@ export const defaultTheme: Theme = { fileUploadPreview: { flatList: {}, }, + floatingWrapper: {}, focusedInputBoxContainer: {}, imageAttachmentUploadPreview: { - itemContainer: {}, + container: {}, upload: {}, - }, - imageUploadPreview: { - flatList: {}, + wrapper: {}, }, inputBox: {}, inputBoxContainer: {}, + inputBoxWrapper: {}, + inputButtonsContainer: {}, + inputContainer: {}, + inputFloatingContainer: {}, micButtonContainer: {}, - moreOptionsButton: {}, nativeAttachmentPicker: { buttonContainer: {}, buttonDimmerStyle: {}, container: {}, }, - optionsContainer: {}, - replyContainer: {}, - searchIcon: {}, + outputButtonsContainer: {}, sendButton: {}, sendButtonContainer: {}, sendMessageDisallowedIndicator: { container: {}, text: {}, }, - sendRightIcon: {}, - sendUpIcon: {}, showThreadMessageInChannelButton: { check: {}, checkBoxActive: {}, @@ -1244,6 +1225,7 @@ export const defaultTheme: Theme = { recorderIconContainer: {}, upload: {}, }, + wrapper: {}, }, messageList: { container: {}, @@ -1265,11 +1247,11 @@ export const defaultTheme: Theme = { }, scrollToBottomButton: { container: {}, - touchable: {}, unreadCountNotificationContainer: {}, unreadCountNotificationText: {}, - wrapper: {}, }, + scrollToBottomButtonContainer: {}, + stickyHeaderContainer: {}, typingIndicatorContainer: {}, unreadMessagesNotification: { closeButtonContainer: {}, @@ -1277,6 +1259,7 @@ export const defaultTheme: Theme = { container: {}, text: {}, }, + unreadMessagesNotificationContainer: {}, }, messageMenu: { actionList: { @@ -1667,17 +1650,21 @@ export const defaultTheme: Theme = { thumb: {}, }, reply: { + audioIcon: {}, container: {}, - fileAttachmentContainer: {}, - imageAttachment: {}, - markdownStyles: {}, - messageContainer: {}, - secondaryText: {}, - textContainer: {}, - videoThumbnail: { - container: {}, - image: {}, - }, + dismissWrapper: {}, + fileIcon: {}, + leftContainer: {}, + linkIcon: {}, + locationIcon: {}, + photoIcon: {}, + pollIcon: {}, + rightContainer: {}, + subtitle: {}, + subtitleContainer: {}, + title: {}, + videoIcon: {}, + wrapper: {}, }, screenPadding: 8, spinner: {}, diff --git a/package/src/hooks/useKeyboardVisibility.ts b/package/src/hooks/useKeyboardVisibility.ts new file mode 100644 index 000000000..e013d4124 --- /dev/null +++ b/package/src/hooks/useKeyboardVisibility.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import { EventSubscription, Keyboard } from 'react-native'; + +import { KeyboardControllerPackage } from '../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; + +/** + * A custom hook that provides a boolean value indicating whether the keyboard is visible. + * @returns A boolean value indicating whether the keyboard is visible. + */ +export const useKeyboardVisibility = () => { + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + + useEffect(() => { + const listeners: EventSubscription[] = []; + if (KeyboardControllerPackage?.KeyboardEvents) { + listeners.push( + KeyboardControllerPackage.KeyboardEvents.addListener('keyboardWillShow', () => + setIsKeyboardVisible(true), + ), + ); + listeners.push( + KeyboardControllerPackage.KeyboardEvents.addListener('keyboardWillHide', () => + setIsKeyboardVisible(false), + ), + ); + } else { + listeners.push(Keyboard.addListener('keyboardWillShow', () => setIsKeyboardVisible(true))); + listeners.push(Keyboard.addListener('keyboardWillHide', () => setIsKeyboardVisible(false))); + } + + return () => listeners.forEach((listener) => listener.remove()); + }, []); + + return isKeyboardVisible; +}; diff --git a/package/src/icons/ErrorCircle.tsx b/package/src/icons/ErrorCircle.tsx new file mode 100644 index 000000000..358abc8f3 --- /dev/null +++ b/package/src/icons/ErrorCircle.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const ErrorCircle = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewClose.tsx b/package/src/icons/NewClose.tsx new file mode 100644 index 000000000..d2bea9184 --- /dev/null +++ b/package/src/icons/NewClose.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewClose = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewDown.tsx b/package/src/icons/NewDown.tsx new file mode 100644 index 000000000..0ff62e16f --- /dev/null +++ b/package/src/icons/NewDown.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewDown = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewFile.tsx b/package/src/icons/NewFile.tsx new file mode 100644 index 000000000..615fdfa43 --- /dev/null +++ b/package/src/icons/NewFile.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewFile = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewLink.tsx b/package/src/icons/NewLink.tsx new file mode 100644 index 000000000..6adb196d6 --- /dev/null +++ b/package/src/icons/NewLink.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewLink = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewMapPin.tsx b/package/src/icons/NewMapPin.tsx new file mode 100644 index 000000000..905d2684c --- /dev/null +++ b/package/src/icons/NewMapPin.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewMapPin = ({ height, width, ...rest }: IconProps) => ( + + + + +); diff --git a/package/src/icons/NewMic.tsx b/package/src/icons/NewMic.tsx new file mode 100644 index 000000000..21c104486 --- /dev/null +++ b/package/src/icons/NewMic.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewMic = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewPhoto.tsx b/package/src/icons/NewPhoto.tsx new file mode 100644 index 000000000..6fc8a3b62 --- /dev/null +++ b/package/src/icons/NewPhoto.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPhoto = ({ height, width, ...rest }: IconProps) => ( + + + + +); diff --git a/package/src/icons/NewPlayIcon.tsx b/package/src/icons/NewPlayIcon.tsx new file mode 100644 index 000000000..9ca5d3eca --- /dev/null +++ b/package/src/icons/NewPlayIcon.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPlayIcon = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewPlus.tsx b/package/src/icons/NewPlus.tsx new file mode 100644 index 000000000..e096b8ff4 --- /dev/null +++ b/package/src/icons/NewPlus.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPlus = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewPoll.tsx b/package/src/icons/NewPoll.tsx new file mode 100644 index 000000000..b08396ffc --- /dev/null +++ b/package/src/icons/NewPoll.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPoll = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewTick.tsx b/package/src/icons/NewTick.tsx new file mode 100644 index 000000000..a9b6bdd89 --- /dev/null +++ b/package/src/icons/NewTick.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewTick = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewVideo.tsx b/package/src/icons/NewVideo.tsx new file mode 100644 index 000000000..712911801 --- /dev/null +++ b/package/src/icons/NewVideo.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewVideo = ({ height, width, ...rest }: IconProps) => ( + + + + +); diff --git a/package/src/icons/Search.tsx b/package/src/icons/Search.tsx index 060e21078..9b8723df5 100644 --- a/package/src/icons/Search.tsx +++ b/package/src/icons/Search.tsx @@ -1,12 +1,14 @@ import React from 'react'; -import { IconProps, RootPath, RootSvg } from './utils/base'; +import Svg, { Path } from 'react-native-svg'; -export const Search = (props: IconProps) => ( - - ( + + - + ); diff --git a/package/src/icons/SendRight.tsx b/package/src/icons/SendRight.tsx index a7be88c2c..a01676cd4 100644 --- a/package/src/icons/SendRight.tsx +++ b/package/src/icons/SendRight.tsx @@ -1,21 +1,21 @@ import React from 'react'; -import Svg, { Circle, Path } from 'react-native-svg'; +import Svg, { Path } from 'react-native-svg'; import { IconProps } from './utils/base'; -type Props = IconProps & { - size: number; -}; - -export const SendRight = ({ size, ...rest }: Props) => ( - - +export const SendRight = ({ height, width, ...rest }: IconProps) => ( + ); diff --git a/package/src/state-store/message-input-height-store.ts b/package/src/state-store/message-input-height-store.ts new file mode 100644 index 000000000..a8bab1106 --- /dev/null +++ b/package/src/state-store/message-input-height-store.ts @@ -0,0 +1,15 @@ +import { StateStore } from 'stream-chat'; + +export type MessageInputHeightState = { + height: number; +}; + +const INITIAL_STATE: MessageInputHeightState = { + height: 0, +}; + +export const messageInputHeightStore = new StateStore(INITIAL_STATE); + +export const setMessageInputHeight = (height: number) => { + messageInputHeightStore.next({ height }); +}; From 0c3f87c85f897a6f4cff7e12cc71babcfd9987ec Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 16 Jan 2026 10:50:37 +0530 Subject: [PATCH 11/13] feat: add basic theme variables and fix reply issue --- .../Message/MessageSimple/MessageContent.tsx | 4 +- .../AttachmentRemoveControl.tsx | 58 +++++++++ .../AttachmentUploadPreviewList.tsx | 37 ++++-- .../AudioAttachmentUploadPreview.tsx | 6 +- .../DismissAttachmentUpload.tsx | 45 ------- .../FileAttachmentUploadPreview.tsx | 111 ++++++++--------- .../ImageAttachmentUploadPreview.tsx | 54 ++++---- package/src/components/Reply/Reply.tsx | 21 ++-- package/src/components/ui/OnlineIndicator.tsx | 57 +++++++++ .../src/components/ui/VideoPlayIndicator.tsx | 58 +++++++++ .../src/contexts/themeContext/utils/theme.ts | 15 ++- package/src/theme/primitives/colors.ts | 91 ++++++++++++++ package/src/theme/primitives/palette.ts | 115 ++++++++++++++++++ package/src/theme/primitives/radius.ts | 51 ++++++++ package/src/theme/primitives/spacing.tsx | 11 ++ package/src/theme/primitives/typography.ts | 33 +++++ 16 files changed, 616 insertions(+), 151 deletions(-) create mode 100644 package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx delete mode 100644 package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx create mode 100644 package/src/components/ui/OnlineIndicator.tsx create mode 100644 package/src/components/ui/VideoPlayIndicator.tsx create mode 100644 package/src/theme/primitives/colors.ts create mode 100644 package/src/theme/primitives/palette.ts create mode 100644 package/src/theme/primitives/radius.ts create mode 100644 package/src/theme/primitives/spacing.tsx create mode 100644 package/src/theme/primitives/typography.ts diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index 7f02d2731..7ed160589 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -27,6 +27,7 @@ import { useTranslationContext, } from '../../../contexts/translationContext/TranslationContext'; +import { useViewport } from '../../../hooks'; import { checkMessageEquality, checkQuotedMessageEquality } from '../../../utils/utils'; import { Poll } from '../../Poll/Poll'; import { useMessageData } from '../hooks/useMessageData'; @@ -151,6 +152,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { } = props; const { client } = useChatContext(); const { PollContent: PollContentOverride } = useMessagesContext(); + const { vw } = useViewport(); const { theme: { @@ -322,7 +324,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { key={`quoted_reply_${messageContentOrderIndex}`} style={[styles.replyContainer, replyContainer]} > - + ) ); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx new file mode 100644 index 000000000..dc2ffd334 --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx @@ -0,0 +1,58 @@ +import React, { useMemo } from 'react'; + +import { Pressable, PressableProps, StyleSheet } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { NewClose } from '../../../../icons/NewClose'; + +type AttachmentRemoveControlProps = PressableProps; + +export const AttachmentRemoveControl = ({ onPress }: AttachmentRemoveControlProps) => { + const { + theme: { + colors: { control }, + messageInput: { + dismissAttachmentUpload: { dismiss, dismissIcon, dismissIconColor }, + }, + }, + } = useTheme(); + const styles = useStyles(); + + return ( + [ + styles.dismiss, + { + opacity: pressed ? 0.8 : 1, + }, + dismiss, + ]} + testID='remove-upload-preview' + > + + + ); +}; + +const useStyles = () => { + const { + theme: { + colors: { control }, + radius, + }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + dismiss: { + backgroundColor: control.remove, + borderColor: control.border, + borderRadius: radius.xl, + borderWidth: 2, + overflow: 'hidden', + }, + }), + [control, radius], + ); +}; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index c10db5e1e..60b2fa8ec 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { FlatList, StyleSheet, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -21,8 +21,8 @@ import { import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { isSoundPackageAvailable } from '../../../../native'; -const IMAGE_PREVIEW_SIZE = 100; -const FILE_PREVIEW_HEIGHT = 60; +const IMAGE_PREVIEW_SIZE = 72; +const FILE_PREVIEW_HEIGHT = 224; export type AttachmentUploadListPreviewPropsWithContext = Pick< MessageInputContextValue, @@ -33,6 +33,7 @@ export type AttachmentUploadListPreviewPropsWithContext = Pick< >; const ItemSeparatorComponent = () => { + const styles = useStyles(); const { theme: { messageInput: { @@ -74,6 +75,8 @@ const UnMemoizedAttachmentUploadPreviewList = ( } = props; const { attachmentManager } = useMessageComposer(); const { attachments } = useAttachmentManagerState(); + + const styles = useStyles(); const { theme: { messageInput: { @@ -231,15 +234,25 @@ export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListPr ); }; -const styles = StyleSheet.create({ - flatList: { - overflow: 'visible', - }, - itemSeparator: { - width: 8, - }, - wrapper: {}, -}); +const useStyles = () => { + const { + theme: { spacing }, + } = useTheme(); + + return useMemo( + () => + StyleSheet.create({ + flatList: { + overflow: 'visible', + }, + itemSeparator: { + width: spacing.xs, + }, + wrapper: {}, + }), + [spacing.xs], + ); +}; AttachmentUploadPreviewList.displayName = 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}'; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index 3b094f121..1313d6eca 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -4,9 +4,9 @@ import { StyleSheet, View } from 'react-native'; import { FileReference, LocalAudioAttachment, LocalVoiceRecordingAttachment } from 'stream-chat'; +import { AttachmentRemoveControl } from './AttachmentRemoveControl'; import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator'; import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; -import { DismissAttachmentUpload } from './DismissAttachmentUpload'; import { AudioAttachment } from '../../../../components/Attachment/AudioAttachment'; import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; @@ -71,7 +71,7 @@ export const AudioAttachmentUploadPreview = ({ /> - + {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( @@ -83,7 +83,7 @@ export const AudioAttachmentUploadPreview = ({ const styles = StyleSheet.create({ dismissWrapper: { position: 'absolute', - right: 8, + right: 0, top: 0, }, overlay: { diff --git a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx b/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx deleted file mode 100644 index 631bd8efd..000000000 --- a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; - -import { Pressable, PressableProps, StyleSheet } from 'react-native'; - -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; -import { NewClose } from '../../../../icons/NewClose'; - -type DismissAttachmentUploadProps = PressableProps; - -export const DismissAttachmentUpload = ({ onPress }: DismissAttachmentUploadProps) => { - const { - theme: { - colors: { white }, - messageInput: { - dismissAttachmentUpload: { dismiss, dismissIcon, dismissIconColor }, - }, - }, - } = useTheme(); - - return ( - [ - styles.dismiss, - { - borderColor: white, - opacity: pressed ? 0.8 : 1, - }, - dismiss, - ]} - testID='remove-upload-preview' - > - - - ); -}; - -const styles = StyleSheet.create({ - dismiss: { - backgroundColor: '#384047', - borderRadius: 16, - borderWidth: 2, - overflow: 'hidden', - }, -}); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 9e913ad69..4a40d1d21 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -1,12 +1,12 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { I18nManager, StyleSheet, Text, View } from 'react-native'; import { LocalAudioAttachment, LocalFileAttachment, LocalVideoAttachment } from 'stream-chat'; +import { AttachmentRemoveControl } from './AttachmentRemoveControl'; import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator'; import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; -import { DismissAttachmentUpload } from './DismissAttachmentUpload'; import { getFileSizeDisplayText } from '../../../../components/Attachment/FileAttachment'; import { WritingDirectionAwareText } from '../../../../components/RTLComponents/WritingDirectionAwareText'; @@ -41,7 +41,6 @@ export const FileAttachmentUploadPreview = ({ const { theme: { - colors: { black, grey, grey_whisper }, messageInput: { fileAttachmentUploadPreview: { fileContainer, @@ -54,6 +53,7 @@ export const FileAttachmentUploadPreview = ({ }, }, } = useTheme(); + const styles = useStyles(); const onRetryHandler = useCallback(() => { handleRetry(attachment); @@ -70,15 +70,7 @@ export const FileAttachmentUploadPreview = ({ style={[styles.overlay, uploadProgressOverlay]} type={indicatorType} > - + @@ -87,9 +79,6 @@ export const FileAttachmentUploadPreview = ({ numberOfLines={1} style={[ styles.filenameText, - { - color: black, - }, I18nManager.isRTL ? { writingDirection: 'rtl' } : { writingDirection: 'ltr' }, filenameText, ]} @@ -99,9 +88,7 @@ export const FileAttachmentUploadPreview = ({ {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( ) : ( - + {attachment.duration ? getDurationLabelFromDuration(attachment.duration) : getFileSizeDisplayText(attachment.file_size)} @@ -111,45 +98,59 @@ export const FileAttachmentUploadPreview = ({ - + ); }; -const styles = StyleSheet.create({ - dismissWrapper: { position: 'absolute', right: 0, top: 0 }, - fileContainer: { - borderRadius: 12, - borderWidth: 1, - flexDirection: 'row', - gap: 12, - maxWidth: 224, // TODO: Not sure how to omit this - padding: 16, - }, - fileContent: { - flexShrink: 1, - justifyContent: 'space-between', - }, - fileIcon: { - alignItems: 'center', - alignSelf: 'center', - justifyContent: 'center', - }, - filenameText: { - fontSize: 12, - fontWeight: '600', - }, - fileNameTextContainer: { - flexShrink: 1, - }, - fileSizeText: { - fontSize: 12, - }, - overlay: { - borderRadius: 12, - }, - wrapper: { - padding: 4, - }, -}); +const useStyles = () => { + const { + theme: { + colors: { borderSurfaceSubtle, text }, + radius, + spacing, + typography: { fontSize, fontWeight }, + }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + dismissWrapper: { position: 'absolute', right: 0, top: 0 }, + fileContainer: { + borderRadius: radius.lg, + borderColor: borderSurfaceSubtle, + borderWidth: 1, + flexDirection: 'row', + gap: spacing.sm, + maxWidth: 224, // TODO: Not sure how to omit this + padding: spacing.md, + }, + fileContent: { + flexShrink: 1, + justifyContent: 'space-between', + }, + fileIcon: { + alignItems: 'center', + alignSelf: 'center', + justifyContent: 'center', + }, + filenameText: { + color: text.primary, + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + }, + fileSizeText: { + color: text.secondary, + fontSize: fontSize.xs, + }, + overlay: { + borderRadius: radius.lg, + }, + wrapper: { + padding: spacing.xxs, + }, + }), + [radius, borderSurfaceSubtle, spacing, text, fontSize, fontWeight], + ); +}; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index c476a9b33..a0ed819af 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -1,12 +1,12 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Image, StyleSheet, View } from 'react-native'; import { LocalImageAttachment } from 'stream-chat'; +import { AttachmentRemoveControl } from './AttachmentRemoveControl'; import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator'; import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator'; -import { DismissAttachmentUpload } from './DismissAttachmentUpload'; import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; @@ -36,6 +36,7 @@ export const ImageAttachmentUploadPreview = ({ }, }, } = useTheme(); + const styles = useStyles(); const onRetryHandler = useCallback(() => { handleRetry(attachment); @@ -74,30 +75,35 @@ export const ImageAttachmentUploadPreview = ({ - +
); }; -const styles = StyleSheet.create({ - container: { - borderColor: '#E2E6EA', - borderRadius: 12, - borderWidth: 1, - flexDirection: 'row', - overflow: 'hidden', - }, - dismissWrapper: { position: 'absolute', right: 0, top: 0 }, - fileSizeText: { - fontSize: 12, - paddingHorizontal: 10, - }, - upload: { - height: IMAGE_PREVIEW_SIZE, - width: IMAGE_PREVIEW_SIZE, - }, - wrapper: { - padding: 4, - }, -}); +const useStyles = () => { + const { + theme: { spacing, radius, colors }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + borderColor: colors.borderImage, + borderRadius: radius.lg, + borderWidth: 1, + flexDirection: 'row', + overflow: 'hidden', + }, + dismissWrapper: { position: 'absolute', right: 0, top: 0 }, + upload: { + height: IMAGE_PREVIEW_SIZE, + width: IMAGE_PREVIEW_SIZE, + }, + wrapper: { + padding: spacing.xxs, + }, + }), + [colors, radius, spacing], + ); +}; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 764345705..eb7a19998 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react'; -import { Image, StyleSheet, Text, View } from 'react-native'; +import { Image, StyleSheet, Text, View, ViewStyle } from 'react-native'; import dayjs from 'dayjs'; import { LocalMessage, MessageComposerState, PollState } from 'stream-chat'; @@ -18,13 +18,13 @@ import { NewLink } from '../../icons/NewLink'; import { NewMapPin } from '../../icons/NewMapPin'; import { NewMic } from '../../icons/NewMic'; import { NewPhoto } from '../../icons/NewPhoto'; -import { NewPlayIcon } from '../../icons/NewPlayIcon'; import { NewPoll } from '../../icons/NewPoll'; import { NewVideo } from '../../icons/NewVideo'; import { FileTypes } from '../../types/types'; import { checkQuotedMessageEquality } from '../../utils/utils'; import { FileIcon } from '../Attachment/FileIcon'; -import { DismissAttachmentUpload } from '../MessageInput/components/AttachmentPreview/DismissAttachmentUpload'; +import { AttachmentRemoveControl } from '../MessageInput/components/AttachmentPreview/AttachmentRemoveControl'; +import { VideoPlayIndicator } from '../ui/VideoPlayIndicator'; const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ quotedMessage: state.quotedMessage, @@ -56,9 +56,7 @@ const RightContent = React.memo((props: { message: LocalMessage }) => { - - - + ); @@ -266,10 +264,12 @@ export type ReplyPropsWithContext = Pick & isMyMessage: boolean; onDismiss: () => void; mode: 'reply' | 'edit'; + // This is temporary for the MessageContent Component to style the Reply component + style?: ViewStyle; }; export const ReplyWithContext = (props: ReplyPropsWithContext) => { - const { isMyMessage, message: messageFromContext, mode, onDismiss, quotedMessage } = props; + const { isMyMessage, message: messageFromContext, mode, onDismiss, quotedMessage, style } = props; const { theme: { colors: { grey_whisper }, @@ -306,6 +306,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { styles.container, { backgroundColor: isMyMessage ? '#F2F4F6' : '#D2E3FF', borderColor: grey_whisper }, container, + style, ]} > { {!messageFromContext?.quoted_message ? ( - + ) : null} @@ -372,7 +373,9 @@ const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithCon export const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof ReplyWithContext; -export type ReplyProps = Partial; +export type ReplyProps = Partial & { + style: ViewStyle; +}; export const Reply = (props: ReplyProps) => { const { message: messageFromContext } = useMessageContext(); diff --git a/package/src/components/ui/OnlineIndicator.tsx b/package/src/components/ui/OnlineIndicator.tsx new file mode 100644 index 000000000..2dda8b0c3 --- /dev/null +++ b/package/src/components/ui/OnlineIndicator.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; + +export type OnlineIndicatorProps = { + online: boolean; + size: 'lg' | 'sm' | 'md'; +}; + +const sizes = { + lg: { + borderWidth: 2, + height: 14, + width: 14, + }, + md: { + borderWidth: 2, + height: 12, + width: 12, + }, + sm: { + borderWidth: 1, + height: 8, + width: 8, + }, +}; + +export const OnlineIndicator = ({ online, size = 'md' }: OnlineIndicatorProps) => { + const styles = useStyles(); + return ; +}; + +const useStyles = () => { + const { + theme: { + colors: { accent, presence }, + radius, + }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + indicator: { + borderColor: presence.border, + borderRadius: radius.full, + }, + online: { + backgroundColor: accent.success, + }, + offline: { + backgroundColor: accent.neutral, + }, + }), + [accent, presence, radius], + ); +}; diff --git a/package/src/components/ui/VideoPlayIndicator.tsx b/package/src/components/ui/VideoPlayIndicator.tsx new file mode 100644 index 000000000..3bffc0dba --- /dev/null +++ b/package/src/components/ui/VideoPlayIndicator.tsx @@ -0,0 +1,58 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { NewPlayIcon } from '../../icons/NewPlayIcon'; +import { palette } from '../../theme/primitives/palette'; + +const sizes = { + lg: { + height: 48, + width: 48, + }, + md: { + height: 40, + width: 40, + }, + sm: { + height: 20, + width: 20, + }, +}; + +const iconSizes = { + lg: 20, + md: 16, + sm: 10, +}; + +export type VideoPlayIndicatorProps = { + size: 'sm' | 'md' | 'lg'; +}; + +export const VideoPlayIndicator = (props: VideoPlayIndicatorProps) => { + const { size = 'md' } = props; + const styles = useStyles(); + + return ( + + + + ); +}; + +const useStyles = () => { + const { + theme: { radius }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: palette.black, + borderRadius: radius.full, + justifyContent: 'center', + }, + }); + }, [radius]); +}; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 6795d52f0..61cc752b9 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -1,7 +1,11 @@ -import type { ColorValue, ImageStyle, TextStyle, ViewStyle } from 'react-native'; +import { type ColorValue, type ImageStyle, type TextStyle, type ViewStyle } from 'react-native'; import type { CircleProps, StopProps } from 'react-native-svg'; import type { IconProps } from '../../../icons/utils/base'; +import { lightColors, type NewColors } from '../../../theme/primitives/colors'; +import { Radius } from '../../../theme/primitives/radius'; +import { Spacing } from '../../../theme/primitives/spacing'; +import { Typography } from '../../../theme/primitives/typography'; export const DEFAULT_STATUS_ICON_SIZE = 16; export const BASE_AVATAR_SIZE = 32; @@ -187,7 +191,7 @@ export type Theme = { unreadContainer: ViewStyle; unreadText: TextStyle; }; - colors: typeof Colors & { [key: string]: string }; + colors: typeof Colors & NewColors & { [key: string]: string | { [key: string]: string } }; dateHeader: { container: ViewStyle; text: TextStyle; @@ -885,9 +889,15 @@ export type Theme = { thumb: ViewStyle; waveform: ViewStyle; }; + spacing: typeof Spacing; + radius: typeof Radius; + typography: typeof Typography; }; export const defaultTheme: Theme = { + spacing: Spacing, + radius: Radius, + typography: Typography, aiTypingIndicatorView: { container: {}, text: {}, @@ -1004,6 +1014,7 @@ export const defaultTheme: Theme = { }, colors: { ...Colors, + ...lightColors, }, dateHeader: { container: {}, diff --git a/package/src/theme/primitives/colors.ts b/package/src/theme/primitives/colors.ts new file mode 100644 index 000000000..e6fa2006a --- /dev/null +++ b/package/src/theme/primitives/colors.ts @@ -0,0 +1,91 @@ +import { palette } from './palette'; + +export type NewColors = typeof lightColors; + +export const lightColors = { + brand: palette.blue, + accent: { + primary: palette.blue[500], + success: palette.green[500], + warning: palette.yellow[500], + error: palette.red[500], + neutral: palette.slate[500], + }, + state: { + hover: palette.black5, + pressed: palette.black10, + selected: palette.black10, + bgOverlay: palette.black50, + bgDisabled: palette.slate[200], + textDisabled: palette.slate[400], + }, + text: { + primary: palette.slate[900], + secondary: palette.slate[700], + tertiary: palette.slate[500], + inverse: palette.white, + onAccent: palette.white, + disabled: palette.slate[400], + link: palette.blue[500], + }, + borderImage: palette.black10, + borderSurfaceSubtle: palette.slate[200], + control: { + remove: palette.slate[900], + icon: palette.white, + border: palette.white, + }, + presence: { + border: palette.white, + }, +}; + +export const darkColors = { + brand: { + 50: palette.blue[900], + 100: palette.blue[800], + 200: palette.blue[700], + 300: palette.blue[600], + 400: palette.blue[500], + 500: palette.blue[400], + 600: palette.blue[300], + 700: palette.blue[200], + 800: palette.blue[100], + 900: palette.blue[50], + 950: palette.white, + }, + accent: { + primary: palette.blue[500], + success: palette.green[400], + warning: palette.yellow[400], + error: palette.red[400], + neutral: palette.neutral[500], + }, + state: { + hover: palette.black5, + pressed: palette.black10, + selected: palette.black10, + bgOverlay: palette.black50, + bgDisabled: palette.neutral[800], + textDisabled: palette.neutral[600], + }, + text: { + primary: palette.neutral[50], + secondary: palette.neutral[300], + tertiary: palette.neutral[400], + inverse: palette.black, + onAccent: palette.white, + disabled: palette.neutral[600], + link: palette.blue[500], + }, + borderImage: palette.white20, + borderSurfaceSubtle: palette.neutral[700], + control: { + remove: palette.neutral[800], + icon: palette.white, + border: palette.white, + }, + presence: { + border: palette.black, + }, +}; diff --git a/package/src/theme/primitives/palette.ts b/package/src/theme/primitives/palette.ts new file mode 100644 index 000000000..f52105f3a --- /dev/null +++ b/package/src/theme/primitives/palette.ts @@ -0,0 +1,115 @@ +export const palette = { + transparent: 'transparent', + black: '#000000', + white: '#FFFFFF', + white10: 'hsla(0, 0%, 100%, 0.1)', + white20: 'hsla(0, 0%, 100%, 0.2)', + white70: 'hsla(0, 0%, 100%, 0.7)', + black5: 'hsla(0, 0%, 0%, 0.05)', + black10: 'hsla(0, 0%, 0%, 0.1)', + black50: 'hsla(0, 0%, 0%, 0.5)', + slate: { + 50: '#FAFBFC', + 100: '#F2F4F6', + 200: '#E2E6EA', + 300: '#D0D5DA', + 400: '#B8BEC4', + 500: '#9EA4AA', + 600: '#838990', + 700: '#6A7077', + 800: '#50565D', + 900: '#384047', + 950: '#1E252B', + }, + neutral: { + 50: '#F7F7F7', + 100: '#EDEDED', + 200: '#D9D9D9', + 300: '#C1C1C1', + 400: '#A3A3A3', + 500: '#7F7F7F', + 600: '#636363', + 700: '#4A4A4A', + 800: '#383838', + 900: '#262626', + 950: '#151515', + }, + blue: { + 50: '#EBF3FF', + 100: '#D2E3FF', + 200: '#A6C4FF', + 300: '#7AA7FF', + 400: '#4E8BFF', + 500: '#005FFF', + 600: '#0052CE', + 700: '#0042A3', + 800: '#003179', + 900: '#001F4F', + 950: '#001025', + }, + red: { + 50: '#FCEBEA', + 100: '#F8CFCD', + 200: '#F3B3B0', + 300: '#ED958F', + 400: '#E6756C', + 500: '#D92F26', + 600: '#B9261F', + 700: '#98201A', + 800: '#761915', + 900: '#54120F', + 950: '#360B09', + }, + yellow: { + 50: '#FFF9E5', + 100: '#FFF1C2', + 200: '#FFE8A0', + 300: '#FFDE7D', + 400: '#FFD65A', + 500: '#FFD233', + 600: '#E6B400', + 700: '#C59600', + 800: '#9F7700', + 900: '#7A5A00', + 950: '#4F3900', + }, + purple: { + 50: '#F5EFFE', + 100: '#EBDEFD', + 200: '#D8BFFC', + 300: '#C79FFC', + 400: '#B98AF9', + 500: '#B38AF8', + 600: '#996CE3', + 700: '#7F55C7', + 800: '#6640AB', + 900: '#4D2C8F', + 950: '#351C6B', + }, + green: { + 50: '#E8FFF5', + 100: '#C9FCE7', + 200: '#A9F8D9', + 300: '#88F2CA', + 400: '#59E9B5', + 500: '#00E2A1', + 600: '#00B681', + 700: '#008D64', + 800: '#006548', + 900: '#003D2B', + 950: '#002319', + }, + cyan: { + 50: '#F0FCFE', + 100: '#D7F7FB', + 200: '#BDF1F8', + 300: '#A3ECF4', + 400: '#89E6F1', + 500: '#69E5F6', + 600: '#3EC9D9', + 700: '#28A8B5', + 800: '#1C8791', + 900: '#125F66', + 950: '#0B3D44', + }, +}; diff --git a/package/src/theme/primitives/radius.ts b/package/src/theme/primitives/radius.ts new file mode 100644 index 000000000..a192a008b --- /dev/null +++ b/package/src/theme/primitives/radius.ts @@ -0,0 +1,51 @@ +import { Platform } from 'react-native'; + +export const Radius = { + none: 0, + xxs: Platform.select({ + android: 0, + ios: 2, + web: 2, + }), + xs: Platform.select({ + android: 2, + ios: 4, + web: 4, + }), + sm: Platform.select({ + android: 4, + ios: 6, + web: 6, + }), + md: Platform.select({ + android: 6, + ios: 8, + web: 8, + }), + lg: Platform.select({ + android: 8, + ios: 12, + web: 12, + }), + xl: Platform.select({ + android: 12, + ios: 16, + web: 16, + }), + xxl: Platform.select({ + android: 16, + ios: 20, + web: 20, + }), + xxxl: Platform.select({ + android: 20, + ios: 24, + web: 24, + }), + xxxxl: Platform.select({ + android: 24, + ios: 32, + web: 32, + }), + full: 9999, +}; diff --git a/package/src/theme/primitives/spacing.tsx b/package/src/theme/primitives/spacing.tsx new file mode 100644 index 000000000..639222523 --- /dev/null +++ b/package/src/theme/primitives/spacing.tsx @@ -0,0 +1,11 @@ +export const Spacing = { + none: 0, + xxs: 4, + xs: 8, + sm: 12, + md: 16, + lg: 20, + xl: 24, + xxl: 32, + xxxl: 40, +}; diff --git a/package/src/theme/primitives/typography.ts b/package/src/theme/primitives/typography.ts new file mode 100644 index 000000000..9cb3c056d --- /dev/null +++ b/package/src/theme/primitives/typography.ts @@ -0,0 +1,33 @@ +import { TextStyle } from 'react-native'; + +export type FontWeightType = TextStyle['fontWeight']; + +export type TypographyType = { + fontWeight: Record; + lineHeight: Record; + fontSize: Record; +}; + +export const Typography: TypographyType = { + fontWeight: { + regular: '400', + medium: '500', + semibold: '600', + bold: '700', + }, + lineHeight: { + tight: 16, + normal: 24, + relaxed: 32, + }, + fontSize: { + micro: 8, + xxs: 10, + xs: 12, + sm: 13, + md: 15, + lg: 17, + xl: 20, + xxl: 24, + }, +}; From 5db18de01c28c9cfa63d5add91c01fac4c06ff73 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 16 Jan 2026 10:50:53 +0530 Subject: [PATCH 12/13] feat: add basic theme variables and fix reply issue --- package/src/components/Reply/Reply.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index eb7a19998..fe3af2612 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -373,9 +373,7 @@ const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithCon export const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof ReplyWithContext; -export type ReplyProps = Partial & { - style: ViewStyle; -}; +export type ReplyProps = Partial; export const Reply = (props: ReplyProps) => { const { message: messageFromContext } = useMessageContext(); From 66c4d005ea180f56fd0adb595ceddcea299ac5b2 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 16 Jan 2026 23:05:20 +0530 Subject: [PATCH 13/13] feat: add primitive components and theme --- .../src/components/ChannelInfoOverlay.tsx | 14 +- .../components/ConfirmationBottomSheet.tsx | 2 +- .../MessageSearch/MessageSearchList.tsx | 2 +- .../SampleApp/src/components/ScreenHeader.tsx | 2 +- .../src/components/UnreadCountBadge.tsx | 51 +--- .../src/components/UserInfoOverlay.tsx | 10 +- .../UserSearch/UserSearchResults.tsx | 2 +- .../SampleApp/src/hooks/useStreamChatTheme.ts | 2 - .../src/screens/ChannelFilesScreen.tsx | 2 +- .../src/screens/GroupChannelDetailsScreen.tsx | 16 +- .../src/screens/NewDirectMessagingScreen.tsx | 2 +- .../NewGroupChannelAddMemberScreen.tsx | 2 +- .../NewGroupChannelAssignNameScreen.tsx | 2 +- .../screens/OneOnOneChannelDetailScreen.tsx | 26 +-- .../src/screens/UserSelectorScreen.tsx | 4 +- package/eslint.config.mjs | 11 - .../Attachment/AttachmentActions.tsx | 2 +- package/src/components/Avatar/Avatar.tsx | 34 +-- .../src/components/Avatar/ChannelAvatar.tsx | 51 ++++ package/src/components/Avatar/UserAvatar.tsx | 77 +++++++ package/src/components/Avatar/constants.ts | 37 +++ .../src/components/ChannelList/Skeleton.tsx | 2 +- .../ChannelPreviewMessenger.tsx | 27 ++- .../ChannelPreviewUnreadCount.tsx | 36 +-- .../components/MessageInput/MessageInput.tsx | 2 +- .../SendMessageDisallowedIndicator.tsx | 2 +- .../AttachmentRemoveControl.tsx | 2 +- .../FileAttachmentUploadPreview.tsx | 8 +- .../ImageAttachmentUploadPreview.tsx | 2 +- .../MessageList/ScrollToBottomButton.tsx | 53 ++--- .../ScrollToBottomButton.test.js.snap | 217 ++++++++++-------- .../__snapshots__/Thread.test.js.snap | 2 +- package/src/components/index.ts | 2 + package/src/components/ui/Avatar.tsx | 75 ++++++ package/src/components/ui/BadgeCount.tsx | 55 +++++ .../src/components/ui/BadgeNotification.tsx | 74 ++++++ package/src/components/ui/index.ts | 6 + .../src/contexts/themeContext/utils/theme.ts | 2 +- package/src/icons/GroupIcon.tsx | 17 ++ package/src/icons/PeopleIcon.tsx | 22 ++ package/src/theme/primitives/colors.ts | 198 +++++++++++++++- 41 files changed, 836 insertions(+), 319 deletions(-) create mode 100644 package/src/components/Avatar/ChannelAvatar.tsx create mode 100644 package/src/components/Avatar/UserAvatar.tsx create mode 100644 package/src/components/Avatar/constants.ts create mode 100644 package/src/components/ui/Avatar.tsx create mode 100644 package/src/components/ui/BadgeCount.tsx create mode 100644 package/src/components/ui/BadgeNotification.tsx create mode 100644 package/src/components/ui/index.ts create mode 100644 package/src/icons/GroupIcon.tsx create mode 100644 package/src/icons/PeopleIcon.tsx diff --git a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx index 911353cd9..8a3c9dca8 100644 --- a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx +++ b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx @@ -332,7 +332,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > @@ -347,7 +347,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > @@ -364,7 +364,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > @@ -379,7 +379,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { {otherMembers.length > 1 && ( - + @@ -392,7 +392,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > @@ -409,8 +409,8 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.lastRow, { - borderBottomColor: border, - borderTopColor: border, + borderBottomColor: border.surfaceSubtle, + borderTopColor: border.surfaceSubtle, }, ]} > diff --git a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx index 4f33bc994..581ac92ca 100644 --- a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx +++ b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx @@ -86,7 +86,7 @@ export const ConfirmationBottomSheet: React.FC = () => { style={[ styles.actionButtonsContainer, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > diff --git a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx index b3e91999c..24bcc54f4 100644 --- a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx +++ b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx @@ -128,7 +128,7 @@ export const MessageSearchList: React.FC = React.forward messageId: item.id, }); }} - style={[styles.itemContainer, { borderBottomColor: border }]} + style={[styles.itemContainer, { borderBottomColor: border.surfaceSubtle }]} testID='channel-preview-button' > = (props) => { styles.safeAreaContainer, { backgroundColor: white, - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, height: HEADER_CONTENT_HEIGHT + (inSafeArea ? 0 : insets.top), }, style, diff --git a/examples/SampleApp/src/components/UnreadCountBadge.tsx b/examples/SampleApp/src/components/UnreadCountBadge.tsx index 71d3a2694..87c42c8ee 100644 --- a/examples/SampleApp/src/components/UnreadCountBadge.tsx +++ b/examples/SampleApp/src/components/UnreadCountBadge.tsx @@ -1,33 +1,21 @@ import React, { useEffect, useState } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { useStateStore, useTheme } from 'stream-chat-react-native'; +import { BadgeNotification, useStateStore } from 'stream-chat-react-native'; import { useAppContext } from '../context/AppContext'; import { ThreadManagerState } from 'stream-chat'; -const styles = StyleSheet.create({ - unreadContainer: { - alignItems: 'center', - borderRadius: 8, - justifyContent: 'center', - }, - unreadText: { - color: '#FFFFFF', - fontSize: 11, - fontWeight: '700', - paddingHorizontal: 5, - paddingVertical: 1, - }, -}); - const selector = (nextValue: ThreadManagerState) => - ({ unreadCount: nextValue.unreadThreadCount } as const); + ({ unreadCount: nextValue.unreadThreadCount }) as const; export const ThreadsUnreadCountBadge: React.FC = () => { const { chatClient } = useAppContext(); const { unreadCount } = useStateStore(chatClient?.threads?.state, selector) ?? { unreadCount: 0 }; - return ; + if (unreadCount === 0) { + return null; + } + + return ; }; export const ChannelsUnreadCountBadge: React.FC = () => { @@ -59,26 +47,9 @@ export const ChannelsUnreadCountBadge: React.FC = () => { }; }, [chatClient]); - return ; -}; - -type UnreadCountBadgeProps = { - unreadCount: number | undefined; -}; - -const UnreadCountBadge: React.FC = (props) => { - const { unreadCount } = props; - const { - theme: { - colors: { accent_red }, - }, - } = useTheme(); + if (unreadCount === 0) { + return null; + } - return ( - - {!!unreadCount && ( - {unreadCount > 99 ? '99+' : unreadCount} - )} - - ); + return ; }; diff --git a/examples/SampleApp/src/components/UserInfoOverlay.tsx b/examples/SampleApp/src/components/UserInfoOverlay.tsx index 1d487b8ab..5c664fa8f 100644 --- a/examples/SampleApp/src/components/UserInfoOverlay.tsx +++ b/examples/SampleApp/src/components/UserInfoOverlay.tsx @@ -283,7 +283,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > @@ -298,7 +298,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > @@ -314,7 +314,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > @@ -332,8 +332,8 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.lastRow, { - borderBottomColor: border, - borderTopColor: border, + borderBottomColor: border.surfaceSubtle, + borderTopColor: border.surfaceSubtle, }, ]} > diff --git a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx index cc1628f10..03ebef413 100644 --- a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx +++ b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx @@ -199,7 +199,7 @@ export const UserSearchResults: React.FC = ({ styles.searchResultContainer, { backgroundColor: white_snow, - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > diff --git a/examples/SampleApp/src/hooks/useStreamChatTheme.ts b/examples/SampleApp/src/hooks/useStreamChatTheme.ts index f868e9ee2..8a0ad4f44 100644 --- a/examples/SampleApp/src/hooks/useStreamChatTheme.ts +++ b/examples/SampleApp/src/hooks/useStreamChatTheme.ts @@ -14,7 +14,6 @@ const getChatStyle = (colorScheme: ColorSchemeName): DeepPartial => ({ bg_user: '#17191C', black: '#FFFFFF', blue_alice: '#00193D', - border: '#141924', button_background: '#FFFFFF', button_text: '#005FFF', code_block: '#222222', @@ -43,7 +42,6 @@ const getChatStyle = (colorScheme: ColorSchemeName): DeepPartial => ({ bg_gradient_start: '#FCFCFC', black: '#000000', blue_alice: '#E9F2FF', - border: '#00000014', // 14 = 8% opacity; top: x=0, y=-1; bottom: x=0, y=1 button_background: '#005FFF', button_text: '#FFFFFF', grey: '#7A7A7A', diff --git a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx index cde55c465..d657aeb69 100644 --- a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx @@ -149,7 +149,7 @@ export const ChannelFilesScreen: React.FC = ({ Alert.alert('Not implemented.'); }} style={{ - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, borderBottomWidth: index === section.data.length - 1 ? 0 : 1, }} > diff --git a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx index 0746bd158..b0677c50f 100644 --- a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx @@ -276,7 +276,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.memberContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -306,7 +306,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.loadMoreButton, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -330,7 +330,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.changeNameContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -382,7 +382,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -427,7 +427,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -457,7 +457,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -487,7 +487,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -513,7 +513,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > diff --git a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx index 7ecb9e999..fdbc9d6e5 100644 --- a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx +++ b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx @@ -208,7 +208,7 @@ export const NewDirectMessagingScreen: React.FC = styles.searchContainer, { backgroundColor: white, - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > diff --git a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx index eb963ed8c..79819b6de 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx @@ -111,7 +111,7 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) styles.inputBoxContainer, { backgroundColor: white, - borderColor: border, + borderColor: border.surfaceSubtle, marginBottom: selectedUsers.length === 0 ? 8 : 16, }, ]} diff --git a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx index 6a3a03933..cf4c98aac 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx @@ -128,7 +128,7 @@ export const NewGroupChannelAssignNameScreen: React.FC diff --git a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx index d492bc795..ccd83de70 100644 --- a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx +++ b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx @@ -1,13 +1,5 @@ import React, { useState } from 'react'; -import { - Image, - ScrollView, - StyleSheet, - Switch, - Text, - TouchableOpacity, - View, -} from 'react-native'; +import { Image, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { Delete, useTheme } from 'stream-chat-react-native'; import { useAppContext } from '../context/AppContext'; @@ -235,7 +227,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.userNameContainer, { - borderTopColor: border, + borderTopColor: border.surfaceSubtle, }, ]} > @@ -274,7 +266,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -313,7 +305,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -359,7 +351,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -389,7 +381,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -419,7 +411,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -449,7 +441,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > @@ -476,7 +468,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border, + borderBottomColor: border.surfaceSubtle, }, ]} > diff --git a/examples/SampleApp/src/screens/UserSelectorScreen.tsx b/examples/SampleApp/src/screens/UserSelectorScreen.tsx index 0448c4ead..19987dc12 100644 --- a/examples/SampleApp/src/screens/UserSelectorScreen.tsx +++ b/examples/SampleApp/src/screens/UserSelectorScreen.tsx @@ -125,7 +125,7 @@ export const UserSelectorScreen: React.FC = ({ navigation }) => { onPress={() => { switchUser(u.id); }} - style={[styles.userContainer, { borderBottomColor: border }]} + style={[styles.userContainer, { borderBottomColor: border.surfaceSubtle }]} testID={`user-selector-button-${u.id}`} > = ({ navigation }) => { onPress={() => { navigation.navigate('AdvancedUserSelectorScreen'); }} - style={[styles.userContainer, { borderBottomColor: border }]} + style={[styles.userContainer, { borderBottomColor: border.surfaceSubtle }]} > ; testID?: string; }; @@ -67,17 +67,14 @@ export const Avatar = (props: AvatarProps) => { ImageComponent = Image, imageStyle, name, - online, - presenceIndicator: presenceIndicatorProp, - presenceIndicatorContainerStyle, + online = false, size, testID, } = props; const { resizableCDNHosts } = useChatConfigContext(); const { theme: { - avatar: { container, image, presenceIndicator, presenceIndicatorContainer }, - colors: { accent_green, white }, + avatar: { container, image, presenceIndicatorContainer }, }, } = useTheme(); @@ -157,26 +154,13 @@ export const Avatar = (props: AvatarProps) => { /> )} - {online && ( - - - - + {online ? ( + + - )} + ) : null} ); }; -Avatar.displayName = 'Avatar{avatar}'; +Avatar.displayName = 'Avatar'; diff --git a/package/src/components/Avatar/ChannelAvatar.tsx b/package/src/components/Avatar/ChannelAvatar.tsx new file mode 100644 index 000000000..ad7f2f311 --- /dev/null +++ b/package/src/components/Avatar/ChannelAvatar.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react'; + +import { StyleSheet, View } from 'react-native'; + +import { Channel } from 'stream-chat'; + +import { iconSizes, indicatorSizes } from './constants'; + +import { GroupIcon } from '../../icons/GroupIcon'; +import { NewAvatar } from '../ui/Avatar'; + +import { OnlineIndicator } from '../ui/OnlineIndicator'; + +export type NewChannelAvatarProps = { + channel: Channel; + showOnlineIndicator?: boolean; + size: 'xs' | 'sm' | 'md' | 'lg'; + showBorder?: boolean; +}; + +export const NewChannelAvatar = (props: NewChannelAvatarProps) => { + const { channel, size, showBorder = false, showOnlineIndicator = false } = props; + + const placeholder = useMemo(() => { + return ; + }, [size]); + + return ( + + + {showOnlineIndicator ? ( + + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + onlineIndicatorWrapper: { + position: 'absolute', + right: -2, + top: -2, + }, +}); diff --git a/package/src/components/Avatar/UserAvatar.tsx b/package/src/components/Avatar/UserAvatar.tsx new file mode 100644 index 000000000..b65638f4f --- /dev/null +++ b/package/src/components/Avatar/UserAvatar.tsx @@ -0,0 +1,77 @@ +import React, { useMemo } from 'react'; + +import { StyleSheet, Text, View } from 'react-native'; + +import { UserResponse } from 'stream-chat'; + +import { fontSizes, iconSizes, indicatorSizes, numberOfInitials } from './constants'; + +import { PeopleIcon } from '../../icons/PeopleIcon'; +import { NewAvatar } from '../ui/Avatar'; + +import { OnlineIndicator } from '../ui/OnlineIndicator'; + +const getInitials = (name: string, numberOfInitials: number = 2) => { + return name + .split(' ') + .slice(0, numberOfInitials) + .map((n) => n.charAt(0)) + .join(''); +}; + +export type NewUserAvatarProps = { + user?: UserResponse; + showOnlineIndicator?: boolean; + size: 'xs' | 'sm' | 'md' | 'lg'; + showBorder?: boolean; +}; + +export const NewUserAvatar = (props: NewUserAvatarProps) => { + const { user, size, showBorder = false, showOnlineIndicator } = props; + + const placeholder = useMemo(() => { + if (user?.name) { + return ( + + {getInitials(user.name, numberOfInitials[size])} + + ); + } else { + return ; + } + }, [user?.name, size]); + + if (!user) { + return null; + } + + return ( + + + {showOnlineIndicator ? ( + + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + text: { + color: '#003179', + }, + onlineIndicatorWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + wrapper: { + padding: 2, + }, +}); diff --git a/package/src/components/Avatar/constants.ts b/package/src/components/Avatar/constants.ts new file mode 100644 index 000000000..0b37fd44e --- /dev/null +++ b/package/src/components/Avatar/constants.ts @@ -0,0 +1,37 @@ +import { NewUserAvatarProps } from './UserAvatar'; + +import { FontWeightType } from '../../theme/primitives/typography'; +import { OnlineIndicatorProps } from '../ui/OnlineIndicator'; + +const indicatorSizes: Record = { + xs: 'sm', + sm: 'sm', + md: 'md', + lg: 'lg', +}; + +const iconSizes: Record = { + xs: 10, + sm: 12, + md: 16, + lg: 20, +}; + +const fontSizes: Record< + NewUserAvatarProps['size'], + { fontSize: number; lineHeight: number; fontWeight: FontWeightType } +> = { + xs: { fontSize: 12, lineHeight: 16, fontWeight: '600' }, + sm: { fontSize: 13, lineHeight: 16, fontWeight: '600' }, + md: { fontSize: 13, lineHeight: 16, fontWeight: '600' }, + lg: { fontSize: 15, lineHeight: 20, fontWeight: '600' }, +}; + +const numberOfInitials: Record = { + xs: 1, + sm: 1, + md: 2, + lg: 2, +}; + +export { indicatorSizes, iconSizes, fontSizes, numberOfInitials }; diff --git a/package/src/components/ChannelList/Skeleton.tsx b/package/src/components/ChannelList/Skeleton.tsx index 088a15a9f..4b7cc2521 100644 --- a/package/src/components/ChannelList/Skeleton.tsx +++ b/package/src/components/ChannelList/Skeleton.tsx @@ -124,7 +124,7 @@ export const Skeleton = () => { return ( diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index 65f87b0b6..1b66c19d5 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; -import { ChannelAvatar } from './ChannelAvatar'; import type { ChannelPreviewProps } from './ChannelPreview'; import { ChannelPreviewMessage } from './ChannelPreviewMessage'; import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; @@ -10,14 +9,18 @@ import { ChannelPreviewTitle } from './ChannelPreviewTitle'; import { ChannelPreviewUnreadCount } from './ChannelPreviewUnreadCount'; import { useChannelPreviewDisplayName } from './hooks/useChannelPreviewDisplayName'; +import { useChannelPreviewDisplayPresence } from './hooks/useChannelPreviewDisplayPresence'; import type { LatestMessagePreview } from './hooks/useLatestMessagePreview'; import { ChannelsContextValue, useChannelsContext, } from '../../contexts/channelsContext/ChannelsContext'; +import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useViewport } from '../../hooks/useViewport'; +import { NewChannelAvatar } from '../Avatar/ChannelAvatar'; +import { NewUserAvatar } from '../Avatar/UserAvatar'; const styles = StyleSheet.create({ container: { @@ -104,7 +107,7 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW maxUnreadCount, muted, onSelect, - PreviewAvatar = ChannelAvatar, + // PreviewAvatar = ChannelAvatar, PreviewMessage = ChannelPreviewMessage, PreviewMutedStatus = ChannelPreviewMutedStatus, PreviewStatus = ChannelPreviewStatus, @@ -113,6 +116,7 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW unread, } = props; const { vw } = useViewport(); + const { client } = useChatContext(); const maxWidth = vw(80) - 16 - 40; @@ -128,6 +132,12 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW Math.floor(maxWidth / ((title.fontSize || styles.title.fontSize) / 2)), ); + const members = channel.state.members; + const membersValues = Object.values(members); + const otherMembers = membersValues.filter((member) => member.user?.id !== client?.user?.id); + + const online = useChannelPreviewDisplayPresence(channel); + return ( { @@ -138,12 +148,21 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW style={[ // { opacity: pressed ? 0.5 : 1 }, styles.container, - { backgroundColor: white_snow, borderBottomColor: border }, + { backgroundColor: white_snow, borderBottomColor: border.surfaceSubtle }, container, ]} testID='channel-preview-button' > - + {otherMembers.length === 1 ? ( + + ) : ( + + )} & Pick & { @@ -16,38 +15,15 @@ export type ChannelPreviewUnreadCountProps = Pick { const { maxUnreadCount, unread } = props; - const { - theme: { - channelPreview: { unreadContainer, unreadText }, - colors: { accent_red }, - }, - } = useTheme(); - if (!unread) { return null; } return ( - - - {unread > maxUnreadCount ? `${maxUnreadCount}+` : unread} - - + maxUnreadCount ? maxUnreadCount : unread} + size='md' + type='primary' + /> ); }; - -const styles = StyleSheet.create({ - unreadContainer: { - alignItems: 'center', - borderRadius: 8, - flexShrink: 1, - justifyContent: 'center', - }, - unreadText: { - color: '#FFFFFF', - fontSize: 11, - fontWeight: '700', - paddingHorizontal: 5, - paddingVertical: 1, - }, -}); diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 5c6080e79..0ddba3e3c 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -473,7 +473,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { styles.wrapper, { backgroundColor: white, - borderColor: border, + borderColor: border.surfaceSubtle, paddingBottom: BOTTOM_OFFSET, }, wrapper, diff --git a/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx b/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx index f3c59bc58..d8dcf3c04 100644 --- a/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx +++ b/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx @@ -34,7 +34,7 @@ export const SendMessageDisallowedIndicator = () => { styles.container, { backgroundColor: white, - borderTopColor: border, + borderTopColor: border.surfaceSubtle, height: 50, }, container, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx index dc2ffd334..79fdbf3ca 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx @@ -46,7 +46,7 @@ const useStyles = () => { () => StyleSheet.create({ dismiss: { - backgroundColor: control.remove, + backgroundColor: control.bg, borderColor: control.border, borderRadius: radius.xl, borderWidth: 2, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 4a40d1d21..06e615616 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -107,7 +107,7 @@ export const FileAttachmentUploadPreview = ({ const useStyles = () => { const { theme: { - colors: { borderSurfaceSubtle, text }, + colors: { border, text }, radius, spacing, typography: { fontSize, fontWeight }, @@ -119,11 +119,11 @@ const useStyles = () => { dismissWrapper: { position: 'absolute', right: 0, top: 0 }, fileContainer: { borderRadius: radius.lg, - borderColor: borderSurfaceSubtle, + borderColor: border.surfaceSubtle, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, - maxWidth: 224, // TODO: Not sure how to omit this + width: 224, // TODO: Not sure how to omit this padding: spacing.md, }, fileContent: { @@ -151,6 +151,6 @@ const useStyles = () => { padding: spacing.xxs, }, }), - [radius, borderSurfaceSubtle, spacing, text, fontSize, fontWeight], + [radius, border, spacing, text, fontSize, fontWeight], ); }; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index a0ed819af..f28c6a489 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -89,7 +89,7 @@ const useStyles = () => { () => StyleSheet.create({ container: { - borderColor: colors.borderImage, + borderColor: colors.border.image, borderRadius: radius.lg, borderWidth: 1, flexDirection: 'row', diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 5e7c732a7..3bc212c4a 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -1,28 +1,21 @@ import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { NewDown } from '../../icons/NewDown'; +import { BadgeNotification } from '../ui'; import { IconButton } from '../ui/IconButton'; const styles = StyleSheet.create({ unreadCountNotificationContainer: { - alignItems: 'center', - borderRadius: 10, - elevation: 6, - height: 20, - justifyContent: 'center', - minWidth: 20, - paddingHorizontal: 4, position: 'absolute', + right: 0, top: 0, }, - unreadCountNotificationText: { - fontSize: 11, - textAlign: 'center', - textAlignVertical: 'center', + container: { + padding: 4, }, }); @@ -39,13 +32,8 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { const { theme: { - colors: { accent_blue, white }, messageList: { - scrollToBottomButton: { - container, - unreadCountNotificationContainer, - unreadCountNotificationText, - }, + scrollToBottomButton: { container }, }, }, } = useTheme(); @@ -55,7 +43,7 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { } return ( - <> + { type='secondary' /> - {!!unreadCount && ( - - - {unreadCount} - - - )} - + + {unreadCount ? ( + + ) : null} + + ); }; diff --git a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap index eddcde61d..ea4ebf392 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap @@ -2,122 +2,103 @@ exports[`ScrollToBottomButton should render the message notification and match snapshot 1`] = ` + - - - - - + > + + + + + `; diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 36267f673..81fbb67be 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1889,7 +1889,7 @@ exports[`Thread should match thread snapshot 1`] = ` }, { "backgroundColor": "#FFFFFF", - "borderColor": "#00000014", + "borderColor": "#E2E6EA", "paddingBottom": 32, }, {}, diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 152a0849e..020ec8d57 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -179,6 +179,8 @@ export * from './Poll'; export * from './Reply/Reply'; +export * from './ui'; + export * from './UIComponents/BottomSheetModal'; export * from './UIComponents/ImageBackground'; export * from './UIComponents/Spinner'; diff --git a/package/src/components/ui/Avatar.tsx b/package/src/components/ui/Avatar.tsx new file mode 100644 index 000000000..2c160bd74 --- /dev/null +++ b/package/src/components/ui/Avatar.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; +import { Image, StyleSheet, View } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; + +export type NewAvatarProps = { + size: 'xs' | 'sm' | 'md' | 'lg'; + imageUrl?: string; + placeholder?: React.ReactNode; + showBorder?: boolean; +}; + +const sizes = { + lg: { + height: 40, + width: 40, + }, + md: { + height: 32, + width: 32, + }, + sm: { + height: 24, + width: 24, + }, + xs: { + height: 20, + width: 20, + }, +}; + +export const NewAvatar = (props: NewAvatarProps) => { + const { size, imageUrl, placeholder, showBorder } = props; + const styles = useStyles(); + + return ( + + {imageUrl ? ( + + ) : ( + placeholder + )} + + ); +}; + +const useStyles = () => { + const { + theme: { colors, radius }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + border: { + borderColor: colors.border.image, + borderWidth: 2, + }, + container: { + alignItems: 'center', + borderRadius: radius.full, + justifyContent: 'center', + overflow: 'hidden', + }, + image: {}, + }), + [colors, radius], + ); +}; diff --git a/package/src/components/ui/BadgeCount.tsx b/package/src/components/ui/BadgeCount.tsx new file mode 100644 index 000000000..87fb66136 --- /dev/null +++ b/package/src/components/ui/BadgeCount.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; + +export type BadgeCountProps = { + count: number; + size: 'sm' | 'xs'; +}; + +const sizes = { + sm: { + borderRadius: 12, + minWidth: 24, + lineHeight: 22, + }, + xs: { + borderRadius: 10, + minWidth: 20, + lineHeight: 18, + }, +}; + +export const BadgeCount = (props: BadgeCountProps) => { + const { count, size = 'sm' } = props; + const styles = useStyles(); + + return {count}; +}; + +const useStyles = () => { + const { + theme: { + colors: { border, badge }, + typography, + }, + } = useTheme(); + + return useMemo( + () => + StyleSheet.create({ + text: { + backgroundColor: badge.bgInverse, + borderColor: border.surfaceSubtle, + borderWidth: 1, + color: badge.textInverse, + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.bold, + includeFontPadding: false, + textAlign: 'center', + }, + }), + [border, badge, typography], + ); +}; diff --git a/package/src/components/ui/BadgeNotification.tsx b/package/src/components/ui/BadgeNotification.tsx new file mode 100644 index 000000000..36b81811f --- /dev/null +++ b/package/src/components/ui/BadgeNotification.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; + +export type BadgeNotificationProps = { + type: 'primary' | 'error' | 'neutral'; + count: number; + size: 'sm' | 'md'; + testID?: string; +}; + +const sizes = { + md: { + fontSize: 12, + lineHeight: 16, + minWidth: 20, + borderWidth: 2, + }, + sm: { + fontSize: 10, + lineHeight: 14, + minWidth: 16, + borderWidth: 1, + }, +}; + +export const BadgeNotification = (props: BadgeNotificationProps) => { + const { type, count, size = 'md', testID } = props; + const styles = useStyles(); + const { + theme: { + colors: { accent }, + }, + } = useTheme(); + + const colors = { + error: accent.error, + neutral: accent.neutral, + primary: accent.primary, + }; + + return ( + + {count} + + ); +}; + +const useStyles = () => { + const { + theme: { + radius, + colors: { badge }, + typography, + spacing, + }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + text: { + color: badge.text, + fontWeight: typography.fontWeight.bold, + includeFontPadding: false, + textAlign: 'center', + paddingHorizontal: spacing.xxs, + borderColor: badge.border, + borderRadius: radius.full, + }, + }), + [radius, badge, typography, spacing], + ); +}; diff --git a/package/src/components/ui/index.ts b/package/src/components/ui/index.ts new file mode 100644 index 000000000..6c1291a19 --- /dev/null +++ b/package/src/components/ui/index.ts @@ -0,0 +1,6 @@ +export * from './Avatar'; +export * from './BadgeCount'; +export * from './BadgeNotification'; +export * from './IconButton'; +export * from './OnlineIndicator'; +export * from './VideoPlayIndicator'; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 61cc752b9..59e875c11 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -22,7 +22,7 @@ export const Colors = { bg_user: '#F7F7F8', black: '#000000', blue_alice: '#E9F2FF', - border: '#00000014', // 14 = 8% opacity; top: x=0, y=-1; bottom: x=0, y=1 + // border: '#00000014', // 14 = 8% opacity; top: x=0, y=-1; bottom: x=0, y=1 code_block: '#DDDDDD', disabled: '#B4BBBA', grey: '#7A7A7A', diff --git a/package/src/icons/GroupIcon.tsx b/package/src/icons/GroupIcon.tsx new file mode 100644 index 000000000..49c12eef7 --- /dev/null +++ b/package/src/icons/GroupIcon.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const GroupIcon = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/PeopleIcon.tsx b/package/src/icons/PeopleIcon.tsx new file mode 100644 index 000000000..2d52bcd37 --- /dev/null +++ b/package/src/icons/PeopleIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const PeopleIcon = ({ height, width, ...rest }: IconProps) => ( + + + + +); diff --git a/package/src/theme/primitives/colors.ts b/package/src/theme/primitives/colors.ts index e6fa2006a..48919f291 100644 --- a/package/src/theme/primitives/colors.ts +++ b/package/src/theme/primitives/colors.ts @@ -1,6 +1,154 @@ import { palette } from './palette'; -export type NewColors = typeof lightColors; +type Pallete = { + 50: string; + 100: string; + 200: string; + 300: string; + 400: string; + 500: string; + 600: string; + 700: string; + 800: string; + 900: string; + 950: string; +}; + +type AccentColors = { + primary: string; + success: string; + warning: string; + error: string; + neutral: string; +}; + +type StateColors = { + hover: string; + pressed: string; + selected: string; + bgOverlay: string; + bgDisabled: string; + textDisabled: string; +}; + +type TextColors = { + primary: string; + secondary: string; + tertiary: string; + inverse: string; + onAccent: string; + disabled: string; + link: string; +}; + +type PresenceColors = { + border: string; + bgOnline: string; + bgOffline: string; +}; + +type BorderCore = { + surface: string; + surfaceSubtle: string; + surfaceStrong: string; + onDark: string; + onAccent: string; + subtle: string; + image: string; +}; + +export type BadgeColors = { + border: string; + bgInverse: string; + bgPrimary: string; + bgNeutral: string; + bgError: string; + text: string; + textInverse: string; +}; + +export type RemoveControlColors = { + bg: string; + border: string; + icon: string; +}; + +export type NewColors = { + brand: Pallete; + accent: AccentColors; + state: StateColors; + text: TextColors; + presence: PresenceColors; + border: BorderCore; + badge: BadgeColors; + control: RemoveControlColors; +}; + +export function resolveTheme(input: NewColors) { + const brand = input.brand ?? palette.blue; + const accent = input.accent ?? { + primary: brand[500], + success: palette.green[500], + warning: palette.yellow[500], + error: palette.red[500], + neutral: palette.slate[500], + }; + const text = input.text ?? { + primary: brand[900], + secondary: brand[700], + tertiary: brand[500], + inverse: palette.white, + onAccent: palette.white, + disabled: brand[400], + link: accent.primary, + }; + const state = input.state ?? { + hover: palette.black5, + pressed: palette.black10, + selected: palette.black10, + bgOverlay: palette.black50, + bgDisabled: palette.slate[200], + textDisabled: palette.slate[400], + }; + const presence = input.presence ?? { + border: palette.white, + bgOnline: accent.success, + bgOffline: accent.neutral, + }; + const border = input.border ?? { + surface: palette.slate[400], + surfaceSubtle: palette.slate[200], + surfaceStrong: palette.slate[600], + onDark: palette.white, + onAccent: palette.white, + subtle: palette.slate[100], + image: palette.black10, + }; + const badge = input.badge ?? { + border: palette.white, + bgInverse: palette.white, + bgPrimary: accent.primary, + bgNeutral: accent.neutral, + bgError: accent.error, + text: palette.white, + textInverse: palette.slate[900], + }; + const control = input.control ?? { + bg: palette.slate[900], + border: border.onDark, + icon: palette.white, + }; + return { + brand, + accent, + text, + state, + presence, + border, + badge, + control, + }; +} export const lightColors = { brand: palette.blue, @@ -28,15 +176,33 @@ export const lightColors = { disabled: palette.slate[400], link: palette.blue[500], }, - borderImage: palette.black10, - borderSurfaceSubtle: palette.slate[200], + border: { + surface: palette.slate[400], + surfaceSubtle: palette.slate[200], + surfaceStrong: palette.slate[600], + onDark: palette.white, + onAccent: palette.white, + subtle: palette.slate[100], + image: palette.black10, + }, control: { - remove: palette.slate[900], + bg: palette.slate[900], icon: palette.white, border: palette.white, }, presence: { border: palette.white, + bgOnline: palette.green[500], + bgOffline: palette.slate[500], + }, + badge: { + border: palette.white, + bgInverse: palette.white, + bgPrimary: palette.blue[500], + bgNeutral: palette.slate[500], + bgError: palette.red[500], + text: palette.white, + textInverse: palette.slate[900], }, }; @@ -55,7 +221,7 @@ export const darkColors = { 950: palette.white, }, accent: { - primary: palette.blue[500], + primary: palette.blue[400], success: palette.green[400], warning: palette.yellow[400], error: palette.red[400], @@ -78,14 +244,30 @@ export const darkColors = { disabled: palette.neutral[600], link: palette.blue[500], }, - borderImage: palette.white20, - borderSurfaceSubtle: palette.neutral[700], + border: { + surface: palette.neutral[500], + surfaceSubtle: palette.neutral[700], + surfaceStrong: palette.neutral[400], + onDark: palette.white, + onAccent: palette.white, + subtle: palette.neutral[800], + image: palette.white20, + }, control: { - remove: palette.neutral[800], + bg: palette.neutral[800], icon: palette.white, border: palette.white, }, presence: { border: palette.black, }, + badge: { + border: palette.black, + bgInverse: palette.white, + bgPrimary: palette.blue[400], + bgNeutral: palette.neutral[500], + bgError: palette.red[400], + text: palette.white, + textInverse: palette.neutral[50], + }, };