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/76] 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 a93c62eca5..973ffc78bb 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/76] 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 016c787058..cd3f2670ce 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 18ff62f08f..a73ddf45a8 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 ed225bbabe..d14f51a2be 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 7ced90ea08..37e594f47e 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 d1de2afb5c..3b094f121f 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 1e5f378205..4bea5b3941 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 f845176ce8..0000000000 --- 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 31af18f478..0000000000 --- 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 0000000000..86ca1c4866 --- /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 e4c53c8134..0c7ee0c49c 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 d258eb6ee6..3a509a9060 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 344c7e144f..f58d6de86e 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 bb8dd5ccca..c3eed6f53e 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/76] 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 7e6f1a7720..c524c794a2 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/76] 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 723cd9b907..bc4dc9af2f 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 d47c70fc5d..824f30cab5 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 690a34a23d..0d469486b2 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 842a3f355a..31f31185ca 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 575debf957..0000000000 --- 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 55630bd6f2..e3dec9e16a 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 f58d6de86e..6c0d40a33d 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 a41c90e312..36e3c63ed1 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 45e7f0e51c..f1851a50d7 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 f0158ef0a5..fdfa6a27d2 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/76] 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 171a4084a1..16f46a1c00 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 5da8d63f2a..105ff7f23a 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 406938e40d..ba4898e133 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 5d203517a1..c119e101b9 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 0f4028fa85..71ad526cc5 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 c23af98dc6..59669fd70d 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 bc4dc9af2f..60edac220d 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 ccd2193b93..866973e785 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 0000000000..f75e5853b7 --- /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 1949d3d6bb..fb95d80bc0 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 4176eb86da..3d8e319750 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 dca13d3320..05a556a80a 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 100f186f7e..336dfb3363 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/76] 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 771d8db352..b71b8c6a67 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 f3ca98b78b..79546eb2e2 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 16f46a1c00..acfbd0474e 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 105ff7f23a..c1171deb10 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 c119e101b9..a93301a6f6 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 5b2987f3d0..85760c08a8 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 71ad526cc5..4126ff35e2 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 60edac220d..6afeff2d77 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 876bb5b483..b12e2f874d 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 fb95d80bc0..e9dfa47373 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 425ebb4d85..bddd71b580 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 472d4c47be..db9e5b4f7e 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 69779d57fa..1ea9467329 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 b44fa768e5..67b5ce394e 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 0000000000..fb22c07e49 --- /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 c88aacebce..ce8d93e13d 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 ca6b9d765b..193309cc73 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 31f31185ca..bac31e5441 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 45618918f2..ac6693a396 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 6fc69abfe2..14dbd05024 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 47f8c26cf5..332dd0e6b4 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 72131b94be..bb8c552117 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 ee7dfe5da7..c8e3c3c743 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 3a3ca771c0..181f4cef1a 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 b3d4ad8f92..63fb815d1b 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 e93cefa59b..db623849bd 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 0000000000..7693c0c474 --- /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 f67630a758..96683a101a 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 7a20c757d7..ed31d5e161 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 a917135ec3..b9c34b762b 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 3d8e319750..5de007e7e8 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 90fb5d4872..71245fbf15 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 fdfa6a27d2..aa4e4d36b0 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 0000000000..cf1226712c --- /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 e4397032fb..35d03fba4f 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 642110a9c6..4d896c62a9 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 0000000000..898f705553 --- /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 9d26951c21..ebab5759fe 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 336dfb3363..5d968e079d 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/76] 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 ddf81d1ec4..1cd305ff1c 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/76] 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 1cd305ff1c..a06d0f1a57 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 bb8c552117..700ff4b50c 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/76] 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 5ea72ef943..a8066671b4 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/76] 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 b71b8c6a67..4bdc24765c 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 0037645b82..aff708538f 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 9ec7c80990..b0e7921d67 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 796406673d..8bc0d965cf 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 ba4898e133..70b489cca4 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 a49bfe7c7e..ac23dd1054 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 c3591fc026..945581876e 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 6afeff2d77..fe0469bd88 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 5c5d4a0607..197d1419b8 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 bddd71b580..7f02d27310 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 d14f51a2be..0000000000 --- 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 7ca093dece..0000000000 --- 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 36c524d2db..0000000000 --- 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 37e594f47e..5c6080e790 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 f02227da61..0000000000 --- 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 b5b9959cf4..0000000000 --- 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 d0201770aa..dbd9ae7305 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 5532989480..91c86b386a 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 00a93050cb..4aebfdb773 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 cd226cb652..0000000000 --- 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 501fb72dba..d25e38492d 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 634472fb1a..8cea3bef40 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 e501588eb6..f237aad828 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 ce8d93e13d..8c62fffa57 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 193309cc73..9ba722b90a 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 6ff419c2f3..096d293824 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 0000000000..c10db5e1e0 --- /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 43158d4b92..631bd8efd8 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 856bc31e87..9e913ad692 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 7c29cc5715..c476a9b330 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 0186f0d571..39782431dd 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 c789f657f1..f8c6c58f8b 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 cb05599899..0000000000 --- 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 8fd09bf49d..0000000000 --- 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 0000000000..ecf6105a2e --- /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 0000000000..48adc924f5 --- /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 0000000000..b0ccabad26 --- /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 0000000000..dda8a74d56 --- /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 bac31e5441..02c618be86 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 ac6693a396..7960e11735 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 8c15cb851c..5e7c732a70 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 47192ee192..0872d8be42 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 286fdbf9a2..eddcde61d4 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 0e7e0fa17b..e34ff46d31 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 1ad146941e..764345705d 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 b9c34b762b..36267f673e 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 05a556a80a..e076b7ea7b 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 7ad8d8f712..9d966bac51 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 f1b26969d9..6795d52f00 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 0000000000..e013d41241 --- /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 0000000000..358abc8f35 --- /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 0000000000..d2bea91841 --- /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 0000000000..0ff62e16f3 --- /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 0000000000..615fdfa439 --- /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 0000000000..6adb196d6e --- /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 0000000000..905d2684ca --- /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 0000000000..21c1044862 --- /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 0000000000..6fc8a3b62a --- /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 0000000000..9ca5d3eca0 --- /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 0000000000..e096b8ff42 --- /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 0000000000..b08396ffc6 --- /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 0000000000..a9b6bdd89d --- /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 0000000000..7129118011 --- /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 060e210780..9b8723df59 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 a7be88c2c7..a01676cd4f 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 0000000000..a8bab1106a --- /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/76] 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 7f02d27310..7ed1605897 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 0000000000..dc2ffd3349 --- /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 c10db5e1e0..60b2fa8ecc 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 3b094f121f..1313d6ecaf 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 631bd8efd8..0000000000 --- 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 9e913ad692..4a40d1d21d 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 c476a9b330..a0ed819af0 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 764345705d..eb7a199987 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 0000000000..2dda8b0c3f --- /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 0000000000..3bffc0dbaf --- /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 6795d52f00..61cc752b95 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 0000000000..e6fa2006ac --- /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 0000000000..f52105f3a9 --- /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 0000000000..a192a008bf --- /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 0000000000..6392225230 --- /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 0000000000..9cb3c056db --- /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/76] 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 eb7a199987..fe3af26126 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/76] 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 911353cd9a..8a3c9dca8d 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 4f33bc9948..581ac92ca0 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 b3e91999c3..24bcc54f41 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 71d3a2694a..87c42c8ee2 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 1d487b8abb..5c664fa8fc 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 cc1628f10d..03ebef4139 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 f868e9ee2c..8a0ad4f44f 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 cde55c465a..d657aeb696 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 0746bd158d..b0677c50fe 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 7ecb9e9992..fdbc9d6e5a 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 eb963ed8ce..79819b6de8 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 6a3a039338..cf4c98aac5 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 d492bc795b..ccd83de704 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 0448c4ead6..19987dc12a 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 0000000000..ad7f2f311a --- /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 0000000000..b65638f4f3 --- /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 0000000000..0b37fd44ea --- /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 088a15a9fc..4b7cc2521e 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 65f87b0b6b..1b66c19d57 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 5c6080e790..0ddba3e3cb 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 f3c59bc582..d8dcf3c047 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 dc2ffd3349..79fdbf3ca9 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 4a40d1d21d..06e6156161 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 a0ed819af0..f28c6a4896 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 5e7c732a70..3bc212c4a2 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 eddcde61d4..ea4ebf392b 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 36267f673e..81fbb67be1 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 152a0849e7..020ec8d578 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 0000000000..2c160bd74a --- /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 0000000000..87fb661361 --- /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 0000000000..36b81811f9 --- /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 0000000000..6c1291a195 --- /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 61cc752b95..59e875c112 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 0000000000..49c12eef76 --- /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 0000000000..2d52bcd37e --- /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 e6fa2006ac..48919f2917 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], + }, }; From 2d8e9ad33c49fb7ea089a01470cd255f0b78e160 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 19 Jan 2026 16:24:13 +0530 Subject: [PATCH 14/76] feat!: introduce image gallery state store (#3330) This pull request refactors the image gallery to simplify state management and remove legacy props and logic related to image gallery behavior. The main improvement is the introduction of a centralized `imageGalleryStateStore`, which replaces the previous pattern of passing multiple state setters and props. Additionally, the video is now managed by a pool and a player class same as audio. This is for easy state management and ease of usage. Additionally, the dependency on `@gorhom/bottom-sheet` is updated, and related code is streamlined to use the new approach. **Refactoring and State Management Improvements:** * Replaced legacy props (`setMessages`, `setSelectedMessage`, `legacyImageViewerSwipeBehaviour`) in `Gallery`, `Giphy`, and related components with a single `imageGalleryStateStore` prop, centralizing image gallery state handling. [[1]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L39-R39) [[2]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782R51-L58) [[3]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L68-L80) [[4]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782R73-L101) [[5]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782R189-L213) [[6]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L222-L224) [[7]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L255-L274) [[8]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L283-L285) [[9]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L307-R288) [[10]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L588-R564) [[11]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L612) [[12]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L634) [[13]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782R620-L665) [[14]](diffhunk://#diff-9175eb9fcc09f05267618eca638f8502019ed92e3751d38c2771d7083903ab5bL135-R135) [[15]](diffhunk://#diff-9175eb9fcc09f05267618eca638f8502019ed92e3751d38c2771d7083903ab5bR164) [[16]](diffhunk://#diff-9175eb9fcc09f05267618eca638f8502019ed92e3751d38c2771d7083903ab5bL175-L177) [[17]](diffhunk://#diff-9175eb9fcc09f05267618eca638f8502019ed92e3751d38c2771d7083903ab5bL212-R211) [[18]](diffhunk://#diff-9175eb9fcc09f05267618eca638f8502019ed92e3751d38c2771d7083903ab5bL455-R453) [[19]](diffhunk://#diff-9175eb9fcc09f05267618eca638f8502019ed92e3751d38c2771d7083903ab5bR471) [[20]](diffhunk://#diff-9175eb9fcc09f05267618eca638f8502019ed92e3751d38c2771d7083903ab5bL481-L483) * Updated the logic for opening the image viewer and Giphy attachments to use `imageGalleryStateStore.openImageGallery`, removing conditional logic for legacy behavior. [[1]](diffhunk://#diff-e7d0e211073121199347eedf392123d964951dbd5ebbda17d1f86c921ec39782L307-R288) [[2]](diffhunk://#diff-9175eb9fcc09f05267618eca638f8502019ed92e3751d38c2771d7083903ab5bL212-R211) * Removed all references to `legacyImageViewerSwipeBehaviour` from the codebase, including context, props, and hooks. [[1]](diffhunk://#diff-f7139f4cdb523365cfc277d72b827a3432325b9c6460cf14628f9df67d0e4d85L346) [[2]](diffhunk://#diff-f7139f4cdb523365cfc277d72b827a3432325b9c6460cf14628f9df67d0e4d85L664) [[3]](diffhunk://#diff-f7139f4cdb523365cfc277d72b827a3432325b9c6460cf14628f9df67d0e4d85L1945) [[4]](diffhunk://#diff-d3e4f4cdcef10807a38eab86f6ead6bbfe01980e7326f3cadfeb8186760d7af1L55) [[5]](diffhunk://#diff-d3e4f4cdcef10807a38eab86f6ead6bbfe01980e7326f3cadfeb8186760d7af1L173) **Dependency and Import Updates:** * Upgraded `@gorhom/bottom-sheet` dependency from version 5.1.8 to 5.2.8. * Updated `ImageGallery.tsx` to use the new `imageGalleryStateStore` and removed unused imports and legacy bottom sheet modal code. [[1]](diffhunk://#diff-b6da04367e722c0873941022e180cec3e33f89445cb2427ecaa78c2040037e5aL1-R16) [[2]](diffhunk://#diff-b6da04367e722c0873941022e180cec3e33f89445cb2427ecaa78c2040037e5aL39-L52) [[3]](diffhunk://#diff-b6da04367e722c0873941022e180cec3e33f89445cb2427ecaa78c2040037e5aR99-R128) These changes modernize and simplify the gallery and attachment components, making state management more robust and maintainable. --- .../src/screens/ChannelImagesScreen.tsx | 70 +- package/package.json | 2 +- package/src/components/Attachment/Gallery.tsx | 61 +- package/src/components/Attachment/Giphy.tsx | 19 +- package/src/components/Channel/Channel.tsx | 10 +- .../Channel/hooks/useCreateMessagesContext.ts | 2 - .../components/ImageGallery/ImageGallery.tsx | 416 +++----- .../__tests__/AnimatedVideoGallery.test.tsx | 156 --- .../__tests__/ImageGallery.test.tsx | 205 +--- .../__tests__/ImageGalleryFooter.test.tsx | 222 ++--- .../__tests__/ImageGalleryGrid.test.tsx | 88 +- .../__tests__/ImageGalleryGridHandle.test.tsx | 14 +- .../__tests__/ImageGalleryHeader.test.tsx | 151 ++- .../ImageGalleryVideoControl.test.tsx | 89 -- .../components/AnimatedGalleryImage.tsx | 48 +- .../components/AnimatedGalleryVideo.tsx | 97 +- .../components/ImageGalleryFooter.tsx | 130 +-- .../components/ImageGalleryHeader.tsx | 29 +- .../components/ImageGalleryVideoControl.tsx | 140 ++- .../ImageGallery/components/ImageGrid.tsx | 41 +- .../__tests__/ImageGalleryHeader.test.tsx | 44 +- .../hooks/useImageGalleryGestures.tsx | 64 +- .../hooks/useImageGalleryVideoPlayer.ts | 23 + .../MessageList/MessageFlashList.tsx | 68 -- .../components/MessageList/MessageList.tsx | 74 +- package/src/components/index.ts | 2 + .../ImageGalleryContext.tsx | 59 +- .../messagesContext/MessagesContext.tsx | 1 - .../overlayContext/OverlayContext.tsx | 16 +- .../overlayContext/OverlayProvider.tsx | 45 +- package/src/native.ts | 3 +- .../image-gallery-state-store.test.ts | 918 ++++++++++++++++++ .../__tests__/video-player-pool.test.ts | 445 +++++++++ .../__tests__/video-player.test.ts | 677 +++++++++++++ .../state-store/image-gallery-state-store.ts | 225 +++++ package/src/state-store/index.ts | 5 +- package/src/state-store/video-player-pool.ts | 86 ++ package/src/state-store/video-player.ts | 155 +++ package/src/utils/constants.ts | 1 + package/src/utils/getUrlOfImageAttachment.ts | 11 +- package/yarn.lock | 8 +- 41 files changed, 3318 insertions(+), 1602 deletions(-) delete mode 100644 package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx delete mode 100644 package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx create mode 100644 package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts create mode 100644 package/src/state-store/__tests__/image-gallery-state-store.test.ts create mode 100644 package/src/state-store/__tests__/video-player-pool.test.ts create mode 100644 package/src/state-store/__tests__/video-player.test.ts create mode 100644 package/src/state-store/image-gallery-state-store.ts create mode 100644 package/src/state-store/video-player-pool.ts create mode 100644 package/src/state-store/video-player.ts diff --git a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx index fd124f72a4..793b7a00e7 100644 --- a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx @@ -13,10 +13,11 @@ import Dayjs from 'dayjs'; import { SafeAreaView } from 'react-native-safe-area-context'; import { DateHeader, - Photo, useImageGalleryContext, useOverlayContext, useTheme, + ImageGalleryState, + useStateStore, } from 'stream-chat-react-native'; import { ScreenHeader } from '../components/ScreenHeader'; @@ -24,7 +25,6 @@ import { usePaginatedAttachments } from '../hooks/usePaginatedAttachments'; import { Picture } from '../icons/Picture'; import type { RouteProp } from '@react-navigation/native'; -import type { Attachment } from 'stream-chat'; import type { StackNavigatorParamList } from '../types'; @@ -61,16 +61,17 @@ export type ChannelImagesScreenProps = { route: ChannelImagesScreenRouteProp; }; +const selector = (state: ImageGalleryState) => ({ + assets: state.assets, +}); + export const ChannelImagesScreen: React.FC = ({ route: { params: { channel }, }, }) => { - const { - messages: images, - setMessages: setImages, - setSelectedMessage: setImage, - } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { assets } = useStateStore(imageGalleryStateStore.state, selector); const { setOverlay } = useOverlayContext(); const { loading, loadMore, messages } = usePaginatedAttachments(channel, 'image'); const { @@ -79,8 +80,6 @@ export const ChannelImagesScreen: React.FC = ({ }, } = useTheme(); - const channelImages = useRef(images); - const [stickyHeaderDate, setStickyHeaderDate] = useState( Dayjs(messages?.[0]?.created_at).format('MMM YYYY'), ); @@ -106,30 +105,6 @@ export const ChannelImagesScreen: React.FC = ({ } }); - /** - * Photos array created from all currently available - * photo attachments - */ - const photos = messages.reduce((acc: Photo[], cur) => { - const attachmentImages = - (cur.attachments as Attachment[])?.filter( - (attachment) => - attachment.type === 'image' && - !attachment.title_link && - !attachment.og_scrape_url && - (attachment.image_url || attachment.thumb_url), - ) || []; - - const attachmentPhotos = attachmentImages.map((attachmentImage) => ({ - created_at: cur.created_at, - id: `photoId-${cur.id}-${attachmentImage.image_url || attachmentImage.thumb_url}`, - messageId: cur.id, - uri: attachmentImage.image_url || (attachmentImage.thumb_url as string), - })); - - return [...acc, ...attachmentPhotos]; - }, []); - const messagesWithImages = messages .map((message) => ({ ...message, groupStyles: [], readBy: false })) .filter((message) => { @@ -145,24 +120,11 @@ export const ChannelImagesScreen: React.FC = ({ return false; }); - /** - * This is for the useEffect to run again in the case that a message - * gets edited with more or the same number of images - */ - const imageString = messagesWithImages - .map((message) => - (message.attachments as Attachment[]) - .map((attachment) => attachment.image_url || attachment.thumb_url || '') - .join(), - ) - .join(); - useEffect(() => { - setImages(messagesWithImages); - const channelImagesCurrent = channelImages.current; - return () => setImages(channelImagesCurrent); + imageGalleryStateStore.openImageGallery({ messages: messagesWithImages }); + return () => imageGalleryStateStore.clear(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageString, setImages]); + }, [imageGalleryStateStore, messagesWithImages.length]); return ( @@ -170,7 +132,7 @@ export const ChannelImagesScreen: React.FC = ({ `${item.id}-${index}`} ListEmptyComponent={EmptyListComponent} numColumns={3} @@ -180,9 +142,9 @@ export const ChannelImagesScreen: React.FC = ({ renderItem={({ item }) => ( { - setImage({ - messageId: item.messageId, - url: item.uri, + imageGalleryStateStore.openImageGallery({ + messages: messagesWithImages, + selectedAttachmentUrl: item.uri, }); setOverlay('gallery'); }} @@ -202,7 +164,7 @@ export const ChannelImagesScreen: React.FC = ({ viewAreaCoveragePercentThreshold: 50, }} /> - {photos && photos.length ? ( + {assets.length > 0 ? ( diff --git a/package/package.json b/package/package.json index 4126ff35e2..aa90a43338 100644 --- a/package/package.json +++ b/package/package.json @@ -68,7 +68,7 @@ ] }, "dependencies": { - "@gorhom/bottom-sheet": "^5.1.8", + "@gorhom/bottom-sheet": "^5.2.8", "@ungap/structured-clone": "^1.3.0", "dayjs": "1.11.13", "emoji-regex": "^10.4.0", diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 7da851ded1..e5ad796bf8 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -36,10 +36,7 @@ import { isVideoPlayerAvailable } from '../../native'; import { FileTypes } from '../../types/types'; import { getUrlWithoutParams } from '../../utils/utils'; -export type GalleryPropsWithContext = Pick< - ImageGalleryContextValue, - 'setSelectedMessage' | 'setMessages' -> & +export type GalleryPropsWithContext = Pick & Pick< MessageContextValue, | 'alignment' @@ -51,11 +48,11 @@ export type GalleryPropsWithContext = Pick< | 'onPressIn' | 'preventPress' | 'threadList' + | 'message' > & Pick< MessagesContextValue, | 'additionalPressableProps' - | 'legacyImageViewerSwipeBehaviour' | 'VideoThumbnail' | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' @@ -65,19 +62,6 @@ export type GalleryPropsWithContext = Pick< Pick & { channelId: string | undefined; hasThreadReplies?: boolean; - /** - * `message` prop has been introduced here as part of `legacyImageViewerSwipeBehaviour` prop. - * https://github.com/GetStream/stream-chat-react-native/commit/d5eac6193047916f140efe8e396a671675c9a63f - * messageId and messageText may seem redundant now, but to avoid breaking change as part - * of minor release, we are keeping those props. - * - * Also `message` type should ideally be imported from MessageContextValue and not be explicitely mentioned - * here, but due to some circular dependencies within the SDK, it causes "excessive deep nesting" issue with - * typescript within Channel component. We should take it as a mini-project and resolve all these circular imports. - * - * TODO: Fix circular dependencies of imports - */ - message?: LocalMessage; }; const GalleryWithContext = (props: GalleryPropsWithContext) => { @@ -86,19 +70,17 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { alignment, groupStyles, hasThreadReplies, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, images, - legacyImageViewerSwipeBehaviour, message, onLongPress, onPress, onPressIn, preventPress, - setMessages, setOverlay, - setSelectedMessage, threadList, videos, VideoThumbnail, @@ -204,13 +186,13 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { additionalPressableProps={additionalPressableProps} borderRadius={borderRadius} colIndex={colIndex} + imageGalleryStateStore={imageGalleryStateStore} ImageLoadingFailedIndicator={ImageLoadingFailedIndicator} ImageLoadingIndicator={ImageLoadingIndicator} ImageReloadIndicator={ImageReloadIndicator} imagesAndVideos={imagesAndVideos} invertedDirections={invertedDirections || false} key={rowIndex} - legacyImageViewerSwipeBehaviour={legacyImageViewerSwipeBehaviour} message={message} numOfColumns={numOfColumns} numOfRows={numOfRows} @@ -219,9 +201,7 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { onPressIn={onPressIn} preventPress={preventPress} rowIndex={rowIndex} - setMessages={setMessages} setOverlay={setOverlay} - setSelectedMessage={setSelectedMessage} thumbnail={thumbnail} VideoThumbnail={VideoThumbnail} /> @@ -252,13 +232,12 @@ type GalleryThumbnailProps = { } & Pick< MessagesContextValue, | 'additionalPressableProps' - | 'legacyImageViewerSwipeBehaviour' | 'VideoThumbnail' | 'ImageLoadingIndicator' | 'ImageLoadingFailedIndicator' | 'ImageReloadIndicator' > & - Pick & + Pick & Pick & Pick; @@ -266,12 +245,12 @@ const GalleryThumbnail = ({ additionalPressableProps, borderRadius, colIndex, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, imagesAndVideos, invertedDirections, - legacyImageViewerSwipeBehaviour, message, numOfColumns, numOfRows, @@ -280,9 +259,7 @@ const GalleryThumbnail = ({ onPressIn, preventPress, rowIndex, - setMessages, setOverlay, - setSelectedMessage, thumbnail, VideoThumbnail, }: GalleryThumbnailProps) => { @@ -304,17 +281,14 @@ const GalleryThumbnail = ({ const { t } = useTranslationContext(); const openImageViewer = () => { - if (!legacyImageViewerSwipeBehaviour && message) { - // Added if-else to keep the logic readable, instead of DRY. - // if - legacyImageViewerSwipeBehaviour is disabled - // else - legacyImageViewerSwipeBehaviour is enabled - setMessages([message]); - setSelectedMessage({ messageId: message.id, url: thumbnail.url }); - setOverlay('gallery'); - } else if (legacyImageViewerSwipeBehaviour) { - setSelectedMessage({ messageId: message?.id, url: thumbnail.url }); - setOverlay('gallery'); + if (!message) { + return; } + imageGalleryStateStore.openImageGallery({ + messages: [message], + selectedAttachmentUrl: thumbnail.url, + }); + setOverlay('gallery'); }; const defaultOnPress = () => { @@ -585,13 +559,12 @@ export const Gallery = (props: GalleryProps) => { onPressIn: propOnPressIn, preventPress: propPreventPress, setOverlay: propSetOverlay, - setSelectedMessage: propSetSelectedMessage, threadList: propThreadList, videos: propVideos, VideoThumbnail: PropVideoThumbnail, } = props; - const { setMessages, setSelectedMessage: contextSetSelectedMessage } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); const { alignment: contextAlignment, groupStyles: contextGroupStyles, @@ -609,7 +582,6 @@ export const Gallery = (props: GalleryProps) => { ImageLoadingFailedIndicator: ContextImageLoadingFailedIndicator, ImageLoadingIndicator: ContextImageLoadingIndicator, ImageReloadIndicator: ContextImageReloadIndicator, - legacyImageViewerSwipeBehaviour, myMessageTheme: contextMyMessageTheme, VideoThumbnail: ContextVideoThumnbnail, } = useMessagesContext(); @@ -631,7 +603,6 @@ export const Gallery = (props: GalleryProps) => { const onPress = propOnPress || contextOnPress; const preventPress = typeof propPreventPress === 'boolean' ? propPreventPress : contextPreventPress; - const setSelectedMessage = propSetSelectedMessage || contextSetSelectedMessage; const setOverlay = propSetOverlay || contextSetOverlay; const threadList = propThreadList || contextThreadList; const VideoThumbnail = PropVideoThumbnail || ContextVideoThumnbnail; @@ -649,20 +620,18 @@ export const Gallery = (props: GalleryProps) => { channelId: message?.cid, groupStyles, hasThreadReplies: hasThreadReplies || !!message?.reply_count, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, ImageReloadIndicator, images, - legacyImageViewerSwipeBehaviour, message, myMessageTheme, onLongPress, onPress, onPressIn, preventPress, - setMessages, setOverlay, - setSelectedMessage, threadList, videos, VideoThumbnail, diff --git a/package/src/components/Attachment/Giphy.tsx b/package/src/components/Attachment/Giphy.tsx index 8b71abb187..39a5788857 100644 --- a/package/src/components/Attachment/Giphy.tsx +++ b/package/src/components/Attachment/Giphy.tsx @@ -132,10 +132,7 @@ const styles = StyleSheet.create({ }, }); -export type GiphyPropsWithContext = Pick< - ImageGalleryContextValue, - 'setSelectedMessage' | 'setMessages' -> & +export type GiphyPropsWithContext = Pick & Pick< MessageContextValue, | 'handleAction' @@ -164,6 +161,7 @@ const GiphyWithContext = (props: GiphyPropsWithContext) => { giphyVersion, handleAction, ImageComponent = Image, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, isMyMessage, @@ -172,9 +170,7 @@ const GiphyWithContext = (props: GiphyPropsWithContext) => { onPress, onPressIn, preventPress, - setMessages, setOverlay, - setSelectedMessage, } = props; const { actions, giphy: giphyData, image_url, thumb_url, title, type } = attachment; @@ -209,8 +205,10 @@ const GiphyWithContext = (props: GiphyPropsWithContext) => { const giphyDimensions: { height?: number; width?: number } = {}; const defaultOnPress = () => { - setMessages([message]); - setSelectedMessage({ messageId: message.id, url: uri }); + if (!uri) { + return; + } + imageGalleryStateStore.openImageGallery({ messages: [message], selectedAttachmentUrl: uri }); setOverlay('gallery'); }; @@ -452,7 +450,7 @@ export const Giphy = (props: GiphyProps) => { useMessageContext(); const { ImageComponent } = useChatContext(); const { additionalPressableProps, giphyVersion } = useMessagesContext(); - const { setMessages, setSelectedMessage } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); const { setOverlay } = useOverlayContext(); const { @@ -470,6 +468,7 @@ export const Giphy = (props: GiphyProps) => { giphyVersion, handleAction, ImageComponent, + imageGalleryStateStore, ImageLoadingFailedIndicator, ImageLoadingIndicator, isMyMessage, @@ -478,9 +477,7 @@ export const Giphy = (props: GiphyProps) => { onPress, onPressIn, preventPress, - setMessages, setOverlay, - setSelectedMessage, }} {...props} /> diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index fe0469bd88..f769ee4841 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -50,8 +50,7 @@ import { CreatePollIcon as DefaultCreatePollIcon } from '../../components/Poll/c import { AttachmentPickerContextValue, AttachmentPickerProvider, - MessageContextValue, -} from '../../contexts'; +} from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { AudioPlayerContextProps, AudioPlayerProvider, @@ -61,6 +60,7 @@ import type { UseChannelStateValue } from '../../contexts/channelsStateContext/u import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { MessageComposerProvider } from '../../contexts/messageComposerContext/MessageComposerContext'; +import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { InputMessageInputContextValue, MessageInputProvider, @@ -356,7 +356,6 @@ export type ChannelPropsWithContext = Pick & | 'InlineDateSeparator' | 'InlineUnreadIndicator' | 'isAttachmentEqual' - | 'legacyImageViewerSwipeBehaviour' | 'ImageLoadingFailedIndicator' | 'ImageLoadingIndicator' | 'ImageReloadIndicator' @@ -590,7 +589,7 @@ const ChannelWithContext = (props: PropsWithChildren) = attachmentPickerErrorText, numberOfAttachmentImagesToLoadPerCall = 60, numberOfAttachmentPickerImageColumns = 3, - + giphyVersion = 'fixed_height', bottomInset = 0, CameraSelectorIcon = DefaultCameraSelectorIcon, FileSelectorIcon = DefaultFileSelectorIcon, @@ -634,7 +633,6 @@ const ChannelWithContext = (props: PropsWithChildren) = Gallery = GalleryDefault, getMessageGroupStyle, Giphy = GiphyDefault, - giphyVersion = 'fixed_height', handleAttachButtonPress, handleBan, handleCopy, @@ -672,7 +670,6 @@ const ChannelWithContext = (props: PropsWithChildren) = keyboardBehavior, KeyboardCompatibleView = KeyboardCompatibleViewDefault, keyboardVerticalOffset, - legacyImageViewerSwipeBehaviour = false, LoadingErrorIndicator = LoadingErrorIndicatorDefault, LoadingIndicator = LoadingIndicatorDefault, loadingMore: loadingMoreProp, @@ -1955,7 +1952,6 @@ const ChannelWithContext = (props: PropsWithChildren) = InlineUnreadIndicator, isAttachmentEqual, isMessageAIGenerated, - legacyImageViewerSwipeBehaviour, markdownRules, Message, MessageActionList, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 0d469486b2..8e61a7dc60 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -52,7 +52,6 @@ export const useCreateMessagesContext = ({ InlineUnreadIndicator, isAttachmentEqual, isMessageAIGenerated, - legacyImageViewerSwipeBehaviour, markdownRules, Message, MessageActionList, @@ -171,7 +170,6 @@ export const useCreateMessagesContext = ({ InlineUnreadIndicator, isAttachmentEqual, isMessageAIGenerated, - legacyImageViewerSwipeBehaviour, markdownRules, Message, MessageActionList, diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index 866973e785..c3816d2593 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -1,20 +1,17 @@ -import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, - runOnJS, - runOnUI, - SharedValue, + useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; -import { BottomSheetModal as BottomSheetModalOriginal } from '@gorhom/bottom-sheet'; -import type { UserResponse } from 'stream-chat'; +import BottomSheet from '@gorhom/bottom-sheet'; import { AnimatedGalleryImage } from './components/AnimatedGalleryImage'; import { AnimatedGalleryVideo } from './components/AnimatedGalleryVideo'; @@ -35,20 +32,19 @@ import { import { useImageGalleryGestures } from './hooks/useImageGalleryGestures'; -import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext'; -import { useImageGalleryContext } from '../../contexts/imageGalleryContext/ImageGalleryContext'; -import { OverlayProviderProps } from '../../contexts/overlayContext/OverlayContext'; +import { + ImageGalleryProviderProps, + useImageGalleryContext, +} from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + OverlayContextValue, + useOverlayContext, +} from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks'; import { useViewport } from '../../hooks/useViewport'; -import { isVideoPlayerAvailable, VideoType } from '../../native'; +import { ImageGalleryState } from '../../state-store/image-gallery-state-store'; import { FileTypes } from '../../types/types'; -import { getResizedImageUrl } from '../../utils/getResizedImageUrl'; -import { getUrlOfImageAttachment } from '../../utils/getUrlOfImageAttachment'; -import { getGiphyMimeType } from '../Attachment/utils/getGiphyMimeType'; -import { - BottomSheetModal, - BottomSheetModalProvider, -} from '../BottomSheetCompatibility/BottomSheetModal'; import { dismissKeyboard } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; const MARGIN = 32; @@ -104,36 +100,40 @@ export type ImageGalleryCustomComponents = { }; }; -type Props = ImageGalleryCustomComponents & { - overlayOpacity: SharedValue; -} & Pick< - OverlayProviderProps, - | 'giphyVersion' - | 'imageGalleryGridSnapPoints' - | 'imageGalleryGridHandleHeight' - | 'numberOfImageGalleryGridColumns' - | 'autoPlayVideo' - >; - -export const ImageGallery = (props: Props) => { +const imageGallerySelector = (state: ImageGalleryState) => ({ + assets: state.assets, + currentIndex: state.currentIndex, +}); + +type ImageGalleryWithContextProps = Pick< + ImageGalleryProviderProps, + | 'imageGalleryCustomComponents' + | 'imageGalleryGridSnapPoints' + | 'imageGalleryGridHandleHeight' + | 'numberOfImageGalleryGridColumns' +> & + Pick; + +export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => { const { - autoPlayVideo = false, - giphyVersion = 'fixed_height', - imageGalleryCustomComponents, - imageGalleryGridHandleHeight = 40, + imageGalleryGridHandleHeight, imageGalleryGridSnapPoints, + imageGalleryCustomComponents, numberOfImageGalleryGridColumns, overlayOpacity, } = props; - const { resizableCDNHosts } = useChatConfigContext(); const { theme: { colors: { white_snow }, imageGallery: { backgroundColor, pager, slide }, }, } = useTheme(); - const [gridPhotos, setGridPhotos] = useState([]); - const { messages, selectedMessage, setSelectedMessage } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { assets, currentIndex } = useStateStore( + imageGalleryStateStore.state, + imageGallerySelector, + ); + const { videoPlayerPool } = imageGalleryStateStore; const { vh, vw } = useViewport(); @@ -144,7 +144,7 @@ export const ImageGallery = (props: Props) => { const halfScreenHeight = fullWindowHeight / 2; const quarterScreenHeight = fullWindowHeight / 4; const snapPoints = React.useMemo( - () => [(fullWindowHeight * 3) / 4, fullWindowHeight - imageGalleryGridHandleHeight], + () => [(fullWindowHeight * 3) / 4, fullWindowHeight - (imageGalleryGridHandleHeight ?? 0)], // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -152,7 +152,7 @@ export const ImageGallery = (props: Props) => { /** * BottomSheetModal ref */ - const bottomSheetModalRef = useRef(null); + const bottomSheetModalRef = useRef(null); /** * BottomSheetModal state @@ -165,13 +165,13 @@ export const ImageGallery = (props: Props) => { * set to none for fast opening */ const screenTranslateY = useSharedValue(fullWindowHeight); - const showScreen = () => { + const showScreen = useCallback(() => { 'worklet'; screenTranslateY.value = withTiming(0, { duration: 250, easing: Easing.out(Easing.ease), }); - }; + }, [screenTranslateY]); /** * Run the fade animation on visible change @@ -179,8 +179,7 @@ export const ImageGallery = (props: Props) => { useEffect(() => { dismissKeyboard(); showScreen(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [showScreen]); /** * Image height from URL or default to full screen height @@ -199,128 +198,25 @@ export const ImageGallery = (props: Props) => { const translateY = useSharedValue(0); const offsetScale = useSharedValue(1); const scale = useSharedValue(1); - const translationX = useSharedValue(0); + const translationX = useSharedValue(-(fullWindowWidth + MARGIN) * currentIndex); - /** - * Photos array created from all currently available - * photo attachments - */ - - const photos = useMemo( - () => - messages.reduce((acc: Photo[], cur) => { - const attachmentImages = - cur.attachments?.filter( - (attachment) => - (attachment.type === FileTypes.Giphy && - (attachment.giphy?.[giphyVersion]?.url || - attachment.thumb_url || - attachment.image_url)) || - (attachment.type === FileTypes.Image && - !attachment.title_link && - !attachment.og_scrape_url && - getUrlOfImageAttachment(attachment)) || - (isVideoPlayerAvailable() && attachment.type === FileTypes.Video), - ) || []; - - const attachmentPhotos = attachmentImages.map((a) => { - const imageUrl = getUrlOfImageAttachment(a) as string; - const giphyURL = a.giphy?.[giphyVersion]?.url || a.thumb_url || a.image_url; - const isInitiallyPaused = !autoPlayVideo; - - return { - channelId: cur.cid, - created_at: cur.created_at, - duration: 0, - id: `photoId-${cur.id}-${imageUrl}`, - messageId: cur.id, - mime_type: a.type === 'giphy' ? getGiphyMimeType(giphyURL ?? '') : a.mime_type, - original_height: a.original_height, - original_width: a.original_width, - paused: isInitiallyPaused, - progress: 0, - thumb_url: a.thumb_url, - type: a.type, - uri: - a.type === 'giphy' - ? giphyURL - : getResizedImageUrl({ - height: fullWindowHeight, - resizableCDNHosts, - url: imageUrl, - width: fullWindowWidth, - }), - user: cur.user, - user_id: cur.user_id, - }; - }); - - return [...attachmentPhotos, ...acc] as Photo[]; - }, []), - [autoPlayVideo, fullWindowHeight, fullWindowWidth, giphyVersion, messages, resizableCDNHosts], + useAnimatedReaction( + () => currentIndex, + (index) => { + translationX.value = -(fullWindowWidth + MARGIN) * index; + }, + [currentIndex, fullWindowWidth], ); - /** - * The URL for the images may differ because of dimensions passed as - * part of the query. - */ - const stripQueryFromUrl = (url: string) => url.split('?')[0]; - - const photoSelectedIndex = useMemo(() => { - const idx = photos.findIndex( - (photo) => - photo.messageId === selectedMessage?.messageId && - stripQueryFromUrl(photo.uri) === stripQueryFromUrl(selectedMessage?.url || ''), - ); - - return idx === -1 ? 0 : idx; - }, [photos, selectedMessage]); - - /** - * JS and UI index values, the JS follows the UI but is needed - * for rendering the virtualized image list - */ - const [selectedIndex, setSelectedIndex] = useState(photoSelectedIndex); - const index = useSharedValue(photoSelectedIndex); - - const [imageGalleryAttachments, setImageGalleryAttachments] = useState(photos); - - /** - * Photos length needs to be kept as a const here so if the length - * changes it causes the pan gesture handler function to refresh. This - * does not work if the calculation for the length of the array is left - * inside the gesture handler as it will have an array as a dependency - */ - const photoLength = photos.length; - - /** - * Set selected photo when changed via pressing in the message list - */ - useEffect(() => { - const updatePosition = (newIndex: number) => { - 'worklet'; - - if (newIndex > -1) { - index.value = newIndex; - translationX.value = -(fullWindowWidth + MARGIN) * newIndex; - runOnJS(setSelectedIndex)(newIndex); - } - }; - - runOnUI(updatePosition)(photoSelectedIndex); - }, [fullWindowWidth, index, photoSelectedIndex, translationX]); - /** * Image heights are not provided and therefore need to be calculated. * We start by allowing the image to be the full height then reduce it * to the proper scaled height based on the width being restricted to the * screen width when the dimensions are received. */ - const uriForCurrentImage = imageGalleryAttachments[selectedIndex]?.uri; - useEffect(() => { let currentImageHeight = fullWindowHeight; - const photo = imageGalleryAttachments[index.value]; + const photo = assets[currentIndex]; const height = photo?.original_height; const width = photo?.original_width; @@ -338,7 +234,16 @@ export const ImageGallery = (props: Props) => { setCurrentImageHeight(currentImageHeight); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uriForCurrentImage]); + }, [currentIndex]); + + // If you change the current index, pause the active video player. + useEffect(() => { + const activePlayer = videoPlayerPool.getActivePlayer(); + + if (activePlayer) { + activePlayer.pause(); + } + }, [currentIndex, videoPlayerPool]); const { doubleTap, pan, pinch, singleTap } = useImageGalleryGestures({ currentImageHeight, @@ -347,12 +252,9 @@ export const ImageGallery = (props: Props) => { headerFooterVisible, offsetScale, overlayOpacity, - photoLength, scale, screenHeight: fullWindowHeight, screenWidth: fullWindowWidth, - selectedIndex, - setSelectedIndex, translateX, translateY, translationX, @@ -422,7 +324,6 @@ export const ImageGallery = (props: Props) => { const closeGridView = () => { if (bottomSheetModalRef.current?.close) { bottomSheetModalRef.current.close(); - setGridPhotos([]); } }; @@ -430,79 +331,8 @@ export const ImageGallery = (props: Props) => { * Function to open BottomSheetModal with image grid */ const openGridView = () => { - if (bottomSheetModalRef.current?.present) { - bottomSheetModalRef.current.present(); - setGridPhotos(imageGalleryAttachments); - } - }; - - const handleEnd = () => { - handlePlayPause(imageGalleryAttachments[selectedIndex].id, true); - handleProgress(imageGalleryAttachments[selectedIndex].id, 1, true); - }; - - const videoRef = useRef(null); - - const handleLoad = (index: string, duration: number) => { - setImageGalleryAttachments((prevImageGalleryAttachment) => - prevImageGalleryAttachment.map((imageGalleryAttachment) => ({ - ...imageGalleryAttachment, - duration: imageGalleryAttachment.id === index ? duration : imageGalleryAttachment.duration, - })), - ); - }; - - const handleProgress = (index: string, progress: number, hasEnd?: boolean) => { - setImageGalleryAttachments((prevImageGalleryAttachments) => - prevImageGalleryAttachments.map((imageGalleryAttachment) => ({ - ...imageGalleryAttachment, - progress: - imageGalleryAttachment.id === index - ? hasEnd - ? 1 - : progress - : imageGalleryAttachment.progress, - })), - ); - }; - - const handlePlayPause = (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. - setImageGalleryAttachments((prevImageGalleryAttachment) => - prevImageGalleryAttachment.map((imageGalleryAttachment) => ({ - ...imageGalleryAttachment, - paused: imageGalleryAttachment.id === index ? false : true, - })), - ); - - if (videoRef.current?.play) { - videoRef.current.play(); - } - } else { - // If the status is true we simply set all the audio's paused state as true. - setImageGalleryAttachments((prevImageGalleryAttachment) => - prevImageGalleryAttachment.map((imageGalleryAttachment) => ({ - ...imageGalleryAttachment, - paused: true, - })), - ); - - if (videoRef.current?.pause) { - videoRef.current.pause(); - } - } - }; - - const onPlayPause = (status?: boolean) => { - if (status === undefined) { - if (imageGalleryAttachments[selectedIndex].paused) { - handlePlayPause(imageGalleryAttachments[selectedIndex].id, false); - } else { - handlePlayPause(imageGalleryAttachments[selectedIndex].id, true); - } - } else { - handlePlayPause(imageGalleryAttachments[selectedIndex].id, status); + if (bottomSheetModalRef.current?.snapToIndex) { + bottomSheetModalRef.current.snapToIndex(0); } }; @@ -526,24 +356,16 @@ export const ImageGallery = (props: Props) => { - {imageGalleryAttachments.map((photo, i) => + {assets.map((photo, i) => photo.type === FileTypes.Video ? ( i} - repeat={true} + photo={photo} scale={scale} screenHeight={fullWindowHeight} - selected={selectedIndex === i} - shouldRender={Math.abs(selectedIndex - i) < 4} - source={{ uri: photo.uri }} style={[ { height: fullWindowHeight * 8, @@ -554,20 +376,17 @@ export const ImageGallery = (props: Props) => { ]} translateX={translateX} translateY={translateY} - videoRef={videoRef as RefObject} /> ) : ( i} scale={scale} screenHeight={fullWindowHeight} - selected={selectedIndex === i} - shouldRender={Math.abs(selectedIndex - i) < 4} + screenWidth={fullWindowWidth} style={[ { height: fullWindowHeight * 8, @@ -586,59 +405,66 @@ export const ImageGallery = (props: Props) => { - {imageGalleryAttachments[selectedIndex] && ( - } - visible={headerFooterVisible} - {...imageGalleryCustomComponents?.footer} - /> - )} + - - setCurrentBottomSheetIndex(index)} - ref={bottomSheetModalRef} - snapPoints={imageGalleryGridSnapPoints || snapPoints} - > - - - + setCurrentBottomSheetIndex(index)} + ref={bottomSheetModalRef} + snapPoints={imageGalleryGridSnapPoints || snapPoints} + > + + ); }; +export type ImageGalleryProps = Partial; + +export const ImageGallery = (props: ImageGalleryProps) => { + const { + imageGalleryCustomComponents, + imageGalleryGridHandleHeight, + imageGalleryGridSnapPoints, + numberOfImageGalleryGridColumns, + } = useImageGalleryContext(); + const { overlayOpacity } = useOverlayContext(); + return ( + + ); +}; + /** * Clamping worklet to clamp the scaling */ @@ -654,22 +480,4 @@ const styles = StyleSheet.create({ }, }); -export type Photo = { - id: string; - uri: string; - channelId?: string; - created_at?: string | Date; - duration?: number; - messageId?: string; - mime_type?: string; - original_height?: number; - original_width?: number; - paused?: boolean; - progress?: number; - thumb_url?: string; - type?: string; - user?: UserResponse | null; - user_id?: string; -}; - ImageGallery.displayName = 'ImageGallery{imageGallery}'; diff --git a/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx b/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx deleted file mode 100644 index 139f90ec76..0000000000 --- a/package/src/components/ImageGallery/__tests__/AnimatedVideoGallery.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from 'react'; - -import type { SharedValue } from 'react-native-reanimated'; - -import { act, fireEvent, render, screen } from '@testing-library/react-native'; - -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; -import { AnimatedGalleryVideo, AnimatedGalleryVideoType } from '../components/AnimatedGalleryVideo'; - -const getComponent = (props: Partial) => ( - - - -); - -describe('ImageGallery', () => { - it('render image gallery component with video rendered', () => { - render( - getComponent({ - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - expect(screen.queryAllByLabelText('Image Gallery Video')).toHaveLength(1); - }); - - it('render empty view when shouldRender is false', () => { - render( - getComponent({ - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: false, - translateX: { value: 1 } as SharedValue, - }), - ); - - expect(screen.getByLabelText('Empty View Image Gallery')).not.toBeUndefined(); - }); - - it('trigger onEnd and onProgress events handlers of Video component', () => { - const handleEndMock = jest.fn(); - const handleProgressMock = jest.fn(); - - render( - getComponent({ - handleEnd: handleEndMock, - handleProgress: handleProgressMock, - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - - const videoComponent = screen.getByTestId('video-player'); - - act(() => { - fireEvent(videoComponent, 'onEnd'); - fireEvent(videoComponent, 'onProgress', { currentTime: 10, seekableDuration: 60 }); - }); - - expect(handleEndMock).toHaveBeenCalled(); - expect(handleProgressMock).toHaveBeenCalled(); - }); - - it('trigger onLoadStart event handler of Video component', () => { - render( - getComponent({ - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - - const videoComponent = screen.getByTestId('video-player'); - const spinnerComponent = screen.queryByLabelText('Spinner'); - - act(() => { - fireEvent(videoComponent, 'onLoadStart'); - }); - expect(spinnerComponent?.props.style[1].opacity).toBe(1); - }); - - it('trigger onLoad event handler of Video component', () => { - const handleLoadMock = jest.fn(); - - render( - getComponent({ - handleLoad: handleLoadMock, - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - - const videoComponent = screen.getByTestId('video-player'); - const spinnerComponent = screen.queryByLabelText('Spinner'); - - act(() => { - fireEvent(videoComponent, 'onLoad', { duration: 10 }); - }); - - expect(handleLoadMock).toHaveBeenCalled(); - expect(spinnerComponent?.props.style[1].opacity).toBe(0); - }); - - it('trigger onBuffer event handler of Video component', () => { - render( - getComponent({ - offsetScale: { value: 1 } as SharedValue, - scale: { value: 1 } as SharedValue, - shouldRender: true, - source: { - uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', - }, - translateX: { value: 1 } as SharedValue, - }), - ); - - const videoComponent = screen.getByTestId('video-player'); - const spinnerComponent = screen.queryByLabelText('Spinner'); - - act(() => { - fireEvent(videoComponent, 'onBuffer', { - isBuffering: false, - }); - }); - - expect(spinnerComponent?.props.style[1].opacity).toBe(0); - - act(() => { - fireEvent(videoComponent, 'onBuffer', { - isBuffering: true, - }); - }); - - expect(spinnerComponent?.props.style[1].opacity).toBe(1); - }); -}); diff --git a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx index e72cd70e3b..02a477da4a 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import type { SharedValue } from 'react-native-reanimated'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { render, screen, waitFor } from '@testing-library/react-native'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; @@ -13,8 +13,6 @@ import { ImageGalleryContextValue, } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; import { generateGiphyAttachment, generateImageAttachment, @@ -22,7 +20,8 @@ import { } from '../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { ImageGallery } from '../ImageGallery'; +import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store'; +import { ImageGallery, ImageGalleryProps } from '../ImageGallery'; dayjs.extend(duration); @@ -39,30 +38,48 @@ jest.mock('../../../native.ts', () => { }; }); -const getComponent = (props: Partial) => ( - - - - } /> - - - -); +const ImageGalleryComponent = (props: ImageGalleryProps & { message: LocalMessage }) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + const { attachments } = props.message; + imageGalleryStateStore.openImageGallery({ + messages: [props.message], + selectedAttachmentUrl: attachments?.[0]?.asset_url || attachments?.[0]?.image_url || '', + }); + + return ( + }}> + + + + + ); +}; describe('ImageGallery', () => { it('render image gallery component', async () => { render( - getComponent({ - messages: [ + , ); await waitFor(() => { @@ -70,154 +87,4 @@ describe('ImageGallery', () => { expect(screen.queryAllByLabelText('Image Gallery Video')).toHaveLength(1); }); }); - - it('handle handleLoad function when video item present and payload duration is available', async () => { - const attachment = generateVideoAttachment({ type: 'video' }); - const message = generateMessage({ - attachments: [attachment], - }); - render( - getComponent({ - messages: [message] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent( - videoItemComponent, - 'handleLoad', - `photoId-${message.id}-${attachment.asset_url}`, - 10 * 1000, - ); - }); - - const videoDurationComponent = screen.getByLabelText('Video Duration'); - - await waitFor(() => { - expect(videoDurationComponent.children[0]).toBe('00:10'); - }); - }); - - it('handle handleLoad function when video item present and payload duration is undefined', async () => { - render( - getComponent({ - messages: [ - generateMessage({ - attachments: [generateVideoAttachment({ type: 'video' })], - }), - ] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent(videoItemComponent, 'handleLoad', { - duration: undefined, - }); - }); - - const videoDurationComponent = screen.getByLabelText('Video Duration'); - await waitFor(() => { - expect(videoDurationComponent.children[0]).toBe('00:00'); - }); - }); - - it('handle handleProgress function when video item present and payload is well defined', async () => { - const attachment = generateVideoAttachment({ type: 'video' }); - const message = generateMessage({ - attachments: [attachment], - }); - - render( - getComponent({ - messages: [message] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent( - videoItemComponent, - 'handleLoad', - `photoId-${message.id}-${attachment.asset_url}`, - 10, - ); - fireEvent( - videoItemComponent, - 'handleProgress', - `photoId-${message.id}-${attachment.asset_url}`, - 0.3 * 1000, - ); - }); - - const progressDurationComponent = screen.getByLabelText('Progress Duration'); - - await waitFor(() => { - expect(progressDurationComponent.children[0]).toBe('00:03'); - }); - }); - - it('handle handleProgress function when video item present and payload is not defined', async () => { - render( - getComponent({ - messages: [ - generateMessage({ - attachments: [generateVideoAttachment({ type: 'video' })], - }), - ] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent(videoItemComponent, 'handleLoad', { - duration: 10 * 1000, - }); - fireEvent(videoItemComponent, 'handleProgress', { - currentTime: undefined, - seekableDuration: undefined, - }); - }); - - const progressDurationComponent = screen.getByLabelText('Progress Duration'); - - await waitFor(() => { - expect(progressDurationComponent.children[0]).toBe('00:00'); - }); - }); - - it('handle handleEnd function when video item present', async () => { - const attachment = generateVideoAttachment({ type: 'video' }); - const message = generateMessage({ - attachments: [attachment], - }); - render( - getComponent({ - messages: [message] as unknown as LocalMessage[], - }), - ); - - const videoItemComponent = screen.getByLabelText('Image Gallery Video'); - - act(() => { - fireEvent( - videoItemComponent, - 'handleLoad', - `photoId-${message.id}-${attachment.asset_url}`, - 10 * 1000, - ); - fireEvent(videoItemComponent, 'handleEnd'); - }); - - const progressDurationComponent = screen.getByLabelText('Progress Duration'); - await waitFor(() => { - expect(screen.queryAllByLabelText('Play Icon').length).toBeGreaterThan(0); - expect(progressDurationComponent.children[0]).toBe('00:10'); - }); - }); }); diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx index ea553a0eaf..54131fd622 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryFooter.test.tsx @@ -1,13 +1,12 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Text, View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; -import { LocalMessage } from 'stream-chat'; +import { Attachment, LocalMessage } from 'stream-chat'; -import { Chat } from '../../../components/Chat/Chat'; import { ImageGalleryContext, ImageGalleryContextValue, @@ -18,9 +17,9 @@ import { generateVideoAttachment, } from '../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; import { NativeHandlers } from '../../../native'; -import { ImageGallery, ImageGalleryCustomComponents } from '../ImageGallery'; +import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store'; +import { ImageGallery, ImageGalleryCustomComponents, ImageGalleryProps } from '../ImageGallery'; jest.mock('../../../native.ts', () => { const { View } = require('react-native'); @@ -38,10 +37,75 @@ jest.mock('../../../native.ts', () => { }; }); +const ImageGalleryComponentVideo = (props: ImageGalleryProps) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + const attachment = generateVideoAttachment({ type: 'video' }); + imageGalleryStateStore.openImageGallery({ + messages: [ + generateMessage({ + attachments: [attachment], + }) as unknown as LocalMessage, + ], + selectedAttachmentUrl: attachment.asset_url, + }); + + return ( + }}> + + + + + ); +}; + +const ImageGalleryComponentImage = ( + props: ImageGalleryProps & { + attachment: Attachment; + }, +) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + imageGalleryStateStore.openImageGallery({ + messages: [ + generateMessage({ + attachments: [props.attachment], + }) as unknown as LocalMessage, + ], + selectedAttachmentUrl: props.attachment.image_url as string, + }); + + return ( + }}> + + + + + ); +}; + describe('ImageGalleryFooter', () => { it('render image gallery footer component with custom component footer props', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); - const CustomFooterLeftElement = () => ( Left element @@ -67,35 +131,18 @@ describe('ImageGalleryFooter', () => { ); render( - - - - } - /> - - - , + , ); await waitFor(() => { @@ -107,8 +154,6 @@ describe('ImageGalleryFooter', () => { }); it('render image gallery footer component with custom component footer Grid Icon and Share Icon component', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); - const CustomShareIconElement = () => ( Share Icon element @@ -122,33 +167,17 @@ describe('ImageGalleryFooter', () => { ); render( - - - - , - ShareIcon: , - }, - } as ImageGalleryCustomComponents['imageGalleryCustomComponents'] - } - overlayOpacity={{ value: 1 } as SharedValue} - /> - - - , + , + ShareIcon: , + }, + } as ImageGalleryCustomComponents['imageGalleryCustomComponents'] + } + overlayOpacity={{ value: 1 } as SharedValue} + />, ); await waitFor(() => { @@ -159,32 +188,13 @@ describe('ImageGalleryFooter', () => { it('should trigger the share button onPress Handler with local attachment and no mime_type', async () => { const user = userEvent.setup(); - const chatClient = await getTestClientWithUser({ id: 'testID' }); const saveFileMock = jest.spyOn(NativeHandlers, 'saveFile'); const shareImageMock = jest.spyOn(NativeHandlers, 'shareImage'); const deleteFileMock = jest.spyOn(NativeHandlers, 'deleteFile'); const attachment = generateImageAttachment(); - render( - - - - } /> - - - , - ); + render(); const { getByLabelText } = screen; @@ -204,32 +214,13 @@ describe('ImageGalleryFooter', () => { it('should trigger the share button onPress Handler with local attachment and existing mime_type', async () => { const user = userEvent.setup(); - const chatClient = await getTestClientWithUser({ id: 'testID' }); const saveFileMock = jest.spyOn(NativeHandlers, 'saveFile'); const shareImageMock = jest.spyOn(NativeHandlers, 'shareImage'); const deleteFileMock = jest.spyOn(NativeHandlers, 'deleteFile'); const attachment = { ...generateImageAttachment(), mime_type: 'image/png' }; - render( - - - - } /> - - - , - ); + render(); const { getByLabelText } = screen; @@ -249,7 +240,6 @@ describe('ImageGalleryFooter', () => { it('should trigger the share button onPress Handler with cdn attachment', async () => { const user = userEvent.setup(); - const chatClient = await getTestClientWithUser({ id: 'testID' }); const saveFileMock = jest .spyOn(NativeHandlers, 'saveFile') .mockResolvedValue('file:///local/asset/url'); @@ -262,25 +252,7 @@ describe('ImageGalleryFooter', () => { mime_type: 'image/png', }; - render( - - - - } /> - - - , - ); + render(); const { getByLabelText } = screen; diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx index 1ea5816c2a..36e2d8d079 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryGrid.test.tsx @@ -1,46 +1,71 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Text, View } from 'react-native'; +import { SharedValue } from 'react-native-reanimated'; + import { act, fireEvent, render, screen } from '@testing-library/react-native'; -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { LocalMessage } from '../../../../../../stream-chat-js/dist/types/types'; import { - TranslationContextValue, - TranslationProvider, -} from '../../../contexts/translationContext/TranslationContext'; + ImageGalleryContext, + ImageGalleryContextValue, +} from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { generateImageAttachment, generateVideoAttachment, } from '../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store'; import { ImageGrid, ImageGridType } from '../components/ImageGrid'; -const getComponent = (props: Partial = {}) => { - const t = jest.fn((key) => key); +const ImageGalleryGridComponent = (props: Partial & { message: LocalMessage }) => { + const { message } = props; + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + imageGalleryStateStore.openImageGallery({ + messages: [message], + selectedAttachmentUrl: + message.attachments?.[0]?.asset_url || message.attachments?.[0]?.image_url || '', + }); return ( - - + }}> + - - + + ); }; describe('ImageGalleryOverlay', () => { it('should render ImageGalleryGrid', () => { - render(getComponent({ photos: [generateImageAttachment(), generateImageAttachment()] })); + const message = generateMessage({ + attachments: [generateImageAttachment(), generateImageAttachment()], + }) as unknown as LocalMessage; + + render(); expect(screen.queryAllByLabelText('Image Grid')).toHaveLength(1); }); it('should render ImageGalleryGrid individual images', () => { - render( - getComponent({ - photos: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], - }), - ); + const message = generateMessage({ + attachments: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], + }) as unknown as LocalMessage; + + render(); expect(screen.queryAllByLabelText('Grid Image')).toHaveLength(2); }); @@ -52,27 +77,23 @@ describe('ImageGalleryOverlay', () => { ); - render( - getComponent({ - imageComponent: CustomImageComponent, - photos: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], - }), - ); + const message = generateMessage({ + attachments: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], + }) as unknown as LocalMessage; + + render(); expect(screen.queryAllByText('Image Attachment')).toHaveLength(2); }); it('should trigger the selectAndClose when the Image item is pressed', () => { const closeGridViewMock = jest.fn(); - const setSelectedMessageMock = jest.fn(); - - render( - getComponent({ - closeGridView: closeGridViewMock, - photos: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], - setSelectedMessage: setSelectedMessageMock, - }), - ); + + const message = generateMessage({ + attachments: [generateImageAttachment(), generateVideoAttachment({ type: 'video' })], + }) as unknown as LocalMessage; + + render(); const component = screen.getAllByLabelText('Grid Image'); @@ -81,6 +102,5 @@ describe('ImageGalleryOverlay', () => { }); expect(closeGridViewMock).toHaveBeenCalledTimes(1); - expect(setSelectedMessageMock).toHaveBeenCalledTimes(1); }); }); diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryGridHandle.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryGridHandle.test.tsx index 4ab178ed4e..bdc8bee50f 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryGridHandle.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryGridHandle.test.tsx @@ -6,10 +6,6 @@ import { render, screen } from '@testing-library/react-native'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; -import { - TranslationContextValue, - TranslationProvider, -} from '../../../contexts/translationContext/TranslationContext'; import { ImageGalleryGridHandleCustomComponentProps, ImageGridHandle, @@ -20,14 +16,10 @@ type ImageGridHandleProps = ImageGalleryGridHandleCustomComponentProps & { }; const getComponent = (props: Partial = {}) => { - const t = jest.fn((key) => key); - return ( - - - - - + + + ); }; diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx index f39ffaa28f..a608ea2c00 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryHeader.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Text, View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; @@ -7,24 +7,17 @@ import { act, render, screen, userEvent, waitFor } from '@testing-library/react- import { LocalMessage } from 'stream-chat'; -import { Chat } from '../../../components/Chat/Chat'; import { ImageGalleryContext, ImageGalleryContextValue, } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; -import { - OverlayContext, - OverlayContextValue, -} from '../../../contexts/overlayContext/OverlayContext'; +import * as overlayContext from '../../../contexts/overlayContext/OverlayContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; -import { - generateImageAttachment, - generateVideoAttachment, -} from '../../../mock-builders/generator/attachment'; +import { generateImageAttachment } from '../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { ImageGallery, ImageGalleryCustomComponents } from '../ImageGallery'; +import { ImageGalleryStateStore } from '../../../state-store/image-gallery-state-store'; +import { ImageGallery, ImageGalleryCustomComponents, ImageGalleryProps } from '../ImageGallery'; jest.mock('../../../native.ts', () => { const { View } = require('react-native'); @@ -39,10 +32,35 @@ jest.mock('../../../native.ts', () => { }; }); +const ImageGalleryComponent = (props: ImageGalleryProps) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore()); + const attachment = generateImageAttachment(); + imageGalleryStateStore.openImageGallery({ + messages: [generateMessage({ attachments: [attachment] }) as unknown as LocalMessage], + selectedAttachmentUrl: attachment.url, + }); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + return ( + }}> + + + + + ); +}; + describe('ImageGalleryHeader', () => { it('render image gallery header component with custom component header props', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); - const CustomHeaderLeftElement = () => ( Left element @@ -62,34 +80,17 @@ describe('ImageGalleryHeader', () => { ); render( - - - - } - /> - - - , + , ); await waitFor(() => { @@ -100,8 +101,6 @@ describe('ImageGalleryHeader', () => { }); it('render image gallery header component with custom Close Icon component', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); - const CustomCloseIconElement = () => ( Close Icon element @@ -109,32 +108,15 @@ describe('ImageGalleryHeader', () => { ); render( - - - - , - }, - } as ImageGalleryCustomComponents['imageGalleryCustomComponents'] - } - overlayOpacity={{ value: 1 } as SharedValue} - /> - - - , + , + }, + } as ImageGalleryCustomComponents['imageGalleryCustomComponents'] + } + />, ); await waitFor(() => { expect(screen.queryAllByText('Close Icon element')).toHaveLength(1); @@ -142,33 +124,16 @@ describe('ImageGalleryHeader', () => { }); it('should trigger the hideOverlay function on button onPress', async () => { - const chatClient = await getTestClientWithUser({ id: 'testID' }); const setOverlayMock = jest.fn(); const user = userEvent.setup(); - render( - - - - } /> - - - , - ); + jest.spyOn(overlayContext, 'useOverlayContext').mockImplementation(() => ({ + setOverlay: setOverlayMock, + })); + + render(); - act(() => { + await act(() => { user.press(screen.getByLabelText('Hide Overlay')); }); diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx deleted file mode 100644 index 4788260271..0000000000 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryVideoControl.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react'; - -import { ReactTestInstance } from 'react-test-renderer'; - -import { act, render, screen, userEvent, waitFor } from '@testing-library/react-native'; - -import dayjs from 'dayjs'; -import duration from 'dayjs/plugin/duration'; - -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; -import type { ImageGalleryFooterVideoControlProps } from '../components/ImageGalleryFooter'; -import { ImageGalleryVideoControl } from '../components/ImageGalleryVideoControl'; - -dayjs.extend(duration); - -const getComponent = (props: Partial) => ( - - - -); - -describe('ImageGalleryOverlay', () => { - it('should trigger the onPlayPause when play/pause button is pressed', async () => { - const onPlayPauseMock = jest.fn(); - const user = userEvent.setup(); - - render(getComponent({ onPlayPause: onPlayPauseMock })); - - const component = screen.queryByLabelText('Play Pause Button') as ReactTestInstance; - - act(() => { - user.press(component); - }); - - await waitFor(() => { - expect(component).not.toBeUndefined(); - expect(onPlayPauseMock).toHaveBeenCalled(); - }); - }); - - it('should render the play icon when paused prop is true', async () => { - render(getComponent({ paused: true })); - - const components = screen.queryAllByLabelText('Play Icon').length; - - await waitFor(() => { - expect(components).toBeGreaterThan(0); - }); - }); - - it('should calculate the videoDuration and progressDuration when they are greater than or equal to 3600', () => { - jest.spyOn(dayjs, 'duration'); - - render( - getComponent({ - duration: 3600 * 1000, - progress: 1, - }), - ); - - const videoDurationComponent = screen.queryByLabelText('Video Duration') as ReactTestInstance; - const progressDurationComponent = screen.queryByLabelText( - 'Progress Duration', - ) as ReactTestInstance; - - expect(videoDurationComponent.children[0]).toBe('01:00:00'); - expect(progressDurationComponent.children[0]).toBe('01:00:00'); - }); - - it('should calculate the videoDuration and progressDuration when they are less than 3600', () => { - jest.spyOn(dayjs, 'duration'); - - render( - getComponent({ - duration: 60 * 1000, - progress: 0.5, - }), - ); - - const videoDurationComponent = screen.queryByLabelText('Video Duration') as ReactTestInstance; - const progressDurationComponent = screen.queryByLabelText( - 'Progress Duration', - ) as ReactTestInstance; - - expect(videoDurationComponent.children[0]).toBe('01:00'); - expect(progressDurationComponent.children[0]).toBe('00:30'); - }); -}); diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx index f3aab7ea39..b6c87ab893 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx @@ -1,8 +1,16 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { View } from 'react-native'; import type { ImageStyle, StyleProp } from 'react-native'; import Animated, { SharedValue } from 'react-native-reanimated'; +import { useChatConfigContext } from '../../../contexts/chatConfigContext/ChatConfigContext'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { useStateStore } from '../../../hooks'; +import { + ImageGalleryAsset, + ImageGalleryState, +} from '../../../state-store/image-gallery-state-store'; +import { getResizedImageUrl } from '../../../utils/getResizedImageUrl'; import { useAnimatedGalleryStyle } from '../hooks/useAnimatedGalleryStyle'; const oneEighth = 1 / 8; @@ -11,17 +19,19 @@ type Props = { accessibilityLabel: string; index: number; offsetScale: SharedValue; - photo: { uri: string }; - previous: boolean; + photo: ImageGalleryAsset; scale: SharedValue; screenHeight: number; - selected: boolean; - shouldRender: boolean; + screenWidth: number; translateX: SharedValue; translateY: SharedValue; style?: StyleProp; }; +const imageGallerySelector = (state: ImageGalleryState) => ({ + currentIndex: state.currentIndex, +}); + export const AnimatedGalleryImage = React.memo( (props: Props) => { const { @@ -29,15 +39,29 @@ export const AnimatedGalleryImage = React.memo( index, offsetScale, photo, - previous, scale, screenHeight, - selected, - shouldRender, + screenWidth, style, translateX, translateY, } = props; + const { imageGalleryStateStore } = useImageGalleryContext(); + const { resizableCDNHosts } = useChatConfigContext(); + const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); + + const uri = useMemo(() => { + return getResizedImageUrl({ + height: screenHeight, + resizableCDNHosts, + url: photo.uri, + width: screenWidth, + }); + }, [photo.uri, resizableCDNHosts, screenHeight, screenWidth]); + + const selected = currentIndex === index; + const previous = currentIndex > index; + const shouldRender = Math.abs(currentIndex - index) < 4; const animatedStyles = useAnimatedGalleryStyle({ index, @@ -63,19 +87,17 @@ export const AnimatedGalleryImage = React.memo( ); }, (prevProps, nextProps) => { if ( - prevProps.selected === nextProps.selected && - prevProps.shouldRender === nextProps.shouldRender && prevProps.photo.uri === nextProps.photo.uri && - prevProps.previous === nextProps.previous && prevProps.index === nextProps.index && - prevProps.screenHeight === nextProps.screenHeight + prevProps.screenHeight === nextProps.screenHeight && + prevProps.screenWidth === nextProps.screenWidth ) { return true; } diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx index 0fe17ca843..9941168f78 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx @@ -1,8 +1,11 @@ -import React, { useState } from 'react'; +import React, { RefObject, useEffect, useRef, useState } from 'react'; import { StyleSheet, View, ViewStyle } from 'react-native'; import type { StyleProp } from 'react-native'; import Animated, { SharedValue } from 'react-native-reanimated'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { useStateStore } from '../../../hooks'; + import { isVideoPlayerAvailable, NativeHandlers, @@ -12,29 +15,27 @@ import { VideoType, } from '../../../native'; +import { + ImageGalleryAsset, + ImageGalleryState, +} from '../../../state-store/image-gallery-state-store'; +import { VideoPlayerState } from '../../../state-store/video-player'; +import { ONE_SECOND_IN_MILLISECONDS } from '../../../utils/constants'; import { Spinner } from '../../UIComponents/Spinner'; import { useAnimatedGalleryStyle } from '../hooks/useAnimatedGalleryStyle'; +import { useImageGalleryVideoPlayer } from '../hooks/useImageGalleryVideoPlayer'; const oneEighth = 1 / 8; export type AnimatedGalleryVideoType = { attachmentId: string; - handleEnd: () => void; - handleLoad: (index: string, duration: number) => void; - handleProgress: (index: string, progress: number, hasEnd?: boolean) => void; index: number; offsetScale: SharedValue; - paused: boolean; - previous: boolean; scale: SharedValue; screenHeight: number; - selected: boolean; - shouldRender: boolean; - source: { uri: string }; + photo: ImageGalleryAsset; translateX: SharedValue; translateY: SharedValue; - videoRef: React.RefObject; - repeat?: boolean; style?: StyleProp; }; @@ -48,46 +49,62 @@ const styles = StyleSheet.create({ }, }); +const imageGallerySelector = (state: ImageGalleryState) => ({ + currentIndex: state.currentIndex, +}); + +const videoPlayerSelector = (state: VideoPlayerState) => ({ + isPlaying: state.isPlaying, +}); + export const AnimatedGalleryVideo = React.memo( (props: AnimatedGalleryVideoType) => { const [opacity, setOpacity] = useState(1); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { attachmentId, - handleEnd, - handleLoad, - handleProgress, index, offsetScale, - paused, - previous, - repeat, scale, screenHeight, - selected, - shouldRender, - source, style, + photo, translateX, translateY, - videoRef, } = props; + + const videoRef = useRef(null); + + const videoPlayer = useImageGalleryVideoPlayer({ + id: attachmentId, + }); + + useEffect(() => { + if (videoRef.current) { + videoPlayer.initPlayer({ playerRef: videoRef.current }); + } + }, [videoPlayer]); + + const { isPlaying } = useStateStore(videoPlayer.state, videoPlayerSelector); + const onLoadStart = () => { setOpacity(1); }; const onLoad = (payload: VideoPayloadData) => { setOpacity(0); - // Duration is in seconds so we convert to milliseconds. - handleLoad(attachmentId, payload.duration * 1000); + + videoPlayer.duration = payload.duration * ONE_SECOND_IN_MILLISECONDS; }; const onEnd = () => { - handleEnd(); + videoPlayer.stop(); }; const onProgress = (data: VideoProgressData) => { - handleProgress(attachmentId, data.currentTime / data.seekableDuration); + videoPlayer.position = data.currentTime * ONE_SECOND_IN_MILLISECONDS; }; const onBuffer = ({ isBuffering }: { isBuffering: boolean }) => { @@ -108,13 +125,10 @@ export const AnimatedGalleryVideo = React.memo( } else { // Update your UI for the loaded state setOpacity(0); - handleLoad(attachmentId, playbackStatus.durationMillis); + videoPlayer.duration = playbackStatus.durationMillis; if (playbackStatus.isPlaying) { // Update your UI for the playing state - handleProgress( - attachmentId, - playbackStatus.positionMillis / playbackStatus.durationMillis, - ); + videoPlayer.progress = playbackStatus.positionMillis / playbackStatus.durationMillis; } if (playbackStatus.isBuffering) { @@ -124,11 +138,15 @@ export const AnimatedGalleryVideo = React.memo( if (playbackStatus.didJustFinish && !playbackStatus.isLooping) { // The player has just finished playing and will stop. Maybe you want to play something else? - handleEnd(); + videoPlayer.stop(); } } }; + const selected = currentIndex === index; + const previous = currentIndex > index; + const shouldRender = Math.abs(currentIndex - index) < 4; + const animatedStyles = useAnimatedGalleryStyle({ index, offsetScale, @@ -164,13 +182,13 @@ export const AnimatedGalleryVideo = React.memo( onLoadStart={onLoadStart} onPlaybackStatusUpdate={onPlayBackStatusUpdate} onProgress={onProgress} - paused={paused} - repeat={repeat} + paused={!isPlaying} + repeat={true} resizeMode='contain' style={style} testID='video-player' - uri={source.uri} - videoRef={videoRef} + uri={photo.uri} + videoRef={videoRef as RefObject} /> ) : null} { if ( - prevProps.paused === nextProps.paused && - prevProps.repeat === nextProps.repeat && - prevProps.shouldRender === nextProps.shouldRender && - prevProps.source.uri === nextProps.source.uri && prevProps.screenHeight === nextProps.screenHeight && - prevProps.selected === nextProps.selected && - prevProps.previous === nextProps.previous && - prevProps.index === nextProps.index + prevProps.index === nextProps.index && + prevProps.photo === nextProps.photo ) { return true; } diff --git a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx index a9592771d1..f3ac4e53e8 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx @@ -16,19 +16,16 @@ import Animated, { import { ImageGalleryVideoControl } from './ImageGalleryVideoControl'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { Grid as GridIconDefault, Share as ShareIconDefault } from '../../../icons'; -import { - isFileSystemAvailable, - isShareImageAvailable, - NativeHandlers, - VideoType, -} from '../../../native'; +import { isFileSystemAvailable, isShareImageAvailable, NativeHandlers } from '../../../native'; +import { ImageGalleryState } from '../../../state-store/image-gallery-state-store'; import { FileTypes } from '../../../types/types'; import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper'; -import type { Photo } from '../ImageGallery'; const ReanimatedSafeAreaView = Animated.createAnimatedComponent ? Animated.createAnimatedComponent(SafeAreaView) @@ -36,29 +33,20 @@ const ReanimatedSafeAreaView = Animated.createAnimatedComponent export type ImageGalleryFooterCustomComponent = ({ openGridView, - photo, share, shareMenuOpen, }: { openGridView: () => void; share: () => Promise; shareMenuOpen: boolean; - photo?: Photo; }) => React.ReactElement | null; export type ImageGalleryFooterVideoControlProps = { - duration: number; - onPlayPause: (status?: boolean) => void; - paused: boolean; - progress: number; - videoRef: React.RefObject; + attachmentId: string; }; export type ImageGalleryFooterVideoControlComponent = ({ - duration, - onPlayPause, - paused, - progress, + attachmentId, }: ImageGalleryFooterVideoControlProps) => React.ReactElement | null; export type ImageGalleryFooterCustomComponentProps = { @@ -72,38 +60,27 @@ export type ImageGalleryFooterCustomComponentProps = { type ImageGalleryFooterPropsWithContext = ImageGalleryFooterCustomComponentProps & { accessibilityLabel: string; - duration: number; - onPlayPause: () => void; opacity: SharedValue; openGridView: () => void; - paused: boolean; - photo: Photo; - photoLength: number; - progress: number; - selectedIndex: number; - videoRef: React.RefObject; visible: SharedValue; }; +const imageGallerySelector = (state: ImageGalleryState) => ({ + asset: state.assets[state.currentIndex], + currentIndex: state.currentIndex, +}); + export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWithContext) => { const { accessibilityLabel, centerElement, - duration, GridIcon, leftElement, - onPlayPause, opacity, openGridView, - paused, - photo, - photoLength, - progress, rightElement, - selectedIndex, ShareIcon, videoControlElement, - videoRef, visible, } = props; @@ -119,6 +96,8 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWith }, } = useTheme(); const { t } = useTranslationContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { asset, currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const footerStyle = useAnimatedStyle( () => ({ @@ -141,26 +120,26 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWith if (!NativeHandlers.shareImage || !NativeHandlers.deleteFile) { return; } - const extension = photo.mime_type?.split('/')[1] || 'jpg'; - const shouldDownload = photo.uri && photo.uri.includes('http'); + const extension = asset.mime_type?.split('/')[1] || 'jpg'; + const shouldDownload = asset.uri && asset.uri.includes('http'); let localFile; // If the file is already uploaded to a CDN, create a local reference to // it first; otherwise just use the local file if (shouldDownload) { setSavingInProgress(true); localFile = await NativeHandlers.saveFile({ - fileName: `${photo.user?.id || 'ChatPhoto'}-${ - photo.messageId - }-${selectedIndex}.${extension}`, - fromUrl: photo.uri, + fileName: `${asset.user?.id || 'ChatPhoto'}-${ + asset.messageId + }-${currentIndex}.${extension}`, + fromUrl: asset.uri, }); setSavingInProgress(false); } else { - localFile = photo.uri; + localFile = asset.uri; } // `image/jpeg` is added for the case where the mime_type isn't available for a file/image - await NativeHandlers.shareImage({ type: photo.mime_type || 'image/jpeg', url: localFile }); + await NativeHandlers.shareImage({ type: asset.mime_type || 'image/jpeg', url: localFile }); // Only delete the file if a local reference has been created beforehand if (shouldDownload) { await NativeHandlers.deleteFile({ uri: localFile }); @@ -172,6 +151,10 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterPropsWith shareIsInProgressRef.current = false; }; + if (!asset) { + return null; + } + return ( - {photo.type === FileTypes.Video ? ( + {asset.type === FileTypes.Video ? ( videoControlElement ? ( - videoControlElement({ duration, onPlayPause, paused, progress, videoRef }) + videoControlElement({ attachmentId: asset.id }) ) : ( - + ) ) : null} {leftElement ? ( - leftElement({ openGridView, photo, share, shareMenuOpen: savingInProgress }) + leftElement({ openGridView, share, shareMenuOpen: savingInProgress }) ) : ( )} {centerElement ? ( - centerElement({ openGridView, photo, share, shareMenuOpen: savingInProgress }) + centerElement({ openGridView, share, shareMenuOpen: savingInProgress }) ) : ( {t('{{ index }} of {{ photoLength }}', { - index: selectedIndex + 1, - photoLength, + index: currentIndex + 1, + photoLength: imageGalleryStateStore.assets.length, })} )} {rightElement ? ( - rightElement({ openGridView, photo, share, shareMenuOpen: savingInProgress }) + rightElement({ openGridView, share, shareMenuOpen: savingInProgress }) ) : ( @@ -265,49 +242,8 @@ const ShareButton = ({ share, ShareIcon, savingInProgress }: ShareButtonProps) = ); }; -const areEqual = ( - prevProps: ImageGalleryFooterPropsWithContext, - nextProps: ImageGalleryFooterPropsWithContext, -) => { - const { - duration: prevDuration, - paused: prevPaused, - progress: prevProgress, - selectedIndex: prevSelectedIndex, - } = prevProps; - const { - duration: nextDuration, - paused: nextPaused, - progress: nextProgress, - selectedIndex: nextSelectedIndex, - } = nextProps; - - const isDurationEqual = prevDuration === nextDuration; - if (!isDurationEqual) { - return false; - } - - const isPausedEqual = prevPaused === nextPaused; - if (!isPausedEqual) { - return false; - } - - const isProgressEqual = prevProgress === nextProgress; - if (!isProgressEqual) { - return false; - } - - const isSelectedIndexEqual = prevSelectedIndex === nextSelectedIndex; - if (!isSelectedIndexEqual) { - return false; - } - - return true; -}; - const MemoizedImageGalleryFooter = React.memo( ImageGalleryFooterWithContext, - areEqual, ) as typeof ImageGalleryFooterWithContext; export type ImageGalleryFooterProps = ImageGalleryFooterPropsWithContext; diff --git a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx index d009e93ef0..e49549009b 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx @@ -9,14 +9,16 @@ import Animated, { useAnimatedStyle, } from 'react-native-reanimated'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useOverlayContext } from '../../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { Close } from '../../../icons'; +import { ImageGalleryState } from '../../../state-store/image-gallery-state-store'; import { getDateString } from '../../../utils/i18n/getDateString'; import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper'; -import type { Photo } from '../ImageGallery'; const ReanimatedSafeAreaView = Animated.createAnimatedComponent ? Animated.createAnimatedComponent(SafeAreaView) @@ -24,10 +26,8 @@ const ReanimatedSafeAreaView = Animated.createAnimatedComponent export type ImageGalleryHeaderCustomComponent = ({ hideOverlay, - photo, }: { hideOverlay: () => void; - photo?: Photo; }) => React.ReactElement | null; export type ImageGalleryHeaderCustomComponentProps = { @@ -40,12 +40,14 @@ export type ImageGalleryHeaderCustomComponentProps = { type Props = ImageGalleryHeaderCustomComponentProps & { opacity: SharedValue; visible: SharedValue; - photo?: Photo; - /* Lookup key in the language corresponding translations sheet to perform date formatting */ }; +const imageGallerySelector = (state: ImageGalleryState) => ({ + asset: state.assets[state.currentIndex], +}); + export const ImageGalleryHeader = (props: Props) => { - const { centerElement, CloseIcon, leftElement, opacity, photo, rightElement, visible } = props; + const { centerElement, CloseIcon, leftElement, opacity, rightElement, visible } = props; const [height, setHeight] = useState(200); const { theme: { @@ -64,17 +66,19 @@ export const ImageGalleryHeader = (props: Props) => { }, } = useTheme(); const { t, tDateTimeParser } = useTranslationContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { asset } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { setOverlay } = useOverlayContext(); const date = useMemo( () => getDateString({ - date: photo?.created_at, + date: asset?.created_at, t, tDateTimeParser, timestampTranslationKey: 'timestamp/ImageGalleryHeader', }), - [photo?.created_at, t, tDateTimeParser], + [asset?.created_at, t, tDateTimeParser], ); const headerStyle = useAnimatedStyle(() => ({ @@ -88,6 +92,7 @@ export const ImageGalleryHeader = (props: Props) => { const hideOverlay = () => { setOverlay('none'); + imageGalleryStateStore.clear(); }; return ( @@ -101,7 +106,7 @@ export const ImageGalleryHeader = (props: Props) => { > {leftElement ? ( - leftElement({ hideOverlay, photo }) + leftElement({ hideOverlay }) ) : ( @@ -110,17 +115,17 @@ export const ImageGalleryHeader = (props: Props) => { )} {centerElement ? ( - centerElement({ hideOverlay, photo }) + centerElement({ hideOverlay }) ) : ( - {photo?.user?.name || photo?.user?.id || t('Unknown User')} + {asset?.user?.name || asset?.user?.id || t('Unknown User')} {date && {date}} )} {rightElement ? ( - rightElement({ hideOverlay, photo }) + rightElement({ hideOverlay }) ) : ( )} diff --git a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx index a73ddf45a8..f2732c7c05 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx @@ -5,9 +5,12 @@ import type { ImageGalleryFooterVideoControlProps } from './ImageGalleryFooter'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { Pause, Play } from '../../../icons'; +import { VideoPlayerState } from '../../../state-store/video-player'; import { getDurationLabelFromDuration } from '../../../utils/utils'; import { ProgressControl } from '../../ProgressControl/ProgressControl'; +import { useImageGalleryVideoPlayer } from '../hooks/useImageGalleryVideoPlayer'; const styles = StyleSheet.create({ durationTextStyle: { @@ -40,87 +43,74 @@ const styles = StyleSheet.create({ }, }); -export const ImageGalleryVideoControl = React.memo( - (props: ImageGalleryFooterVideoControlProps) => { - const { duration, onPlayPause, paused, progress, videoRef } = props; +const videoPlayerSelector = (state: VideoPlayerState) => ({ + duration: state.duration, + isPlaying: state.isPlaying, + progress: state.progress, +}); + +export const ImageGalleryVideoControl = React.memo((props: ImageGalleryFooterVideoControlProps) => { + const { attachmentId } = props; + + const videoPlayer = useImageGalleryVideoPlayer({ + id: attachmentId, + }); - const videoDuration = getDurationLabelFromDuration(duration); + const { duration, isPlaying, progress } = useStateStore(videoPlayer.state, videoPlayerSelector); - const progressValueInSeconds = progress * duration; + const videoDuration = getDurationLabelFromDuration(duration); - const progressDuration = getDurationLabelFromDuration(progressValueInSeconds); + const progressValueInSeconds = progress * duration; - const { - theme: { - colors: { accent_blue, black, static_black, static_white }, - imageGallery: { - videoControl: { durationTextStyle, progressDurationText, roundedView, videoContainer }, - }, + const progressDuration = getDurationLabelFromDuration(progressValueInSeconds); + + const { + theme: { + colors: { accent_blue, black, static_black, static_white }, + imageGallery: { + videoControl: { durationTextStyle, progressDurationText, roundedView, videoContainer }, }, - } = useTheme(); - - const handlePlayPause = async () => { - // Note: Not particularly sure why this was ever added, but - // will keep it for now for backwards compatibility. - if (progress === 1) { - // For expo CLI, expo-av - if (videoRef.current?.setPositionAsync) { - await videoRef.current.setPositionAsync(0); - } - // For expo CLI, expo-video - if (videoRef.current?.replay) { - await videoRef.current.replay(); - } - } - onPlayPause(); - }; - - return ( - - - - {paused ? ( - - ) : ( - - )} - - - - {progressDuration} - - - - + }, + } = useTheme(); + + const handlePlayPause = () => { + videoPlayer.toggle(); + }; - - {videoDuration} - + return ( + + + + {!isPlaying ? ( + + ) : ( + + )} + + + + {progressDuration} + + + - ); - }, - (prevProps, nextProps) => { - if ( - prevProps.duration === nextProps.duration && - prevProps.paused === nextProps.paused && - prevProps.progress === nextProps.progress - ) { - return true; - } else { - return false; - } - }, -); + + + {videoDuration} + + + ); +}); ImageGalleryVideoControl.displayName = 'ImageGalleryVideoControl{imageGallery{videoControl}}'; diff --git a/package/src/components/ImageGallery/components/ImageGrid.tsx b/package/src/components/ImageGallery/components/ImageGrid.tsx index 0cf5a70172..f3982f9715 100644 --- a/package/src/components/ImageGallery/components/ImageGrid.tsx +++ b/package/src/components/ImageGallery/components/ImageGrid.tsx @@ -2,14 +2,18 @@ import React from 'react'; import { Image, StyleSheet, View } from 'react-native'; import { VideoThumbnail } from '../../../components/Attachment/VideoThumbnail'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../../hooks/useStateStore'; import { useViewport } from '../../../hooks/useViewport'; +import type { + ImageGalleryAsset, + ImageGalleryState, +} from '../../../state-store/image-gallery-state-store'; import { FileTypes } from '../../../types/types'; import { BottomSheetFlatList } from '../../BottomSheetCompatibility/BottomSheetFlatList'; import { BottomSheetTouchableOpacity } from '../../BottomSheetCompatibility/BottomSheetTouchableOpacity'; -import type { Photo } from '../ImageGallery'; - const styles = StyleSheet.create({ avatarImage: { borderRadius: 22, @@ -34,7 +38,7 @@ const styles = StyleSheet.create({ export type ImageGalleryGridImageComponent = ({ item, }: { - item: Photo & { + item: ImageGalleryAsset & { selectAndClose: () => void; numberOfImageGalleryGridColumns?: number; }; @@ -45,7 +49,7 @@ export type ImageGalleryGridImageComponents = { imageComponent?: ImageGalleryGridImageComponent; }; -export type GridImageItem = Photo & +export type GridImageItem = ImageGalleryAsset & ImageGalleryGridImageComponents & { selectAndClose: () => void; numberOfImageGalleryGridColumns?: number; @@ -87,28 +91,17 @@ const renderItem = ({ item }: { item: GridImageItem }) => void; - photos: Photo[]; - setSelectedMessage: React.Dispatch< - React.SetStateAction< - | { - messageId?: string | undefined; - url?: string | undefined; - } - | undefined - > - >; numberOfImageGalleryGridColumns?: number; }; +const imageGallerySelector = (state: ImageGalleryState) => ({ + assets: state.assets, +}); + export const ImageGrid = (props: ImageGridType) => { - const { - avatarComponent, - closeGridView, - imageComponent, - numberOfImageGalleryGridColumns, - photos, - setSelectedMessage, - } = props; + const { avatarComponent, closeGridView, imageComponent, numberOfImageGalleryGridColumns } = props; + const { imageGalleryStateStore } = useImageGalleryContext(); + const { assets } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { theme: { @@ -119,13 +112,13 @@ export const ImageGrid = (props: ImageGridType) => { }, } = useTheme(); - const imageGridItems = photos.map((photo) => ({ + const imageGridItems = assets.map((photo, index) => ({ ...photo, avatarComponent, imageComponent, numberOfImageGalleryGridColumns, selectAndClose: () => { - setSelectedMessage({ messageId: photo.messageId, url: photo.uri }); + imageGalleryStateStore.currentIndex = index; closeGridView(); }, })); diff --git a/package/src/components/ImageGallery/components/__tests__/ImageGalleryHeader.test.tsx b/package/src/components/ImageGallery/components/__tests__/ImageGalleryHeader.test.tsx index 00b484f0a0..41bffa5fb1 100644 --- a/package/src/components/ImageGallery/components/__tests__/ImageGalleryHeader.test.tsx +++ b/package/src/components/ImageGallery/components/__tests__/ImageGalleryHeader.test.tsx @@ -1,12 +1,44 @@ -import React from 'react'; +import React, { PropsWithChildren, useState } from 'react'; import { SharedValue, useSharedValue } from 'react-native-reanimated'; import { render, renderHook, waitFor } from '@testing-library/react-native'; -import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { LocalMessage } from 'stream-chat'; +import { + ImageGalleryContext, + ImageGalleryContextValue, +} from '../../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider'; +import { generateImageAttachment } from '../../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { ImageGalleryStateStore } from '../../../../state-store/image-gallery-state-store'; import { ImageGalleryHeader } from '../ImageGalleryHeader'; +const ImageGalleryComponentWrapper = ({ children }: PropsWithChildren) => { + const initialImageGalleryStateStore = new ImageGalleryStateStore(); + const attachment = generateImageAttachment(); + initialImageGalleryStateStore.openImageGallery({ + message: generateMessage({ + attachments: [attachment], + user: {}, + }) as unknown as LocalMessage, + selectedAttachmentUrl: attachment.url, + }); + + const [imageGalleryStateStore] = useState(initialImageGalleryStateStore); + + return ( + }}> + + {children} + + + ); +}; + it('doesnt fail if fromNow is not available on first render', async () => { try { let sharedValueOpacity: SharedValue; @@ -16,18 +48,14 @@ it('doesnt fail if fromNow is not available on first render', async () => { sharedValueVisible = useSharedValue(1); }); const { getAllByText } = render( - + - , + , ); await waitFor(() => { expect(getAllByText('Unknown User')).toBeTruthy(); diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx index c9b8a2e1b3..733b3be696 100644 --- a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx +++ b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { Platform } from 'react-native'; import { Gesture, GestureType } from 'react-native-gesture-handler'; import { @@ -14,7 +14,9 @@ import { import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useOverlayContext } from '../../../contexts/overlayContext/OverlayContext'; +import { useStateStore } from '../../../hooks'; import { NativeHandlers } from '../../../native'; +import { ImageGalleryState } from '../../../state-store/image-gallery-state-store'; export enum HasPinched { FALSE = 0, @@ -29,6 +31,10 @@ export enum IsSwiping { const MARGIN = 32; +const imageGallerySelector = (state: ImageGalleryState) => ({ + currentIndex: state.currentIndex, +}); + export const useImageGalleryGestures = ({ currentImageHeight, halfScreenHeight, @@ -36,12 +42,9 @@ export const useImageGalleryGestures = ({ headerFooterVisible, offsetScale, overlayOpacity, - photoLength, scale, screenHeight, screenWidth, - selectedIndex, - setSelectedIndex, translateX, translateY, translationX, @@ -52,12 +55,9 @@ export const useImageGalleryGestures = ({ headerFooterVisible: SharedValue; offsetScale: SharedValue; overlayOpacity: SharedValue; - photoLength: number; scale: SharedValue; screenHeight: number; screenWidth: number; - selectedIndex: number; - setSelectedIndex: React.Dispatch>; translateX: SharedValue; translateY: SharedValue; translationX: SharedValue; @@ -72,8 +72,11 @@ export const useImageGalleryGestures = ({ * it was always assumed that one started at index 0 in the * gallery. * */ - const [index, setIndex] = useState(selectedIndex); - const { setMessages, setSelectedMessage } = useImageGalleryContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); + + const [index, setIndex] = useState(currentIndex); + /** * Gesture handler refs */ @@ -100,20 +103,6 @@ export const useImageGalleryGestures = ({ const focalX = useSharedValue(0); const focalY = useSharedValue(0); - /** - * if a specific image index > 0 has been passed in - * while creating the hook, set the value of the index - * reference to its value. - * - * This makes it possible to seelct an image in the list, - * and scroll/pan as normal. Prior to this, - * it was always assumed that one started at index 0 in the - * gallery. - * */ - useEffect(() => { - setIndex(selectedIndex); - }, [selectedIndex]); - /** * Shared values for movement */ @@ -167,6 +156,23 @@ export const useImageGalleryGestures = ({ offsetScale.value = 1; }; + const assetsLength = imageGalleryStateStore.assets.length; + + const clearImageGallery = () => { + runOnJS(imageGalleryStateStore.clear)(); + runOnJS(setOverlay)('none'); + }; + + const moveToNextImage = () => { + runOnJS(setIndex)(index + 1); + imageGalleryStateStore.currentIndex = index + 1; + }; + + const moveToPreviousImage = () => { + runOnJS(setIndex)(index - 1); + imageGalleryStateStore.currentIndex = index - 1; + }; + /** * We use simultaneousHandlers to allow pan and pinch gesture handlers * depending on the gesture. The touch is fully handled by the pinch @@ -290,7 +296,7 @@ export const useImageGalleryGestures = ({ * As we move towards the left to move to next image, the translationX value will be negative on X axis. */ if ( - index < photoLength - 1 && + index < assetsLength - 1 && Math.abs(halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && translateX.value < 0 && finalXPosition > halfScreenWidth && @@ -305,8 +311,7 @@ export const useImageGalleryGestures = ({ }, () => { resetMovementValues(); - runOnJS(setIndex)(index + 1); - runOnJS(setSelectedIndex)(index + 1); + runOnJS(moveToNextImage)(); }, ); @@ -333,8 +338,7 @@ export const useImageGalleryGestures = ({ }, () => { resetMovementValues(); - runOnJS(setIndex)(index - 1); - runOnJS(setSelectedIndex)(index - 1); + runOnJS(moveToPreviousImage)(); }, ); } @@ -433,9 +437,7 @@ export const useImageGalleryGestures = ({ easing: Easing.out(Easing.ease), }, () => { - runOnJS(setSelectedMessage)(undefined); - runOnJS(setMessages)([]); - runOnJS(setOverlay)('none'); + runOnJS(clearImageGallery)(); }, ); scale.value = withTiming(0.6, { diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts b/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts new file mode 100644 index 0000000000..1961497070 --- /dev/null +++ b/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; + +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { VideoPlayerOptions } from '../../../state-store/video-player'; + +export type UseImageGalleryVideoPlayerProps = VideoPlayerOptions; + +/** + * Hook to get the video player instance. + * @param options - The options for the video player. + * @returns The video player instance. + */ +export const useImageGalleryVideoPlayer = (options: UseImageGalleryVideoPlayerProps) => { + const { autoPlayVideo, imageGalleryStateStore } = useImageGalleryContext(); + const videoPlayer = useMemo(() => { + return imageGalleryStateStore.videoPlayerPool.getOrAddPlayer({ + ...options, + autoPlay: autoPlayVideo, + }); + }, [autoPlayVideo, imageGalleryStateStore, options]); + + return videoPlayer; +}; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 02c618be86..26c1ed2e1f 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -28,10 +28,6 @@ import { useChannelContext, } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; -import { - ImageGalleryContextValue, - useImageGalleryContext, -} from '../../contexts/imageGalleryContext/ImageGalleryContext'; import { MessageInputContextValue, useMessageInputContext, @@ -60,7 +56,6 @@ import { MessageInputHeightState, messageInputHeightStore, } from '../../state-store/message-input-height-store'; -import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; let FlashList; @@ -139,7 +134,6 @@ type MessageFlashListPropsWithContext = Pick< > & Pick & Pick & - Pick & Pick & Pick< MessagesContextValue, @@ -148,7 +142,6 @@ type MessageFlashListPropsWithContext = Pick< | 'FlatList' | 'InlineDateSeparator' | 'InlineUnreadIndicator' - | 'legacyImageViewerSwipeBehaviour' | 'Message' | 'ScrollToBottomButton' | 'MessageSystem' @@ -197,7 +190,6 @@ type MessageFlashListPropsWithContext = Pick< HeaderComponent?: React.ComponentType; /** Whether or not the FlatList is inverted. Defaults to true */ inverted?: boolean; - isListActive?: boolean; /** Turn off grouping of messages by user */ noGroupByUser?: boolean; onListScroll?: ScrollViewProps['onScroll']; @@ -290,9 +282,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => FooterComponent = LoadingMoreRecentIndicator, HeaderComponent = InlineLoadingMoreIndicator, hideStickyDateHeader, - isListActive = false, isLiveStreaming = false, - legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, LoadingIndicator, @@ -314,7 +304,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => selectedPicker, setChannelUnreadState, setFlatListRef, - setMessages, setSelectedPicker, setTargetedMessage, StickyHeader, @@ -771,59 +760,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ], ); - const messagesWithImages = - legacyImageViewerSwipeBehaviour && - processedMessageList.filter((message) => { - const isMessageTypeDeleted = message.type === 'deleted'; - if (!isMessageTypeDeleted && message.attachments) { - return message.attachments.some( - (attachment) => - attachment.type === FileTypes.Image && - !attachment.title_link && - !attachment.og_scrape_url && - (attachment.image_url || attachment.thumb_url), - ); - } - return false; - }); - - /** - * This is for the useEffect to run again in the case that a message - * gets edited with more or the same number of images - */ - const imageString = - legacyImageViewerSwipeBehaviour && - messagesWithImages && - messagesWithImages - .map((message) => - message.attachments - ?.map((attachment) => attachment.image_url || attachment.thumb_url || '') - .join(), - ) - .join(); - - const numberOfMessagesWithImages = - legacyImageViewerSwipeBehaviour && messagesWithImages && messagesWithImages.length; - const threadExists = !!thread; - - useEffect(() => { - if ( - legacyImageViewerSwipeBehaviour && - isListActive && - ((threadList && thread) || (!threadList && !thread)) - ) { - setMessages(messagesWithImages as LocalMessage[]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - imageString, - isListActive, - legacyImageViewerSwipeBehaviour, - numberOfMessagesWithImages, - threadExists, - threadList, - ]); - /** * We are keeping full control on message pagination, and not relying on react-native for it. * The reasons being, @@ -1204,14 +1140,12 @@ export const MessageFlashList = (props: MessageFlashListProps) => { threadList, } = useChannelContext(); const { client } = useChatContext(); - const { setMessages } = useImageGalleryContext(); const { DateHeader, disableTypingIndicator, FlatList, InlineDateSeparator, InlineUnreadIndicator, - legacyImageViewerSwipeBehaviour, Message, MessageSystem, myMessageTheme, @@ -1245,7 +1179,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { InlineDateSeparator, InlineUnreadIndicator, isListActive: isChannelActive, - legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, LoadingIndicator, @@ -1266,7 +1199,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { scrollToFirstUnreadThreshold, selectedPicker, setChannelUnreadState, - setMessages, setSelectedPicker, setTargetedMessage, shouldShowUnreadUnderlay, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 7960e11735..1403632c7e 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -30,10 +30,7 @@ import { } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useDebugContext } from '../../contexts/debugContext/DebugContext'; -import { - ImageGalleryContextValue, - useImageGalleryContext, -} from '../../contexts/imageGalleryContext/ImageGalleryContext'; + import { MessageInputContextValue, useMessageInputContext, @@ -63,7 +60,6 @@ import { MessageInputHeightState, messageInputHeightStore, } from '../../state-store/message-input-height-store'; -import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; // This is just to make sure that the scrolling happens in a different task queue. @@ -168,14 +164,15 @@ type MessageListPropsWithContext = Pick< | 'maximumMessageLimit' > & Pick & - Pick & Pick & Pick< MessagesContextValue, | 'DateHeader' | 'disableTypingIndicator' | 'FlatList' - | 'legacyImageViewerSwipeBehaviour' + | 'InlineDateSeparator' + | 'InlineUnreadIndicator' + | 'Message' | 'ScrollToBottomButton' | 'myMessageTheme' | 'TypingIndicator' @@ -222,7 +219,6 @@ type MessageListPropsWithContext = Pick< HeaderComponent?: React.ComponentType; /** Whether or not the FlatList is inverted. Defaults to true */ inverted?: boolean; - isListActive?: boolean; /** Turn off grouping of messages by user */ noGroupByUser?: boolean; onListScroll?: ScrollViewProps['onScroll']; @@ -291,9 +287,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { HeaderComponent = LoadingMoreRecentIndicator, hideStickyDateHeader, inverted = true, - isListActive = false, isLiveStreaming = false, - legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, LoadingIndicator, @@ -315,7 +309,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { selectedPicker, setChannelUnreadState, setFlatListRef, - setMessages, setSelectedPicker, setTargetedMessage, StickyHeader, @@ -1043,59 +1036,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }, ); - const messagesWithImages = - legacyImageViewerSwipeBehaviour && - processedMessageList.filter((message) => { - const isMessageTypeDeleted = message.type === 'deleted'; - if (!isMessageTypeDeleted && message.attachments) { - return message.attachments.some( - (attachment) => - attachment.type === FileTypes.Image && - !attachment.title_link && - !attachment.og_scrape_url && - (attachment.image_url || attachment.thumb_url), - ); - } - return false; - }); - - /** - * This is for the useEffect to run again in the case that a message - * gets edited with more or the same number of images - */ - const imageString = - legacyImageViewerSwipeBehaviour && - messagesWithImages && - messagesWithImages - .map((message) => - message.attachments - ?.map((attachment) => attachment.image_url || attachment.thumb_url || '') - .join(), - ) - .join(); - - const numberOfMessagesWithImages = - legacyImageViewerSwipeBehaviour && messagesWithImages && messagesWithImages.length; - const threadExists = !!thread; - - useEffect(() => { - if ( - legacyImageViewerSwipeBehaviour && - isListActive && - ((threadList && thread) || (!threadList && !thread)) - ) { - setMessages(messagesWithImages as LocalMessage[]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - imageString, - isListActive, - legacyImageViewerSwipeBehaviour, - numberOfMessagesWithImages, - threadExists, - threadList, - ]); - const dismissImagePicker = useStableCallback(() => { if (selectedPicker) { setSelectedPicker(undefined); @@ -1286,7 +1226,6 @@ export const MessageList = (props: MessageListProps) => { error, hideStickyDateHeader, highlightedMessageId, - isChannelActive, loadChannelAroundMessage, loading, LoadingIndicator, @@ -1302,7 +1241,6 @@ export const MessageList = (props: MessageListProps) => { threadList, } = useChannelContext(); const { client } = useChatContext(); - const { setMessages } = useImageGalleryContext(); const { readEvents } = useOwnCapabilitiesContext(); const { DateHeader, @@ -1310,7 +1248,6 @@ export const MessageList = (props: MessageListProps) => { FlatList, InlineDateSeparator, InlineUnreadIndicator, - legacyImageViewerSwipeBehaviour, Message, MessageSystem, myMessageTheme, @@ -1342,8 +1279,6 @@ export const MessageList = (props: MessageListProps) => { highlightedMessageId, InlineDateSeparator, InlineUnreadIndicator, - isListActive: isChannelActive, - legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, LoadingIndicator, @@ -1364,7 +1299,6 @@ export const MessageList = (props: MessageListProps) => { scrollToFirstUnreadThreshold, selectedPicker, setChannelUnreadState, - setMessages, setSelectedPicker, setTargetedMessage, shouldShowUnreadUnderlay, diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 020ec8d578..43004838c3 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -81,6 +81,8 @@ export * from './ImageGallery/components/ImageGalleryHeader'; export * from './ImageGallery/components/ImageGalleryOverlay'; export * from './ImageGallery/components/ImageGrid'; export * from './ImageGallery/components/ImageGridHandle'; +export * from './ImageGallery/components/ImageGalleryVideoControl'; +export * from './ImageGallery/hooks/useImageGalleryVideoPlayer'; export * from './Indicators/EmptyStateIndicator'; export * from './Indicators/LoadingDot'; diff --git a/package/src/contexts/imageGalleryContext/ImageGalleryContext.tsx b/package/src/contexts/imageGalleryContext/ImageGalleryContext.tsx index 6643114a42..66768cc554 100644 --- a/package/src/contexts/imageGalleryContext/ImageGalleryContext.tsx +++ b/package/src/contexts/imageGalleryContext/ImageGalleryContext.tsx @@ -1,42 +1,57 @@ -import React, { PropsWithChildren, useContext, useState } from 'react'; +import React, { PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'; -import { LocalMessage } from 'stream-chat'; +import { Attachment } from 'stream-chat'; -import type { UnknownType } from '../../types/types'; +import { ImageGalleryCustomComponents } from '../../components/ImageGallery/ImageGallery'; +import { ImageGalleryStateStore } from '../../state-store/image-gallery-state-store'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; -type SelectedMessage = { - messageId?: string; - url?: string; +export type ImageGalleryProviderProps = ImageGalleryCustomComponents & { + autoPlayVideo?: boolean; + /** + * The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default + * */ + giphyVersion?: keyof NonNullable; + imageGalleryGridHandleHeight?: number; + imageGalleryGridSnapPoints?: [string | number, string | number]; + numberOfImageGalleryGridColumns?: number; }; -export type ImageGalleryContextValue = { - messages: LocalMessage[]; - setMessages: React.Dispatch>; - setSelectedMessage: React.Dispatch>; - selectedMessage?: SelectedMessage; +export type ImageGalleryContextValue = ImageGalleryProviderProps & { + imageGalleryStateStore: ImageGalleryStateStore; }; export const ImageGalleryContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as ImageGalleryContextValue, ); -export const ImageGalleryProvider = ({ children }: PropsWithChildren) => { - const [messages, setMessages] = useState([]); - const [selectedMessage, setSelectedMessage] = useState(); +export const ImageGalleryProvider = ({ + children, + value, +}: PropsWithChildren<{ value: ImageGalleryProviderProps }>) => { + const [imageGalleryStateStore] = useState(() => new ImageGalleryStateStore(value)); + + useEffect(() => { + const unsubscribe = imageGalleryStateStore.registerSubscriptions(); + return () => { + unsubscribe(); + }; + }, [imageGalleryStateStore]); + + const imageGalleryContextValue = useMemo( + () => ({ + autoPlayVideo: value?.autoPlayVideo, + imageGalleryStateStore, + ...value, + }), + [imageGalleryStateStore, value], + ); return ( {children} diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index aa4e4d36b0..ec24bac966 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -444,7 +444,6 @@ export type MessagesContextValue = Pick boolean; - legacyImageViewerSwipeBehaviour?: boolean; /** Object specifying rules defined within simple-markdown https://github.com/Khan/simple-markdown#adding-a-simple-extension */ markdownRules?: MarkdownRules; /** diff --git a/package/src/contexts/overlayContext/OverlayContext.tsx b/package/src/contexts/overlayContext/OverlayContext.tsx index e094429cb8..0417048c94 100644 --- a/package/src/contexts/overlayContext/OverlayContext.tsx +++ b/package/src/contexts/overlayContext/OverlayContext.tsx @@ -1,10 +1,9 @@ import React, { useContext } from 'react'; -import type { Attachment } from 'stream-chat'; - -import type { ImageGalleryCustomComponents } from '../../components/ImageGallery/ImageGallery'; +import { SharedValue } from 'react-native-reanimated'; import type { Streami18n } from '../../utils/i18n/Streami18n'; +import { ImageGalleryProviderProps } from '../imageGalleryContext/ImageGalleryContext'; import type { DeepPartial } from '../themeContext/ThemeContext'; import type { Theme } from '../themeContext/utils/theme'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -15,6 +14,7 @@ export type Overlay = 'alert' | 'gallery' | 'none'; export type OverlayContextValue = { overlay: Overlay; + overlayOpacity: SharedValue; setOverlay: React.Dispatch>; style?: DeepPartial; }; @@ -23,17 +23,9 @@ export const OverlayContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as OverlayContextValue, ); -export type OverlayProviderProps = ImageGalleryCustomComponents & { - autoPlayVideo?: boolean; - /** - * The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default - * */ - giphyVersion?: keyof NonNullable; +export type OverlayProviderProps = ImageGalleryProviderProps & { /** https://github.com/GetStream/stream-chat-react-native/wiki/Internationalization-(i18n) */ i18nInstance?: Streami18n; - imageGalleryGridHandleHeight?: number; - imageGalleryGridSnapPoints?: [string | number, string | number]; - numberOfImageGalleryGridColumns?: number; value?: Partial; }; diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 35d03fba4f..6879899319 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useEffect, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; import { BackHandler } from 'react-native'; @@ -10,7 +10,6 @@ import { MessageOverlayHostLayer } from './MessageOverlayHostLayer'; import { OverlayContext, OverlayProviderProps } from './OverlayContext'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; - import { useStreami18n } from '../../hooks/useStreami18n'; import { ImageGalleryProvider } from '../imageGalleryContext/ImageGalleryContext'; @@ -42,15 +41,15 @@ import { */ export const OverlayProvider = (props: PropsWithChildren) => { const { - autoPlayVideo, children, - giphyVersion, i18nInstance, + value, + autoPlayVideo, + giphyVersion, imageGalleryCustomComponents, - imageGalleryGridHandleHeight = 40, + imageGalleryGridHandleHeight, imageGalleryGridSnapPoints, numberOfImageGalleryGridColumns, - value, } = props; const [overlay, setOverlay] = useState(value?.overlay || 'none'); @@ -87,28 +86,38 @@ export const OverlayProvider = (props: PropsWithChildren) const overlayContext = { overlay, + overlayOpacity, setOverlay, style: value?.style, }; + const imageGalleryProviderProps = useMemo( + () => ({ + autoPlayVideo, + giphyVersion, + imageGalleryCustomComponents, + imageGalleryGridHandleHeight, + imageGalleryGridSnapPoints, + numberOfImageGalleryGridColumns, + }), + [ + autoPlayVideo, + giphyVersion, + imageGalleryCustomComponents, + imageGalleryGridHandleHeight, + imageGalleryGridSnapPoints, + numberOfImageGalleryGridColumns, + ], + ); + return ( - + {children} - {overlay === 'gallery' && ( - - )} + {overlay === 'gallery' && } diff --git a/package/src/native.ts b/package/src/native.ts index cfcb627e5f..85d1faaafb 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -277,7 +277,8 @@ export type VideoType = { repeat?: boolean; replayAsync?: () => void; resizeMode?: string; - seek?: (progress: number) => void; + seek?: (seconds: number) => void; + seekBy?: (seconds: number) => void; setPositionAsync?: (position: number) => void; style?: StyleProp; play?: () => void; diff --git a/package/src/state-store/__tests__/image-gallery-state-store.test.ts b/package/src/state-store/__tests__/image-gallery-state-store.test.ts new file mode 100644 index 0000000000..e6b7c9fd9a --- /dev/null +++ b/package/src/state-store/__tests__/image-gallery-state-store.test.ts @@ -0,0 +1,918 @@ +import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; + +import { + generateImageAttachment, + generateVideoAttachment, +} from '../../mock-builders/generator/attachment'; +import { generateMessage } from '../../mock-builders/generator/message'; +import { getUrlOfImageAttachment } from '../../utils/getUrlOfImageAttachment'; +import { ImageGalleryStateStore } from '../image-gallery-state-store'; +import { VideoPlayerPool } from '../video-player-pool'; + +// Mock dependencies +jest.mock('../video-player-pool', () => ({ + VideoPlayerPool: jest.fn().mockImplementation(() => ({ + clear: jest.fn(), + pool: new Map(), + state: { + getLatestValue: () => ({ activeVideoPlayer: null }), + }, + })), +})); + +jest.mock('../../native', () => ({ + isVideoPlayerAvailable: jest.fn(() => true), +})); + +const { isVideoPlayerAvailable } = jest.requireMock('../../native') as { + isVideoPlayerAvailable: jest.Mock; +}; + +const createGiphyAttachment = (overrides: Partial = {}): Attachment => ({ + giphy: { + fixed_height: { + height: 200, + url: 'https://giphy.com/test.gif', + width: 200, + }, + }, + thumb_url: 'https://giphy.com/thumb.gif', + type: 'giphy', + ...overrides, +}); + +describe('ImageGalleryStateStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + (isVideoPlayerAvailable as jest.Mock).mockReturnValue(true); + }); + + describe('constructor', () => { + it('should initialize with default options', () => { + const store = new ImageGalleryStateStore(); + + expect(store.options).toEqual({ + autoPlayVideo: false, + giphyVersion: 'fixed_height', + }); + }); + + it('should merge custom options with defaults', () => { + const store = new ImageGalleryStateStore({ + autoPlayVideo: true, + giphyVersion: 'original', + }); + + expect(store.options).toEqual({ + autoPlayVideo: true, + giphyVersion: 'original', + }); + }); + + it('should partially override options', () => { + const store = new ImageGalleryStateStore({ + autoPlayVideo: true, + }); + + expect(store.options).toEqual({ + autoPlayVideo: true, + giphyVersion: 'fixed_height', + }); + }); + + it('should initialize state with default values', () => { + const store = new ImageGalleryStateStore(); + const state = store.state.getLatestValue(); + + expect(state).toEqual({ + assets: [], + currentIndex: 0, + messages: [], + selectedAttachmentUrl: undefined, + }); + }); + + it('should create a VideoPlayerPool instance', () => { + const store = new ImageGalleryStateStore(); + + expect(VideoPlayerPool).toHaveBeenCalled(); + expect(store.videoPlayerPool).toBeDefined(); + }); + }); + + describe('messages getter and setter', () => { + it('should get messages from state', () => { + const store = new ImageGalleryStateStore(); + const messages = [generateMessage({ id: 1 }), generateMessage({ id: 2 })]; + + store.messages = messages; + + expect(store.messages).toEqual(messages); + }); + + it('should update state when setting messages', () => { + const store = new ImageGalleryStateStore(); + const messages = [generateMessage({ id: 1 })]; + + store.messages = messages; + + expect(store.state.getLatestValue().messages).toEqual(messages); + }); + + it('should return empty array when no messages are set', () => { + const store = new ImageGalleryStateStore(); + + expect(store.messages).toEqual([]); + }); + }); + + describe('selectedAttachmentUrl getter and setter', () => { + it('should get selectedAttachmentUrl from state', () => { + const store = new ImageGalleryStateStore(); + const url = 'https://example.com/image.jpg'; + + store.selectedAttachmentUrl = url; + + expect(store.selectedAttachmentUrl).toBe(url); + }); + + it('should update state when setting selectedAttachmentUrl', () => { + const store = new ImageGalleryStateStore(); + const url = 'https://example.com/image.jpg'; + + store.selectedAttachmentUrl = url; + + expect(store.state.getLatestValue().selectedAttachmentUrl).toBe(url); + }); + + it('should return undefined when no url is set', () => { + const store = new ImageGalleryStateStore(); + + expect(store.selectedAttachmentUrl).toBeUndefined(); + }); + + it('should allow setting undefined', () => { + const store = new ImageGalleryStateStore(); + store.selectedAttachmentUrl = 'https://example.com/image.jpg'; + + store.selectedAttachmentUrl = undefined; + + expect(store.selectedAttachmentUrl).toBeUndefined(); + }); + }); + + describe('currentIndex setter', () => { + it('should update currentIndex in state', () => { + const store = new ImageGalleryStateStore(); + + store.currentIndex = 5; + + expect(store.state.getLatestValue().currentIndex).toBe(5); + }); + + it('should allow setting to 0', () => { + const store = new ImageGalleryStateStore(); + store.currentIndex = 5; + + store.currentIndex = 0; + + expect(store.state.getLatestValue().currentIndex).toBe(0); + }); + }); + + describe('attachmentsWithMessage getter', () => { + it('should return empty array when no messages', () => { + const store = new ImageGalleryStateStore(); + + expect(store.attachmentsWithMessage).toEqual([]); + }); + + it('should filter messages with viewable image attachments', () => { + const store = new ImageGalleryStateStore(); + const imageAttachment = generateImageAttachment({ + image_url: 'https://example.com/image.jpg', + }); + const message = generateMessage({ attachments: [imageAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(1); + expect(store.attachmentsWithMessage[0].attachments).toContain(imageAttachment); + }); + + it('should filter messages with viewable video attachments', () => { + const store = new ImageGalleryStateStore(); + const videoAttachment = generateVideoAttachment({ + asset_url: 'https://example.com/video.mp4', + }); + const message = generateMessage({ attachments: [videoAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(1); + expect(store.attachmentsWithMessage[0].attachments).toContain(videoAttachment); + }); + + it('should filter messages with giphy attachments', () => { + const store = new ImageGalleryStateStore(); + const giphyAttachment = createGiphyAttachment(); + const message = generateMessage({ attachments: [giphyAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(1); + expect(store.attachmentsWithMessage[0].attachments).toContain(giphyAttachment); + }); + + it('should exclude video attachments when video player is not available', () => { + (isVideoPlayerAvailable as jest.Mock).mockReturnValue(false); + const store = new ImageGalleryStateStore(); + const videoAttachment = generateVideoAttachment({ + asset_url: 'https://example.com/video.mp4', + }); + const message = generateMessage({ attachments: [videoAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should exclude image attachments with title_link (link previews)', () => { + const store = new ImageGalleryStateStore(); + const linkPreviewAttachment = generateImageAttachment({ + image_url: 'https://example.com/image.jpg', + title_link: 'https://example.com', + }); + const message = generateMessage({ attachments: [linkPreviewAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should exclude image attachments with og_scrape_url (OpenGraph previews)', () => { + const store = new ImageGalleryStateStore(); + const linkAttachment = generateImageAttachment({ + image_url: 'https://example.com/image.jpg', + og_scrape_url: 'https://example.com', + }); + const message = generateMessage({ attachments: [linkAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should handle messages with mixed viewable and non-viewable attachments', () => { + const store = new ImageGalleryStateStore(); + const viewableImage = generateImageAttachment({ image_url: 'https://example.com/image.jpg' }); + const linkPreview = generateImageAttachment({ + image_url: 'https://example.com/preview.jpg', + title_link: 'https://example.com', + }); + const message = generateMessage({ attachments: [viewableImage, linkPreview], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(1); + }); + + it('should exclude messages with only non-viewable attachments', () => { + const store = new ImageGalleryStateStore(); + const fileAttachment: Attachment = { + asset_url: 'https://example.com/file.pdf', + type: 'file', + }; + const message = generateMessage({ attachments: [fileAttachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should handle null attachments gracefully', () => { + const store = new ImageGalleryStateStore(); + const message = generateMessage({ attachments: [null as unknown as Attachment], id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + + it('should handle messages without attachments array', () => { + const store = new ImageGalleryStateStore(); + const message = generateMessage({ attachments: undefined, id: 1 }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + }); + }); + + describe('getAssetId', () => { + it('should generate unique asset id from messageId and assetUrl', () => { + const store = new ImageGalleryStateStore(); + const assetId = store.getAssetId('message-123', 'https://example.com/image.jpg'); + + expect(assetId).toBe('photoId-message-123-https://example.com/image.jpg'); + }); + + it('should handle empty messageId', () => { + const store = new ImageGalleryStateStore(); + const assetId = store.getAssetId('', 'https://example.com/image.jpg'); + + expect(assetId).toBe('photoId--https://example.com/image.jpg'); + }); + }); + + describe('assets getter', () => { + it('should return empty array when no messages', () => { + const store = new ImageGalleryStateStore(); + + expect(store.assets).toEqual([]); + }); + + it('should transform image attachments to assets', () => { + const store = new ImageGalleryStateStore(); + const imageAttachment = generateImageAttachment({ + image_url: 'https://example.com/image.jpg', + original_height: 600, + original_width: 800, + thumb_url: 'https://example.com/thumb.jpg', + }); + const user: Partial = { id: 'user-1', name: 'Test User' }; + const message = generateMessage({ + attachments: [imageAttachment], + cid: 'channel-msg-1', + id: 'msg-1', + user, + user_id: user.id, + }); + + store.messages = [message]; + + const assets = store.assets; + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ + channelId: 'channel-msg-1', + messageId: 'msg-1', + mime_type: undefined, + original_height: 600, + original_width: 800, + thumb_url: 'https://example.com/thumb.jpg', + type: 'image', + uri: 'https://example.com/image.jpg', + user_id: 'user-1', + }); + }); + + it('should transform video attachments to assets', () => { + const store = new ImageGalleryStateStore(); + const videoAttachment = generateVideoAttachment({ + asset_url: 'https://example.com/video.mp4', + thumb_url: 'https://example.com/video-thumb.jpg', + }); + const message = generateMessage({ attachments: [videoAttachment], id: 1 }); + + store.messages = [message]; + + const assets = store.assets; + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ + mime_type: 'video/mp4', + type: 'video', + uri: 'https://example.com/video.mp4', + }); + }); + + it('should transform giphy attachments with correct mime type', () => { + const store = new ImageGalleryStateStore(); + const giphyAttachment = createGiphyAttachment(); + const message = generateMessage({ attachments: [giphyAttachment], id: 1 }); + + store.messages = [message]; + + const assets = store.assets; + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ + mime_type: 'image/gif', + type: 'giphy', + uri: 'https://giphy.com/test.gif', + }); + }); + + it('should generate unique asset ids for each attachment', () => { + const store = new ImageGalleryStateStore(); + const attachment1 = generateImageAttachment({ image_url: 'https://example.com/image1.jpg' }); + const attachment2 = generateImageAttachment({ image_url: 'https://example.com/image2.jpg' }); + const message = generateMessage({ attachments: [attachment1, attachment2], id: 1 }); + + store.messages = [message]; + + const assets = store.assets; + expect(assets).toHaveLength(2); + expect(assets[0].id).not.toBe(assets[1].id); + }); + + it('should use custom giphyVersion from options', () => { + const store = new ImageGalleryStateStore({ giphyVersion: 'original' }); + const giphyAttachment: Attachment = { + giphy: { + fixed_height: { height: 200, url: 'https://giphy.com/fixed.gif', width: 200 }, + original: { height: 400, url: 'https://giphy.com/original.gif', width: 400 }, + }, + type: 'giphy', + }; + const message = generateMessage({ attachments: [giphyAttachment], id: 1 }); + + store.messages = [message]; + + expect(getUrlOfImageAttachment(giphyAttachment, 'original')).toBe( + 'https://giphy.com/original.gif', + ); + }); + + it('should handle messages with multiple attachments', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ + attachments: [ + generateImageAttachment({ image_url: 'https://example.com/image1.jpg' }), + generateImageAttachment({ image_url: 'https://example.com/image2.jpg' }), + ], + id: 1, + }); + const message2 = generateMessage({ + attachments: [generateVideoAttachment({ asset_url: 'https://example.com/video.mp4' })], + id: 2, + }); + + store.messages = [message1, message2]; + + expect(store.assets).toHaveLength(3); + }); + }); + + describe('appendMessages', () => { + it('should append messages to existing messages', () => { + const store = new ImageGalleryStateStore(); + const initialMessages = [generateMessage({ id: 'msg-1' })]; + const newMessages = [generateMessage({ id: 'msg-2' }), generateMessage({ id: 'msg-3' })]; + + store.messages = initialMessages; + store.appendMessages(newMessages); + + expect(store.messages).toHaveLength(3); + expect(store.messages).toEqual([...initialMessages, ...newMessages]); + }); + + it('should work with empty initial messages', () => { + const store = new ImageGalleryStateStore(); + const newMessages = [generateMessage({ id: 'msg-1' })]; + + store.appendMessages(newMessages); + + expect(store.messages).toEqual(newMessages); + }); + + it('should work with empty new messages', () => { + const store = new ImageGalleryStateStore(); + const initialMessages = [generateMessage({ id: 'msg-1' })]; + + store.messages = initialMessages; + store.appendMessages([]); + + expect(store.messages).toEqual(initialMessages); + }); + }); + + describe('removeMessages', () => { + it('should remove specified messages', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ id: 'msg-1' }); + const message2 = generateMessage({ id: 'msg-2' }); + const message3 = generateMessage({ id: 'msg-3' }); + + store.messages = [message1, message2, message3]; + store.removeMessages([message2]); + + expect(store.messages).toHaveLength(2); + expect(store.messages).toEqual([message1, message3]); + }); + + it('should remove multiple messages', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ id: 'msg-1' }); + const message2 = generateMessage({ id: 'msg-2' }); + const message3 = generateMessage({ id: 'msg-3' }); + + store.messages = [message1, message2, message3]; + store.removeMessages([message1, message3]); + + expect(store.messages).toHaveLength(1); + expect(store.messages).toEqual([message2]); + }); + + it('should handle removing non-existent messages', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ id: 'msg-1' }); + const message2 = generateMessage({ id: 'msg-2' }); + const nonExistentMessage = generateMessage({ id: 'non-existent' }); + + store.messages = [message1, message2]; + store.removeMessages([nonExistentMessage]); + + expect(store.messages).toHaveLength(2); + expect(store.messages).toEqual([message1, message2]); + }); + + it('should handle empty removal array', () => { + const store = new ImageGalleryStateStore(); + const message1 = generateMessage({ id: 'msg-1' }); + + store.messages = [message1]; + store.removeMessages([]); + + expect(store.messages).toEqual([message1]); + }); + }); + + describe('openImageGallery', () => { + it('should set messages and selectedAttachmentUrl', () => { + const store = new ImageGalleryStateStore(); + const messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + const selectedUrl = 'https://example.com/1.jpg'; + + store.openImageGallery({ messages, selectedAttachmentUrl: selectedUrl }); + + expect(store.messages).toEqual(messages); + expect(store.selectedAttachmentUrl).toBe(selectedUrl); + }); + + it('should work without selectedAttachmentUrl', () => { + const store = new ImageGalleryStateStore(); + const messages = [generateMessage({ id: 'msg-1' })]; + + store.openImageGallery({ messages }); + + expect(store.messages).toEqual(messages); + expect(store.selectedAttachmentUrl).toBeUndefined(); + }); + + it('should replace existing messages', () => { + const store = new ImageGalleryStateStore(); + const oldMessages = [generateMessage({ id: 'msg-1' })]; + const newMessages = [generateMessage({ id: 'msg-2' })]; + + store.messages = oldMessages; + store.openImageGallery({ messages: newMessages }); + + expect(store.messages).toEqual(newMessages); + }); + }); + + describe('subscribeToMessages', () => { + it('should update assets when messages change', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.subscribeToMessages(); + + const message = generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }); + store.messages = [message]; + + expect(store.state.getLatestValue().assets).toHaveLength(1); + + unsubscribe(); + }); + + it('should return unsubscribe function', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.subscribeToMessages(); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + + it('should recalculate assets when messages are appended', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.subscribeToMessages(); + + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + expect(store.state.getLatestValue().assets).toHaveLength(1); + + store.appendMessages([ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/2.jpg' })], + id: 'msg-2', + }), + ]); + expect(store.state.getLatestValue().assets).toHaveLength(2); + + unsubscribe(); + }); + }); + + describe('subscribeToSelectedAttachmentUrl', () => { + it('should update currentIndex when selectedAttachmentUrl matches an asset', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + const messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/2.jpg' })], + id: 'msg-2', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/3.jpg' })], + id: 'msg-3', + }), + ]; + + store.messages = messages; + store.selectedAttachmentUrl = 'https://example.com/2.jpg'; + + expect(store.state.getLatestValue().currentIndex).toBe(1); + + unsubscribe(); + }); + + it('should set currentIndex to 0 when selectedAttachmentUrl is not found', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + store.selectedAttachmentUrl = 'https://example.com/non-existent.jpg'; + + expect(store.state.getLatestValue().currentIndex).toBe(0); + + unsubscribe(); + }); + + it('should not update currentIndex when selectedAttachmentUrl is undefined', () => { + const store = new ImageGalleryStateStore(); + store.currentIndex = 5; + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + store.selectedAttachmentUrl = undefined; + + expect(store.state.getLatestValue().currentIndex).toBe(5); + + unsubscribe(); + }); + + it('should strip query params when matching URLs', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + store.messages = [ + generateMessage({ + attachments: [ + generateImageAttachment({ image_url: 'https://example.com/image.jpg?size=small' }), + ], + id: 'msg-1', + }), + ]; + store.selectedAttachmentUrl = 'https://example.com/image.jpg?size=large'; + + expect(store.state.getLatestValue().currentIndex).toBe(0); + + unsubscribe(); + }); + + it('should return unsubscribe function', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + }); + + describe('registerSubscriptions', () => { + it('should register both message and selectedAttachmentUrl subscriptions', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.registerSubscriptions(); + + // Test that message subscription is working + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + expect(store.state.getLatestValue().assets).toHaveLength(1); + + // Test that selectedAttachmentUrl subscription is working + store.selectedAttachmentUrl = 'https://example.com/1.jpg'; + expect(store.state.getLatestValue().currentIndex).toBe(0); + + unsubscribe(); + }); + + it('should return unsubscribe function that cleans up all subscriptions', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.registerSubscriptions(); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + + // Verify videoPlayerPool.clear was called + expect(store.videoPlayerPool.clear).toHaveBeenCalled(); + }); + + it('should clear videoPlayerPool when unsubscribing', () => { + const store = new ImageGalleryStateStore(); + const unsubscribe = store.registerSubscriptions(); + + unsubscribe(); + + expect(store.videoPlayerPool.clear).toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should reset state to initial values', () => { + const store = new ImageGalleryStateStore(); + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + ]; + store.selectedAttachmentUrl = 'https://example.com/1.jpg'; + store.currentIndex = 5; + + store.clear(); + + const state = store.state.getLatestValue(); + expect(state.assets).toEqual([]); + expect(state.currentIndex).toBe(0); + expect(state.messages).toEqual([]); + expect(state.selectedAttachmentUrl).toBeUndefined(); + }); + + it('should clear videoPlayerPool', () => { + const store = new ImageGalleryStateStore(); + + store.clear(); + + expect(store.videoPlayerPool.clear).toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should handle message with undefined user', () => { + const store = new ImageGalleryStateStore(); + const message = { + ...generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + user: undefined, + } as LocalMessage; + + store.messages = [message]; + + expect(store.assets).toHaveLength(1); + expect(store.assets[0].user).toBeUndefined(); + }); + + it('should handle multiple stores independently', () => { + const store1 = new ImageGalleryStateStore({ autoPlayVideo: true }); + const store2 = new ImageGalleryStateStore({ autoPlayVideo: false }); + + store1.messages = [generateMessage({ id: 'msg-1' })]; + store2.messages = [generateMessage({ id: 'msg-2' }), generateMessage({ id: 'msg-3' })]; + + expect(store1.messages).toHaveLength(1); + expect(store2.messages).toHaveLength(2); + expect(store1.options.autoPlayVideo).toBe(true); + expect(store2.options.autoPlayVideo).toBe(false); + }); + + it('should handle rapid state updates', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + + for (let i = 0; i < 100; i++) { + store.messages = [ + generateMessage({ + attachments: [ + generateImageAttachment({ image_url: `https://example.com/image-${i}.jpg` }), + ], + id: `msg-${i}`, + }), + ]; + } + + expect(store.state.getLatestValue().assets).toHaveLength(1); + expect(store.messages).toHaveLength(1); + }); + + it('should handle empty attachment arrays in messages', () => { + const store = new ImageGalleryStateStore(); + const message = generateMessage({ attachments: [], id: 'msg-1' }); + + store.messages = [message]; + + expect(store.attachmentsWithMessage).toHaveLength(0); + expect(store.assets).toEqual([]); + }); + + it('should maintain order of assets based on message order', () => { + const store = new ImageGalleryStateStore(); + const messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/first.jpg' })], + id: 'msg-1', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/second.jpg' })], + id: 'msg-2', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/third.jpg' })], + id: 'msg-3', + }), + ]; + + store.messages = messages; + + const assets = store.assets; + expect(assets[0].uri).toBe('https://example.com/first.jpg'); + expect(assets[1].uri).toBe('https://example.com/second.jpg'); + expect(assets[2].uri).toBe('https://example.com/third.jpg'); + }); + }); + + describe('state reactivity', () => { + it('should notify subscribers when messages change', () => { + const store = new ImageGalleryStateStore(); + const callback = jest.fn(); + + store.state.subscribeWithSelector( + (state) => ({ messages: state.messages }), + ({ messages }) => callback(messages), + ); + + const newMessages = [generateMessage({ id: 'msg-1' })]; + store.messages = newMessages; + + expect(callback).toHaveBeenCalledWith(newMessages); + }); + + it('should notify subscribers when selectedAttachmentUrl changes', () => { + const store = new ImageGalleryStateStore(); + const callback = jest.fn(); + + store.state.subscribeWithSelector( + (state) => ({ selectedAttachmentUrl: state.selectedAttachmentUrl }), + ({ selectedAttachmentUrl }) => callback(selectedAttachmentUrl), + ); + + store.selectedAttachmentUrl = 'https://example.com/image.jpg'; + + expect(callback).toHaveBeenCalledWith('https://example.com/image.jpg'); + }); + + it('should notify subscribers when currentIndex changes', () => { + const store = new ImageGalleryStateStore(); + const callback = jest.fn(); + + store.state.subscribeWithSelector( + (state) => ({ currentIndex: state.currentIndex }), + ({ currentIndex }) => callback(currentIndex), + ); + + store.currentIndex = 3; + + expect(callback).toHaveBeenCalledWith(3); + }); + }); +}); diff --git a/package/src/state-store/__tests__/video-player-pool.test.ts b/package/src/state-store/__tests__/video-player-pool.test.ts new file mode 100644 index 0000000000..4d5defe4c9 --- /dev/null +++ b/package/src/state-store/__tests__/video-player-pool.test.ts @@ -0,0 +1,445 @@ +import { VideoPlayer, VideoPlayerOptions } from '../video-player'; +import { VideoPlayerPool } from '../video-player-pool'; + +// Mock the VideoPlayer class +jest.mock('../video-player', () => ({ + VideoPlayer: jest.fn().mockImplementation((options: VideoPlayerOptions) => ({ + id: options.id, + isPlaying: false, + onRemove: jest.fn(), + pause: jest.fn(), + play: jest.fn(), + })), +})); + +const createMockPlayer = (id: string, overrides: Partial = {}): VideoPlayer => + ({ + id, + isPlaying: false, + onRemove: jest.fn(), + pause: jest.fn(), + play: jest.fn(), + ...overrides, + }) as unknown as VideoPlayer; + +describe('VideoPlayerPool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with an empty pool', () => { + const pool = new VideoPlayerPool(); + + expect(pool.pool.size).toBe(0); + expect(pool.players).toEqual([]); + }); + + it('should initialize state with null activeVideoPlayer', () => { + const pool = new VideoPlayerPool(); + const state = pool.state.getLatestValue(); + + expect(state.activeVideoPlayer).toBeNull(); + }); + }); + + describe('players getter', () => { + it('should return an empty array when pool is empty', () => { + const pool = new VideoPlayerPool(); + + expect(pool.players).toEqual([]); + }); + + it('should return all players as an array', () => { + const pool = new VideoPlayerPool(); + + pool.getOrAddPlayer({ id: 'player-1' }); + pool.getOrAddPlayer({ id: 'player-2' }); + pool.getOrAddPlayer({ id: 'player-3' }); + + expect(pool.players).toHaveLength(3); + }); + }); + + describe('getOrAddPlayer', () => { + it('should create a new player when id does not exist', () => { + const pool = new VideoPlayerPool(); + const options: VideoPlayerOptions = { id: 'new-player' }; + + const player = pool.getOrAddPlayer(options); + + expect(VideoPlayer).toHaveBeenCalledWith(options); + expect(pool.pool.has('new-player')).toBe(true); + expect(player.id).toBe('new-player'); + }); + + it('should return existing player when id already exists', () => { + const pool = new VideoPlayerPool(); + const options: VideoPlayerOptions = { id: 'existing-player' }; + + const firstPlayer = pool.getOrAddPlayer(options); + const secondPlayer = pool.getOrAddPlayer(options); + + expect(VideoPlayer).toHaveBeenCalledTimes(1); + expect(firstPlayer).toBe(secondPlayer); + }); + + it('should set pool reference on new player', () => { + const pool = new VideoPlayerPool(); + const options: VideoPlayerOptions = { id: 'player-with-pool' }; + + const player = pool.getOrAddPlayer(options); + + expect(player.pool).toBe(pool); + }); + + it('should pass autoPlay option to VideoPlayer', () => { + const pool = new VideoPlayerPool(); + const options: VideoPlayerOptions = { autoPlay: true, id: 'autoplay-player' }; + + pool.getOrAddPlayer(options); + + expect(VideoPlayer).toHaveBeenCalledWith(options); + }); + }); + + describe('setActivePlayer', () => { + it('should set the active video player', () => { + const pool = new VideoPlayerPool(); + const mockPlayer = createMockPlayer('active-player'); + + pool.setActivePlayer(mockPlayer); + + expect(pool.state.getLatestValue().activeVideoPlayer).toBe(mockPlayer); + }); + + it('should allow setting active player to null', () => { + const pool = new VideoPlayerPool(); + const mockPlayer = createMockPlayer('active-player'); + + pool.setActivePlayer(mockPlayer); + pool.setActivePlayer(null); + + expect(pool.state.getLatestValue().activeVideoPlayer).toBeNull(); + }); + }); + + describe('getActivePlayer', () => { + it('should return null when no active player is set', () => { + const pool = new VideoPlayerPool(); + + expect(pool.getActivePlayer()).toBeNull(); + }); + + it('should return the current active player', () => { + const pool = new VideoPlayerPool(); + const mockPlayer = createMockPlayer('active-player'); + + pool.setActivePlayer(mockPlayer); + + expect(pool.getActivePlayer()).toBe(mockPlayer); + }); + }); + + describe('removePlayer', () => { + it('should do nothing when player does not exist', () => { + const pool = new VideoPlayerPool(); + + // Should not throw + pool.removePlayer('non-existent-player'); + + expect(pool.pool.size).toBe(0); + }); + + it('should remove player from pool', () => { + const pool = new VideoPlayerPool(); + pool.getOrAddPlayer({ id: 'player-to-remove' }); + + expect(pool.pool.has('player-to-remove')).toBe(true); + + pool.removePlayer('player-to-remove'); + + expect(pool.pool.has('player-to-remove')).toBe(false); + }); + + it('should call onRemove on the player being removed', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-to-remove' }); + + pool.removePlayer('player-to-remove'); + + expect(player.onRemove).toHaveBeenCalled(); + }); + + it('should set active player to null if removed player was active', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'active-player' }); + + pool.setActivePlayer(player as unknown as VideoPlayer); + expect(pool.getActivePlayer()).toBe(player); + + pool.removePlayer('active-player'); + + expect(pool.getActivePlayer()).toBeNull(); + }); + + it('should not affect active player if removed player was not active', () => { + const pool = new VideoPlayerPool(); + const activePlayer = pool.getOrAddPlayer({ id: 'active-player' }); + pool.getOrAddPlayer({ id: 'other-player' }); + + pool.setActivePlayer(activePlayer as unknown as VideoPlayer); + pool.removePlayer('other-player'); + + expect(pool.getActivePlayer()).toBe(activePlayer); + }); + }); + + describe('deregister', () => { + it('should do nothing when player does not exist', () => { + const pool = new VideoPlayerPool(); + + // Should not throw + pool.deregister('non-existent-player'); + + expect(pool.pool.size).toBe(0); + }); + + it('should remove player from pool without calling onRemove', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-to-deregister' }); + + pool.deregister('player-to-deregister'); + + expect(pool.pool.has('player-to-deregister')).toBe(false); + expect(player.onRemove).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should do nothing when pool is empty', () => { + const pool = new VideoPlayerPool(); + + // Should not throw + pool.clear(); + + expect(pool.pool.size).toBe(0); + expect(pool.getActivePlayer()).toBeNull(); + }); + + it('should remove all players from pool', () => { + const pool = new VideoPlayerPool(); + pool.getOrAddPlayer({ id: 'player-1' }); + pool.getOrAddPlayer({ id: 'player-2' }); + pool.getOrAddPlayer({ id: 'player-3' }); + + expect(pool.pool.size).toBe(3); + + pool.clear(); + + expect(pool.pool.size).toBe(0); + }); + + it('should call onRemove on all players', () => { + const pool = new VideoPlayerPool(); + const player1 = pool.getOrAddPlayer({ id: 'player-1' }); + const player2 = pool.getOrAddPlayer({ id: 'player-2' }); + + pool.clear(); + + expect(player1.onRemove).toHaveBeenCalled(); + expect(player2.onRemove).toHaveBeenCalled(); + }); + + it('should set active player to null', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'active-player' }); + + pool.setActivePlayer(player as unknown as VideoPlayer); + expect(pool.getActivePlayer()).toBe(player); + + pool.clear(); + + expect(pool.getActivePlayer()).toBeNull(); + }); + }); + + describe('requestPlay', () => { + it('should set active player when player exists in pool', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-1' }); + + pool.requestPlay('player-1'); + + expect(pool.getActivePlayer()).toBe(player); + }); + + it('should not change active player when player does not exist', () => { + const pool = new VideoPlayerPool(); + const existingPlayer = pool.getOrAddPlayer({ id: 'existing-player' }); + pool.setActivePlayer(existingPlayer as unknown as VideoPlayer); + + pool.requestPlay('non-existent-player'); + + expect(pool.getActivePlayer()).toBe(existingPlayer); + }); + + it('should pause current active player if it is playing and different from requested', () => { + const pool = new VideoPlayerPool(); + const currentPlayer = createMockPlayer('current-player', { isPlaying: true }); + pool.pool.set('current-player', currentPlayer); + pool.pool.set('new-player', createMockPlayer('new-player')); + + pool.setActivePlayer(currentPlayer); + pool.requestPlay('new-player'); + + expect(currentPlayer.pause).toHaveBeenCalled(); + }); + + it('should not pause current player if it is not playing', () => { + const pool = new VideoPlayerPool(); + const currentPlayer = createMockPlayer('current-player', { isPlaying: false }); + pool.pool.set('current-player', currentPlayer); + pool.pool.set('new-player', createMockPlayer('new-player')); + + pool.setActivePlayer(currentPlayer); + pool.requestPlay('new-player'); + + expect(currentPlayer.pause).not.toHaveBeenCalled(); + }); + + it('should not pause current player if it is the same as requested', () => { + const pool = new VideoPlayerPool(); + const currentPlayer = createMockPlayer('same-player', { isPlaying: true }); + pool.pool.set('same-player', currentPlayer); + + pool.setActivePlayer(currentPlayer); + pool.requestPlay('same-player'); + + expect(currentPlayer.pause).not.toHaveBeenCalled(); + }); + + it('should handle case when there is no current active player', () => { + const pool = new VideoPlayerPool(); + const newPlayer = pool.getOrAddPlayer({ id: 'new-player' }); + + expect(pool.getActivePlayer()).toBeNull(); + + pool.requestPlay('new-player'); + + expect(pool.getActivePlayer()).toBe(newPlayer); + }); + }); + + describe('notifyPaused', () => { + it('should set active player to null', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'active-player' }); + + pool.setActivePlayer(player as unknown as VideoPlayer); + expect(pool.getActivePlayer()).toBe(player); + + pool.notifyPaused(); + + expect(pool.getActivePlayer()).toBeNull(); + }); + + it('should work even when there is no active player', () => { + const pool = new VideoPlayerPool(); + + expect(pool.getActivePlayer()).toBeNull(); + + // Should not throw + pool.notifyPaused(); + + expect(pool.getActivePlayer()).toBeNull(); + }); + }); + + describe('state reactivity', () => { + it('should notify subscribers when active player changes', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-1' }); + const callback = jest.fn(); + + pool.state.subscribeWithSelector( + (state) => ({ + activeVideoPlayer: state.activeVideoPlayer, + }), + ({ activeVideoPlayer }) => callback(activeVideoPlayer), + ); + + pool.setActivePlayer(player as unknown as VideoPlayer); + + expect(callback).toHaveBeenCalledWith(player); + }); + + it('should notify subscribers when active player is cleared', () => { + const pool = new VideoPlayerPool(); + const player = pool.getOrAddPlayer({ id: 'player-1' }); + pool.setActivePlayer(player as unknown as VideoPlayer); + + const callback = jest.fn(); + pool.state.subscribeWithSelector( + (state) => ({ + activeVideoPlayer: state.activeVideoPlayer, + }), + ({ activeVideoPlayer }) => callback(activeVideoPlayer), + ); + + pool.notifyPaused(); + + expect(callback).toHaveBeenCalledWith(null); + }); + }); + + describe('edge cases', () => { + it('should handle multiple sequential play requests', () => { + const pool = new VideoPlayerPool(); + const player1 = createMockPlayer('player-1', { isPlaying: true }); + const player2 = createMockPlayer('player-2', { isPlaying: false }); + const player3 = createMockPlayer('player-3', { isPlaying: false }); + + pool.pool.set('player-1', player1); + pool.pool.set('player-2', player2); + pool.pool.set('player-3', player3); + + pool.setActivePlayer(player1); + pool.requestPlay('player-2'); + // After switching, player2 is now active. Update mock to reflect playing state + (player2 as { isPlaying: boolean }).isPlaying = true; + pool.requestPlay('player-3'); + + expect(player1.pause).toHaveBeenCalled(); + expect(player2.pause).toHaveBeenCalled(); + expect(pool.getActivePlayer()).toBe(player3); + }); + + it('should handle removing all players one by one', () => { + const pool = new VideoPlayerPool(); + const player1 = pool.getOrAddPlayer({ id: 'player-1' }); + pool.getOrAddPlayer({ id: 'player-2' }); + + pool.setActivePlayer(player1 as unknown as VideoPlayer); + + pool.removePlayer('player-1'); + expect(pool.getActivePlayer()).toBeNull(); + expect(pool.pool.size).toBe(1); + + pool.removePlayer('player-2'); + expect(pool.pool.size).toBe(0); + }); + + it('should handle adding player with same id after removal', () => { + const pool = new VideoPlayerPool(); + + const originalPlayer = pool.getOrAddPlayer({ id: 'reusable-id' }); + pool.removePlayer('reusable-id'); + + const newPlayer = pool.getOrAddPlayer({ id: 'reusable-id' }); + + expect(newPlayer).not.toBe(originalPlayer); + expect(pool.pool.has('reusable-id')).toBe(true); + }); + }); +}); diff --git a/package/src/state-store/__tests__/video-player.test.ts b/package/src/state-store/__tests__/video-player.test.ts new file mode 100644 index 0000000000..cc935e1bc3 --- /dev/null +++ b/package/src/state-store/__tests__/video-player.test.ts @@ -0,0 +1,677 @@ +import { NativeHandlers } from '../../native'; +import { ONE_SECOND_IN_MILLISECONDS } from '../../utils/constants'; +import { INITIAL_VIDEO_PLAYER_STATE, VideoPlayer, VideoPlayerOptions } from '../video-player'; +import { VideoPlayerPool } from '../video-player-pool'; + +// Mock the native module +jest.mock('../../native', () => ({ + NativeHandlers: { + SDK: '', + }, +})); + +const createMockPlayerRef = () => ({ + pause: jest.fn(), + play: jest.fn(), + seek: jest.fn(), + seekBy: jest.fn(), +}); + +const createMockPool = (): jest.Mocked => + ({ + notifyPaused: jest.fn(), + requestPlay: jest.fn(), + }) as unknown as jest.Mocked; + +describe('VideoPlayer', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset SDK to default (non-Expo) for most tests + (NativeHandlers as { SDK: string }).SDK = ''; + }); + + describe('INITIAL_VIDEO_PLAYER_STATE', () => { + it('should have correct initial values', () => { + expect(INITIAL_VIDEO_PLAYER_STATE).toEqual({ + duration: 0, + isPlaying: false, + position: 0, + progress: 0, + }); + }); + }); + + describe('constructor', () => { + it('should initialize with default state when no autoPlay option', () => { + const options: VideoPlayerOptions = { id: 'test-player' }; + const player = new VideoPlayer(options); + + expect(player.id).toBe('test-player'); + expect(player.isPlaying).toBe(false); + expect(player.duration).toBe(0); + expect(player.position).toBe(0); + expect(player.progress).toBe(0); + }); + + it('should initialize with isPlaying=true when autoPlay is true', () => { + const options: VideoPlayerOptions = { autoPlay: true, id: 'autoplay-player' }; + const player = new VideoPlayer(options); + + expect(player.isPlaying).toBe(true); + }); + + it('should initialize with isPlaying=false when autoPlay is false', () => { + const options: VideoPlayerOptions = { autoPlay: false, id: 'no-autoplay-player' }; + const player = new VideoPlayer(options); + + expect(player.isPlaying).toBe(false); + }); + + it('should store options', () => { + const options: VideoPlayerOptions = { autoPlay: true, id: 'options-player' }; + const player = new VideoPlayer(options); + + expect(player.options).toBe(options); + }); + + it('should initialize playerRef as null', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.playerRef).toBeNull(); + }); + + it('should detect Expo SDK', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-expo'; + const player = new VideoPlayer({ id: 'expo-player' }); + + // isExpoCLI is private, but we can test its effect through seek behavior + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + player.seek(5000); + + // Expo uses seekBy instead of seek + expect(mockRef.seekBy).toHaveBeenCalled(); + expect(mockRef.seek).not.toHaveBeenCalled(); + }); + + it('should detect non-Expo SDK', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native'; + const player = new VideoPlayer({ id: 'rn-player' }); + + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + player.seek(5000); + + // Non-Expo uses seek instead of seekBy + expect(mockRef.seek).toHaveBeenCalled(); + expect(mockRef.seekBy).not.toHaveBeenCalled(); + }); + }); + + describe('initPlayer', () => { + it('should set playerRef when provided', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + + player.initPlayer({ playerRef: mockRef as never }); + + expect(player.playerRef).toBe(mockRef); + }); + + it('should set playerRef to null when not provided', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + + player.initPlayer({ playerRef: mockRef as never }); + player.initPlayer({}); + + expect(player.playerRef).toBeNull(); + }); + }); + + describe('id getter', () => { + it('should return the player id', () => { + const player = new VideoPlayer({ id: 'unique-id' }); + + expect(player.id).toBe('unique-id'); + }); + }); + + describe('isPlaying getter and setter', () => { + it('should get isPlaying from state', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + + expect(player.isPlaying).toBe(true); + }); + + it('should set isPlaying in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.isPlaying = true; + + expect(player.state.getLatestValue().isPlaying).toBe(true); + + player.isPlaying = false; + + expect(player.state.getLatestValue().isPlaying).toBe(false); + }); + }); + + describe('duration getter and setter', () => { + it('should get duration from state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.duration).toBe(0); + }); + + it('should set duration in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.duration = 5000; + + expect(player.state.getLatestValue().duration).toBe(5000); + expect(player.duration).toBe(5000); + }); + }); + + describe('position getter and setter', () => { + it('should get position from state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.position).toBe(0); + }); + + it('should set position and calculate progress in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.position = 5000; + + expect(player.state.getLatestValue().position).toBe(5000); + expect(player.state.getLatestValue().progress).toBe(0.5); + }); + + it('should handle position when duration is 0', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.position = 5000; + + expect(player.state.getLatestValue().position).toBe(5000); + expect(player.state.getLatestValue().progress).toBe(Infinity); + }); + }); + + describe('progress getter and setter', () => { + it('should get progress from state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.progress).toBe(0); + }); + + it('should set progress and calculate position in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.progress = 0.75; + + expect(player.state.getLatestValue().progress).toBe(0.75); + expect(player.state.getLatestValue().position).toBe(7500); + }); + + it('should handle progress when duration is 0', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.progress = 0.5; + + expect(player.state.getLatestValue().progress).toBe(0.5); + expect(player.state.getLatestValue().position).toBe(0); + }); + }); + + describe('pool setter', () => { + it('should set pool reference', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockPool = createMockPool(); + + player.pool = mockPool; + + // Test pool is set by checking play() behavior + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.play(); + + expect(mockPool.requestPlay).toHaveBeenCalledWith('test-player'); + }); + }); + + describe('play', () => { + it('should call requestPlay on pool when pool is set', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockPool = createMockPool(); + player.pool = mockPool; + + player.play(); + + expect(mockPool.requestPlay).toHaveBeenCalledWith('test-player'); + }); + + it('should call play on playerRef when available', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.play(); + + expect(mockRef.play).toHaveBeenCalled(); + }); + + it('should set isPlaying to true in state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.play(); + + expect(player.state.getLatestValue().isPlaying).toBe(true); + }); + + it('should not throw when no pool or playerRef', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(() => player.play()).not.toThrow(); + expect(player.isPlaying).toBe(true); + }); + + it('should not call playerRef.play when play method is undefined', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = { pause: jest.fn() }; // No play method + player.initPlayer({ playerRef: mockRef as never }); + + expect(() => player.play()).not.toThrow(); + expect(player.isPlaying).toBe(true); + }); + }); + + describe('pause', () => { + it('should call pause on playerRef when available', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.pause(); + + expect(mockRef.pause).toHaveBeenCalled(); + }); + + it('should set isPlaying to false in state', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + + player.pause(); + + expect(player.state.getLatestValue().isPlaying).toBe(false); + }); + + it('should call notifyPaused on pool when pool is set', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockPool = createMockPool(); + player.pool = mockPool; + + player.pause(); + + expect(mockPool.notifyPaused).toHaveBeenCalled(); + }); + + it('should not throw when no pool or playerRef', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(() => player.pause()).not.toThrow(); + expect(player.isPlaying).toBe(false); + }); + + it('should not call playerRef.pause when pause method is undefined', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = { play: jest.fn() }; // No pause method + player.initPlayer({ playerRef: mockRef as never }); + + expect(() => player.pause()).not.toThrow(); + }); + }); + + describe('toggle', () => { + it('should call pause when currently playing', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.toggle(); + + expect(mockRef.pause).toHaveBeenCalled(); + expect(player.isPlaying).toBe(false); + }); + + it('should call play when currently paused', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.toggle(); + + expect(mockRef.play).toHaveBeenCalled(); + expect(player.isPlaying).toBe(true); + }); + + it('should toggle state correctly multiple times', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(player.isPlaying).toBe(false); + + player.toggle(); + expect(player.isPlaying).toBe(true); + + player.toggle(); + expect(player.isPlaying).toBe(false); + + player.toggle(); + expect(player.isPlaying).toBe(true); + }); + }); + + describe('seek', () => { + it('should update position state', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.seek(5000); + + expect(player.position).toBe(5000); + expect(player.progress).toBe(0.5); + }); + + it('should call seek on playerRef for non-Expo SDK', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native'; + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + player.seek(5000); + + expect(mockRef.seek).toHaveBeenCalledWith(5000 / ONE_SECOND_IN_MILLISECONDS); + expect(mockRef.seekBy).not.toHaveBeenCalled(); + }); + + it('should call seekBy on playerRef for Expo SDK', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-expo'; + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + player.seek(5000); + + expect(mockRef.seekBy).toHaveBeenCalledWith(5000 / ONE_SECOND_IN_MILLISECONDS); + expect(mockRef.seek).not.toHaveBeenCalled(); + }); + + it('should not throw when playerRef has no seek methods', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = {}; + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + expect(() => player.seek(5000)).not.toThrow(); + expect(player.position).toBe(5000); + }); + + it('should not throw when no playerRef', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + expect(() => player.seek(5000)).not.toThrow(); + expect(player.position).toBe(5000); + }); + + it('should convert milliseconds to seconds correctly', () => { + (NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native'; + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + player.seek(2500); + + expect(mockRef.seek).toHaveBeenCalledWith(2.5); + }); + }); + + describe('stop', () => { + it('should seek to 0 and pause', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + player.position = 5000; + + player.stop(); + + expect(player.position).toBe(0); + expect(player.isPlaying).toBe(false); + expect(mockRef.pause).toHaveBeenCalled(); + }); + + it('should work without playerRef', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + player.duration = 10000; + player.position = 5000; + + player.stop(); + + expect(player.position).toBe(0); + expect(player.isPlaying).toBe(false); + }); + }); + + describe('onRemove', () => { + it('should pause playerRef if available', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.onRemove(); + + expect(mockRef.pause).toHaveBeenCalled(); + }); + + it('should set playerRef to null', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.onRemove(); + + expect(player.playerRef).toBeNull(); + }); + + it('should reset state to initial values', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + player.duration = 10000; + player.position = 5000; + + player.onRemove(); + + expect(player.state.getLatestValue()).toEqual(INITIAL_VIDEO_PLAYER_STATE); + }); + + it('should not throw when no playerRef', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + expect(() => player.onRemove()).not.toThrow(); + }); + + it('should not throw when playerRef has no pause method', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = { play: jest.fn() }; + player.initPlayer({ playerRef: mockRef as never }); + + expect(() => player.onRemove()).not.toThrow(); + expect(player.playerRef).toBeNull(); + }); + }); + + describe('state reactivity', () => { + it('should notify subscribers when isPlaying changes', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const callback = jest.fn(); + + player.state.subscribeWithSelector( + (state) => ({ isPlaying: state.isPlaying }), + ({ isPlaying }) => callback(isPlaying), + ); + + player.play(); + + expect(callback).toHaveBeenCalledWith(true); + }); + + it('should notify subscribers when duration changes', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const callback = jest.fn(); + + player.state.subscribeWithSelector( + (state) => ({ duration: state.duration }), + ({ duration }) => callback(duration), + ); + + player.duration = 5000; + + expect(callback).toHaveBeenCalledWith(5000); + }); + + it('should notify subscribers when position changes', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const callback = jest.fn(); + player.duration = 10000; + + player.state.subscribeWithSelector( + (state) => ({ position: state.position }), + ({ position }) => callback(position), + ); + + player.position = 2500; + + expect(callback).toHaveBeenCalledWith(2500); + }); + + it('should notify subscribers when progress changes', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const callback = jest.fn(); + player.duration = 10000; + + player.state.subscribeWithSelector( + (state) => ({ progress: state.progress }), + ({ progress }) => callback(progress), + ); + + player.progress = 0.5; + + expect(callback).toHaveBeenCalledWith(0.5); + }); + }); + + describe('edge cases', () => { + it('should handle play/pause cycle correctly', () => { + const player = new VideoPlayer({ id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.play(); + expect(player.isPlaying).toBe(true); + expect(mockRef.play).toHaveBeenCalledTimes(1); + + player.pause(); + expect(player.isPlaying).toBe(false); + expect(mockRef.pause).toHaveBeenCalledTimes(1); + + player.play(); + expect(player.isPlaying).toBe(true); + expect(mockRef.play).toHaveBeenCalledTimes(2); + }); + + it('should handle seek during playback', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + player.duration = 10000; + + player.seek(7500); + + expect(player.position).toBe(7500); + expect(player.progress).toBe(0.75); + expect(player.isPlaying).toBe(true); // Should remain playing + }); + + it('should handle zero duration gracefully', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.position = 1000; + expect(player.progress).toBe(Infinity); + + player.progress = 0.5; + expect(player.position).toBe(0); // 0.5 * 0 = 0 + }); + + it('should handle negative position values', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.position = -1000; + + expect(player.position).toBe(-1000); + expect(player.progress).toBe(-0.1); + }); + + it('should handle progress values greater than 1', () => { + const player = new VideoPlayer({ id: 'test-player' }); + player.duration = 10000; + + player.progress = 1.5; + + expect(player.progress).toBe(1.5); + expect(player.position).toBe(15000); + }); + + it('should handle multiple state updates in sequence', () => { + const player = new VideoPlayer({ id: 'test-player' }); + + player.duration = 10000; + player.position = 2500; + player.isPlaying = true; + player.progress = 0.75; + + const state = player.state.getLatestValue(); + expect(state.duration).toBe(10000); + expect(state.progress).toBe(0.75); + expect(state.position).toBe(7500); // progress setter updates position + expect(state.isPlaying).toBe(true); + }); + + it('should maintain state consistency after onRemove', () => { + const player = new VideoPlayer({ autoPlay: true, id: 'test-player' }); + player.duration = 10000; + player.position = 5000; + const mockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: mockRef as never }); + + player.onRemove(); + + const state = player.state.getLatestValue(); + expect(state).toEqual(INITIAL_VIDEO_PLAYER_STATE); + expect(player.playerRef).toBeNull(); + + // Should be able to reinitialize + const newMockRef = createMockPlayerRef(); + player.initPlayer({ playerRef: newMockRef as never }); + expect(player.playerRef).toBe(newMockRef); + }); + }); +}); diff --git a/package/src/state-store/image-gallery-state-store.ts b/package/src/state-store/image-gallery-state-store.ts new file mode 100644 index 0000000000..1ba675d4f4 --- /dev/null +++ b/package/src/state-store/image-gallery-state-store.ts @@ -0,0 +1,225 @@ +import { Attachment, LocalMessage, StateStore, Unsubscribe, UserResponse } from 'stream-chat'; + +import { VideoPlayerPool } from './video-player-pool'; + +import { getGiphyMimeType } from '../components/Attachment/utils/getGiphyMimeType'; +import { isVideoPlayerAvailable } from '../native'; +import { FileTypes } from '../types/types'; +import { getUrlOfImageAttachment } from '../utils/getUrlOfImageAttachment'; + +export type ImageGalleryAsset = { + id: string; + uri: string; + channelId?: string; + created_at?: string | Date; + messageId?: string; + mime_type?: string; + original_height?: number; + original_width?: number; + thumb_url?: string; + type?: string; + user?: UserResponse | null; + user_id?: string; +}; + +const isViewableImageAttachment = (attachment: Attachment) => { + return attachment.type === FileTypes.Image && !attachment.title_link && !attachment.og_scrape_url; +}; + +const isViewableVideoAttachment = (attachment: Attachment) => { + return attachment.type === FileTypes.Video && isVideoPlayerAvailable(); +}; + +const isViewableGiphyAttachment = (attachment: Attachment) => { + return attachment.type === FileTypes.Giphy; +}; + +const stripQueryFromUrl = (url: string) => url.split('?')[0]; + +export type ImageGalleryState = { + assets: ImageGalleryAsset[]; + messages: LocalMessage[]; + selectedAttachmentUrl?: string; + currentIndex: number; +}; + +const INITIAL_STATE: ImageGalleryState = { + assets: [], + currentIndex: 0, + messages: [], + selectedAttachmentUrl: undefined, +}; + +export type ImageGalleryOptions = { + autoPlayVideo?: boolean; + giphyVersion?: keyof NonNullable; +}; + +const INITIAL_IMAGE_GALLERY_OPTIONS: ImageGalleryOptions = { + autoPlayVideo: false, + giphyVersion: 'fixed_height', +}; + +export class ImageGalleryStateStore { + state: StateStore; + options: ImageGalleryOptions; + videoPlayerPool: VideoPlayerPool; + + constructor(options: Partial = {}) { + this.options = { ...INITIAL_IMAGE_GALLERY_OPTIONS, ...options }; + this.state = new StateStore(INITIAL_STATE); + this.videoPlayerPool = new VideoPlayerPool(); + } + + // Getters + get messages() { + return this.state.getLatestValue().messages; + } + + get selectedAttachmentUrl() { + return this.state.getLatestValue().selectedAttachmentUrl; + } + + get attachmentsWithMessage() { + const messages = this.messages; + + const attachmentsWithMessage = messages + .map((message) => ({ + attachments: message.attachments ?? [], + message, + })) + .filter(({ attachments }) => + attachments.some((attachment) => { + if (!attachment) { + return false; + } + return ( + isViewableImageAttachment(attachment) || + isViewableVideoAttachment(attachment) || + isViewableGiphyAttachment(attachment) + ); + }), + ); + + return attachmentsWithMessage; + } + + getAssetId(messageId: string, assetUrl: string) { + return `photoId-${messageId}-${assetUrl}`; + } + + get assets() { + const attachmentsWithMessage = this.attachmentsWithMessage; + const { giphyVersion = 'fixed_height' } = this.options; + + return attachmentsWithMessage.flatMap(({ message, attachments }) => { + return attachments.map((attachment) => { + const assetUrl = getUrlOfImageAttachment(attachment, giphyVersion) as string; + const assetId = this.getAssetId(message?.id ?? '', assetUrl); + const giphyURL = + attachment.giphy?.[giphyVersion]?.url || attachment.thumb_url || attachment.image_url; + const giphyMimeType = getGiphyMimeType(giphyURL ?? ''); + + return { + channelId: message?.cid, + created_at: message?.created_at, + id: assetId, + messageId: message?.id, + mime_type: attachment.type === 'giphy' ? giphyMimeType : attachment.mime_type, + original_height: attachment.original_height, + original_width: attachment.original_width, + thumb_url: attachment.thumb_url, + type: attachment.type, + uri: assetUrl, + user: message?.user, + user_id: message?.user_id, + }; + }); + }); + } + + // Setters + set messages(messages: LocalMessage[]) { + this.state.partialNext({ messages }); + } + + set selectedAttachmentUrl(selectedAttachmentUrl: string | undefined) { + this.state.partialNext({ selectedAttachmentUrl }); + } + + set currentIndex(currentIndex: number) { + this.state.partialNext({ currentIndex }); + } + + // APIs for managing messages + appendMessages = (messages: LocalMessage[]) => { + this.state.partialNext({ messages: [...this.messages, ...messages] }); + }; + + removeMessages = (messages: LocalMessage[]) => { + this.state.partialNext({ + messages: this.messages.filter((message) => !messages.includes(message)), + }); + }; + + openImageGallery = ({ + messages, + selectedAttachmentUrl, + }: { + messages: LocalMessage[]; + selectedAttachmentUrl?: string; + }) => { + this.state.partialNext({ messages, selectedAttachmentUrl }); + }; + + subscribeToMessages = () => { + const unsubscribe = this.state.subscribeWithSelector( + (currentValue) => ({ + messages: currentValue.messages, + }), + () => { + const assets = this.assets; + this.state.partialNext({ assets }); + }, + ); + + return unsubscribe; + }; + + subscribeToSelectedAttachmentUrl = () => { + const unsubscribe = this.state.subscribeWithSelector( + (currentValue) => ({ + messages: currentValue.messages, + selectedAttachmentUrl: currentValue.selectedAttachmentUrl, + }), + ({ selectedAttachmentUrl }) => { + if (!selectedAttachmentUrl) { + return; + } + const index = this.assets.findIndex( + (asset) => + stripQueryFromUrl(asset.uri) === stripQueryFromUrl(selectedAttachmentUrl ?? ''), + ); + this.state.partialNext({ currentIndex: index === -1 ? 0 : index }); + }, + ); + + return unsubscribe; + }; + + registerSubscriptions = () => { + const subscriptions: Unsubscribe[] = []; + subscriptions.push(this.subscribeToMessages()); + subscriptions.push(this.subscribeToSelectedAttachmentUrl()); + + return () => { + subscriptions.forEach((subscription) => subscription()); + this.videoPlayerPool.clear(); + }; + }; + + clear = () => { + this.state.partialNext(INITIAL_STATE); + this.videoPlayerPool.clear(); + }; +} diff --git a/package/src/state-store/index.ts b/package/src/state-store/index.ts index 4d896c62a9..015248e1b0 100644 --- a/package/src/state-store/index.ts +++ b/package/src/state-store/index.ts @@ -1,4 +1,7 @@ -export * from './audio-player'; export * from './in-app-notifications-store'; +export * from './audio-player'; export * from './audio-player-pool'; +export * from './video-player'; +export * from './video-player-pool'; +export * from './image-gallery-state-store'; export * from './message-overlay-store'; diff --git a/package/src/state-store/video-player-pool.ts b/package/src/state-store/video-player-pool.ts new file mode 100644 index 0000000000..0ab04df7fd --- /dev/null +++ b/package/src/state-store/video-player-pool.ts @@ -0,0 +1,86 @@ +import { StateStore } from 'stream-chat'; + +import { VideoPlayer, VideoPlayerOptions } from './video-player'; + +export type VideoPlayerPoolState = { + activeVideoPlayer: VideoPlayer | null; +}; + +export class VideoPlayerPool { + pool: Map; + state: StateStore = new StateStore({ + activeVideoPlayer: null, + }); + + constructor() { + this.pool = new Map(); + } + + get players() { + return Array.from(this.pool.values()); + } + + getOrAddPlayer(params: VideoPlayerOptions) { + const player = this.pool.get(params.id); + if (player) { + return player; + } + const newPlayer = new VideoPlayer(params); + newPlayer.pool = this; + + this.pool.set(params.id, newPlayer); + return newPlayer; + } + + setActivePlayer(activeVideoPlayer: VideoPlayer | null) { + this.state.partialNext({ + activeVideoPlayer, + }); + } + + getActivePlayer() { + return this.state.getLatestValue().activeVideoPlayer; + } + + removePlayer(id: string) { + const player = this.pool.get(id); + if (!player) return; + player.onRemove(); + this.pool.delete(id); + + if (this.getActivePlayer()?.id === id) { + this.setActivePlayer(null); + } + } + + deregister(id: string) { + if (this.pool.has(id)) { + this.pool.delete(id); + } + } + + clear() { + for (const player of this.pool.values()) { + this.removePlayer(player.id); + } + this.setActivePlayer(null); + } + + requestPlay(id: string) { + if (this.getActivePlayer()?.id !== id) { + const currentPlayer = this.getActivePlayer(); + if (currentPlayer && currentPlayer.isPlaying) { + currentPlayer.pause(); + } + } + + const activePlayer = this.pool.get(id); + if (activePlayer) { + this.setActivePlayer(activePlayer); + } + } + + notifyPaused() { + this.setActivePlayer(null); + } +} diff --git a/package/src/state-store/video-player.ts b/package/src/state-store/video-player.ts new file mode 100644 index 0000000000..7f8eb059eb --- /dev/null +++ b/package/src/state-store/video-player.ts @@ -0,0 +1,155 @@ +import { StateStore } from 'stream-chat'; + +import { VideoPlayerPool } from './video-player-pool'; + +import { NativeHandlers, VideoType } from '../native'; +import { ONE_SECOND_IN_MILLISECONDS } from '../utils/constants'; + +export type VideoPlayerState = { + duration: number; + position: number; + progress: number; + isPlaying: boolean; +}; + +export type VideoDescriptor = { + id: string; +}; + +export type VideoPlayerOptions = VideoDescriptor & { + autoPlay?: boolean; +}; + +export const INITIAL_VIDEO_PLAYER_STATE: VideoPlayerState = { + duration: 0, + isPlaying: false, + position: 0, + progress: 0, +}; + +export class VideoPlayer { + state: StateStore; + options: VideoPlayerOptions; + playerRef: VideoType | null = null; + _pool: VideoPlayerPool | null = null; + private _id: string; + private isExpoCLI: boolean; + + constructor(options: VideoPlayerOptions) { + this.isExpoCLI = NativeHandlers.SDK === 'stream-chat-expo'; + this.state = new StateStore({ + ...INITIAL_VIDEO_PLAYER_STATE, + isPlaying: options.autoPlay ?? false, + }); + this.options = options; + this._id = options.id; + } + + initPlayer = ({ playerRef }: { playerRef?: VideoType }) => { + this.playerRef = playerRef ?? null; + }; + + get id() { + return this._id; + } + + get isPlaying() { + return this.state.getLatestValue().isPlaying; + } + + get duration() { + return this.state.getLatestValue().duration; + } + + get position() { + return this.state.getLatestValue().position; + } + + get progress() { + return this.state.getLatestValue().progress; + } + + set pool(pool: VideoPlayerPool) { + this._pool = pool; + } + + set duration(duration: number) { + this.state.partialNext({ + duration, + }); + } + + set position(position: number) { + this.state.partialNext({ + position, + progress: position / this.duration, + }); + } + + set progress(progress: number) { + this.state.partialNext({ + position: progress * this.duration, + progress, + }); + } + + set isPlaying(isPlaying: boolean) { + this.state.partialNext({ + isPlaying, + }); + } + + play() { + if (this._pool) { + this._pool.requestPlay(this.id); + } + if (this.playerRef?.play) { + this.playerRef.play(); + } + this.state.partialNext({ + isPlaying: true, + }); + } + + pause() { + if (this.playerRef?.pause) { + this.playerRef.pause(); + } + this.state.partialNext({ + isPlaying: false, + }); + if (this._pool) { + this._pool.notifyPaused(); + } + } + + toggle() { + this.isPlaying ? this.pause() : this.play(); + } + + seek(position: number) { + this.position = position; + if (this.isExpoCLI) { + if (this.playerRef?.seekBy) { + this.playerRef.seekBy(position / ONE_SECOND_IN_MILLISECONDS); + } + } else { + if (this.playerRef?.seek) { + this.playerRef.seek(position / ONE_SECOND_IN_MILLISECONDS); + } + } + } + + stop() { + this.seek(0); + this.pause(); + } + + onRemove() { + if (this.playerRef?.pause) { + this.playerRef.pause(); + } + this.playerRef = null; + this.state.partialNext(INITIAL_VIDEO_PLAYER_STATE); + } +} diff --git a/package/src/utils/constants.ts b/package/src/utils/constants.ts index df34a63b25..3fc805ae85 100644 --- a/package/src/utils/constants.ts +++ b/package/src/utils/constants.ts @@ -5,3 +5,4 @@ export const defaultMentionAllAppUsersQuery = { sort: {}, }; export const POLL_OPTION_HEIGHT = 71; +export const ONE_SECOND_IN_MILLISECONDS = 1000; diff --git a/package/src/utils/getUrlOfImageAttachment.ts b/package/src/utils/getUrlOfImageAttachment.ts index 3f012ffccb..ec979d74b4 100644 --- a/package/src/utils/getUrlOfImageAttachment.ts +++ b/package/src/utils/getUrlOfImageAttachment.ts @@ -1,10 +1,19 @@ import type { Attachment } from 'stream-chat'; +import { FileTypes } from '../types/types'; + /** * Extract url of image from image attachment. * @param image Image attachment * @returns {string} */ -export function getUrlOfImageAttachment(image: Attachment) { +export function getUrlOfImageAttachment( + image: Attachment, + giphyVersion: keyof NonNullable = 'fixed_height', +) { + if (image.type === FileTypes.Giphy) { + return image.giphy?.[giphyVersion]?.url || image.thumb_url; + } + return image.image_url || image.asset_url; } diff --git a/package/yarn.lock b/package/yarn.lock index 5d968e079d..155cf0d750 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -1362,10 +1362,10 @@ "@eslint/core" "^0.14.0" levn "^0.4.1" -"@gorhom/bottom-sheet@^5.1.8": - version "5.1.8" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.8.tgz#65547917f5b1dae5a1291dabd4ea8bfee09feba4" - integrity sha512-QuYIVjn3K9bW20n5bgOSjvxBYoWG4YQXiLGOheEAMgISuoT6sMcA270ViSkkb0fenPxcIOwzCnFNuxmr739T9A== +"@gorhom/bottom-sheet@^5.2.8": + version "5.2.8" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz#25e49122c30ffe83d3813b3bcf3dec39f3359aeb" + integrity sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" From 239ed5a68013e0af5a07643cad81b25cbdfd034d Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 19 Jan 2026 20:44:06 +0530 Subject: [PATCH 15/76] chore: add android build and deploy workflows and improve ios workflow (#3334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The following are the changes in the PR: - The android package name for sample app is changed to `io.getstream.reactnative.sampleapp` as it was `com.sampleapp` before. Doesn't make any sense. - Added lanes for android firebase build and upload. - Improved the ios lanes for tesflight build and upload. Our first firebase deployment build after a long time(lane was run manually locally): Screenshot 2025-12-18 at 1 48 03โ€ฏPM --- .../actions/install-and-build-sdk/action.yml | 5 - .github/workflows/check-pr.yml | 2 +- .github/workflows/release.yml | 12 +- .github/workflows/sample-distribution.yml | 39 ++-- examples/SampleApp/.gitignore | 4 + examples/SampleApp/android/app/build.gradle | 6 +- .../android/app/google-services.json | 4 +- .../java/com/sampleapp/DetoxTest.java | 4 +- .../main/java/com/sampleapp/MainActivity.kt | 2 +- .../java/com/sampleapp/MainApplication.kt | 2 +- examples/SampleApp/fastlane/Fastfile | 175 +++++++++++------- examples/SampleApp/package.json | 6 +- 12 files changed, 151 insertions(+), 110 deletions(-) diff --git a/.github/actions/install-and-build-sdk/action.yml b/.github/actions/install-and-build-sdk/action.yml index 5ba82e4bc2..f79f913aa5 100644 --- a/.github/actions/install-and-build-sdk/action.yml +++ b/.github/actions/install-and-build-sdk/action.yml @@ -16,11 +16,6 @@ runs: cd package/native-package/ yarn shell: bash - - name: Install & Build the Expo Package - run: | - cd package/expo-package/ - yarn - shell: bash - name: Install & Build the Sample App working-directory: examples/SampleApp run: yarn diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml index 4e5656143d..98113381d5 100644 --- a/.github/workflows/check-pr.yml +++ b/.github/workflows/check-pr.yml @@ -10,7 +10,7 @@ on: jobs: check_pr: - runs-on: ubuntu-latest + runs-on: public strategy: matrix: node-version: [24.x] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c524c794a2..d782fa42e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,13 +7,13 @@ on: # - develop permissions: - id-token: write # for OIDC / npm provenance if you use it - actions: write # if you dispatch other workflows - contents: write # commits / tags / merge-back + id-token: write # for OIDC / npm provenance if you use it + actions: write # if you dispatch other workflows + contents: write # commits / tags / merge-back jobs: publish: - runs-on: ubuntu-latest + runs-on: public strategy: matrix: node-version: [24.x] @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v2 with: - fetch-depth: "0" + fetch-depth: '0' - name: Fetch tags run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* @@ -30,7 +30,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - registry-url: "https://registry.npmjs.org" + registry-url: 'https://registry.npmjs.org' - name: Prepare git run: | diff --git a/.github/workflows/sample-distribution.yml b/.github/workflows/sample-distribution.yml index 981c8bb2c6..87600b6912 100644 --- a/.github/workflows/sample-distribution.yml +++ b/.github/workflows/sample-distribution.yml @@ -17,7 +17,7 @@ jobs: runs-on: [macos-15] strategy: matrix: - node-version: [ 24.x ] + node-version: [24.x] steps: - name: Connect Bot uses: webfactory/ssh-agent@v0.7.0 @@ -51,18 +51,18 @@ jobs: bundle exec pod install - name: Build and release Testflight QA working-directory: examples/SampleApp - run: bundle exec fastlane deploy_to_testflight_qa deploy:${{ github.ref == 'refs/heads/develop' }}; + run: bundle exec fastlane ios deploy_to_testflight_qa deploy:${{ github.ref == 'refs/heads/develop' }}; env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} - build_and_deploy_android_s3: + build_and_deploy_android_firebase: name: Build SampleApp Android and Deploy-${{ github.ref == 'refs/heads/develop' }} - runs-on: ubuntu-latest + runs-on: public strategy: matrix: - node-version: [ 24.x ] + node-version: [24.x] steps: - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -74,26 +74,15 @@ jobs: distribution: 'zulu' java-version: '17' check-latest: true + - name: Setup Android SDK + uses: amyu/setup-android@v5 + + - uses: ./.github/actions/ruby-cache - name: Install && Build - SDK and Sample App uses: ./.github/actions/install-and-build-sdk - - name: Build + - name: Build and deploy Android Firebase working-directory: examples/SampleApp - run: | - mkdir android/app/src/main/assets - mkdir tmp - yarn react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest tmp - cd android - rm -rf $HOME/.gradle/caches/ && ./gradlew assembleRelease - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - if: ${{ github.ref == 'refs/heads/develop' }} - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 - - name: Upload APK - if: ${{ github.ref == 'refs/heads/develop' }} - # https://getstream.io/downloads/rn-sample-app.apk - run: | - cp examples/SampleApp/android/app/build/outputs/apk/release/app-release.apk rn-sample-app.apk - aws s3 cp rn-sample-app.apk s3://${{ secrets.AWS_S3_BUCKET }} --sse AES256 + run: bundle exec fastlane android firebase_build_and_upload deploy:${{ github.ref == 'refs/heads/develop' }}; + env: + ANDROID_FIREBASE_APP_ID: ${{ secrets.ANDROID_FIREBASE_APP_ID }} + FIREBASE_CREDENTIALS_JSON: ${{ secrets.FIREBASE_CREDENTIALS_JSON }} diff --git a/examples/SampleApp/.gitignore b/examples/SampleApp/.gitignore index 1f3e68a22e..7388cefebf 100644 --- a/examples/SampleApp/.gitignore +++ b/examples/SampleApp/.gitignore @@ -73,3 +73,7 @@ yarn-error.log !.yarn/releases !.yarn/sdks !.yarn/versions + +# Credentials +credentials/ +app-build/ diff --git a/examples/SampleApp/android/app/build.gradle b/examples/SampleApp/android/app/build.gradle index e1c59595a5..39b1680d7a 100644 --- a/examples/SampleApp/android/app/build.gradle +++ b/examples/SampleApp/android/app/build.gradle @@ -79,15 +79,15 @@ android { buildToolsVersion rootProject.ext.buildToolsVersion compileSdk rootProject.ext.compileSdkVersion - namespace "com.sampleapp" + namespace "io.getstream.reactnative.sampleapp" defaultConfig { - applicationId "com.sampleapp" + applicationId "io.getstream.reactnative.sampleapp" minSdkVersion rootProject.ext.minSdkVersion multiDexEnabled true targetSdkVersion rootProject.ext.targetSdkVersion vectorDrawables.useSupportLibrary = true - versionCode 22 + versionCode 1 versionName "0.0.22" } diff --git a/examples/SampleApp/android/app/google-services.json b/examples/SampleApp/android/app/google-services.json index 1d5c701c81..ec09246afb 100644 --- a/examples/SampleApp/android/app/google-services.json +++ b/examples/SampleApp/android/app/google-services.json @@ -62,7 +62,7 @@ "client_info": { "mobilesdk_app_id": "1:674907137625:android:5effa1cd0fef9003d7f348", "android_client_info": { - "package_name": "com.sampleapp" + "package_name": "io.getstream.reactnative.sampleapp" } }, "oauth_client": [ @@ -98,7 +98,7 @@ "client_info": { "mobilesdk_app_id": "1:674907137625:android:07c76802bbfd5654d7f348", "android_client_info": { - "package_name": "com.sampleapp.rnpushtest" + "package_name": "io.getstream.reactnative.sampleapp.rnpushtest" } }, "oauth_client": [ diff --git a/examples/SampleApp/android/app/src/androidTest/java/com/sampleapp/DetoxTest.java b/examples/SampleApp/android/app/src/androidTest/java/com/sampleapp/DetoxTest.java index df226139a6..9557ec76a9 100644 --- a/examples/SampleApp/android/app/src/androidTest/java/com/sampleapp/DetoxTest.java +++ b/examples/SampleApp/android/app/src/androidTest/java/com/sampleapp/DetoxTest.java @@ -1,4 +1,4 @@ -package com.sampleapp; +package io.getstream.reactnative.sampleapp; import com.wix.detox.Detox; import com.wix.detox.config.DetoxConfig; @@ -24,7 +24,7 @@ public void runDetoxTests() { DetoxConfig detoxConfig = new DetoxConfig(); detoxConfig.idlePolicyConfig.masterTimeoutSec = 90; detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60; - detoxConfig.rnContextLoadTimeoutSec = (com.sampleapp.BuildConfig.DEBUG ? 180 : 60); + detoxConfig.rnContextLoadTimeoutSec = (io.getstream.reactnative.sampleapp.BuildConfig.DEBUG ? 180 : 60); Detox.runTests(mActivityRule, detoxConfig); } 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 79546eb2e2..c811c58912 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,4 +1,4 @@ -package com.sampleapp +package io.getstream.reactnative.sampleapp import android.os.Build import android.os.Bundle diff --git a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainApplication.kt b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainApplication.kt index 69b92b8443..84c3f9a65e 100644 --- a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainApplication.kt +++ b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainApplication.kt @@ -1,4 +1,4 @@ -package com.sampleapp +package io.getstream.reactnative.sampleapp import android.app.Application import com.facebook.react.PackageList diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index bf10e28713..321188bab7 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -1,13 +1,23 @@ -default_platform(:ios) skip_docs +# Common Configuration github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-react-native' -bundle_id = 'io.getstream.reactnative.SampleApp' -xcode_project = 'ios/SampleApp.xcodeproj' -xcode_workspace = 'ios/SampleApp.xcworkspace' root_path = File.absolute_path('../../../') sdk_size_ext = 'KB' @force_check = false +build_output_directory = "./app-build" + +# iOS Platform Configuration +bundle_id = 'io.getstream.reactnative.SampleApp' +xcode_project = 'ios/SampleApp.xcodeproj' +xcode_workspace = 'ios/SampleApp.xcworkspace' +output_ipa_name = "reactnativesampleapp.ipa" + +# Android Platform Configuration +package_name = 'io.getstream.reactnative.sampleapp' +output_apk_name = "reactnativesampleapp.apk" +apk_path = "#{build_output_directory}/#{output_apk_name}" + before_all do if is_ci @@ -20,82 +30,121 @@ end ###### iOS lanes ###### ####################### -lane :deploy_to_testflight_qa do |options| - match_me - - settings_to_override = { - BUNDLE_IDENTIFIER: bundle_id, - PROVISIONING_PROFILE_SPECIFIER: "match AppStore #{bundle_id}" - } - - increment_version_number( - version_number: load_json(json_path: './package.json')['version'], - xcodeproj: xcode_project - ) - - current_build_number = app_store_build_number( - api_key: appstore_api_key, - live: false, - app_identifier: bundle_id +platform :ios do + lane :deploy_to_testflight_qa do |options| + match_me + + deploy = options.fetch(:deploy, false) + + UI.message("Deploying to Testflight: #{deploy}") + + settings_to_override = { + BUNDLE_IDENTIFIER: bundle_id, + PROVISIONING_PROFILE_SPECIFIER: "match AppStore #{bundle_id}" + } + + gym( + workspace: xcode_workspace, + scheme: 'SampleApp', + export_method: 'app-store', + export_options: './fastlane/testflight_gym_export_options.plist', + silent: true, + clean: true, + xcargs: settings_to_override, + include_symbols: true, + output_directory: build_output_directory ) - increment_build_number( - build_number: current_build_number + 1, - xcodeproj: xcode_project - ) + if deploy + increment_version_number( + version_number: load_json(json_path: './package.json')['version'], + xcodeproj: xcode_project + ) - gym( - workspace: xcode_workspace, - scheme: 'SampleApp', - export_method: 'app-store', - export_options: './fastlane/testflight_gym_export_options.plist', - silent: true, - clean: true, - xcargs: settings_to_override, - include_symbols: true, - output_directory: './dist' - ) + current_build_number = app_store_build_number( + api_key: appstore_api_key, + live: false, + app_identifier: bundle_id + ) + + increment_build_number( + build_number: current_build_number + 1, + xcodeproj: xcode_project + ) - if options[:deploy] - begin upload_to_testflight( groups: ['Testers'], changelog: 'Lots of amazing new features to test out!', - reject_build_waiting_for_review: false + reject_build_waiting_for_review: false, + ipa: "#{build_output_directory}/#{output_ipa_name}" ) - rescue StandardError => e - if e.message.include?('Another build is in review') - UI.important('Another build is already in beta review. Skipping beta review submission') - else - UI.user_error!(e) - end + else + UI.message("Skipping Testflight upload! (deploy: #{deploy})") end end -end -private_lane :appstore_api_key do - @appstore_api_key ||= app_store_connect_api_key( - key_id: 'MT3PRT8TB7', - issuer_id: '69a6de96-0738-47e3-e053-5b8c7c11a4d1', - key_content: ENV.fetch('APPSTORE_API_KEY', nil), - in_house: false - ) -end - -desc "If `readonly: true` (by default), installs all Certs and Profiles necessary for development and ad-hoc.\nIf `readonly: false`, recreates all Profiles necessary for development and ad-hoc, updates them locally and remotely." -lane :match_me do |options| - custom_match( - api_key: appstore_api_key, - app_identifier: [bundle_id], - readonly: options[:readonly], - register_device: options[:register_device] - ) + private_lane :appstore_api_key do + @appstore_api_key ||= app_store_connect_api_key( + key_id: 'MT3PRT8TB7', + issuer_id: '69a6de96-0738-47e3-e053-5b8c7c11a4d1', + key_content: ENV.fetch('APPSTORE_API_KEY', nil), + in_house: false + ) + end + + desc "If `readonly: true` (by default), installs all Certs and Profiles necessary for development and ad-hoc.\nIf `readonly: false`, recreates all Profiles necessary for development and ad-hoc, updates them locally and remotely." + lane :match_me do |options| + custom_match( + api_key: appstore_api_key, + app_identifier: [bundle_id], + readonly: options[:readonly], + register_device: options[:register_device] + ) + end end ########################### ###### Android lanes ###### ########################### +platform :android do + lane :firebase_build_and_upload do |options| + deploy = options.fetch(:deploy, false) + + UI.message("Deploying to Firebase: #{deploy}") + + # Clean + gradle( + task: "clean", + project_dir: "./android" + ) + + # Build the AAB + gradle( + task: "assemble", + build_type: "Release", + project_dir: "./android" + ) + + Dir.chdir('..') do + sh("mkdir -p #{build_output_directory} && mv -f #{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]} #{apk_path}") + end + + if deploy + # Upload to Firebase App Distribution + firebase_app_distribution( + app: ENV.fetch('ANDROID_FIREBASE_APP_ID', nil), + service_credentials_json: ENV.fetch('FIREBASE_CREDENTIALS_JSON', nil), + android_artifact_path: apk_path, + android_artifact_type: "APK", + groups: "stream-testers" + ) + else + UI.message("Skipping Firebase upload! (deploy: #{deploy})") + end + end +end + ########################## ###### Common lanes ###### ########################## diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index c1171deb10..eb08fce2bb 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -20,7 +20,11 @@ "release-next": "echo \"Skipping next release for SampleApp\"", "test:unit": "echo \"Skipping unit tests for SampleApp\"", "clean": "watchman watch-del-all && yarn cache clean && rm -rf ios/build && pod cache clean --all && rm -rf android/build && cd android && ./gradlew clean && cd -", - "clean-all": "yarn clean && rm -rf node_modules && rm -rf ios/Pods && rm -rf vendor && bundle install && yarn install && cd ios && bundle exec pod install && cd -" + "clean-all": "yarn clean && rm -rf node_modules && rm -rf ios/Pods && rm -rf vendor && bundle install && yarn install && cd ios && bundle exec pod install && cd -", + "fastlane:android-build": "bundle exec fastlane android firebase_build_and_upload deploy:false", + "fastlane:android-deploy": "bundle exec fastlane android firebase_build_and_upload deploy:true", + "fastlane:ios-build": "bundle exec fastlane ios deploy_to_testflight_qa deploy:false", + "fastlane:ios-deploy": "bundle exec fastlane ios deploy_to_testflight_qa deploy:true" }, "dependencies": { "@emoji-mart/data": "^1.2.1", From e463b5df7feee4027f31654af0330d824642d803 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 19 Jan 2026 21:04:21 +0530 Subject: [PATCH 16/76] chore: fix firebase android distribution param --- examples/SampleApp/fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index 321188bab7..90a537bf6d 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -134,7 +134,7 @@ platform :android do # Upload to Firebase App Distribution firebase_app_distribution( app: ENV.fetch('ANDROID_FIREBASE_APP_ID', nil), - service_credentials_json: ENV.fetch('FIREBASE_CREDENTIALS_JSON', nil), + service_credentials_json_data: ENV.fetch('FIREBASE_CREDENTIALS_JSON', nil), android_artifact_path: apk_path, android_artifact_type: "APK", groups: "stream-testers" From a18a02a25b1f6a0b0baa241e83e48113b2f21182 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 20 Jan 2026 11:23:27 +0530 Subject: [PATCH 17/76] fix: change usage of avatar component everywhere in the code (#3349) --- .../src/components/ChannelInfoOverlay.tsx | 17 +- .../src/components/MessageInfoBottomSheet.tsx | 4 +- .../MessageSearch/MessageSearchList.tsx | 12 +- .../src/components/UserInfoOverlay.tsx | 20 +-- .../components/UserSearch/UserGridItem.tsx | 14 +- .../UserSearch/UserSearchResults.tsx | 4 +- .../SampleApp/src/screens/ChannelScreen.tsx | 20 ++- .../src/screens/GroupChannelDetailsScreen.tsx | 12 +- .../src/screens/SharedGroupsScreen.tsx | 29 +-- .../AutoCompleteSuggestionItem.tsx | 8 +- package/src/components/Avatar/Avatar.tsx | 166 ------------------ .../Avatar/__tests__/Avatar.test.js | 50 ------ .../ChannelPreview/ChannelAvatar.tsx | 66 ------- .../ChannelPreview/ChannelPreviewAvatar.tsx | 30 ++++ .../ChannelPreviewMessenger.tsx | 27 +-- .../Message/MessageSimple/MessageAvatar.tsx | 19 +- .../MessageSimple/MessageRepliesAvatars.tsx | 22 +-- .../__tests__/MessageAvatar.test.js | 4 +- .../__snapshots__/MessageAvatar.test.js.snap | 38 ++-- .../MessageList/ScrollToBottomButton.tsx | 2 +- .../MessageUserReactionsAvatar.tsx | 24 ++- .../MessageMenu/MessageUserReactionsItem.tsx | 21 +-- .../Poll/components/PollAnswersList.tsx | 4 +- .../components/Poll/components/PollOption.tsx | 4 +- .../Poll/components/PollResults/PollVote.tsx | 14 +- .../__snapshots__/Thread.test.js.snap | 144 +++++++-------- .../components/ThreadList/ThreadListItem.tsx | 11 +- package/src/components/index.ts | 2 - .../src/components/ui/{ => Avatar}/Avatar.tsx | 7 +- .../{ => ui}/Avatar/ChannelAvatar.tsx | 14 +- .../components/{ => ui}/Avatar/UserAvatar.tsx | 31 +--- .../components/{ => ui}/Avatar/constants.ts | 14 +- package/src/components/ui/Avatar/index.ts | 3 + .../channelsContext/ChannelsContext.tsx | 3 +- package/src/utils/utils.ts | 18 ++ 35 files changed, 287 insertions(+), 591 deletions(-) delete mode 100644 package/src/components/Avatar/Avatar.tsx delete mode 100644 package/src/components/Avatar/__tests__/Avatar.test.js delete mode 100644 package/src/components/ChannelPreview/ChannelAvatar.tsx create mode 100644 package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx rename package/src/components/ui/{ => Avatar}/Avatar.tsx (88%) rename package/src/components/{ => ui}/Avatar/ChannelAvatar.tsx (79%) rename package/src/components/{ => ui}/Avatar/UserAvatar.tsx (63%) rename package/src/components/{ => ui}/Avatar/constants.ts (55%) create mode 100644 package/src/components/ui/Avatar/index.ts diff --git a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx index 8a3c9dca8d..822a115556 100644 --- a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx +++ b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx @@ -15,13 +15,13 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { - Avatar, CircleClose, Delete, User, UserMinus, useTheme, useViewport, + UserAvatar, } from 'stream-chat-react-native'; import { ChannelMemberResponse } from 'stream-chat'; @@ -34,8 +34,6 @@ import { SafeAreaView } from 'react-native-safe-area-context'; dayjs.extend(relativeTime); -const avatarSize = 64; - const styles = StyleSheet.create({ avatarPresenceIndicator: { right: 5, @@ -83,7 +81,7 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '700', }, - userItemContainer: { marginHorizontal: 8, width: 64 }, + userItemContainer: { marginHorizontal: 8, alignItems: 'center' }, userName: { fontSize: 12, fontWeight: '600', @@ -311,13 +309,12 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { renderItem={({ item }) => item ? ( - + {item.name || item.id || ''} diff --git a/examples/SampleApp/src/components/MessageInfoBottomSheet.tsx b/examples/SampleApp/src/components/MessageInfoBottomSheet.tsx index a4ad65728b..d3a385a951 100644 --- a/examples/SampleApp/src/components/MessageInfoBottomSheet.tsx +++ b/examples/SampleApp/src/components/MessageInfoBottomSheet.tsx @@ -1,18 +1,18 @@ import React, { useMemo } from 'react'; import { - Avatar, BottomSheetModal, useChatContext, useMessageDeliveredData, useMessageReadData, useTheme, + UserAvatar, } from 'stream-chat-react-native'; import { LocalMessage, UserResponse } from 'stream-chat'; import { FlatList, StyleSheet, Text, View } from 'react-native'; const renderUserItem = ({ item }: { item: UserResponse }) => ( - + {item.name ?? item.id} ); diff --git a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx index 24bcc54f41..f36d525c84 100644 --- a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx +++ b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx @@ -3,7 +3,7 @@ import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native import { NavigationProp, useNavigation } from '@react-navigation/native'; import dayjs from 'dayjs'; import calendar from 'dayjs/plugin/calendar'; -import { Avatar, Spinner, useTheme, useViewport } from 'stream-chat-react-native'; +import { Spinner, useTheme, useViewport, UserAvatar } from 'stream-chat-react-native'; import { DEFAULT_PAGINATION_LIMIT } from '../../utils/constants'; import type { MessageResponse } from 'stream-chat'; @@ -27,6 +27,8 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, itemContainer: { + alignItems: 'center', + justifyContent: 'center', borderBottomWidth: 1, flex: 1, flexDirection: 'row', @@ -131,12 +133,8 @@ export const MessageSearchList: React.FC = React.forward style={[styles.itemContainer, { borderBottomColor: border.surfaceSubtle }]} testID='channel-preview-button' > - + + { : `Last Seen ${dayjs(member.user?.last_active).fromNow()}`} - diff --git a/examples/SampleApp/src/components/UserSearch/UserGridItem.tsx b/examples/SampleApp/src/components/UserSearch/UserGridItem.tsx index c61449e923..2fad413d0f 100644 --- a/examples/SampleApp/src/components/UserSearch/UserGridItem.tsx +++ b/examples/SampleApp/src/components/UserSearch/UserGridItem.tsx @@ -1,13 +1,10 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { TouchableOpacity } from '@gorhom/bottom-sheet'; -import { Avatar, Close, useTheme } from 'stream-chat-react-native'; +import { Close, useTheme, UserAvatar } from 'stream-chat-react-native'; import type { UserResponse } from 'stream-chat'; - -const presenceIndicator = { cx: 7, cy: 7, r: 5 }; - const styles = StyleSheet.create({ presenceIndicatorContainer: { bottom: 0, @@ -55,13 +52,8 @@ export const UserGridItem: React.FC = ({ } = useTheme(); return ( - + + {removeButton && ( = ({ }, ]} > - + = ({ channel }) => { const { closePicker } = useAttachmentPickerContext(); const membersStatus = useChannelMembersStatus(channel); const displayName = useChannelPreviewDisplayName(channel, 30); + const online = useChannelPreviewDisplayPresence(channel); const { isOnline } = useChatContext(); const { chatClient } = useAppContext(); const navigation = useNavigation(); @@ -92,6 +95,10 @@ const ChannelHeader: React.FC = ({ channel }) => { return null; } + const members = channel.state.members; + const membersValues = Object.values(members); + const otherMembers = membersValues.filter((member) => member.user?.id !== chatClient?.user?.id); + return ( = ({ channel }) => { opacity: pressed ? 0.5 : 1, })} > - + {otherMembers.length === 1 ? ( + + ) : ( + + )} )} showUnreadCountBadge diff --git a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx index b0677c50fe..bba9882581 100644 --- a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx @@ -11,10 +11,10 @@ import { import { RouteProp, useNavigation } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { - Avatar, useChannelPreviewDisplayName, useOverlayContext, useTheme, + UserAvatar, } from 'stream-chat-react-native'; import { RoundButton } from '../components/RoundButton'; @@ -281,11 +281,11 @@ export const GroupChannelDetailsScreen: React.FC = ({ ]} > - {member.user?.name} diff --git a/examples/SampleApp/src/screens/SharedGroupsScreen.tsx b/examples/SampleApp/src/screens/SharedGroupsScreen.tsx index c0c0894e97..8067c2407f 100644 --- a/examples/SampleApp/src/screens/SharedGroupsScreen.tsx +++ b/examples/SampleApp/src/screens/SharedGroupsScreen.tsx @@ -1,8 +1,7 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { NavigationProp, RouteProp, useNavigation } from '@react-navigation/native'; import { - Avatar, ChannelList, ChannelListMessenger, ChannelListMessengerProps, @@ -12,6 +11,8 @@ import { useChannelPreviewDisplayName, useChannelsContext, useTheme, + Avatar, + getInitialsFromName, } from 'stream-chat-react-native'; import { ScreenHeader } from '../components/ScreenHeader'; @@ -66,6 +67,16 @@ const CustomPreview: React.FC = ({ channel }) => { }, } = useTheme(); + const displayAvatar = getChannelPreviewDisplayAvatar(channel, chatClient); + + const placeholder = useMemo(() => { + if (displayAvatar?.name) { + return {getInitialsFromName(displayAvatar?.name)}; + } else { + return ?; + } + }, [displayAvatar.name]); + if (!chatClient) { return null; } @@ -74,8 +85,6 @@ const CustomPreview: React.FC = ({ channel }) => { return null; } - const displayAvatar = getChannelPreviewDisplayAvatar(channel, chatClient); - const switchToChannel = () => { navigation.reset({ index: 1, @@ -106,17 +115,9 @@ const CustomPreview: React.FC = ({ channel }) => { > {displayAvatar.images ? ( - + ) : ( - + )} {name} diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx index ba0ce85893..46175f0283 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx @@ -10,7 +10,7 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { AtMentions } from '../../icons/AtMentions'; import type { Emoji } from '../../types/types'; -import { Avatar } from '../Avatar/Avatar'; +import { UserAvatar } from '../ui/Avatar/UserAvatar'; export type AutoCompleteSuggestionItemProps = { itemProps: TextComposerSuggestion; @@ -18,20 +18,20 @@ export type AutoCompleteSuggestionItemProps = { }; export const MentionSuggestionItem = (item: UserSuggestion) => { - const { id, image, name, online } = item; + const { id, name, online } = item; const { theme: { colors: { accent_blue, black }, messageInput: { suggestions: { - mention: { avatarSize, column, container: mentionContainer, name: nameStyle }, + mention: { column, container: mentionContainer, name: nameStyle }, }, }, }, } = useTheme(); return ( - + {name || id} diff --git a/package/src/components/Avatar/Avatar.tsx b/package/src/components/Avatar/Avatar.tsx deleted file mode 100644 index 2553623e78..0000000000 --- a/package/src/components/Avatar/Avatar.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { - Image, - ImageProps, - ImageStyle, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; -import { CircleProps } from 'react-native-svg'; - -import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useLoadingImage } from '../../hooks/useLoadingImage'; -import { getResizedImageUrl } from '../../utils/getResizedImageUrl'; -import { OnlineIndicator } from '../ui/OnlineIndicator'; - -const randomImageBaseUrl = 'https://getstream.io/random_png/'; -const randomSvgBaseUrl = 'https://getstream.io/random_svg/'; -const streamCDN = 'stream-io-cdn.com'; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', - }, - presenceIndicatorContainer: { - height: 12, - position: 'absolute', - right: 0, - top: 0, - width: 12, - }, -}); - -const getInitials = (fullName: string) => - fullName - .split(' ') - .slice(0, 2) - .map((name) => name.charAt(0)) - .join(''); - -export type AvatarProps = { - /** size in pixels */ - size: number; - containerStyle?: StyleProp; - /** image url */ - image?: string; - ImageComponent?: React.ComponentType; - /** name of the picture, used for fallback */ - imageStyle?: StyleProp; - name?: string; - online?: boolean; - presenceIndicator?: CircleProps; - testID?: string; -}; - -/** - * Avatar - A round avatar image with fallback to user's initials. - */ -export const Avatar = (props: AvatarProps) => { - const { - containerStyle, - image: imageProp, - ImageComponent = Image, - imageStyle, - name, - online = false, - size, - testID, - } = props; - const { resizableCDNHosts } = useChatConfigContext(); - const { - theme: { - avatar: { container, image, presenceIndicatorContainer }, - }, - } = useTheme(); - - const { isLoadingImageError, setLoadingImageError } = useLoadingImage(); - - const onError = useCallback(() => { - setLoadingImageError(true); - }, [setLoadingImageError]); - - const uri = useMemo(() => { - let imageUrl; - if ( - !imageProp || - imageProp.includes(randomImageBaseUrl) || - imageProp.includes(randomSvgBaseUrl) - ) { - if (imageProp?.includes(streamCDN)) { - imageUrl = imageProp; - } else { - imageUrl = `${randomImageBaseUrl}${name ? `?name=${getInitials(name)}&size=${size}` : ''}`; - } - } else { - imageUrl = getResizedImageUrl({ - height: size, - resizableCDNHosts, - url: imageProp, - width: size, - }); - } - - return imageUrl; - }, [imageProp, name, size, resizableCDNHosts]); - - return ( - - - {isLoadingImageError ? ( - - ) : ( - - )} - - {online ? ( - - - - ) : null} - - ); -}; - -Avatar.displayName = 'Avatar'; diff --git a/package/src/components/Avatar/__tests__/Avatar.test.js b/package/src/components/Avatar/__tests__/Avatar.test.js deleted file mode 100644 index 1af16be805..0000000000 --- a/package/src/components/Avatar/__tests__/Avatar.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; - -import { render, waitFor } from '@testing-library/react-native'; - -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; -import { Avatar } from '../Avatar'; - -describe('Avatar', () => { - it('should render an image with no name and default size', async () => { - const { queryByTestId } = render( - - - , - ); - - await waitFor(() => { - expect(queryByTestId('avatar-image')).toBeTruthy(); - }); - }); - - it('should render an image with name and default size', async () => { - const { queryByTestId } = render( - - - , - ); - - await waitFor(() => { - expect(queryByTestId('avatar-image')).toBeTruthy(); - }); - }); - - it('should render an image with custom size', async () => { - const { queryByTestId } = render( - - - , - ); - - await waitFor(() => { - expect(queryByTestId('avatar-image')).toBeTruthy(); - }); - }); -}); diff --git a/package/src/components/ChannelPreview/ChannelAvatar.tsx b/package/src/components/ChannelPreview/ChannelAvatar.tsx deleted file mode 100644 index e38b3e428e..0000000000 --- a/package/src/components/ChannelPreview/ChannelAvatar.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; - -import type { ChannelPreviewProps } from './ChannelPreview'; -import { useChannelPreviewDisplayAvatar } from './hooks/useChannelPreviewDisplayAvatar'; -import { useChannelPreviewDisplayPresence } from './hooks/useChannelPreviewDisplayPresence'; - -import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; - -import { Avatar } from '../Avatar/Avatar'; -import { GroupAvatar } from '../Avatar/GroupAvatar'; - -export type ChannelAvatarProps = Pick & { - /** - * The size of the avatar. - */ - size?: number; -}; - -/** - * This UI component displays an avatar for a particular channel. - */ -export const ChannelAvatarWithContext = ( - props: ChannelAvatarProps & Pick, -) => { - const { channel, ImageComponent, size: propSize } = props; - const { - theme: { - channelPreview: { - avatar: { size: themeSize }, - }, - }, - } = useTheme(); - - const size = propSize || themeSize; - - const displayAvatar = useChannelPreviewDisplayAvatar(channel); - const displayPresence = useChannelPreviewDisplayPresence(channel); - - if (displayAvatar.images) { - return ( - - ); - } - - return ( - - ); -}; - -export const ChannelAvatar = (props: ChannelAvatarProps) => { - const { ImageComponent } = useChatContext(); - - return ; -}; diff --git a/package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx b/package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx new file mode 100644 index 0000000000..fb8e6df09f --- /dev/null +++ b/package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { ChannelPreviewProps } from './ChannelPreview'; + +import { useChannelPreviewDisplayPresence } from './hooks/useChannelPreviewDisplayPresence'; + +import { useChatContext } from '../../contexts/chatContext/ChatContext'; +import { ChannelAvatar, UserAvatar } from '../ui'; + +export type ChannelPreviewAvatarProps = Pick; + +export const ChannelPreviewAvatar = ({ channel }: ChannelPreviewAvatarProps) => { + const { client } = useChatContext(); + 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 otherMembers.length === 1 ? ( + + ) : ( + + ); +}; diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index 1b66c19d57..f4c6f452ee 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; import type { ChannelPreviewProps } from './ChannelPreview'; +import { ChannelPreviewAvatar } from './ChannelPreviewAvatar'; import { ChannelPreviewMessage } from './ChannelPreviewMessage'; import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; import { ChannelPreviewStatus } from './ChannelPreviewStatus'; @@ -9,18 +10,14 @@ 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: { @@ -107,7 +104,7 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW maxUnreadCount, muted, onSelect, - // PreviewAvatar = ChannelAvatar, + PreviewAvatar = ChannelPreviewAvatar, PreviewMessage = ChannelPreviewMessage, PreviewMutedStatus = ChannelPreviewMutedStatus, PreviewStatus = ChannelPreviewStatus, @@ -116,7 +113,6 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW unread, } = props; const { vw } = useViewport(); - const { client } = useChatContext(); const maxWidth = vw(80) - 16 - 40; @@ -132,12 +128,6 @@ 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 ( { @@ -153,16 +143,7 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW ]} testID='channel-preview-button' > - {otherMembers.length === 1 ? ( - - ) : ( - - )} + forceUpdate, maxUnreadCount, onSelect, - PreviewAvatar, PreviewMessage, PreviewMutedStatus, PreviewStatus, @@ -218,7 +198,6 @@ export const ChannelPreviewMessenger = (props: ChannelPreviewMessengerProps) => forceUpdate, maxUnreadCount, onSelect, - PreviewAvatar, PreviewMessage, PreviewMutedStatus, PreviewStatus, diff --git a/package/src/components/Message/MessageSimple/MessageAvatar.tsx b/package/src/components/Message/MessageSimple/MessageAvatar.tsx index 0a4f770c46..eb02ed9ead 100644 --- a/package/src/components/Message/MessageSimple/MessageAvatar.tsx +++ b/package/src/components/Message/MessageSimple/MessageAvatar.tsx @@ -1,27 +1,23 @@ import React from 'react'; import { View } from 'react-native'; -import { ChatContextValue, useChatContext } from '../../../contexts/chatContext/ChatContext'; import { MessageContextValue, useMessageContext, } from '../../../contexts/messageContext/MessageContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; - -import { Avatar, AvatarProps } from '../../Avatar/Avatar'; +import { AvatarProps, UserAvatar } from '../../ui'; export type MessageAvatarPropsWithContext = Pick< MessageContextValue, 'alignment' | 'lastGroupMessage' | 'message' | 'showAvatar' > & - Pick & Partial>; const MessageAvatarWithContext = (props: MessageAvatarPropsWithContext) => { - const { alignment, ImageComponent, lastGroupMessage, message, showAvatar, size } = props; + const { alignment, lastGroupMessage, message, showAvatar, size } = props; const { theme: { - avatar: { BASE_AVATAR_SIZE }, messageSimple: { avatarWrapper: { container, leftAlign, rightAlign, spacer }, }, @@ -35,13 +31,8 @@ const MessageAvatarWithContext = (props: MessageAvatarPropsWithContext) => { style={[alignment === 'left' ? leftAlign : rightAlign, container]} testID='message-avatar' > - {visible ? ( - + {visible && message.user ? ( + ) : ( )} @@ -81,13 +72,11 @@ export type MessageAvatarProps = Partial; export const MessageAvatar = (props: MessageAvatarProps) => { const { alignment, lastGroupMessage, message, showAvatar } = useMessageContext(); - const { ImageComponent } = useChatContext(); return ( , ) => { - const { alignment, ImageComponent, message } = props; + const { alignment, message } = props; const { theme: { - colors: { white_snow }, messageSimple: { replies: { - avatar, avatarContainerMultiple, avatarContainerSingle, - avatarSize, leftAvatarsContainer, rightAvatarsContainer, }, @@ -64,19 +60,7 @@ export const MessageRepliesAvatarsWithContext = ( } } > - + ))} diff --git a/package/src/components/Message/MessageSimple/__tests__/MessageAvatar.test.js b/package/src/components/Message/MessageSimple/__tests__/MessageAvatar.test.js index 6245559578..0b69a0c9cf 100644 --- a/package/src/components/Message/MessageSimple/__tests__/MessageAvatar.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/MessageAvatar.test.js @@ -37,7 +37,7 @@ describe('MessageAvatar', () => { await waitFor(() => { expect(screen.getByTestId('spacer')).toBeTruthy(); - expect(screen.queryAllByTestId('avatar-image')).toHaveLength(0); + expect(screen.queryAllByTestId('user-avatar')).toHaveLength(0); }); const staticMessage = generateStaticMessage('hi', { @@ -57,7 +57,7 @@ describe('MessageAvatar', () => { await waitFor(() => { expect(screen.getByTestId('message-avatar')).toBeTruthy(); - expect(screen.getByTestId('avatar-image')).toBeTruthy(); + expect(screen.getByTestId('user-avatar')).toBeTruthy(); expect(screen.toJSON()).toMatchSnapshot(); }); }); diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap index 4b9ebc8f6f..22a0a2318a 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`MessageAvatar should render message avatar 1`] = ` - + diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 3bc212c4a2..c8614c1656 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -5,7 +5,7 @@ import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { NewDown } from '../../icons/NewDown'; -import { BadgeNotification } from '../ui'; +import { BadgeNotification } from '../ui/BadgeNotification'; import { IconButton } from '../ui/IconButton'; const styles = StyleSheet.create({ diff --git a/package/src/components/MessageMenu/MessageUserReactionsAvatar.tsx b/package/src/components/MessageMenu/MessageUserReactionsAvatar.tsx index 3f8f38f4d5..6bf43a4031 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsAvatar.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsAvatar.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useMemo } from 'react'; + +import { StyleSheet, Text } from 'react-native'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { Reaction } from '../../types/types'; -import { Avatar, AvatarProps } from '../Avatar/Avatar'; +import { getInitialsFromName } from '../../utils/utils'; +import { Avatar, AvatarProps } from '../ui'; export type MessageUserReactionsAvatarProps = { /** @@ -17,11 +19,15 @@ export const MessageUserReactionsAvatar = (props: MessageUserReactionsAvatarProp size, } = props; - const { - theme: { - avatar: { BASE_AVATAR_SIZE }, - }, - } = useTheme(); + const placeholder = useMemo(() => { + return {getInitialsFromName(name)}; + }, [name]); - return ; + return ; }; + +const styles = StyleSheet.create({ + text: { + color: '#003179', + }, +}); diff --git a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx index 181f4cef1a..d93d990bd8 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx @@ -39,7 +39,6 @@ export const MessageUserReactionsItem = ({ avatarInnerContainer, avatarName, avatarNameContainer, - avatarSize, filledBackgroundColor = light_blue, iconFilledColor = accent_blue, iconUnFilledColor = grey, @@ -53,19 +52,6 @@ export const MessageUserReactionsItem = ({ } = useTheme(); const { client } = useChatContext(); const alignment = client.userID && client.userID === id ? 'left' : 'right'; - const x = avatarSize / 2 - (avatarSize / (radius * 4)) * (alignment === 'left' ? 1 : -1); - const y = avatarSize - radius; - - const left = - alignment === 'left' - ? x - - (Number(reactionBubbleBackground.width || 0) || styles.reactionBubbleBackground.width) + - radius - : x - radius; - const top = - y - - radius - - (Number(reactionBubbleBackground.height || 0) || styles.reactionBubbleBackground.height); const Icon = supportedReactions.find((reaction) => reaction.type === type)?.Icon ?? Unknown; @@ -75,7 +61,7 @@ export const MessageUserReactionsItem = ({ style={[styles.avatarContainer, avatarContainer]} > - + { {!isAnonymous && answer.user?.image ? ( - + ) : null} {isAnonymous ? t('Anonymous') : answer.user?.name} diff --git a/package/src/components/Poll/components/PollOption.tsx b/package/src/components/Poll/components/PollOption.tsx index cfd5ae034a..096c9d3050 100644 --- a/package/src/components/Poll/components/PollOption.tsx +++ b/package/src/components/Poll/components/PollOption.tsx @@ -17,7 +17,7 @@ import { } from '../../../contexts'; import { Check } from '../../../icons'; -import { Avatar } from '../../Avatar/Avatar'; +import { UserAvatar } from '../../ui/Avatar/UserAvatar'; import { usePollState } from '../hooks/usePollState'; export type PollOptionProps = { @@ -120,7 +120,7 @@ export const PollOption = ({ option, showProgressBar = true }: PollOptionProps) {option.text} {relevantVotes.map((vote: PollVote) => ( - + ))} {voteCountsByOption[option.id] || 0} diff --git a/package/src/components/Poll/components/PollResults/PollVote.tsx b/package/src/components/Poll/components/PollResults/PollVote.tsx index cb63511b38..b76263355a 100644 --- a/package/src/components/Poll/components/PollResults/PollVote.tsx +++ b/package/src/components/Poll/components/PollResults/PollVote.tsx @@ -6,8 +6,8 @@ import { PollVote as PollVoteClass, VotingVisibility } from 'stream-chat'; import { useTheme, useTranslationContext } from '../../../../contexts'; import { getDateString } from '../../../../utils/i18n/getDateString'; +import { UserAvatar } from '../../../ui/Avatar/UserAvatar'; -import { Avatar } from '../../../Avatar/Avatar'; import { usePollState } from '../../hooks/usePollState'; export const PollVote = ({ vote }: { vote: PollVoteClass }) => { @@ -42,10 +42,8 @@ export const PollVote = ({ vote }: { vote: PollVoteClass }) => { return ( - - {!isAnonymous && vote.user?.image ? ( - - ) : null} + + {!isAnonymous ? : null} {isAnonymous ? t('Anonymous') : (vote.user?.name ?? vote.user?.id)} @@ -64,5 +62,9 @@ const styles = StyleSheet.create({ }, voteCount: { fontSize: 16, marginLeft: 16 }, voteDate: { fontSize: 14 }, - voteUserName: { fontSize: 14, marginLeft: 2 }, + voteUserName: { fontSize: 14, marginLeft: 4 }, + userContainer: { + flexDirection: 'row', + alignItems: 'center', + }, }); 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 81fbb67be1..20084d150d 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -294,28 +294,36 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-avatar" > - + @@ -671,28 +671,36 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-avatar" > - + @@ -1086,28 +1086,36 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-avatar" > - + @@ -1467,28 +1467,36 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-avatar" > - + diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index f4da483bc3..86ce5615a6 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -27,9 +27,9 @@ import { useStateStore } from '../../hooks'; import { MessageBubble } from '../../icons'; import { FileTypes } from '../../types/types'; import { getDateString } from '../../utils/i18n/getDateString'; -import { Avatar } from '../Avatar/Avatar'; import { useChannelPreviewDisplayName } from '../ChannelPreview/hooks/useChannelPreviewDisplayName'; import { MessagePreview } from '../MessagePreview/MessagePreview'; +import { UserAvatar } from '../ui/Avatar/UserAvatar'; export type ThreadListItemProps = { thread: Thread; @@ -365,11 +365,10 @@ export const ThreadListItemComponent = () => { ) : null} - + {lastReply?.user ? ( + + ) : null} + {lastReply?.user?.name} diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 43004838c3..898d0188b1 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -27,7 +27,6 @@ export * from './AutoCompleteInput/AutoCompleteSuggestionHeader'; export * from './AutoCompleteInput/AutoCompleteSuggestionItem'; export * from './AutoCompleteInput/AutoCompleteSuggestionList'; -export * from './Avatar/Avatar'; export * from './Avatar/GroupAvatar'; export * from './Channel/Channel'; @@ -51,7 +50,6 @@ export * from './ChannelList/hooks/usePaginatedChannels'; export * from './ChannelList/hooks/useChannelMembershipState'; export * from './ChannelList/Skeleton'; -export * from './ChannelPreview/ChannelAvatar'; export * from './ChannelPreview/ChannelPreview'; export * from './ChannelPreview/ChannelPreviewMessenger'; export * from './ChannelPreview/ChannelPreviewMessage'; diff --git a/package/src/components/ui/Avatar.tsx b/package/src/components/ui/Avatar/Avatar.tsx similarity index 88% rename from package/src/components/ui/Avatar.tsx rename to package/src/components/ui/Avatar/Avatar.tsx index 2c160bd74a..27487a2611 100644 --- a/package/src/components/ui/Avatar.tsx +++ b/package/src/components/ui/Avatar/Avatar.tsx @@ -1,9 +1,9 @@ import React, { useMemo } from 'react'; import { Image, StyleSheet, View } from 'react-native'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -export type NewAvatarProps = { +export type AvatarProps = { size: 'xs' | 'sm' | 'md' | 'lg'; imageUrl?: string; placeholder?: React.ReactNode; @@ -29,7 +29,7 @@ const sizes = { }, }; -export const NewAvatar = (props: NewAvatarProps) => { +export const Avatar = (props: AvatarProps) => { const { size, imageUrl, placeholder, showBorder } = props; const styles = useStyles(); @@ -41,6 +41,7 @@ export const NewAvatar = (props: NewAvatarProps) => { { backgroundColor: imageUrl ? undefined : '#D2E3FF' }, showBorder ? styles.border : undefined, ]} + testID='avatar-image' > {imageUrl ? ( diff --git a/package/src/components/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx similarity index 79% rename from package/src/components/Avatar/ChannelAvatar.tsx rename to package/src/components/ui/Avatar/ChannelAvatar.tsx index ad7f2f311a..7272689e1e 100644 --- a/package/src/components/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -4,21 +4,21 @@ import { StyleSheet, View } from 'react-native'; import { Channel } from 'stream-chat'; -import { iconSizes, indicatorSizes } from './constants'; +import { Avatar } from './Avatar'; -import { GroupIcon } from '../../icons/GroupIcon'; -import { NewAvatar } from '../ui/Avatar'; +import { iconSizes, indicatorSizes } from './constants'; -import { OnlineIndicator } from '../ui/OnlineIndicator'; +import { GroupIcon } from '../../../icons/GroupIcon'; +import { OnlineIndicator } from '../OnlineIndicator'; -export type NewChannelAvatarProps = { +export type ChannelAvatarProps = { channel: Channel; showOnlineIndicator?: boolean; size: 'xs' | 'sm' | 'md' | 'lg'; showBorder?: boolean; }; -export const NewChannelAvatar = (props: NewChannelAvatarProps) => { +export const ChannelAvatar = (props: ChannelAvatarProps) => { const { channel, size, showBorder = false, showOnlineIndicator = false } = props; const placeholder = useMemo(() => { @@ -27,7 +27,7 @@ export const NewChannelAvatar = (props: NewChannelAvatarProps) => { return ( - { - return name - .split(' ') - .slice(0, numberOfInitials) - .map((n) => n.charAt(0)) - .join(''); -}; - -export type NewUserAvatarProps = { +export type UserAvatarProps = { user?: UserResponse; showOnlineIndicator?: boolean; size: 'xs' | 'sm' | 'md' | 'lg'; showBorder?: boolean; }; -export const NewUserAvatar = (props: NewUserAvatarProps) => { +export const UserAvatar = (props: UserAvatarProps) => { const { user, size, showBorder = false, showOnlineIndicator } = props; const placeholder = useMemo(() => { if (user?.name) { return ( - {getInitials(user.name, numberOfInitials[size])} + {getInitialsFromName(user.name, numberOfInitials[size])} ); } else { @@ -46,13 +38,8 @@ export const NewUserAvatar = (props: NewUserAvatarProps) => { } return ( - - + + {showOnlineIndicator ? ( diff --git a/package/src/components/Avatar/constants.ts b/package/src/components/ui/Avatar/constants.ts similarity index 55% rename from package/src/components/Avatar/constants.ts rename to package/src/components/ui/Avatar/constants.ts index 0b37fd44ea..d89a7cc4ca 100644 --- a/package/src/components/Avatar/constants.ts +++ b/package/src/components/ui/Avatar/constants.ts @@ -1,16 +1,16 @@ -import { NewUserAvatarProps } from './UserAvatar'; +import { UserAvatarProps } from './UserAvatar'; -import { FontWeightType } from '../../theme/primitives/typography'; -import { OnlineIndicatorProps } from '../ui/OnlineIndicator'; +import { FontWeightType } from '../../../theme/primitives/typography'; +import { OnlineIndicatorProps } from '../OnlineIndicator'; -const indicatorSizes: Record = { +const indicatorSizes: Record = { xs: 'sm', sm: 'sm', md: 'md', lg: 'lg', }; -const iconSizes: Record = { +const iconSizes: Record = { xs: 10, sm: 12, md: 16, @@ -18,7 +18,7 @@ const iconSizes: Record = { }; const fontSizes: Record< - NewUserAvatarProps['size'], + UserAvatarProps['size'], { fontSize: number; lineHeight: number; fontWeight: FontWeightType } > = { xs: { fontSize: 12, lineHeight: 16, fontWeight: '600' }, @@ -27,7 +27,7 @@ const fontSizes: Record< lg: { fontSize: 15, lineHeight: 20, fontWeight: '600' }, }; -const numberOfInitials: Record = { +const numberOfInitials: Record = { xs: 1, sm: 1, md: 2, diff --git a/package/src/components/ui/Avatar/index.ts b/package/src/components/ui/Avatar/index.ts new file mode 100644 index 0000000000..05193fbc0b --- /dev/null +++ b/package/src/components/ui/Avatar/index.ts @@ -0,0 +1,3 @@ +export * from './Avatar'; +export * from './ChannelAvatar'; +export * from './UserAvatar'; diff --git a/package/src/contexts/channelsContext/ChannelsContext.tsx b/package/src/contexts/channelsContext/ChannelsContext.tsx index 64bbc4f500..1d67605f70 100644 --- a/package/src/contexts/channelsContext/ChannelsContext.tsx +++ b/package/src/contexts/channelsContext/ChannelsContext.tsx @@ -7,7 +7,6 @@ import type { Channel } from 'stream-chat'; import type { HeaderErrorProps } from '../../components/ChannelList/ChannelListHeaderErrorIndicator'; import type { QueryChannels } from '../../components/ChannelList/hooks/usePaginatedChannels'; -import type { ChannelAvatarProps } from '../../components/ChannelPreview/ChannelAvatar'; import type { ChannelPreviewMessageProps } from '../../components/ChannelPreview/ChannelPreviewMessage'; import type { ChannelPreviewMessengerProps } from '../../components/ChannelPreview/ChannelPreviewMessenger'; import type { ChannelPreviewStatusProps } from '../../components/ChannelPreview/ChannelPreviewStatus'; @@ -176,7 +175,7 @@ export type ChannelsContextValue = { * * **Default** [ChannelAvatar](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/ChannelPreview/ChannelAvatar.tsx) */ - PreviewAvatar?: React.ComponentType; + PreviewAvatar?: React.ComponentType; /** * Custom UI component to render preview of latest message on channel. * diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index ebab5759fe..515ab3c2fe 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -355,3 +355,21 @@ export const checkQuotedMessageEquality = ( return quotedMessageEqual; }; + +/** + * Utility to get initials from name. + * @param name string + * @param numberOfInitials number - optional, default is 2 + * @returns string + */ +export const getInitialsFromName = (name: string, numberOfInitials: number = 2) => { + if (!name) return ''; + const trimmed = name.trim(); + + if (!trimmed) return ''; + return trimmed + .split(/\s+/) + .slice(0, numberOfInitials) + .map((n) => n.charAt(0).toUpperCase()) + .join(''); +}; From f86389b69f29ab4e94e59a0f6438dcb769fed4ba Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 20 Jan 2026 14:58:18 +0530 Subject: [PATCH 18/76] fix: ux for the scroll bottom button on floating state (#3350) --- .../MessageSearch/MessageSearchList.tsx | 2 +- .../SampleApp/src/components/SecretMenu.tsx | 30 ++++++++++--------- package/src/components/Channel/Channel.tsx | 3 ++ .../useCreateInputMessageInputContext.ts | 2 ++ .../components/MessageInput/MessageInput.tsx | 18 ++++++----- .../MessageList/MessageFlashList.tsx | 13 ++++---- .../components/MessageList/MessageList.tsx | 13 ++++---- .../MessageInputContext.tsx | 2 ++ .../hooks/useCreateMessageInputContext.ts | 2 ++ .../state-store/message-input-height-store.ts | 14 ++++++--- 10 files changed, 59 insertions(+), 40 deletions(-) diff --git a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx index f36d525c84..a087a9a3e8 100644 --- a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx +++ b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx @@ -133,7 +133,7 @@ export const MessageSearchList: React.FC = React.forward style={[styles.itemContainer, { borderBottomColor: border.surfaceSubtle }]} testID='channel-preview-button' > - + {item.user ? : null} diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index aff708538f..157f8422ed 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -372,22 +372,24 @@ export const SecretMenu = ({ - - - - Message Input Floating - - {messageInputFloatingConfigItems.map((item) => ( - - ))} + {Platform.OS === 'ios' ? ( + + + + Message Input Floating + + {messageInputFloatingConfigItems.map((item) => ( + + ))} + - + ) : null} diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index f769ee4841..4b15de4d91 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -110,6 +110,7 @@ import { ChannelUnreadStateStore, ChannelUnreadStateStoreType, } from '../../state-store/channel-unread-state'; +import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; @@ -785,6 +786,7 @@ const ChannelWithContext = (props: PropsWithChildren) = const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore()); + const [messageInputHeightStore] = useState(new MessageInputHeightStore()); // TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere. const setChannelUnreadState = useCallback( (data: ChannelUnreadStateStoreType['channelUnreadState']) => { @@ -1874,6 +1876,7 @@ const ChannelWithContext = (props: PropsWithChildren) = Input, InputButtons, messageInputFloating, + messageInputHeightStore, openPollCreationDialog, SendButton, sendMessage, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 197d1419b8..5efbc41670 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -48,6 +48,7 @@ export const useCreateInputMessageInputContext = ({ Input, InputButtons, messageInputFloating, + messageInputHeightStore, openPollCreationDialog, SendButton, sendMessage, @@ -111,6 +112,7 @@ export const useCreateInputMessageInputContext = ({ Input, InputButtons, messageInputFloating, + messageInputHeightStore, openPollCreationDialog, SendButton, sendMessage, diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 0ddba3e3cb..512ddcaafb 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -64,11 +64,7 @@ import { import { useKeyboardVisibility } from '../../hooks/useKeyboardVisibility'; import { useStateStore } from '../../hooks/useStateStore'; import { isAudioRecorderAvailable, NativeHandlers } from '../../native'; -import { - MessageInputHeightState, - messageInputHeightStore, - setMessageInputHeight, -} from '../../state-store/message-input-height-store'; +import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; @@ -166,6 +162,7 @@ type MessageInputPropsWithContext = Pick< | 'CreatePollIcon' | 'FileSelectorIcon' | 'messageInputFloating' + | 'messageInputHeightStore' | 'ImageSelectorIcon' | 'VideoRecorderSelectorIcon' | 'CommandInput' @@ -235,6 +232,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { editing, hasAttachments, messageInputFloating, + messageInputHeightStore, Input, inputBoxRef, InputButtons, @@ -261,7 +259,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { command } = useStateStore(textComposer.state, textComposerStateSelector); const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); - const { height } = useStateStore(messageInputHeightStore, messageInputHeightStoreSelector); + const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); const { theme: { colors: { border, grey_whisper, white, white_smoke }, @@ -465,7 +463,11 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { nativeEvent: { layout: { height: newHeight }, }, - }) => setMessageInputHeight(messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight)} // 24 is the position of the input from the bottom of the screen + }) => + messageInputHeightStore.setHeight( + 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] @@ -856,6 +858,7 @@ export const MessageInput = (props: MessageInputProps) => { InputButtons, CommandInput, messageInputFloating, + messageInputHeightStore, openPollCreationDialog, SendButton, sendMessage, @@ -933,6 +936,7 @@ export const MessageInput = (props: MessageInputProps) => { isOnline, members, messageInputFloating, + messageInputHeightStore, openPollCreationDialog, Reply, selectedPicker, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 26c1ed2e1f..8971995945 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -52,10 +52,7 @@ import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext' import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback, useStateStore } from '../../hooks'; -import { - MessageInputHeightState, - messageInputHeightStore, -} from '../../state-store/message-input-height-store'; +import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; let FlashList; @@ -133,7 +130,7 @@ type MessageFlashListPropsWithContext = Pick< | 'maximumMessageLimit' > & Pick & - Pick & + Pick & Pick & Pick< MessagesContextValue, @@ -293,6 +290,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => markRead, maximumMessageLimit, messageInputFloating, + messageInputHeightStore, myMessageTheme, readEvents, NetworkDownIndicator, @@ -318,7 +316,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const flashListRef = useRef | null>(null); const { height: messageInputHeight } = useStateStore( - messageInputHeightStore, + messageInputHeightStore.store, messageInputHeightStoreSelector, ); @@ -1158,7 +1156,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); - const { messageInputFloating } = useMessageInputContext(); + const { messageInputFloating, messageInputHeightStore } = useMessageInputContext(); return ( { maximumMessageLimit, Message, messageInputFloating, + messageInputHeightStore, MessageSystem, myMessageTheme, NetworkDownIndicator, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 1403632c7e..3cdc53ba94 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -56,10 +56,7 @@ import { ThreadContextValue, useThreadContext } from '../../contexts/threadConte import { useStableCallback } from '../../hooks'; import { useStateStore } from '../../hooks/useStateStore'; -import { - MessageInputHeightState, - messageInputHeightStore, -} from '../../state-store/message-input-height-store'; +import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; // This is just to make sure that the scrolling happens in a different task queue. @@ -179,7 +176,7 @@ type MessageListPropsWithContext = Pick< | 'TypingIndicatorContainer' | 'UnreadMessagesNotification' > & - Pick & + Pick & Pick< ThreadContextValue, 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' @@ -298,6 +295,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { markRead, maximumMessageLimit, messageInputFloating, + messageInputHeightStore, myMessageTheme, NetworkDownIndicator, noGroupByUser, @@ -323,7 +321,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const { theme } = useTheme(); const { height: messageInputHeight } = useStateStore( - messageInputHeightStore, + messageInputHeightStore.store, messageInputHeightStoreSelector, ); @@ -1257,7 +1255,7 @@ export const MessageList = (props: MessageListProps) => { TypingIndicatorContainer, UnreadMessagesNotification, } = useMessagesContext(); - const { messageInputFloating } = useMessageInputContext(); + const { messageInputFloating, messageInputHeightStore } = useMessageInputContext(); const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); @@ -1290,6 +1288,7 @@ export const MessageList = (props: MessageListProps) => { maximumMessageLimit, Message, messageInputFloating, + messageInputHeightStore, MessageSystem, myMessageTheme, NetworkDownIndicator, diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index e076b7ea7b..4c47757fcd 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -59,6 +59,7 @@ import { } from '../../middlewares/attachments'; import { isDocumentPickerAvailable, MediaTypes, NativeHandlers } from '../../native'; +import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { File } from '../../types/types'; import { compressedImageURI } from '../../utils/compressImage'; import { @@ -384,6 +385,7 @@ export type InputMessageInputContextValue = { */ setInputRef?: (ref: TextInput | null) => void; showPollCreationDialog?: boolean; + messageInputHeightStore: MessageInputHeightStore; }; export type MessageInputContextValue = LocalMessageInputContext & diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 9d966bac51..d7da708d3a 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -50,6 +50,7 @@ export const useCreateMessageInputContext = ({ inputBoxRef, InputButtons, messageInputFloating, + messageInputHeightStore, openAttachmentPicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, @@ -121,6 +122,7 @@ export const useCreateMessageInputContext = ({ inputBoxRef, InputButtons, messageInputFloating, + messageInputHeightStore, openAttachmentPicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, diff --git a/package/src/state-store/message-input-height-store.ts b/package/src/state-store/message-input-height-store.ts index a8bab1106a..b3abf6615a 100644 --- a/package/src/state-store/message-input-height-store.ts +++ b/package/src/state-store/message-input-height-store.ts @@ -8,8 +8,14 @@ const INITIAL_STATE: MessageInputHeightState = { height: 0, }; -export const messageInputHeightStore = new StateStore(INITIAL_STATE); +export class MessageInputHeightStore { + public store = new StateStore(INITIAL_STATE); -export const setMessageInputHeight = (height: number) => { - messageInputHeightStore.next({ height }); -}; + constructor() { + this.store.next({ height: 0 }); + } + + setHeight(height: number) { + this.store.next({ height }); + } +} From bb3867fa97c26e0074565063f79e6be0d3e18074 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 20 Jan 2026 16:59:09 +0530 Subject: [PATCH 19/76] fix: downgrade bottom sheet version (#3351) --- package/package.json | 2 +- package/yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package/package.json b/package/package.json index aa90a43338..4126ff35e2 100644 --- a/package/package.json +++ b/package/package.json @@ -68,7 +68,7 @@ ] }, "dependencies": { - "@gorhom/bottom-sheet": "^5.2.8", + "@gorhom/bottom-sheet": "^5.1.8", "@ungap/structured-clone": "^1.3.0", "dayjs": "1.11.13", "emoji-regex": "^10.4.0", diff --git a/package/yarn.lock b/package/yarn.lock index 155cf0d750..b1d7cd0a2b 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -1362,7 +1362,7 @@ "@eslint/core" "^0.14.0" levn "^0.4.1" -"@gorhom/bottom-sheet@^5.2.8": +"@gorhom/bottom-sheet@^5.1.8": version "5.2.8" resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz#25e49122c30ffe83d3813b3bcf3dec39f3359aeb" integrity sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA== From bcd758addffffe8fba92b680b3eae171ec7e2647 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 20 Jan 2026 17:23:51 +0530 Subject: [PATCH 20/76] fix: add back the floating config for android --- .../SampleApp/src/components/SecretMenu.tsx | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index 157f8422ed..aff708538f 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -372,24 +372,22 @@ export const SecretMenu = ({ - {Platform.OS === 'ios' ? ( - - - - Message Input Floating - - {messageInputFloatingConfigItems.map((item) => ( - - ))} - + + + + Message Input Floating + + {messageInputFloatingConfigItems.map((item) => ( + + ))} - ) : null} + From 7c5d2c97ee5df4b097c591af5e98064ab559c42b Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 20 Jan 2026 18:36:06 +0530 Subject: [PATCH 21/76] fix: improve avatar error handling --- .../ChannelPreview/ChannelPreviewAvatar.tsx | 7 +------ package/src/components/ui/Avatar/Avatar.tsx | 19 ++++++++++++++----- .../src/components/ui/Avatar/UserAvatar.tsx | 2 +- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx b/package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx index fb8e6df09f..88b0b1dc9a 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx @@ -18,12 +18,7 @@ export const ChannelPreviewAvatar = ({ channel }: ChannelPreviewAvatarProps) => const online = useChannelPreviewDisplayPresence(channel); return otherMembers.length === 1 ? ( - + ) : ( ); diff --git a/package/src/components/ui/Avatar/Avatar.tsx b/package/src/components/ui/Avatar/Avatar.tsx index 27487a2611..21bfc2c76c 100644 --- a/package/src/components/ui/Avatar/Avatar.tsx +++ b/package/src/components/ui/Avatar/Avatar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Image, StyleSheet, View } from 'react-native'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; @@ -30,21 +30,30 @@ const sizes = { }; export const Avatar = (props: AvatarProps) => { + const [error, setError] = useState(false); const { size, imageUrl, placeholder, showBorder } = props; const styles = useStyles(); + const onHandleError = useCallback(() => { + setError(true); + }, []); + return ( - {imageUrl ? ( - + {imageUrl && !error ? ( + ) : ( placeholder )} @@ -61,7 +70,7 @@ const useStyles = () => { StyleSheet.create({ border: { borderColor: colors.border.image, - borderWidth: 2, + borderWidth: 1, }, container: { alignItems: 'center', diff --git a/package/src/components/ui/Avatar/UserAvatar.tsx b/package/src/components/ui/Avatar/UserAvatar.tsx index 3c4e30efcd..4402ccb3da 100644 --- a/package/src/components/ui/Avatar/UserAvatar.tsx +++ b/package/src/components/ui/Avatar/UserAvatar.tsx @@ -19,7 +19,7 @@ export type UserAvatarProps = { }; export const UserAvatar = (props: UserAvatarProps) => { - const { user, size, showBorder = false, showOnlineIndicator } = props; + const { user, size, showBorder = !!user?.image, showOnlineIndicator } = props; const placeholder = useMemo(() => { if (user?.name) { From 6996b50f5e9118468366645e2ab86bbc14bc0b7b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:03:57 +0100 Subject: [PATCH 22/76] fix: message list state synchronisity mismatch (#3352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR fixes an unfortunate issue with state not being properly synchronized in certain situations (typically on lower end devices), where the updates of our `processedMessageList` would be ahead of the updates of the previous and next `message` store. Even thought there's a dependency on `processedMessageList`, since `renderItem` is already memoized and the `message` references are stable from the work done on the `MessageList` the past few months, this does not in fact degrade performance while we focus on correctness. ## ๐Ÿ›  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/ios/Podfile.lock | 63 ------------------- .../SampleApp/ios/SampleApp-tvOS/Info.plist | 2 +- examples/SampleApp/ios/SampleApp/Info.plist | 2 +- examples/SampleApp/package.json | 1 - .../SampleApp/src/screens/ChannelScreen.tsx | 6 +- .../SampleApp/src/screens/ThreadScreen.tsx | 10 ++- examples/SampleApp/yarn.lock | 15 ++--- .../KeyboardControllerAvoidingView.tsx | 44 +++++++------ .../Message/MessageSimple/MessageWrapper.tsx | 27 +++----- .../__snapshots__/MessageAvatar.test.js.snap | 6 +- .../MessageList/MessageFlashList.tsx | 36 +++++------ .../components/MessageList/MessageList.tsx | 47 +++++++------- .../__tests__/useMessageDateSeparator.test.ts | 18 ++---- .../hooks/useMessageDateSeparator.ts | 20 +----- .../hooks/useMessageGroupStyles.ts | 27 ++------ .../MessageList/hooks/useMessageList.ts | 16 +---- .../MessageList/utils/getGroupStyles.ts | 4 +- package/src/components/Thread/Thread.tsx | 6 +- .../__snapshots__/Thread.test.js.snap | 26 ++++++-- .../MessageListItemContext.tsx | 5 -- 20 files changed, 133 insertions(+), 248 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index acfbd0474e..5af66105b1 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2036,65 +2036,6 @@ 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): @@ -3338,7 +3279,6 @@ 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`) @@ -3514,8 +3454,6 @@ 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: @@ -3685,7 +3623,6 @@ 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/ios/SampleApp-tvOS/Info.plist b/examples/SampleApp/ios/SampleApp-tvOS/Info.plist index a8d20ea4c7..5bbd1e0da7 100644 --- a/examples/SampleApp/ios/SampleApp-tvOS/Info.plist +++ b/examples/SampleApp/ios/SampleApp-tvOS/Info.plist @@ -50,4 +50,4 @@ UIViewControllerBasedStatusBarAppearance - \ No newline at end of file + diff --git a/examples/SampleApp/ios/SampleApp/Info.plist b/examples/SampleApp/ios/SampleApp/Info.plist index ae8b0f4d7d..b072573cc4 100644 --- a/examples/SampleApp/ios/SampleApp/Info.plist +++ b/examples/SampleApp/ios/SampleApp/Info.plist @@ -59,4 +59,4 @@ UIViewControllerBasedStatusBarAppearance - \ No newline at end of file + diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index eb08fce2bb..bc555889ab 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -51,7 +51,6 @@ "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 c1e35f34f9..333f5d079e 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -1,6 +1,5 @@ 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, @@ -20,7 +19,7 @@ import { ChannelAvatar, useChannelPreviewDisplayPresence, } from 'stream-chat-react-native'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { Platform, Pressable, StyleSheet, View } from 'react-native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useAppContext } from '../context/AppContext'; @@ -230,7 +229,6 @@ export const ChannelScreen: React.FC = ({ }, [chatClient, colors, t, handleMessageInfo], ); - const headerHeight = useHeaderHeight(); if (!channel || !chatClient) { return null; @@ -247,7 +245,7 @@ export const ChannelScreen: React.FC = ({ disableTypingIndicator enforceUniqueReaction initialScrollToFirstUnreadMessage - keyboardVerticalOffset={headerHeight} + keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -300} messageActions={messageActions} MessageHeader={MessageReminderHeader} MessageLocation={MessageLocation} diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index 5abc0b6073..4bea7bc519 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; -import { StyleSheet, View } from 'react-native'; -import { useHeaderHeight } from '@react-navigation/elements'; +import { Platform, StyleSheet, View } from 'react-native'; import { Channel, @@ -86,8 +85,7 @@ export const ThreadScreen: React.FC = ({ const { client: chatClient } = useChatContext(); const { t } = useTranslationContext(); const { setThread } = useStreamChatContext(); - const { messageInputFloating } = useAppContext(); - const headerHeight = useHeaderHeight(); + const { messageInputFloating, messageListImplementation } = useAppContext(); const onPressMessage: NonNullable['onPressMessage']> = ( payload, @@ -125,7 +123,7 @@ export const ThreadScreen: React.FC = ({ AttachmentPickerSelectionBar={CustomAttachmentPickerSelectionBar} channel={channel} enforceUniqueReaction - keyboardVerticalOffset={headerHeight} + keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -300} messageActions={messageActions} messageInputFloating={messageInputFloating} MessageHeader={MessageReminderHeader} @@ -135,7 +133,7 @@ export const ThreadScreen: React.FC = ({ threadList > - +
); diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index a93301a6f6..3f241a37a9 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -1580,10 +1580,10 @@ "@gorhom/portal" "1.0.14" invariant "^2.2.4" -"@gorhom/bottom-sheet@^5.1.8": - version "5.1.8" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.8.tgz#65547917f5b1dae5a1291dabd4ea8bfee09feba4" - integrity sha512-QuYIVjn3K9bW20n5bgOSjvxBYoWG4YQXiLGOheEAMgISuoT6sMcA270ViSkkb0fenPxcIOwzCnFNuxmr739T9A== +"@gorhom/bottom-sheet@^5.2.8": + version "5.2.8" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz#25e49122c30ffe83d3813b3bcf3dec39f3359aeb" + integrity sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" @@ -7658,13 +7658,6 @@ 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/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx b/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx index f75e5853b7..b535c2680f 100644 --- a/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx +++ b/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Keyboard, @@ -6,42 +6,40 @@ import { 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'; }; +type KeyboardControllerModule = typeof import('react-native-keyboard-controller'); + +const optionalRequire = (): T | undefined => { + try { + return require('react-native-keyboard-controller') as T; + } catch { + return undefined; + } +}; + export type KeyboardCompatibleViewProps = ReactNativeKeyboardAvoidingViewProps & ExtraKeyboardControllerProps; -let KeyboardControllerPackage: - | { - KeyboardAvoidingView: typeof KeyboardControllerPackageKeyboardAvoidingView; - KeyboardController: typeof KeyboardControllerPackageKeyboardController; - KeyboardProvider: typeof KeyboardProvider; - KeyboardEvents: typeof KeyboardEvents; - } - | undefined; +const KeyboardControllerPackage = optionalRequire(); -try { - KeyboardControllerPackage = require('react-native-keyboard-controller'); -} catch (e) { - KeyboardControllerPackage = undefined; -} +const { AndroidSoftInputModes, KeyboardController, KeyboardProvider, KeyboardAvoidingView } = + KeyboardControllerPackage ?? {}; export const KeyboardCompatibleView = (props: KeyboardCompatibleViewProps) => { const { behavior = 'translate-with-padding', children, ...rest } = props; - const KeyboardProvider = KeyboardControllerPackage?.KeyboardProvider; - const KeyboardAvoidingView = KeyboardControllerPackage?.KeyboardAvoidingView; + useEffect(() => { + if (AndroidSoftInputModes) { + KeyboardController?.setInputMode(AndroidSoftInputModes.SOFT_INPUT_ADJUST_RESIZE); + } + + return () => KeyboardController?.setDefaultMode(); + }, []); if (KeyboardProvider && KeyboardAvoidingView) { return ( diff --git a/package/src/components/Message/MessageSimple/MessageWrapper.tsx b/package/src/components/Message/MessageSimple/MessageWrapper.tsx index 39a4a36f22..97da35634e 100644 --- a/package/src/components/Message/MessageSimple/MessageWrapper.tsx +++ b/package/src/components/Message/MessageSimple/MessageWrapper.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { View } from 'react-native'; @@ -14,7 +14,6 @@ import { ThemeProvider, useTheme } from '../../../contexts/themeContext/ThemeCon import { useStateStore } from '../../../hooks/useStateStore'; import { ChannelUnreadStateStoreType } from '../../../state-store/channel-unread-state'; -import { MessagePreviousAndNextMessageStoreType } from '../../../state-store/message-list-prev-next-state'; const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ first_unread_message_id: state.channelUnreadState?.first_unread_message_id, @@ -25,10 +24,12 @@ const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ export type MessageWrapperProps = { message: LocalMessage; + previousMessage?: LocalMessage; + nextMessage?: LocalMessage; }; export const MessageWrapper = React.memo((props: MessageWrapperProps) => { - const { message } = props; + const { message, previousMessage, nextMessage } = props; const { client } = useChatContext(); const { channelUnreadStateStore, @@ -47,34 +48,22 @@ export const MessageWrapper = React.memo((props: MessageWrapperProps) => { myMessageTheme, shouldShowUnreadUnderlay, } = useMessagesContext(); - const { - goToMessage, - onThreadSelect, - noGroupByUser, - modifiedTheme, - messageListPreviousAndNextMessageStore, - } = useMessageListItemContext(); + const { goToMessage, onThreadSelect, noGroupByUser, modifiedTheme } = useMessageListItemContext(); const dateSeparatorDate = useMessageDateSeparator({ hideDateSeparators, message, - messageListPreviousAndNextMessageStore, + previousMessage, }); - const selector = useCallback( - (state: MessagePreviousAndNextMessageStoreType) => ({ - nextMessage: state.messageList[message.id]?.nextMessage, - }), - [message.id], - ); - const { nextMessage } = useStateStore(messageListPreviousAndNextMessageStore.state, selector); const isNewestMessage = nextMessage === undefined; const groupStyles = useMessageGroupStyles({ dateSeparatorDate, getMessageGroupStyle, maxTimeBetweenGroupedMessages, message, - messageListPreviousAndNextMessageStore, + previousMessage, + nextMessage, noGroupByUser, }); diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap index 22a0a2318a..cfea0def75 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap @@ -36,12 +36,16 @@ exports[`MessageAvatar should render message avatar 1`] = ` { "backgroundColor": undefined, }, - undefined, + { + "borderColor": "hsla(0, 0%, 0%, 0.1)", + "borderWidth": 1, + }, ] } testID="avatar-image" > { return 'generic-message'; }; -const renderItem = ({ item: message }: { item: LocalMessage }) => { - return ; -}; - const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -365,17 +361,27 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [myMessageThemeString, theme], ); - const { - messageListPreviousAndNextMessageStore, - processedMessageList, - rawMessageList, - viewabilityChangedCallback, - } = useMessageList({ + const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ isFlashList: true, isLiveStreaming, threadList, }); + const renderItem = useCallback( + ({ item: message, index }: { item: LocalMessage; index: number }) => { + const previousMessage = processedMessageList[index - 1]; + const nextMessage = processedMessageList[index + 1]; + return ( + + ); + }, + [processedMessageList], + ); + /** * We need topMessage and channelLastRead values to set the initial scroll position. * So these values only get used if `initialScrollToFirstUnreadMessage` prop is true. @@ -742,20 +748,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const messageListItemContextValue: MessageListItemContextValue = useMemo( () => ({ goToMessage, - messageListPreviousAndNextMessageStore, modifiedTheme, noGroupByUser, onThreadSelect, setNativeScrollability, }), - [ - goToMessage, - messageListPreviousAndNextMessageStore, - modifiedTheme, - noGroupByUser, - onThreadSelect, - setNativeScrollability, - ], + [goToMessage, modifiedTheme, noGroupByUser, onThreadSelect, setNativeScrollability], ); /** diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 3cdc53ba94..fbd75727ea 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -248,10 +248,6 @@ type MessageListPropsWithContext = Pick< isLiveStreaming?: boolean; }; -const renderItem = ({ item: message }: { item: LocalMessage }) => { - return ; -}; - const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); @@ -349,15 +345,29 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * NOTE: rawMessageList changes only when messages array state changes * processedMessageList changes on any state change */ - const { - messageListPreviousAndNextMessageStore, - processedMessageList, - rawMessageList, - viewabilityChangedCallback, - } = useMessageList({ + const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ isLiveStreaming, threadList, }); + + const processedMessageListRef = useRef(processedMessageList); + processedMessageListRef.current = processedMessageList; + + const renderItem = useCallback( + ({ item: message, index }: { item: LocalMessage; index: number }) => { + const previousMessage = processedMessageListRef.current[index + 1]; + const nextMessage = processedMessageListRef.current[index - 1]; + return ( + + ); + }, + [processedMessageListRef], + ); + const messageListLengthBeforeUpdate = useRef(0); const messageListLengthAfterUpdate = processedMessageList.length; @@ -805,20 +815,12 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const messageListItemContextValue: MessageListItemContextValue = useMemo( () => ({ goToMessage, - messageListPreviousAndNextMessageStore, modifiedTheme, noGroupByUser, onThreadSelect, setNativeScrollability, }), - [ - goToMessage, - messageListPreviousAndNextMessageStore, - modifiedTheme, - noGroupByUser, - onThreadSelect, - setNativeScrollability, - ], + [goToMessage, modifiedTheme, noGroupByUser, onThreadSelect, setNativeScrollability], ); /** @@ -1122,6 +1124,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); } + // TODO: Make sure this is actually overridable as the previous FlatList was. return ( { ) : ( - + // TODO: Consider hiding this behind a feature flag. + layout={LinearTransition.duration(200)} contentContainerStyle={flatListContentContainerStyle} /** Disables the MessageList UI. Which means, message actions, reactions won't work. */ data={processedMessageList} @@ -1159,11 +1164,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onScrollToIndexFailed={onScrollToIndexFailedRef.current} onTouchEnd={dismissImagePicker} onViewableItemsChanged={stableOnViewableItemsChanged} + // @ts-expect-error Safe to do for now ref={refCallback} renderItem={renderItem} scrollEventThrottle={isLiveStreaming ? 16 : undefined} showsVerticalScrollIndicator={false} - // @ts-expect-error react-native internal strictMode={isLiveStreaming} style={flatListStyle} testID='message-flat-list' diff --git a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts index 07203353a6..901ea76a8c 100644 --- a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts +++ b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts @@ -2,15 +2,12 @@ import { renderHook } from '@testing-library/react-native'; import { LocalMessage } from 'stream-chat'; -import { MessagePreviousAndNextMessageStore } from '../../../state-store/message-list-prev-next-state'; import { useMessageDateSeparator } from '../hooks/useMessageDateSeparator'; describe('useMessageDateSeparator', () => { - let messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; let messages: LocalMessage[]; beforeEach(() => { - messageListPreviousAndNextMessageStore = new MessagePreviousAndNextMessageStore(); messages = [ { created_at: new Date('2020-01-01T00:00:00.000Z'), @@ -28,13 +25,10 @@ describe('useMessageDateSeparator', () => { text: 'Hello World', }, ] as LocalMessage[]; - messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ messages }); }); it('should return undefined if no message is passed', () => { - const { result } = renderHook(() => - useMessageDateSeparator({ message: undefined, messageListPreviousAndNextMessageStore }), - ); + const { result } = renderHook(() => useMessageDateSeparator({ message: undefined })); expect(result.current).toBeUndefined(); }); @@ -43,7 +37,7 @@ describe('useMessageDateSeparator', () => { useMessageDateSeparator({ hideDateSeparators: true, message: messages[1], - messageListPreviousAndNextMessageStore, + previousMessage: messages[0], }), ); expect(result.current).toBeUndefined(); @@ -51,7 +45,7 @@ describe('useMessageDateSeparator', () => { it('should return the date separator for a message if previous message is not the same day', () => { const { result } = renderHook(() => - useMessageDateSeparator({ message: messages[1], messageListPreviousAndNextMessageStore }), + useMessageDateSeparator({ message: messages[1], previousMessage: messages[0] }), ); expect(result.current).toBe(messages[1].created_at); }); @@ -69,14 +63,12 @@ describe('useMessageDateSeparator', () => { text: 'World', }, ] as LocalMessage[]; - const messageListPreviousAndNextMessageStore = new MessagePreviousAndNextMessageStore(); - messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ messages }); const { result: resultOfFirstMessage } = renderHook(() => - useMessageDateSeparator({ message: messages[0], messageListPreviousAndNextMessageStore }), + useMessageDateSeparator({ message: messages[0], previousMessage: undefined }), ); expect(resultOfFirstMessage.current).toBe(messages[0].created_at); const { result: resultOfSecondMessage } = renderHook(() => - useMessageDateSeparator({ message: messages[1], messageListPreviousAndNextMessageStore }), + useMessageDateSeparator({ message: messages[1], previousMessage: messages[0] }), ); expect(resultOfSecondMessage.current).toBeUndefined(); }); diff --git a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts index 80957b0b1a..32433fa936 100644 --- a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts +++ b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts @@ -1,13 +1,7 @@ -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { LocalMessage } from 'stream-chat'; -import { useStateStore } from '../../../hooks/useStateStore'; -import { - MessagePreviousAndNextMessageStore, - MessagePreviousAndNextMessageStoreType, -} from '../../../state-store/message-list-prev-next-state'; - export const getDateSeparatorValue = ({ hideDateSeparators, message, @@ -37,20 +31,12 @@ export const getDateSeparatorValue = ({ export const useMessageDateSeparator = ({ hideDateSeparators, message, - messageListPreviousAndNextMessageStore, + previousMessage, }: { hideDateSeparators?: boolean; message?: LocalMessage; - messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; + previousMessage?: LocalMessage; }) => { - const selector = useCallback( - (state: MessagePreviousAndNextMessageStoreType) => ({ - previousMessage: message ? state.messageList[message.id]?.previousMessage : undefined, - }), - [message], - ); - const { previousMessage } = useStateStore(messageListPreviousAndNextMessageStore.state, selector); - const dateSeparatorDate = useMemo(() => { if (!message && !previousMessage) { return undefined; diff --git a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts index 4d4d286bc8..43378ff6420 100644 --- a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts +++ b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts @@ -1,15 +1,10 @@ -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { LocalMessage } from 'stream-chat'; import { useMessageDateSeparator } from './useMessageDateSeparator'; import { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; -import { useStateStore } from '../../../hooks/useStateStore'; -import { - MessagePreviousAndNextMessageStore, - MessagePreviousAndNextMessageStoreType, -} from '../../../state-store/message-list-prev-next-state'; import { getGroupStyle } from '../utils/getGroupStyles'; /** @@ -20,7 +15,8 @@ export const useMessageGroupStyles = ({ dateSeparatorDate, maxTimeBetweenGroupedMessages, message, - messageListPreviousAndNextMessageStore, + previousMessage, + nextMessage, getMessageGroupStyle = getGroupStyle, }: { noGroupByUser?: boolean; @@ -28,24 +24,13 @@ export const useMessageGroupStyles = ({ dateSeparatorDate?: Date; maxTimeBetweenGroupedMessages?: number; message: LocalMessage; - messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; + previousMessage?: LocalMessage; + nextMessage?: LocalMessage; }) => { - const selector = useCallback( - (state: MessagePreviousAndNextMessageStoreType) => ({ - nextMessage: state.messageList[message.id]?.nextMessage, - previousMessage: state.messageList[message.id]?.previousMessage, - }), - [message.id], - ); - const { previousMessage, nextMessage } = useStateStore( - messageListPreviousAndNextMessageStore.state, - selector, - ); - // This is needed to calculate the group styles for the next message const nextMessageDateSeparatorDate = useMessageDateSeparator({ message: nextMessage, - messageListPreviousAndNextMessageStore, + previousMessage: message, }); const groupStyles = useMemo(() => { diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index e5ee25fe65..61d129238c 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import type { LocalMessage } from 'stream-chat'; @@ -11,7 +11,6 @@ import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessa import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; import { useRAFCoalescedValue } from '../../../hooks'; -import { MessagePreviousAndNextMessageStore } from '../../../state-store/message-list-prev-next-state'; export type UseMessageListParams = { threadList?: boolean; @@ -60,9 +59,6 @@ export const useMessageList = (params: UseMessageListParams) => { const { messages, viewabilityChangedCallback } = usePaginatedMessageListContext(); const { threadMessages } = useThreadContext(); const messageList = threadList ? threadMessages : messages; - const [messageListPreviousAndNextMessageStore] = useState( - () => new MessagePreviousAndNextMessageStore(), - ); const processedMessageList = useMemo(() => { const newMessageList = []; @@ -84,24 +80,16 @@ export const useMessageList = (params: UseMessageListParams) => { return newMessageList; }, [messageList, deletedMessagesVisibilityType, client.userID, isFlashList]); - useEffect(() => { - messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ - isFlashList, - messages: processedMessageList, - }); - }, [processedMessageList, messageListPreviousAndNextMessageStore, isFlashList]); - const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming); return useMemo( () => ({ - messageListPreviousAndNextMessageStore, /** Messages enriched with dates/readby/groups and also reversed in order */ processedMessageList: data, /** Raw messages from the channel state */ rawMessageList: messageList, viewabilityChangedCallback, }), - [data, messageList, messageListPreviousAndNextMessageStore, viewabilityChangedCallback], + [data, messageList, viewabilityChangedCallback], ); }; diff --git a/package/src/components/MessageList/utils/getGroupStyles.ts b/package/src/components/MessageList/utils/getGroupStyles.ts index e3dec9e16a..06e704e678 100644 --- a/package/src/components/MessageList/utils/getGroupStyles.ts +++ b/package/src/components/MessageList/utils/getGroupStyles.ts @@ -4,8 +4,8 @@ import { isEditedMessage } from '../../../utils/utils'; export type MessageGroupStylesParams = { message: LocalMessage; - previousMessage: LocalMessage; - nextMessage: LocalMessage; + previousMessage?: LocalMessage; + nextMessage?: LocalMessage; maxTimeBetweenGroupedMessages?: number; dateSeparatorDate?: Date; nextMessageDateSeparatorDate?: Date; diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 93c177ccea..4edc298e1f 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -68,6 +68,7 @@ type ThreadPropsWithContext = Pick & * Call custom function on closing thread if handling thread state elsewhere */ onThreadDismount?: () => void; + shouldUseFlashList?: boolean; }; const ThreadWithContext = (props: ThreadPropsWithContext) => { @@ -86,6 +87,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { parentMessagePreventPress = true, thread, threadInstance, + shouldUseFlashList = false, } = props; useEffect(() => { @@ -119,13 +121,13 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { [parentMessagePreventPress], ); - if (!thread) { + if (!thread?.id) { return null; } return ( - {FlashList ? ( + {FlashList && shouldUseFlashList ? ( void; - /** - * Store to get the previous and next message in the message list - */ - messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; /** * Theme to use for the message list item */ From f121377d7b89364427645a8a4b06ae7618bfe769 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 21 Jan 2026 00:08:53 +0100 Subject: [PATCH 23/76] fix: ios CI Fastlane missing output name --- examples/SampleApp/fastlane/Fastfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index 90a537bf6d..fa88addc52 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -53,6 +53,7 @@ platform :ios do xcargs: settings_to_override, include_symbols: true, output_directory: build_output_directory + output_name: File.basename(output_ipa_name, '.ipa') ) if deploy @@ -91,7 +92,7 @@ platform :ios do in_house: false ) end - + desc "If `readonly: true` (by default), installs all Certs and Profiles necessary for development and ad-hoc.\nIf `readonly: false`, recreates all Profiles necessary for development and ad-hoc, updates them locally and remotely." lane :match_me do |options| custom_match( @@ -112,13 +113,13 @@ platform :android do deploy = options.fetch(:deploy, false) UI.message("Deploying to Firebase: #{deploy}") - - # Clean + + # Clean gradle( task: "clean", project_dir: "./android" ) - + # Build the AAB gradle( task: "assemble", From 6339a844937a4af464b5b083b31c3a85862a30d1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 21 Jan 2026 00:42:31 +0100 Subject: [PATCH 24/76] fix: fastlane syntax error --- examples/SampleApp/fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index fa88addc52..e174086925 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -52,7 +52,7 @@ platform :ios do clean: true, xcargs: settings_to_override, include_symbols: true, - output_directory: build_output_directory + output_directory: build_output_directory, output_name: File.basename(output_ipa_name, '.ipa') ) From b447d13a34600bff8845eafa0bda94dff8b0b775 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 21 Jan 2026 01:23:11 +0100 Subject: [PATCH 25/76] fix: make api_key explicit and increment build number before gym invocation --- examples/SampleApp/fastlane/Fastfile | 37 +++++++++++++++------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index e174086925..5052b25159 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -38,6 +38,24 @@ platform :ios do UI.message("Deploying to Testflight: #{deploy}") + if deploy + increment_version_number( + version_number: load_json(json_path: './package.json')['version'], + xcodeproj: xcode_project + ) + + current_build_number = app_store_build_number( + api_key: appstore_api_key, + live: false, + app_identifier: bundle_id + ) + + increment_build_number( + build_number: current_build_number + 1, + xcodeproj: xcode_project + ) + end + settings_to_override = { BUNDLE_IDENTIFIER: bundle_id, PROVISIONING_PROFILE_SPECIFIER: "match AppStore #{bundle_id}" @@ -57,30 +75,15 @@ platform :ios do ) if deploy - increment_version_number( - version_number: load_json(json_path: './package.json')['version'], - xcodeproj: xcode_project - ) - - current_build_number = app_store_build_number( - api_key: appstore_api_key, - live: false, - app_identifier: bundle_id - ) - - increment_build_number( - build_number: current_build_number + 1, - xcodeproj: xcode_project - ) - upload_to_testflight( + api_key: appstore_api_key, groups: ['Testers'], changelog: 'Lots of amazing new features to test out!', reject_build_waiting_for_review: false, ipa: "#{build_output_directory}/#{output_ipa_name}" ) else - UI.message("Skipping Testflight upload! (deploy: #{deploy})") + UI.message("Skipping Testflight upload! (deploy: #{deploy})") end end From 2030ab2ba6220466aa8d0dfd8b876f3d7b77d963 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 21 Jan 2026 15:48:10 +0100 Subject: [PATCH 26/76] fix: revert bottom sheet version bump --- examples/SampleApp/yarn.lock | 16 ++++++++-------- package/package.json | 2 +- package/yarn.lock | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 3f241a37a9..6782c5d80b 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -1572,18 +1572,18 @@ resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz#a73bab8eb491d7b8b7be2f0e6c310647835afe83" integrity sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ== -"@gorhom/bottom-sheet@^5.1.6": - version "5.1.6" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.6.tgz#92365894ae4d4eefdbaa577408cfaf62463a9490" - integrity sha512-0b5tQj4fTaZAjST1PnkCp0p7d8iRqMezibTcqc8Kkn3N23Vn6upORNTD1fH0bLfwRt6e0WnZ7DjAmq315lrcKQ== +"@gorhom/bottom-sheet@5.1.8": + version "5.1.8" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.8.tgz#65547917f5b1dae5a1291dabd4ea8bfee09feba4" + integrity sha512-QuYIVjn3K9bW20n5bgOSjvxBYoWG4YQXiLGOheEAMgISuoT6sMcA270ViSkkb0fenPxcIOwzCnFNuxmr739T9A== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" -"@gorhom/bottom-sheet@^5.2.8": - version "5.2.8" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz#25e49122c30ffe83d3813b3bcf3dec39f3359aeb" - integrity sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA== +"@gorhom/bottom-sheet@^5.1.6": + version "5.1.6" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.6.tgz#92365894ae4d4eefdbaa577408cfaf62463a9490" + integrity sha512-0b5tQj4fTaZAjST1PnkCp0p7d8iRqMezibTcqc8Kkn3N23Vn6upORNTD1fH0bLfwRt6e0WnZ7DjAmq315lrcKQ== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" diff --git a/package/package.json b/package/package.json index 4126ff35e2..57645fa6dc 100644 --- a/package/package.json +++ b/package/package.json @@ -68,7 +68,7 @@ ] }, "dependencies": { - "@gorhom/bottom-sheet": "^5.1.8", + "@gorhom/bottom-sheet": "5.1.8", "@ungap/structured-clone": "^1.3.0", "dayjs": "1.11.13", "emoji-regex": "^10.4.0", diff --git a/package/yarn.lock b/package/yarn.lock index b1d7cd0a2b..6eb085514c 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -1362,10 +1362,10 @@ "@eslint/core" "^0.14.0" levn "^0.4.1" -"@gorhom/bottom-sheet@^5.1.8": - version "5.2.8" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz#25e49122c30ffe83d3813b3bcf3dec39f3359aeb" - integrity sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA== +"@gorhom/bottom-sheet@5.1.8": + version "5.1.8" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.8.tgz#65547917f5b1dae5a1291dabd4ea8bfee09feba4" + integrity sha512-QuYIVjn3K9bW20n5bgOSjvxBYoWG4YQXiLGOheEAMgISuoT6sMcA270ViSkkb0fenPxcIOwzCnFNuxmr739T9A== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" From 0592bf3a2fd8d4446074d6fc883d73296da6bcbc Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 21 Jan 2026 21:44:48 +0100 Subject: [PATCH 27/76] perf: improve animation fluidity for context menu --- .../MessageOverlayHostLayer.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx index cf1226712c..d2c60de06e 100644 --- a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -9,7 +9,7 @@ import Animated, { useDerivedValue, useSharedValue, withDecay, - withTiming, + withSpring, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { PortalHost } from 'react-native-teleport'; @@ -17,6 +17,8 @@ import { PortalHost } from 'react-native-teleport'; import { closeOverlay, useOverlayController } from '../../state-store'; import { finalizeCloseOverlay } from '../../state-store'; +const DURATION = 300; + export const MessageOverlayHostLayer = () => { const { messageH, topH, bottomH, id, closing } = useOverlayController(); const insets = useSafeAreaInsets(); @@ -39,7 +41,11 @@ export const MessageOverlayHostLayer = () => { useEffect(() => { const target = isActive && !closing ? 1 : 0; - backdrop.value = withTiming(target, { duration: 150 }); + backdrop.value = withSpring(target, { duration: DURATION + target * 100 }, (finished) => { + if (finished && closing) { + runOnJS(finalizeCloseOverlay)(); + } + }); }, [isActive, closing, backdrop]); const backdropStyle = useAnimatedStyle(() => ({ @@ -112,7 +118,7 @@ export const MessageOverlayHostLayer = () => { const closeCompStyle = useAnimatedStyle(() => { const target = closing ? -scrollAtClose.value : 0; return { - transform: [{ translateY: withTiming(target, { duration: 150 }) }], + transform: [{ translateY: withSpring(target, { duration: DURATION }) }], }; }, [closing]); @@ -130,7 +136,10 @@ export const MessageOverlayHostLayer = () => { const topItemTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; return { - transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], + transform: [ + { scale: backdrop.value }, + { translateY: withSpring(target, { duration: DURATION }) }, + ], }; }, [isActive, closing]); @@ -148,7 +157,10 @@ export const MessageOverlayHostLayer = () => { const bottomItemTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; return { - transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], + transform: [ + { scale: backdrop.value }, + { translateY: withSpring(target, { duration: DURATION }) }, + ], }; }, [isActive, closing]); @@ -169,11 +181,7 @@ export const MessageOverlayHostLayer = () => { return { transform: [ { - translateY: withTiming(target, { duration: 150 }, (finished) => { - if (finished && closing) { - runOnJS(finalizeCloseOverlay)(); - } - }), + translateY: withSpring(target, { duration: DURATION }), }, ], }; From bba0651b5c0457a46aa303bf549641b74cac9bf2 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 22 Jan 2026 16:07:09 +0530 Subject: [PATCH 28/76] fix: reanimated 4.2.0 issue --- examples/SampleApp/ios/Podfile.lock | 180 +++++++++--------- examples/SampleApp/package.json | 6 +- examples/SampleApp/yarn.lock | 170 ++++++++++------- .../src/optionalDependencies/Audio.ts | 2 +- .../src/components/ChannelList/Skeleton.tsx | 4 +- 5 files changed, 194 insertions(+), 168 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 5af66105b1..5e9421f522 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2734,7 +2734,7 @@ PODS: - FirebaseCoreExtension - React-Core - RNFBApp - - RNGestureHandler (2.26.0): + - RNGestureHandler (2.30.0): - boost - DoubleConversion - fast_float @@ -2798,7 +2798,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNReanimated (4.0.1): + - RNReanimated (4.2.1): - boost - DoubleConversion - fast_float @@ -2825,11 +2825,11 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNReanimated/reanimated (= 4.0.1) + - RNReanimated/reanimated (= 4.2.1) - RNWorklets - SocketRocket - Yoga - - RNReanimated/reanimated (4.0.1): + - RNReanimated/reanimated (4.2.1): - boost - DoubleConversion - fast_float @@ -2856,11 +2856,11 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNReanimated/reanimated/apple (= 4.0.1) + - RNReanimated/reanimated/apple (= 4.2.1) - RNWorklets - SocketRocket - Yoga - - RNReanimated/reanimated/apple (4.0.1): + - RNReanimated/reanimated/apple (4.2.1): - boost - DoubleConversion - fast_float @@ -3039,7 +3039,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNWorklets (0.4.1): + - RNWorklets (0.7.2): - boost - DoubleConversion - fast_float @@ -3066,10 +3066,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNWorklets/worklets (= 0.4.1) + - RNWorklets/worklets (= 0.7.2) - SocketRocket - Yoga - - RNWorklets/worklets (0.4.1): + - RNWorklets/worklets (0.7.2): - boost - DoubleConversion - fast_float @@ -3096,10 +3096,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNWorklets/worklets/apple (= 0.4.1) + - RNWorklets/worklets/apple (= 0.7.2) - SocketRocket - Yoga - - RNWorklets/worklets/apple (0.4.1): + - RNWorklets/worklets/apple (0.7.2): - boost - DoubleConversion - fast_float @@ -3579,104 +3579,104 @@ SPEC CHECKSUMS: hermes-engine: bbc1152da7d2d40f9e59c28acc6576fcf5d28e2a libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - NitroModules: 8849240f6ee6d3b295514112437e3b09e855cb67 - NitroSound: fe46960c89410e62e05e9a709d8bf28a8202d1b3 - op-sqlite: b9a4028bef60145d7b98fbbc4341c83420cdcfd5 + NitroModules: 01ae20fc1e8fc9a3b088ab8ed06ab92527a04f0d + NitroSound: 347b6a21f2e7d5601c92ef81cec7836f8f8be44c + op-sqlite: a7e46cfdaebeef219fd0e939332967af9fe6d406 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f React: e7a4655b09d0e17e54be188cc34c2f3e2087318a React-callinvoker: 62192daaa2f30c3321fc531e4f776f7b09cf892b React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: c400b068fdb6172177f3b3fae00c10d1077244d7 - React-CoreModules: 8e911a5a504b45824374eec240a78de7a6db8ca2 - React-cxxreact: 06a91f55ac5f842219d6ca47e0f77187a5b5f4ac + React-Core: b23cdaaa9d76389d958c06af3c57aa6ad611c542 + React-CoreModules: 8e0f562e5695991e455abbebe1e968af71d52553 + React-cxxreact: 6ccbe0cc2c652b29409b14b23cfb3cd74e084691 React-debug: ab52e07f7148480ea61c5e9b68408d749c6e2b8f - React-defaultsnativemodule: fab7bf1b5ce1f3ed252aa4e949ec48a8df67d624 - React-domnativemodule: 735fa5238cceebceeecc18f9f4321016461178cf - React-Fabric: c75719fc8818049c3cf9071f0619af988b020c9f - React-FabricComponents: 74a381cc0dfaf2ec3ee29f39ef8533a7fd864b83 - React-FabricImage: 9a3ff143b1ac42e077c0b33ec790f3674ace5783 - React-featureflags: e1eca0727563a61c919131d57bbd0019c3bdb0f0 - React-featureflagsnativemodule: 692211fd48283b2ddee3817767021010e2f1788e - React-graphics: 759b02bde621f12426c1dc1ae2498aaa6b441cd7 - React-hermes: b6e33fcd21aa7523dc76e62acd7a547e68c28a5b - React-idlecallbacksnativemodule: 731552efc0815fc9d65a6931da55e722b1b3a397 - React-ImageManager: 2c510a480f2c358f56a82df823c66d5700949c96 - React-jserrorhandler: 411e18cbdcbdf546f8f8707091faeb00703527c1 - React-jsi: 3fde19aaf675c0607a0824c4d6002a4943820fd9 - React-jsiexecutor: 4f898228240cf261a02568e985dfa7e1d7ad1dfb - React-jsinspector: 2f0751e6a4fb840f4ed325384d0795a9e9afaf39 - React-jsinspectorcdp: 71c48944d97f5f20e8e144e419ddf04ffa931e93 - React-jsinspectornetwork: 102f347669b278644cc9bb4ebf2f90422bd5ccef - React-jsinspectortracing: 0f6f2ec7f3faa9dc73d591b24b460141612515eb - React-jsitooling: b557f8e12efdaf16997e43b0d07dbd8a3fce3a5b - React-jsitracing: f9a77561d99c0cd053a8230bab4829b100903949 - React-logger: ea80169d826e0cd112fa4d68f58b2b3b968f1ecb - React-Mapbuffer: 230c34b1cabd1c4815726c711b9df221c3d3fbfb - React-microtasksnativemodule: 29d62f132e4aba34ebb7f2b936dde754eb08971b - react-native-blob-util: cbd6b292d0f558f09dce85e6afe68074cd031f3e - react-native-cameraroll: 00057cc0ec595fdbdf282ecfb931d484b240565f - react-native-document-picker: c5fa18e9fc47b34cfbab3b0a4447d0df918a5621 - react-native-geolocation: eb39c815c9b58ddc3efb552cafdd4b035e4cf682 - react-native-image-picker: e479ec8884df9d99a62c1f53f2307055ad43ea85 - react-native-maps: ee1e65647460c3d41e778071be5eda10e3da6225 - react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac - react-native-safe-area-context: 7fd4c2c8023da8e18eaa3424cb49d52f626debee - react-native-video: 71973843c2c9ac154c54f95a5a408fd8b041790e - React-NativeModulesApple: d061f458c3febdf0ac99b1b0faf23b7305974b25 + React-defaultsnativemodule: 291d2b0a93c399056121f4f0acc7f46d155a38ec + React-domnativemodule: c4968302e857bd422df8eec50a3cd4d078bd4ac0 + React-Fabric: 7e3ba48433b87a416052c4077d5965aff64cb8c9 + React-FabricComponents: 21de255cf52232644d12d3288cced1f0c519b25d + React-FabricImage: 15a0961a0ab34178f1c803aa0a7d28f21322ffc3 + React-featureflags: 4e5dad365d57e3c3656447dfdad790f75878d9f4 + React-featureflagsnativemodule: 5eac59389131c2b87d165dac4094b5e86067fabb + React-graphics: 2f9b3db89f156afd793da99f23782f400f58c1ee + React-hermes: cc8c77acee1406c258622cd8abbee9049f6b5761 + React-idlecallbacksnativemodule: 1d7e1f73b624926d16db956e87c4885ef485664a + React-ImageManager: 8b6066f6638fba7d4a94fbd0b39b477ea8aced58 + React-jserrorhandler: e5a4626d65b0eda9a11c43a9f14d0423d8a7080d + React-jsi: ea5c640ea63c127080f158dac7f4f393d13d415c + React-jsiexecutor: cf7920f82e46fe9a484c15c9f31e67d7179aa826 + React-jsinspector: 094e3cb99952a0024fa977fa04706e68747cba18 + React-jsinspectorcdp: dca545979146e3ecbdc999c0789dab55beecc140 + React-jsinspectornetwork: 0a105fe74b0b1a93f70409d955c8a0556dc17c59 + React-jsinspectortracing: 76088dd78a2de3ea637a860cdf39a6d9c2637d6b + React-jsitooling: a2e1e87382aae2294bc94a6e9682b9bc83da1d36 + React-jsitracing: 45827be59e673f4c54175c150891301138846906 + React-logger: 7cfc7b1ae1f8e5fe5097f9c746137cc3a8fad4ce + React-Mapbuffer: 8f620d1794c6b59a8c3862c3ae820a2e9e6c9bb0 + React-microtasksnativemodule: dcf5321c9a41659a6718df8a5f202af1577c6825 + react-native-blob-util: a511afccff6511544ebf56928e6afdf837b037a7 + react-native-cameraroll: 8c3ba9b6f511cf645778de19d5039b61d922fdfb + react-native-document-picker: b37cf6660ad9087b782faa78a1e67687fac15bfd + react-native-geolocation: b7f68b8c04e36ee669c630dbc48dd42cf93a0a41 + react-native-image-picker: 9bceb747cd6cde22a3416ffdc819d11b5b11f156 + react-native-maps: 9febd31278b35cd21e4fad2cf6fa708993be5dab + react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 + react-native-safe-area-context: 32293dc61d1b92ccf892499ab6f8acfd609f9aef + react-native-video: 4da16bfca01a02aa2095e40683d74f2d6563207c + React-NativeModulesApple: 342e280bb9fc2aa5f61f6c257b309a86b995e12d React-oscompat: 56d6de59f9ae95cd006a1c40be2cde83bc06a4e1 - React-perflogger: 0633844e495d8b34798c9bf0cb32ce315f1d5c9f - React-performancetimeline: 53bdf62ff49a9b0c4bd4d66329fdcf28d77c1c9d + React-perflogger: 4008bd05a8b6c157b06608c0ea0b8bd5d9c5e6c9 + React-performancetimeline: 3ac316a346fe3d48801a746b754dd8f5b5146838 React-RCTActionSheet: 49138012280ec3bbb35193d8d09adb8bc61c982e - React-RCTAnimation: c7ed4a9d5a4e43c9b10f68bb43cd238c4a2e7e89 - React-RCTAppDelegate: ea2ab6f4aef1489f72025b7128d8ab645b40eafb - React-RCTBlob: c052799460b245e1fffe3d1dddea36fa88e998a0 - React-RCTFabric: fad6230640c42fb8cda29b1d0759f7a1fb8cc677 - React-RCTFBReactNativeSpec: ffb22c3ee3d359ae9245ca94af203845da9371ec - React-RCTImage: 59fc2571f4f109a77139924f5babee8f9cd639c9 - React-RCTLinking: a045cb58c08188dce6c6f4621de105114b1b16ce - React-RCTNetwork: fc7115a2f5e15ae0aa05e9a9be726817feefb482 - React-RCTRuntime: c69b86dc60dcc7297318097fc60bd8e40b050f74 - React-RCTSettings: 30d7dd7eae66290467a1e72bf42d927fa78c3884 - React-RCTText: 755d59284e66c7d33bb4f0ccc428fe69110c3e74 - React-RCTVibration: ffe019e588815df226f6f8ccdc65979f8b2bc440 + React-RCTAnimation: ebfe7c62016d4c17b56b2cab3a221908ae46288d + React-RCTAppDelegate: 0108657ba9a19f6a1cd62dcd19c2c0485b3fc251 + React-RCTBlob: 6cc309d1623f3c2679125a04a7425685b7219e6b + React-RCTFabric: 04d1cf11ee3747a699260492e319e92649d7ac88 + React-RCTFBReactNativeSpec: ff3e37e2456afc04211334e86d07bf20488df0ae + React-RCTImage: bb98a59aeed953a48be3f917b9b745b213b340ab + React-RCTLinking: d6e9795d4d75d154c1dd821fd0746cc3e05d6670 + React-RCTNetwork: 5c8a7a2dd26728323189362f149e788548ac72bc + React-RCTRuntime: 52b28e281aba881e1f94ee8b16611823b730d1c5 + React-RCTSettings: b6a02d545ce10dd936b39914b32674db6e865307 + React-RCTText: c7d9232da0e9b5082a99a617483d9164a9cd46e9 + React-RCTVibration: fe636c985c1bf25e4a5b5b4d9315a3b882468a72 React-rendererconsistency: aba18fa58a4d037004f6bed6bb201eb368016c56 - React-renderercss: c7c140782f5f21103b638abfde7b3f11d6a5fd7e - React-rendererdebug: 111519052db9610f1b93baf7350c800621df3d0c + React-renderercss: b490bd53486a6bae1e9809619735d1f2b2cabd7f + React-rendererdebug: 8db25b276b64d5a1dbf05677de0c4ff1039d5184 React-rncore: 22f344c7f9109b68c3872194b0b5081ca1aee655 - React-RuntimeApple: 30d20d804a216eb297ccc9ce1dc9e931738c03b1 - React-RuntimeCore: 6e1facd50e0b7aed1bc36b090015f33133958bb6 + React-RuntimeApple: 19bdabedda0eeb70acb71e68bfc42d61bbcbacd9 + React-RuntimeCore: 11bf03bdbd6e72857481c46d0f4eb9c19b14c754 React-runtimeexecutor: b35de9cb7f5d19c66ea9b067235f95b947697ba5 - React-RuntimeHermes: 222268a5931a23f095565c4d60e2673c04e2178a - React-runtimescheduler: aea93219348ba3069fe6c7685a84fe17d3a4b4ee + React-RuntimeHermes: d8f736d0a2d38233c7ec7bd36040eb9b0a3ccd8c + React-runtimescheduler: 0c95966d030c8ebbebddaab49630cda2889ca073 React-timing: 42e8212c479d1e956d3024be0a07f205a2e34d9d - React-utils: 0ebf25dd4eb1b5497933f4d8923b862d0fe9566f - ReactAppDependencyProvider: 6c9197c1f6643633012ab646d2bfedd1b0d25989 - ReactCodegen: f8ae44cfcb65af88f409f4b094b879591232f12c - ReactCommon: a237545f08f598b8b37dc3a9163c1c4b85817b0b - RNCAsyncStorage: afe7c3711dc256e492aa3a50dcac43eecebd0ae5 - RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 - RNFBApp: df5caad9f64b6bc87f8a0b110e6bc411fb00a12b - RNFBMessaging: 6586f18ab3411aeb3349088c19fe54283d39e529 - RNGestureHandler: 4d36eb583264375d9f7ece09a2efd918ebc85605 - RNNotifee: 4a6ee5c7deaf00e005050052d73ee6315dff7ec9 - RNReactNativeHapticFeedback: 8bd4a2ba7c3daeb5d2acfceb8b61743f203076d0 - RNReanimated: 408767d090bcbfe3877cfbcc9dc9d29f5e878203 - RNScreens: 5ca475eb30f4362c5808f3ff4a1c7b786bcd878e - RNShare: 083f05646b9534305999cf20452bd87ca0e8b0b0 - RNSVG: fd433fe5da0f7fee8c78f5865f29ab37321dbd7f - RNWorklets: 7d34d4c80edec50bb1eec6bd034e7686db26da8e + React-utils: a185f723baa0c525c361e6c281a846d919623dbe + ReactAppDependencyProvider: 8df342c127fd0c1e30e8b9f71ff814c22414a7c0 + ReactCodegen: 4928682e20747464165effacc170019a18da953c + ReactCommon: ec1cdf708729338070f8c4ad746768a782fd9eb1 + RNCAsyncStorage: f30b3a83064e28b0fc46f1fbd3834589ed64c7b9 + RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 + RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e + RNFBMessaging: de62448d205095171915d622ed5fb45c2be5e075 + RNGestureHandler: 0c0d36c0f3c17fc755543fad1c182e1cd541f898 + RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 + RNReactNativeHapticFeedback: d39b9a5b334ce26f49ca6abe9eea8b3938532aee + RNReanimated: e3dd9527a9614e1c9e127018cca9486f2c13b2a9 + RNScreens: 6a2d1ff4d263d29d3d3db9f3c19aad2f99fdd162 + RNShare: 9d801eafd9ae835f51bcae6b5c8de9bf3389075b + RNSVG: bc7ccfe884848ac924d2279d9025d41b5f05cb0c + RNWorklets: 4bd2a43ae826633e5e0a92953fce2eb8265759d4 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a - Teleport: 9ae328b3384204b23236e47bd0d8e994ce6bdb48 + stream-chat-react-native: f42e234640869e0eafcdd354441414ad1818b9fe + Teleport: c089481dd2bb020e3dced39b7f8849b93d1499f6 Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d -COCOAPODS: 1.14.3 +COCOAPODS: 1.16.2 diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index bc555889ab..18bc840a1c 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -48,20 +48,20 @@ "react-native": "^0.80.2", "react-native-blob-util": "^0.22.2", "react-native-fast-image": "^8.6.3", - "react-native-gesture-handler": "^2.26.0", + "react-native-gesture-handler": "^2.30.0", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^8.2.1", "react-native-maps": "1.20.1", "react-native-nitro-modules": "^0.31.3", "react-native-nitro-sound": "^0.2.9", - "react-native-reanimated": "^4.0.1", + "react-native-reanimated": "^4.2.0", "react-native-safe-area-context": "^5.4.1", "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", + "react-native-worklets": "^0.7.2", "stream-chat-react-native": "link:../../package/native-package", "stream-chat-react-native-core": "link:../../package" }, diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 6782c5d80b..784af4a076 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -28,6 +28,15 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" + integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.26.5": version "7.26.8" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367" @@ -111,13 +120,13 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^3.0.2" -"@babel/generator@^7.28.0": - version "7.28.0" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.0.tgz#9cc2f7bd6eb054d77dc66c2664148a0c5118acd2" - integrity sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg== +"@babel/generator@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.6.tgz#48dcc65d98fcc8626a48f72b62e263d25fc3c3f1" + integrity sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw== dependencies: - "@babel/parser" "^7.28.0" - "@babel/types" "^7.28.0" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" "@jridgewell/gen-mapping" "^0.3.12" "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" @@ -409,12 +418,12 @@ dependencies: "@babel/types" "^7.27.3" -"@babel/parser@^7.28.0": - version "7.28.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.0.tgz#979829fbab51a29e13901e5a80713dbcb840825e" - integrity sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g== +"@babel/parser@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" + integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== dependencies: - "@babel/types" "^7.28.0" + "@babel/types" "^7.28.6" "@babel/plugin-proposal-export-default-from@^7.24.7": version "7.25.9" @@ -577,7 +586,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-arrow-functions@^7.0.0-0": +"@babel/plugin-transform-arrow-functions@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a" integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== @@ -616,7 +625,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.25.9" -"@babel/plugin-transform-class-properties@^7.0.0-0": +"@babel/plugin-transform-class-properties@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz#dd40a6a370dfd49d32362ae206ddaf2bb082a925" integrity sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA== @@ -632,17 +641,17 @@ "@babel/helper-create-class-features-plugin" "^7.25.9" "@babel/helper-plugin-utils" "^7.25.9" -"@babel/plugin-transform-classes@^7.0.0-0": - version "7.28.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz#12fa46cffc32a6e084011b650539e880add8a0f8" - integrity sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA== +"@babel/plugin-transform-classes@7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz#75d66175486788c56728a73424d67cbc7473495c" + integrity sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA== dependencies: "@babel/helper-annotate-as-pure" "^7.27.3" "@babel/helper-compilation-targets" "^7.27.2" "@babel/helper-globals" "^7.28.0" "@babel/helper-plugin-utils" "^7.27.1" "@babel/helper-replace-supers" "^7.27.1" - "@babel/traverse" "^7.28.0" + "@babel/traverse" "^7.28.4" "@babel/plugin-transform-classes@^7.25.4": version "7.25.9" @@ -734,7 +743,7 @@ "@babel/helper-create-regexp-features-plugin" "^7.25.9" "@babel/helper-plugin-utils" "^7.25.9" -"@babel/plugin-transform-nullish-coalescing-operator@^7.0.0-0": +"@babel/plugin-transform-nullish-coalescing-operator@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz#4f9d3153bf6782d73dd42785a9d22d03197bc91d" integrity sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA== @@ -771,7 +780,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.25.9" -"@babel/plugin-transform-optional-chaining@^7.0.0-0": +"@babel/plugin-transform-optional-chaining@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz#874ce3c4f06b7780592e946026eb76a32830454f" integrity sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg== @@ -863,7 +872,7 @@ babel-plugin-polyfill-regenerator "^0.6.1" semver "^6.3.1" -"@babel/plugin-transform-shorthand-properties@^7.0.0-0": +"@babel/plugin-transform-shorthand-properties@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz#532abdacdec87bfee1e0ef8e2fcdee543fe32b90" integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== @@ -892,7 +901,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.25.9" -"@babel/plugin-transform-template-literals@^7.0.0-0": +"@babel/plugin-transform-template-literals@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8" integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg== @@ -921,7 +930,7 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" "@babel/plugin-syntax-typescript" "^7.27.1" -"@babel/plugin-transform-unicode-regex@^7.0.0-0": +"@babel/plugin-transform-unicode-regex@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97" integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== @@ -937,7 +946,7 @@ "@babel/helper-create-regexp-features-plugin" "^7.25.9" "@babel/helper-plugin-utils" "^7.25.9" -"@babel/preset-typescript@^7.16.7": +"@babel/preset-typescript@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz#190742a6428d282306648a55b0529b561484f912" integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== @@ -978,6 +987,15 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.9.tgz#4398f2394ba66d05d988b2ad13c219a2c857461a" @@ -1017,17 +1035,17 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.28.0": - version "7.28.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.0.tgz#518aa113359b062042379e333db18380b537e34b" - integrity sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg== +"@babel/traverse@^7.28.4": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e" + integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.0" + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.28.0" - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.0" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" debug "^4.3.1" "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.25.2", "@babel/types@^7.25.9", "@babel/types@^7.26.9", "@babel/types@^7.3.3": @@ -1054,13 +1072,13 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" -"@babel/types@^7.28.0": - version "7.28.2" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.2.tgz#da9db0856a9a88e0a13b019881d7513588cf712b" - integrity sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ== +"@babel/types@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" + integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== dependencies: "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" "@bcoe/v8-coverage@^0.2.3": version "0.2.3" @@ -4127,7 +4145,7 @@ content-type@~1.0.5: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -convert-source-map@^2.0.0: +convert-source-map@2.0.0, convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== @@ -7629,10 +7647,10 @@ react-native-fast-image@^8.6.3: resolved "https://registry.yarnpkg.com/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz#6edc3f9190092a909d636d93eecbcc54a8822255" integrity sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg== -react-native-gesture-handler@^2.26.0: - version "2.26.0" - resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.26.0.tgz#e8774c8cd90f7e72c0ecade0ac1b4f7160fcbd5f" - integrity sha512-pfE1j9Vzu0qpWj/Aq1IK+cYnougN69mCKvWuq1rdNjH2zs1WIszF0Mum9/oGQTemgjyc/JgiqOOTgwcleAMAGg== +react-native-gesture-handler@^2.30.0: + version "2.30.0" + resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz#990c621fbeeefde853ececdcab7cbe1b621dbb8b" + integrity sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA== dependencies: "@egjs/hammerjs" "^2.0.17" hoist-non-react-statics "^3.3.0" @@ -7648,16 +7666,16 @@ react-native-image-picker@^8.2.1: resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-8.2.1.tgz#1ac7826563cbaa5d5298d9f2acc53c69805e5393" integrity sha512-FBeGYJGFDjMdGCcyubDJgBAPCQ4L1D3hwLXyUU91jY9ahOZMTbluceVvRmrEKqnDPFJ0gF1NVhJ0nr1nROFLdg== +react-native-is-edge-to-edge@1.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-is-edge-to-edge@^1.1.7: version "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-lightbox@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/react-native-lightbox/-/react-native-lightbox-0.7.0.tgz#e52b4d7fcc141f59d7b23f0180de535e35b20ec9" @@ -7693,13 +7711,13 @@ react-native-nitro-sound@^0.2.9: dependencies: "@react-native-community/slider" "^5.0.1" -react-native-reanimated@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.0.1.tgz#6cb8bca007baa18d75e0ef8b03e969d2777cd5e8" - integrity sha512-SZmIpxVd1yijV1MA8KB9S9TUj6JpdU4THjVB0WCkfV9p6F8oR3YxO4e+GRKbNci3mODp7plW095LhjaCB9bqZQ== +react-native-reanimated@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz#fbdee721bff0946a6e5ae67c8c38c37ca4a0a057" + integrity sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg== dependencies: - react-native-is-edge-to-edge "^1.2.1" - semver "7.7.2" + react-native-is-edge-to-edge "1.2.1" + semver "7.7.3" react-native-safe-area-context@^5.4.1: version "5.4.1" @@ -7746,21 +7764,22 @@ react-native-video@^6.16.1: resolved "https://registry.yarnpkg.com/react-native-video/-/react-native-video-6.16.1.tgz#c4f5f71eac930a4ae4e2faadb22fc05d78b9b7fe" integrity sha512-+G6tVVGbwFqNTyPivqb+PhQzWr5OudDQ1dgvBNyBRAgcS8rOcbwuS6oX+m8cxOsXHn1UT9ofQnjQEwkGOsvomg== -react-native-worklets@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.4.1.tgz#563d39160195101d9cf236b54b383d6950508263" - integrity sha512-QXAMZ8jz0sLEoNrc3ej050z6Sd+UJ/Gef4SACeMuoLRinwHIy4uel7XtMPJZMqKhFerkwXZ7Ips5vIjnNyPDBA== - dependencies: - "@babel/plugin-transform-arrow-functions" "^7.0.0-0" - "@babel/plugin-transform-class-properties" "^7.0.0-0" - "@babel/plugin-transform-classes" "^7.0.0-0" - "@babel/plugin-transform-nullish-coalescing-operator" "^7.0.0-0" - "@babel/plugin-transform-optional-chaining" "^7.0.0-0" - "@babel/plugin-transform-shorthand-properties" "^7.0.0-0" - "@babel/plugin-transform-template-literals" "^7.0.0-0" - "@babel/plugin-transform-unicode-regex" "^7.0.0-0" - "@babel/preset-typescript" "^7.16.7" - convert-source-map "^2.0.0" +react-native-worklets@^0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.7.2.tgz#acfbfe4f8c7f3b2889e7f394e4fbd7e78e167134" + integrity sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog== + dependencies: + "@babel/plugin-transform-arrow-functions" "7.27.1" + "@babel/plugin-transform-class-properties" "7.27.1" + "@babel/plugin-transform-classes" "7.28.4" + "@babel/plugin-transform-nullish-coalescing-operator" "7.27.1" + "@babel/plugin-transform-optional-chaining" "7.27.1" + "@babel/plugin-transform-shorthand-properties" "7.27.1" + "@babel/plugin-transform-template-literals" "7.27.1" + "@babel/plugin-transform-unicode-regex" "7.27.1" + "@babel/preset-typescript" "7.27.1" + convert-source-map "2.0.0" + semver "7.7.3" react-native@^0.80.2: version "0.80.2" @@ -8037,16 +8056,21 @@ scheduler@0.26.0, scheduler@^0.26.0: resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.26.0.tgz#4ce8a8c2a2095f13ea11bf9a445be50c555d6337" integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== -semver@7.7.2, semver@^7.1.3, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.2: - version "7.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@^7.1.3, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.2: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + send@0.19.0: version "0.19.0" resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" diff --git a/package/native-package/src/optionalDependencies/Audio.ts b/package/native-package/src/optionalDependencies/Audio.ts index 8df64a62f2..f4a7bdaaf4 100644 --- a/package/native-package/src/optionalDependencies/Audio.ts +++ b/package/native-package/src/optionalDependencies/Audio.ts @@ -137,7 +137,6 @@ class _Audio { startPlayer = async (uri, _, onPlaybackStatusUpdate) => { try { const playback = await audioRecorderPlayer.startPlayer(uri); - console.log({ playback }); audioRecorderPlayer.addPlayBackListener((status) => { onPlaybackStatusUpdate(status); }); @@ -191,6 +190,7 @@ class _Audio { }; stopRecording = async () => { try { + if (!audioRecorderPlayer._isRecording) return; await audioRecorderPlayer.stopRecorder(); audioRecorderPlayer.removeRecordBackListener(); } catch (error) { diff --git a/package/src/components/ChannelList/Skeleton.tsx b/package/src/components/ChannelList/Skeleton.tsx index 4b7cc2521e..ec086a14b5 100644 --- a/package/src/components/ChannelList/Skeleton.tsx +++ b/package/src/components/ChannelList/Skeleton.tsx @@ -17,6 +17,8 @@ const paddingLarge = 16; const paddingMedium = 12; const paddingSmall = 8; +const AnimatedPath = Animated.createAnimatedComponent(Path); + const styles = StyleSheet.create({ background: { height: 64, @@ -148,7 +150,7 @@ export const Skeleton = () => { - +
); From 703b54b8c93f2a2febc1455cc1ae31729979a696 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 22 Jan 2026 17:56:11 +0530 Subject: [PATCH 29/76] fix: add new design changes to the message composer as per figma (#3365) --- .../components/MessageInput/MessageInput.tsx | 14 +++++++----- .../MessageList/MessageFlashList.tsx | 2 +- .../components/MessageList/MessageList.tsx | 2 +- package/src/components/Reply/Reply.tsx | 22 ++++++++++--------- .../__snapshots__/Thread.test.js.snap | 7 +++--- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 512ddcaafb..c1d7895346 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Modal, Platform, StyleSheet, TextInput, TextInputProps, View } from 'react-native'; +import { Modal, StyleSheet, TextInput, TextInputProps, View } from 'react-native'; import { Gesture, @@ -21,6 +21,8 @@ import Animated, { ZoomOut, } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; import { OutputButtons } from './components/OutputButtons'; @@ -83,7 +85,7 @@ const styles = StyleSheet.create({ }, floatingWrapper: { left: 0, - paddingHorizontal: 24, + paddingHorizontal: 16, position: 'absolute', right: 0, }, @@ -123,8 +125,8 @@ const styles = StyleSheet.create({ }, wrapper: { borderTopWidth: 1, - paddingHorizontal: 24, - paddingTop: 24, + paddingHorizontal: 16, + paddingTop: 16, }, }); @@ -452,13 +454,13 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { }, ], })); + const { bottom } = useSafeAreaInsets(); - const BOTTOM_OFFSET = isKeyboardVisible ? 24 : Platform.OS === 'ios' ? 32 : 24; + const BOTTOM_OFFSET = isKeyboardVisible ? 16 : bottom ? bottom : 16; return ( <> { (attachment) => attachment.type === FileTypes.VoiceRecording, ); const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File); + const onlyImages = imageAttachments?.length && imageAttachments?.length === attachments?.length; const onlyAudio = audioAttachments?.length && audioAttachments?.length === attachments?.length; const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length; const onlyVoiceRecordings = @@ -238,21 +235,26 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { ); } - if (fileAttachments?.length) { + if (onlyVideos) { return ( - + ); } - if (onlyVideos) { + if (onlyImages) { return ( - + ); } - if (imageAttachments?.length) { + if ( + fileAttachments?.length || + imageAttachments?.length || + videoAttachments?.length || + audioAttachments?.length + ) { return ( - + ); } 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 5a78c06fc1..2f2cf6cf7d 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1896,19 +1896,18 @@ exports[`Thread should match thread snapshot 1`] = ` />
Date: Thu, 22 Jan 2026 14:34:07 +0100 Subject: [PATCH 30/76] fix: return layout animation of input --- package/src/components/MessageInput/MessageInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index c1d7895346..1f8d91e66c 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -461,6 +461,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { return ( <> Date: Fri, 23 Jan 2026 22:47:35 +0530 Subject: [PATCH 31/76] fix: update podfile.lock for sample app --- examples/SampleApp/ios/Podfile.lock | 156 ++++++++++++++-------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 5e9421f522..e42179c14a 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3579,9 +3579,9 @@ SPEC CHECKSUMS: hermes-engine: bbc1152da7d2d40f9e59c28acc6576fcf5d28e2a libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - NitroModules: 01ae20fc1e8fc9a3b088ab8ed06ab92527a04f0d - NitroSound: 347b6a21f2e7d5601c92ef81cec7836f8f8be44c - op-sqlite: a7e46cfdaebeef219fd0e939332967af9fe6d406 + NitroModules: 8849240f6ee6d3b295514112437e3b09e855cb67 + NitroSound: fe46960c89410e62e05e9a709d8bf28a8202d1b3 + op-sqlite: b9a4028bef60145d7b98fbbc4341c83420cdcfd5 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 @@ -3591,92 +3591,92 @@ SPEC CHECKSUMS: React: e7a4655b09d0e17e54be188cc34c2f3e2087318a React-callinvoker: 62192daaa2f30c3321fc531e4f776f7b09cf892b React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: b23cdaaa9d76389d958c06af3c57aa6ad611c542 - React-CoreModules: 8e0f562e5695991e455abbebe1e968af71d52553 - React-cxxreact: 6ccbe0cc2c652b29409b14b23cfb3cd74e084691 + React-Core: c400b068fdb6172177f3b3fae00c10d1077244d7 + React-CoreModules: 8e911a5a504b45824374eec240a78de7a6db8ca2 + React-cxxreact: 06a91f55ac5f842219d6ca47e0f77187a5b5f4ac React-debug: ab52e07f7148480ea61c5e9b68408d749c6e2b8f - React-defaultsnativemodule: 291d2b0a93c399056121f4f0acc7f46d155a38ec - React-domnativemodule: c4968302e857bd422df8eec50a3cd4d078bd4ac0 - React-Fabric: 7e3ba48433b87a416052c4077d5965aff64cb8c9 - React-FabricComponents: 21de255cf52232644d12d3288cced1f0c519b25d - React-FabricImage: 15a0961a0ab34178f1c803aa0a7d28f21322ffc3 - React-featureflags: 4e5dad365d57e3c3656447dfdad790f75878d9f4 - React-featureflagsnativemodule: 5eac59389131c2b87d165dac4094b5e86067fabb - React-graphics: 2f9b3db89f156afd793da99f23782f400f58c1ee - React-hermes: cc8c77acee1406c258622cd8abbee9049f6b5761 - React-idlecallbacksnativemodule: 1d7e1f73b624926d16db956e87c4885ef485664a - React-ImageManager: 8b6066f6638fba7d4a94fbd0b39b477ea8aced58 - React-jserrorhandler: e5a4626d65b0eda9a11c43a9f14d0423d8a7080d - React-jsi: ea5c640ea63c127080f158dac7f4f393d13d415c - React-jsiexecutor: cf7920f82e46fe9a484c15c9f31e67d7179aa826 - React-jsinspector: 094e3cb99952a0024fa977fa04706e68747cba18 - React-jsinspectorcdp: dca545979146e3ecbdc999c0789dab55beecc140 - React-jsinspectornetwork: 0a105fe74b0b1a93f70409d955c8a0556dc17c59 - React-jsinspectortracing: 76088dd78a2de3ea637a860cdf39a6d9c2637d6b - React-jsitooling: a2e1e87382aae2294bc94a6e9682b9bc83da1d36 - React-jsitracing: 45827be59e673f4c54175c150891301138846906 - React-logger: 7cfc7b1ae1f8e5fe5097f9c746137cc3a8fad4ce - React-Mapbuffer: 8f620d1794c6b59a8c3862c3ae820a2e9e6c9bb0 - React-microtasksnativemodule: dcf5321c9a41659a6718df8a5f202af1577c6825 - react-native-blob-util: a511afccff6511544ebf56928e6afdf837b037a7 - react-native-cameraroll: 8c3ba9b6f511cf645778de19d5039b61d922fdfb - react-native-document-picker: b37cf6660ad9087b782faa78a1e67687fac15bfd - react-native-geolocation: b7f68b8c04e36ee669c630dbc48dd42cf93a0a41 - react-native-image-picker: 9bceb747cd6cde22a3416ffdc819d11b5b11f156 - react-native-maps: 9febd31278b35cd21e4fad2cf6fa708993be5dab - react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 - react-native-safe-area-context: 32293dc61d1b92ccf892499ab6f8acfd609f9aef - react-native-video: 4da16bfca01a02aa2095e40683d74f2d6563207c - React-NativeModulesApple: 342e280bb9fc2aa5f61f6c257b309a86b995e12d + React-defaultsnativemodule: fab7bf1b5ce1f3ed252aa4e949ec48a8df67d624 + React-domnativemodule: 735fa5238cceebceeecc18f9f4321016461178cf + React-Fabric: c75719fc8818049c3cf9071f0619af988b020c9f + React-FabricComponents: 74a381cc0dfaf2ec3ee29f39ef8533a7fd864b83 + React-FabricImage: 9a3ff143b1ac42e077c0b33ec790f3674ace5783 + React-featureflags: e1eca0727563a61c919131d57bbd0019c3bdb0f0 + React-featureflagsnativemodule: 692211fd48283b2ddee3817767021010e2f1788e + React-graphics: 759b02bde621f12426c1dc1ae2498aaa6b441cd7 + React-hermes: b6e33fcd21aa7523dc76e62acd7a547e68c28a5b + React-idlecallbacksnativemodule: 731552efc0815fc9d65a6931da55e722b1b3a397 + React-ImageManager: 2c510a480f2c358f56a82df823c66d5700949c96 + React-jserrorhandler: 411e18cbdcbdf546f8f8707091faeb00703527c1 + React-jsi: 3fde19aaf675c0607a0824c4d6002a4943820fd9 + React-jsiexecutor: 4f898228240cf261a02568e985dfa7e1d7ad1dfb + React-jsinspector: 2f0751e6a4fb840f4ed325384d0795a9e9afaf39 + React-jsinspectorcdp: 71c48944d97f5f20e8e144e419ddf04ffa931e93 + React-jsinspectornetwork: 102f347669b278644cc9bb4ebf2f90422bd5ccef + React-jsinspectortracing: 0f6f2ec7f3faa9dc73d591b24b460141612515eb + React-jsitooling: b557f8e12efdaf16997e43b0d07dbd8a3fce3a5b + React-jsitracing: f9a77561d99c0cd053a8230bab4829b100903949 + React-logger: ea80169d826e0cd112fa4d68f58b2b3b968f1ecb + React-Mapbuffer: 230c34b1cabd1c4815726c711b9df221c3d3fbfb + React-microtasksnativemodule: 29d62f132e4aba34ebb7f2b936dde754eb08971b + react-native-blob-util: cbd6b292d0f558f09dce85e6afe68074cd031f3e + react-native-cameraroll: 00057cc0ec595fdbdf282ecfb931d484b240565f + react-native-document-picker: c5fa18e9fc47b34cfbab3b0a4447d0df918a5621 + react-native-geolocation: eb39c815c9b58ddc3efb552cafdd4b035e4cf682 + react-native-image-picker: e479ec8884df9d99a62c1f53f2307055ad43ea85 + react-native-maps: ee1e65647460c3d41e778071be5eda10e3da6225 + react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac + react-native-safe-area-context: 7fd4c2c8023da8e18eaa3424cb49d52f626debee + react-native-video: 71973843c2c9ac154c54f95a5a408fd8b041790e + React-NativeModulesApple: d061f458c3febdf0ac99b1b0faf23b7305974b25 React-oscompat: 56d6de59f9ae95cd006a1c40be2cde83bc06a4e1 - React-perflogger: 4008bd05a8b6c157b06608c0ea0b8bd5d9c5e6c9 - React-performancetimeline: 3ac316a346fe3d48801a746b754dd8f5b5146838 + React-perflogger: 0633844e495d8b34798c9bf0cb32ce315f1d5c9f + React-performancetimeline: 53bdf62ff49a9b0c4bd4d66329fdcf28d77c1c9d React-RCTActionSheet: 49138012280ec3bbb35193d8d09adb8bc61c982e - React-RCTAnimation: ebfe7c62016d4c17b56b2cab3a221908ae46288d - React-RCTAppDelegate: 0108657ba9a19f6a1cd62dcd19c2c0485b3fc251 - React-RCTBlob: 6cc309d1623f3c2679125a04a7425685b7219e6b - React-RCTFabric: 04d1cf11ee3747a699260492e319e92649d7ac88 - React-RCTFBReactNativeSpec: ff3e37e2456afc04211334e86d07bf20488df0ae - React-RCTImage: bb98a59aeed953a48be3f917b9b745b213b340ab - React-RCTLinking: d6e9795d4d75d154c1dd821fd0746cc3e05d6670 - React-RCTNetwork: 5c8a7a2dd26728323189362f149e788548ac72bc - React-RCTRuntime: 52b28e281aba881e1f94ee8b16611823b730d1c5 - React-RCTSettings: b6a02d545ce10dd936b39914b32674db6e865307 - React-RCTText: c7d9232da0e9b5082a99a617483d9164a9cd46e9 - React-RCTVibration: fe636c985c1bf25e4a5b5b4d9315a3b882468a72 + React-RCTAnimation: c7ed4a9d5a4e43c9b10f68bb43cd238c4a2e7e89 + React-RCTAppDelegate: ea2ab6f4aef1489f72025b7128d8ab645b40eafb + React-RCTBlob: c052799460b245e1fffe3d1dddea36fa88e998a0 + React-RCTFabric: fad6230640c42fb8cda29b1d0759f7a1fb8cc677 + React-RCTFBReactNativeSpec: ffb22c3ee3d359ae9245ca94af203845da9371ec + React-RCTImage: 59fc2571f4f109a77139924f5babee8f9cd639c9 + React-RCTLinking: a045cb58c08188dce6c6f4621de105114b1b16ce + React-RCTNetwork: fc7115a2f5e15ae0aa05e9a9be726817feefb482 + React-RCTRuntime: c69b86dc60dcc7297318097fc60bd8e40b050f74 + React-RCTSettings: 30d7dd7eae66290467a1e72bf42d927fa78c3884 + React-RCTText: 755d59284e66c7d33bb4f0ccc428fe69110c3e74 + React-RCTVibration: ffe019e588815df226f6f8ccdc65979f8b2bc440 React-rendererconsistency: aba18fa58a4d037004f6bed6bb201eb368016c56 - React-renderercss: b490bd53486a6bae1e9809619735d1f2b2cabd7f - React-rendererdebug: 8db25b276b64d5a1dbf05677de0c4ff1039d5184 + React-renderercss: c7c140782f5f21103b638abfde7b3f11d6a5fd7e + React-rendererdebug: 111519052db9610f1b93baf7350c800621df3d0c React-rncore: 22f344c7f9109b68c3872194b0b5081ca1aee655 - React-RuntimeApple: 19bdabedda0eeb70acb71e68bfc42d61bbcbacd9 - React-RuntimeCore: 11bf03bdbd6e72857481c46d0f4eb9c19b14c754 + React-RuntimeApple: 30d20d804a216eb297ccc9ce1dc9e931738c03b1 + React-RuntimeCore: 6e1facd50e0b7aed1bc36b090015f33133958bb6 React-runtimeexecutor: b35de9cb7f5d19c66ea9b067235f95b947697ba5 - React-RuntimeHermes: d8f736d0a2d38233c7ec7bd36040eb9b0a3ccd8c - React-runtimescheduler: 0c95966d030c8ebbebddaab49630cda2889ca073 + React-RuntimeHermes: 222268a5931a23f095565c4d60e2673c04e2178a + React-runtimescheduler: aea93219348ba3069fe6c7685a84fe17d3a4b4ee React-timing: 42e8212c479d1e956d3024be0a07f205a2e34d9d - React-utils: a185f723baa0c525c361e6c281a846d919623dbe - ReactAppDependencyProvider: 8df342c127fd0c1e30e8b9f71ff814c22414a7c0 - ReactCodegen: 4928682e20747464165effacc170019a18da953c - ReactCommon: ec1cdf708729338070f8c4ad746768a782fd9eb1 - RNCAsyncStorage: f30b3a83064e28b0fc46f1fbd3834589ed64c7b9 - RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 - RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e - RNFBMessaging: de62448d205095171915d622ed5fb45c2be5e075 - RNGestureHandler: 0c0d36c0f3c17fc755543fad1c182e1cd541f898 - RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 - RNReactNativeHapticFeedback: d39b9a5b334ce26f49ca6abe9eea8b3938532aee - RNReanimated: e3dd9527a9614e1c9e127018cca9486f2c13b2a9 - RNScreens: 6a2d1ff4d263d29d3d3db9f3c19aad2f99fdd162 - RNShare: 9d801eafd9ae835f51bcae6b5c8de9bf3389075b - RNSVG: bc7ccfe884848ac924d2279d9025d41b5f05cb0c - RNWorklets: 4bd2a43ae826633e5e0a92953fce2eb8265759d4 + React-utils: 0ebf25dd4eb1b5497933f4d8923b862d0fe9566f + ReactAppDependencyProvider: 6c9197c1f6643633012ab646d2bfedd1b0d25989 + ReactCodegen: f8ae44cfcb65af88f409f4b094b879591232f12c + ReactCommon: a237545f08f598b8b37dc3a9163c1c4b85817b0b + RNCAsyncStorage: afe7c3711dc256e492aa3a50dcac43eecebd0ae5 + RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 + RNFBApp: df5caad9f64b6bc87f8a0b110e6bc411fb00a12b + RNFBMessaging: 6586f18ab3411aeb3349088c19fe54283d39e529 + RNGestureHandler: 6f2448500f339bc93dc589a5fd4da00e6d0033b2 + RNNotifee: 4a6ee5c7deaf00e005050052d73ee6315dff7ec9 + RNReactNativeHapticFeedback: 8bd4a2ba7c3daeb5d2acfceb8b61743f203076d0 + RNReanimated: 0d996442e746ee9d947f13914fcc17b43b17c445 + RNScreens: 5ca475eb30f4362c5808f3ff4a1c7b786bcd878e + RNShare: 083f05646b9534305999cf20452bd87ca0e8b0b0 + RNSVG: fd433fe5da0f7fee8c78f5865f29ab37321dbd7f + RNWorklets: a382db09224b0f6fda1c72c4c9bf1abfac9b3db8 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: f42e234640869e0eafcdd354441414ad1818b9fe - Teleport: c089481dd2bb020e3dced39b7f8849b93d1499f6 + stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a + Teleport: 9ae328b3384204b23236e47bd0d8e994ce6bdb48 Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d -COCOAPODS: 1.16.2 +COCOAPODS: 1.14.3 From c563425021749a62ae36f1331d25319f5836727c Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Sat, 24 Jan 2026 13:21:16 +0530 Subject: [PATCH 32/76] chore: fix testflight build issue on develop when deploy mode is true (#3367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal ## ๐Ÿ›  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 --- .github/workflows/sample-distribution.yml | 2 +- examples/SampleApp/fastlane/Fastfile | 29 ++++++++++++++----- .../ios/SampleApp.xcodeproj/project.pbxproj | 4 +-- .../__snapshots__/Thread.test.js.snap | 1 + 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sample-distribution.yml b/.github/workflows/sample-distribution.yml index 87600b6912..ecb42ecf80 100644 --- a/.github/workflows/sample-distribution.yml +++ b/.github/workflows/sample-distribution.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v3 - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4.0' # Update as needed + xcode-version: '26.0' # Update as needed - uses: ./.github/actions/ruby-cache - name: Install && Build - SDK and Sample App uses: ./.github/actions/install-and-build-sdk diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index 5052b25159..3330de51cf 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -31,6 +31,22 @@ end ####################### platform :ios do + private_lane :latest_appstore_version_code do |options| + livestates = [true, false] + version_codes = [] + livestates.each do |livestate| + vc = app_store_build_number( + live: livestate, + app_identifier: bundle_id + ) + rescue StandardError + puts("No app store build found for liveState: #{livestate} bundle_id: #{bundle_id}") + else + version_codes.append(vc) + end + version_codes.max + end + lane :deploy_to_testflight_qa do |options| match_me @@ -44,11 +60,9 @@ platform :ios do xcodeproj: xcode_project ) - current_build_number = app_store_build_number( - api_key: appstore_api_key, - live: false, - app_identifier: bundle_id - ) + current_build_number = latest_appstore_version_code + + puts("Current build number: #{current_build_number}") increment_build_number( build_number: current_build_number + 1, @@ -79,8 +93,9 @@ platform :ios do api_key: appstore_api_key, groups: ['Testers'], changelog: 'Lots of amazing new features to test out!', - reject_build_waiting_for_review: false, - ipa: "#{build_output_directory}/#{output_ipa_name}" + reject_build_waiting_for_review: true, + ipa: "#{build_output_directory}/#{output_ipa_name}", + skip_waiting_for_build_processing: false ) else UI.message("Skipping Testflight upload! (deploy: #{deploy})") diff --git a/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj b/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj index fd2714a5da..51996b9be4 100644 --- a/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj +++ b/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj @@ -497,7 +497,7 @@ CODE_SIGN_ENTITLEMENTS = SampleApp/SampleAppDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 149; + CURRENT_PROJECT_VERSION = 926; DEVELOPMENT_TEAM = EHV7XZLAHA; ENABLE_BITCODE = NO; INFOPLIST_FILE = SampleApp/Info.plist; @@ -534,7 +534,7 @@ CODE_SIGN_ENTITLEMENTS = SampleApp/SampleAppRelease.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 149; + CURRENT_PROJECT_VERSION = 926; DEVELOPMENT_TEAM = EHV7XZLAHA; INFOPLIST_FILE = SampleApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( 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 2f2cf6cf7d..ff81ad7bc7 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1896,6 +1896,7 @@ exports[`Thread should match thread snapshot 1`] = ` />
Date: Sat, 24 Jan 2026 17:25:05 +0530 Subject: [PATCH 33/76] fix: skip waiting for build processing for ios testflight builds --- examples/SampleApp/fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index 3330de51cf..0efb03543f 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -95,7 +95,7 @@ platform :ios do changelog: 'Lots of amazing new features to test out!', reject_build_waiting_for_review: true, ipa: "#{build_output_directory}/#{output_ipa_name}", - skip_waiting_for_build_processing: false + skip_waiting_for_build_processing: true ) else UI.message("Skipping Testflight upload! (deploy: #{deploy})") From 1c9303a0083dfc3deaca66a1ed7cc51ef4f738dd Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 27 Jan 2026 12:47:16 +0530 Subject: [PATCH 34/76] fix: channel avatar logic and usage (#3353) The PR includes the Avatar Stack and the Channel Avatar component which is needed and adds the palette in the avatar through the theme. --- .../SampleApp/src/screens/ChannelScreen.tsx | 18 +--- package/foobar.db-journal | Bin 0 -> 16928 bytes .../ChannelPreview/ChannelPreviewAvatar.tsx | 25 ----- .../ChannelPreviewMessenger.tsx | 6 +- .../__snapshots__/MessageAvatar.test.js.snap | 7 +- .../components/Poll/components/PollOption.tsx | 6 +- .../Poll/components/PollResults/PollVote.tsx | 2 +- package/src/components/Reply/Reply.tsx | 16 ++- .../__snapshots__/Thread.test.js.snap | 28 +---- package/src/components/ui/Avatar/Avatar.tsx | 36 +++---- .../src/components/ui/Avatar/AvatarStack.tsx | 97 ++++++++++++++++++ .../components/ui/Avatar/ChannelAvatar.tsx | 81 ++++++++++----- .../src/components/ui/Avatar/UserAvatar.tsx | 66 +++++++----- package/src/components/ui/Avatar/constants.ts | 21 +++- package/src/components/ui/Avatar/index.ts | 1 + .../src/contexts/themeContext/utils/theme.ts | 5 +- package/src/icons/NewPencil.tsx | 17 +++ package/src/theme/primitives/colors.ts | 75 ++++++++++++++ package/src/utils/utils.ts | 12 +++ 19 files changed, 357 insertions(+), 162 deletions(-) create mode 100644 package/foobar.db-journal delete mode 100644 package/src/components/ChannelPreview/ChannelPreviewAvatar.tsx create mode 100644 package/src/components/ui/Avatar/AvatarStack.tsx create mode 100644 package/src/icons/NewPencil.tsx diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 333f5d079e..8c0a206d98 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -15,9 +15,7 @@ import { AITypingIndicatorView, useTranslationContext, MessageActionsParams, - UserAvatar, ChannelAvatar, - useChannelPreviewDisplayPresence, } from 'stream-chat-react-native'; import { Platform, Pressable, StyleSheet, View } from 'react-native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -54,7 +52,6 @@ const ChannelHeader: React.FC = ({ channel }) => { const { closePicker } = useAttachmentPickerContext(); const membersStatus = useChannelMembersStatus(channel); const displayName = useChannelPreviewDisplayName(channel, 30); - const online = useChannelPreviewDisplayPresence(channel); const { isOnline } = useChatContext(); const { chatClient } = useAppContext(); const navigation = useNavigation(); @@ -94,10 +91,6 @@ const ChannelHeader: React.FC = ({ channel }) => { return null; } - const members = channel.state.members; - const membersValues = Object.values(members); - const otherMembers = membersValues.filter((member) => member.user?.id !== chatClient?.user?.id); - return ( = ({ channel }) => { opacity: pressed ? 0.5 : 1, })} > - {otherMembers.length === 1 ? ( - - ) : ( - - )} + )} showUnreadCountBadge diff --git a/package/foobar.db-journal b/package/foobar.db-journal new file mode 100644 index 0000000000000000000000000000000000000000..334d055043d2fadf0d95cd22e83d67f9a3399004 GIT binary patch literal 16928 zcmeHO%WoUU87C!+q)18hOI};D;*Ap5W~_<#y|WGiR1aEJqem>0K->ZqW_M;SrZg$@ zVaE*`)Xt-qUfLXsUWx*}6$px=J+z0Sr}kD9D0<7GK!N-LEehn&e*4stk?d)}UXrCP z?(EL!H{bXBUh@fLCUf{&TwnVa+;uaVF}R#OoJdDsWCj+Bpxsh1tSK1*d3EelmlmKc4duV;yFNmOANFl`z6w5Mobro4U0BO4BB5Yv|sZ_Tmmnya{YZtiDbO&SAkW|DJ zVj<14HDnWoB3s6i>gvptnQ`7nvt(CQbkl%&ogs}#1lf`%A!f)#GXTY;GA2w`m`RYS zNgx}uEM%!zL9Q-Iw&}{i4;Fkh*Hu)>axu~^10zjkfUCI{qK@kr7SnWx*??@ClBy$v z7%tKjQ$v;nI$N%VsZLxb$<8?+O>-sYNr4R8g_Rn{$TlsCOcPVdG%eF{44vpQvuy`S zn1K~2Fo+#TMZ{*9VU;igtFu0u!(2(nU``to6SR;)He0ojDqEJu00?7;N7J+1Xgso= z@zD&!U@E~nvY7_K$&02vks13*uQ;WFFb3eKg%7hHM)IVb}KZgDxS9UMn*DVluXQG%W?Z>6#{~wu=nQ^AKuE$aWwJn3{zxhnc485*|&9 z+-N+qP5Nj|atL)4(2r0FX*N)XU1}p&wutOPfDp~)k*&au#v@yv8%p+T;K zOfDfpEQktfOD3c~$01hEN7HO7n;K+7-G#8$z$6GPtRe+spF$>s%!zqqo8U&{k?lmF zDOMDsswOhA207nmIB8-=8T@|8}}j z{zdtlkkU{`Cs@B|tJQqNYsz3K2X9LJlHR&GYS$;^KPThk%6F`otUH2eQ2au!c z**(Zp{E$3{ZV&Qg06D7e-GeOiL-I;F6F`otkN2dU4j@O>%zKbip_C))=sn0%$mNLI zdJnS556Nqu$pCUx4ZbI3fgh4r$~-?LPm}3MnF}CC)$eDF%Yik|}0QGH*!uhOWOXWBiS`7yDh-*=x|n z8;d-juUq^s&nNY~9-j*>_#R01_N~oEkBM%(bC2{y6}}OY&1SwTikXYGIZz$GCz3e< zKVQp?{Y(tUaPePRc%KQ!M_)XQmp^!E0%o)2PrWsB*^; z(Qb9)!TjoOb-7j*Ys)K}RWW{iwJ63fXP>lMthw5tVy*f{P2AprpZezJQqk!!(qnYF z=TF4TuC3hpR#BcNu=)mrt zONCn|R=vKvv9-K=M|`b%N4$Ed%90r4R+fr4cXq2A>)XL1Z{kWNC%iJ7%`{q+z16+n z1V7m${a)L@-#chGn|reNe*Uq2B`;jQoP9U%6MO)wHd=RUr2Fk|a(r%p=H$!~Ovk*1 z;?^D=utA1n#=#~V+qLR?byrL(x?HdAY;41xTh;B_QZZIFL^&Xjnr#wUr?(Ggr17S` zXt&q}D)q1Agvvs;>uVoS5JGmN>)BAjjqSDS8{*&^ac4VXyL;7X(51jqm3LoyF)u7E zWIwp#a~DBH*V6->MNgA_kzS8D`}bI@*NylALZ0EIlr>q>X~X-Q?amexS9W$btIONs zTJ`2~eX}OsT;9B01qXhs*CA`9N5XBwU1DZyu%JHVj=qz`3xQ(Ci@fztyMGW#+ubJ} zM)#tq3&DE;7AO%(aplTVF*eIm@hOhKR7|?T)dn4QZlJ#cp?9;oTisr*-WEd_^X*dc z47FC>tU^9mUB11#yjDH*f$NuZ!s^0t5fNEuPmV@sZoQBbuFPkHkck}Pp3OSoGydh& zkuKv;1s>xM2OQ@5^Lb%@KKp?lI!qF-JmfCHZ^sIVBI!8$jR%QzWqWyz9%u8Anca3X z4!&nQSEKa+QpK|4u!E$J1&`ycmWsiHUG^q(2G(`A+LU!juic5nS}GniJTc>t?;HvQ z{zKajSDvZ{zq5FeDTbjrloCI;*Pz9musnaf*Z4;AoW>uVq+(vUb}9R~l*lwubR5>K znIjx0Ugfz?e3MN0p<`~)%9HkUPcMp71Fm$4mT;!SO7g6H6BfxYKvDFo@Idy6g566r z9`VN|5BX9~s9rkG4Pt`#RLF7dZJYrP{p>dgI$S%4 zW|L(!IrXeysLbVrSB0k|d{R%uIq9JB}Kw>BehXTKHE+_0%j`R3I z31YFsUB{u{EqFce>t}PqwQJcsz7Ana)@AqmtmUww+t~pvh9; - -export const ChannelPreviewAvatar = ({ channel }: ChannelPreviewAvatarProps) => { - const { client } = useChatContext(); - 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 otherMembers.length === 1 ? ( - - ) : ( - - ); -}; diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index f4c6f452ee..40a9b65f03 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; import type { ChannelPreviewProps } from './ChannelPreview'; -import { ChannelPreviewAvatar } from './ChannelPreviewAvatar'; import { ChannelPreviewMessage } from './ChannelPreviewMessage'; import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; import { ChannelPreviewStatus } from './ChannelPreviewStatus'; @@ -18,6 +17,7 @@ import { } from '../../contexts/channelsContext/ChannelsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useViewport } from '../../hooks/useViewport'; +import { ChannelAvatar } from '../ui/Avatar/ChannelAvatar'; const styles = StyleSheet.create({ container: { @@ -104,7 +104,7 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW maxUnreadCount, muted, onSelect, - PreviewAvatar = ChannelPreviewAvatar, + PreviewAvatar = ChannelAvatar, PreviewMessage = ChannelPreviewMessage, PreviewMutedStatus = ChannelPreviewMutedStatus, PreviewStatus = ChannelPreviewStatus, @@ -143,7 +143,7 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW ]} testID='channel-preview-button' > - + {option.text} - {relevantVotes.map((vote: PollVote) => ( - - ))} + {relevantVotes.map((vote: PollVote) => + vote.user ? : null, + )} {voteCountsByOption[option.id] || 0} diff --git a/package/src/components/Poll/components/PollResults/PollVote.tsx b/package/src/components/Poll/components/PollResults/PollVote.tsx index b76263355a..d2c19a2ed0 100644 --- a/package/src/components/Poll/components/PollResults/PollVote.tsx +++ b/package/src/components/Poll/components/PollResults/PollVote.tsx @@ -43,7 +43,7 @@ export const PollVote = ({ vote }: { vote: PollVoteClass }) => { return ( - {!isAnonymous ? : null} + {!isAnonymous && vote.user ? : null} {isAnonymous ? t('Anonymous') : (vote.user?.name ?? vote.user?.id)} diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index a3a123152f..4d303ee7d4 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -17,6 +17,7 @@ import { NewFile } from '../../icons/NewFile'; import { NewLink } from '../../icons/NewLink'; import { NewMapPin } from '../../icons/NewMapPin'; import { NewMic } from '../../icons/NewMic'; +import { NewPencil } from '../../icons/NewPencil'; import { NewPhoto } from '../../icons/NewPhoto'; import { NewPoll } from '../../icons/NewPoll'; import { NewVideo } from '../../icons/NewVideo'; @@ -318,9 +319,13 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { leftContainer, ]} > - - {title} - + + {mode === 'edit' ? : null} + + {title} + + + @@ -464,6 +469,11 @@ const styles = StyleSheet.create({ gap: 4, paddingTop: 4, }, + titleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: 4, + }, title: { color: '#384047', fontSize: 12, 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 ff81ad7bc7..2975d9b1d3 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -297,11 +297,6 @@ exports[`Thread should match thread snapshot 1`] = ` testID="message-avatar" > { const [error, setError] = useState(false); - const { size, imageUrl, placeholder, showBorder } = props; + const { + theme: { + colors: { avatarPalette }, + }, + } = useTheme(); + const defaultAvatarBg = avatarPalette?.[0].bg; + const { backgroundColor = defaultAvatarBg, size, imageUrl, placeholder, showBorder } = props; const styles = useStyles(); const onHandleError = useCallback(() => { @@ -42,8 +32,8 @@ export const Avatar = (props: AvatarProps) => { { ) : ( placeholder diff --git a/package/src/components/ui/Avatar/AvatarStack.tsx b/package/src/components/ui/Avatar/AvatarStack.tsx new file mode 100644 index 0000000000..5054689a7a --- /dev/null +++ b/package/src/components/ui/Avatar/AvatarStack.tsx @@ -0,0 +1,97 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { UserResponse } from 'stream-chat'; + +import { avatarSizes } from './constants'; + +import { UserAvatar } from './UserAvatar'; + +import { BadgeCount } from '../BadgeCount'; + +export type AvatarStackProps = { + avatarSize?: 'sm' | 'xs'; + maxVisible?: number; + items: React.ReactNode[]; + overlap?: number; +}; + +export const AvatarStack = (props: AvatarStackProps) => { + const { avatarSize = 'sm', maxVisible = 3, items = [], overlap = 0 } = props; + const styles = useStyles(); + const diameter = avatarSizes[avatarSize].width; + const visiblePortion = diameter * (1 - overlap); + const visibleItems = items.slice(0, maxVisible); + const extraCount = items.length - visibleItems.length; + + if (extraCount > 0) { + visibleItems.push(); + } + + const totalWidth = diameter + (visibleItems.length - 1) * visiblePortion; + + return ( + + {visibleItems.map((item, index) => { + return ( + + {item} + + ); + })} + + ); +}; + +export type UserAvatarStackProps = Pick< + AvatarStackProps, + 'avatarSize' | 'maxVisible' | 'overlap' +> & { + users: UserResponse[]; +}; + +export const UserAvatarStack = ({ + avatarSize, + maxVisible, + overlap, + users, +}: UserAvatarStackProps) => { + const items = users.map((user) => { + return ; + }); + + return ( + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, + item: { + position: 'absolute', + }, + }), + [], + ); +}; diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 7272689e1e..bc1c6dd2d0 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -1,15 +1,18 @@ import React, { useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; - import { Channel } from 'stream-chat'; import { Avatar } from './Avatar'; -import { iconSizes, indicatorSizes } from './constants'; +import { iconSizes } from './constants'; + +import { UserAvatar } from './UserAvatar'; +import { useChannelPreviewDisplayPresence } from '../../../components/ChannelPreview/hooks/useChannelPreviewDisplayPresence'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { GroupIcon } from '../../../icons/GroupIcon'; -import { OnlineIndicator } from '../OnlineIndicator'; +import { hashStringToNumber } from '../../../utils/utils'; export type ChannelAvatarProps = { channel: Channel; @@ -19,33 +22,55 @@ export type ChannelAvatarProps = { }; export const ChannelAvatar = (props: ChannelAvatarProps) => { - const { channel, size, showBorder = false, showOnlineIndicator = false } = props; + const { client } = useChatContext(); + const { channel } = props; + const members = Object.values(channel.state.members); + const online = useChannelPreviewDisplayPresence(channel); + + const { + theme: { + colors: { avatarPalette }, + }, + } = useTheme(); + + const hashedValue = hashStringToNumber(channel.cid); + const index = hashedValue % (avatarPalette?.length ?? 1); + const avatarColors = avatarPalette?.[index]; + + const { size, showBorder = true, showOnlineIndicator = online } = props; + + const channelImage = channel.data?.image; const placeholder = useMemo(() => { - return ; - }, [size]); + return ( + + ); + }, [size, avatarColors]); + + if (!channelImage) { + const otherMembers = members.filter((member) => member.user?.id !== client?.user?.id); + const otherUser = otherMembers?.[0]?.user; + + const user = members.length === 1 ? client.user : members.length === 2 ? otherUser : null; + if (user) { + return ( + + ); + } + } return ( - - - {showOnlineIndicator ? ( - - - - ) : null} - + ); }; - -const styles = StyleSheet.create({ - onlineIndicatorWrapper: { - position: 'absolute', - right: -2, - top: -2, - }, -}); diff --git a/package/src/components/ui/Avatar/UserAvatar.tsx b/package/src/components/ui/Avatar/UserAvatar.tsx index 4402ccb3da..681649c546 100644 --- a/package/src/components/ui/Avatar/UserAvatar.tsx +++ b/package/src/components/ui/Avatar/UserAvatar.tsx @@ -7,39 +7,53 @@ import { UserResponse } from 'stream-chat'; import { Avatar } from './Avatar'; import { fontSizes, iconSizes, indicatorSizes, numberOfInitials } from './constants'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { PeopleIcon } from '../../../icons/PeopleIcon'; -import { getInitialsFromName } from '../../../utils/utils'; +import { getInitialsFromName, hashStringToNumber } from '../../../utils/utils'; import { OnlineIndicator } from '../OnlineIndicator'; export type UserAvatarProps = { - user?: UserResponse; + user: UserResponse; showOnlineIndicator?: boolean; size: 'xs' | 'sm' | 'md' | 'lg'; showBorder?: boolean; }; export const UserAvatar = (props: UserAvatarProps) => { - const { user, size, showBorder = !!user?.image, showOnlineIndicator } = props; + const { user, size, showBorder = !!user.image, showOnlineIndicator } = props; + const { + theme: { + colors: { avatarPalette }, + }, + } = useTheme(); + const styles = useStyles(); + const hashedValue = hashStringToNumber(user.id); + const index = hashedValue % (avatarPalette?.length ?? 1); + const avatarColors = avatarPalette?.[index]; const placeholder = useMemo(() => { - if (user?.name) { + if (user.name) { return ( - + {getInitialsFromName(user.name, numberOfInitials[size])} ); } else { - return ; + return ( + + ); } - }, [user?.name, size]); - - if (!user) { - return null; - } + }, [user.name, size, avatarColors]); return ( - - + + {showOnlineIndicator ? ( @@ -49,16 +63,16 @@ export const UserAvatar = (props: UserAvatarProps) => { ); }; -const styles = StyleSheet.create({ - text: { - color: '#003179', - }, - onlineIndicatorWrapper: { - position: 'absolute', - right: 0, - top: 0, - }, - wrapper: { - padding: 2, - }, -}); +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + onlineIndicatorWrapper: { + position: 'absolute', + right: -2, + top: -2, + }, + }), + [], + ); +}; diff --git a/package/src/components/ui/Avatar/constants.ts b/package/src/components/ui/Avatar/constants.ts index d89a7cc4ca..b16b4569a0 100644 --- a/package/src/components/ui/Avatar/constants.ts +++ b/package/src/components/ui/Avatar/constants.ts @@ -3,6 +3,25 @@ import { UserAvatarProps } from './UserAvatar'; import { FontWeightType } from '../../../theme/primitives/typography'; import { OnlineIndicatorProps } from '../OnlineIndicator'; +const avatarSizes = { + lg: { + height: 40, + width: 40, + }, + md: { + height: 32, + width: 32, + }, + sm: { + height: 24, + width: 24, + }, + xs: { + height: 20, + width: 20, + }, +}; + const indicatorSizes: Record = { xs: 'sm', sm: 'sm', @@ -34,4 +53,4 @@ const numberOfInitials: Record = { lg: 2, }; -export { indicatorSizes, iconSizes, fontSizes, numberOfInitials }; +export { indicatorSizes, iconSizes, fontSizes, numberOfInitials, avatarSizes }; diff --git a/package/src/components/ui/Avatar/index.ts b/package/src/components/ui/Avatar/index.ts index 05193fbc0b..17e92462dd 100644 --- a/package/src/components/ui/Avatar/index.ts +++ b/package/src/components/ui/Avatar/index.ts @@ -1,3 +1,4 @@ export * from './Avatar'; export * from './ChannelAvatar'; export * from './UserAvatar'; +export * from './AvatarStack'; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 59e875c112..c7cf91cab7 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -2,7 +2,7 @@ import { type ColorValue, type ImageStyle, type TextStyle, type ViewStyle } from import type { CircleProps, StopProps } from 'react-native-svg'; import type { IconProps } from '../../../icons/utils/base'; -import { lightColors, type NewColors } from '../../../theme/primitives/colors'; +import { AvatarPalette, 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'; @@ -191,7 +191,8 @@ export type Theme = { unreadContainer: ViewStyle; unreadText: TextStyle; }; - colors: typeof Colors & NewColors & { [key: string]: string | { [key: string]: string } }; + colors: typeof Colors & + NewColors & { [key: string]: string | { [key: string]: string } | AvatarPalette }; dateHeader: { container: ViewStyle; text: TextStyle; diff --git a/package/src/icons/NewPencil.tsx b/package/src/icons/NewPencil.tsx new file mode 100644 index 0000000000..3f83129bb6 --- /dev/null +++ b/package/src/icons/NewPencil.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPencil = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/theme/primitives/colors.ts b/package/src/theme/primitives/colors.ts index 48919f2917..f54c7a1b88 100644 --- a/package/src/theme/primitives/colors.ts +++ b/package/src/theme/primitives/colors.ts @@ -14,6 +14,13 @@ type Pallete = { 950: string; }; +export type AvatarColorPair = { + bg: string; + text: string; +}; + +export type AvatarPalette = AvatarColorPair[]; + type AccentColors = { primary: string; success: string; @@ -75,6 +82,7 @@ export type RemoveControlColors = { export type NewColors = { brand: Pallete; + avatarPalette?: AvatarPalette; accent: AccentColors; state: StateColors; text: TextColors; @@ -86,6 +94,28 @@ export type NewColors = { export function resolveTheme(input: NewColors) { const brand = input.brand ?? palette.blue; + const avatarPalette = input.avatarPalette ?? [ + { + bg: palette.blue[100], + text: palette.blue[800], + }, + { + bg: palette.cyan[100], + text: palette.cyan[800], + }, + { + bg: palette.green[100], + text: palette.green[800], + }, + { + bg: palette.purple[100], + text: palette.purple[800], + }, + { + bg: palette.yellow[100], + text: palette.yellow[800], + }, + ]; const accent = input.accent ?? { primary: brand[500], success: palette.green[500], @@ -140,6 +170,7 @@ export function resolveTheme(input: NewColors) { }; return { brand, + avatarPalette, accent, text, state, @@ -152,6 +183,28 @@ export function resolveTheme(input: NewColors) { export const lightColors = { brand: palette.blue, + avatarPalette: [ + { + bg: palette.blue[100], + text: palette.blue[800], + }, + { + bg: palette.cyan[100], + text: palette.cyan[800], + }, + { + bg: palette.green[100], + text: palette.green[800], + }, + { + bg: palette.purple[100], + text: palette.purple[800], + }, + { + bg: palette.yellow[100], + text: palette.yellow[800], + }, + ], accent: { primary: palette.blue[500], success: palette.green[500], @@ -220,6 +273,28 @@ export const darkColors = { 900: palette.blue[50], 950: palette.white, }, + avatarPalette: [ + { + bg: palette.blue[800], + text: palette.blue[100], + }, + { + bg: palette.cyan[800], + text: palette.cyan[100], + }, + { + bg: palette.green[800], + text: palette.green[100], + }, + { + bg: palette.purple[800], + text: palette.purple[100], + }, + { + bg: palette.yellow[800], + text: palette.yellow[100], + }, + ], accent: { primary: palette.blue[400], success: palette.green[400], diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 515ab3c2fe..22a0d6766a 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -373,3 +373,15 @@ export const getInitialsFromName = (name: string, numberOfInitials: number = 2) .map((n) => n.charAt(0).toUpperCase()) .join(''); }; + +// Utility to hash a string to a number +export const hashStringToNumber = (str: string) => { + let hash = 0; + + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + + return Math.abs(hash) ?? 0; +}; From 96107dd04dfc10aabf5b52aa8a49f6db72a40162 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Tue, 27 Jan 2026 11:22:34 +0000 Subject: [PATCH 35/76] chore: update ruby gems (#3370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal Fix testflight upload ## ๐Ÿงช Test - [x] https://github.com/GetStream/stream-chat-react-native/actions/runs/21393356701 --- examples/SampleApp/Gemfile.lock | 194 ++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 82 deletions(-) diff --git a/examples/SampleApp/Gemfile.lock b/examples/SampleApp/Gemfile.lock index 83479ab205..e16cd2e14a 100644 --- a/examples/SampleApp/Gemfile.lock +++ b/examples/SampleApp/Gemfile.lock @@ -1,48 +1,56 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) + CFPropertyList (3.0.8) + abbrev (0.1.2) + activesupport (7.2.3) base64 - nkf - rexml - activesupport (7.0.8.4) - concurrent-ruby (~> 1.0, >= 1.0.2) + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) artifactory (3.0.17) - ast (2.4.2) + ast (2.4.3) atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.966.0) - aws-sdk-core (3.201.5) + aws-eventstream (1.4.0) + aws-partitions (1.1209.0) + aws-sdk-core (3.241.4) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) + aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.88.0) - aws-sdk-core (~> 3, >= 3.201.0) + logger + aws-sdk-kms (1.121.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.158.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-s3 (1.212.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.9.1) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) - benchmark (0.3.0) - bigdecimal (3.1.5) + benchmark (0.5.0) + bigdecimal (4.0.1) claide (1.1.0) - cocoapods (1.14.3) + cocoapods (1.15.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.14.3) + cocoapods-core (= 1.15.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -57,7 +65,7 @@ GEM nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.14.3) + cocoapods-core (1.15.2) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -80,18 +88,22 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.3) + connection_pool (3.0.2) + csv (3.3.5) declarative (0.0.20) - digest-crc (0.6.5) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) + drb (2.2.3) emoji_regex (3.2.3) escape (0.0.4) - ethon (0.16.0) + ethon (0.18.0) ffi (>= 1.15.0) - excon (0.111.0) - faraday (1.10.3) + logger + excon (0.112.0) + faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -103,32 +115,36 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) - faraday_middleware (1.2.0) + faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.3.1) - fastlane (2.222.0) + fastimage (2.4.0) + fastlane (2.231.1) CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2.0) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -136,6 +152,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -145,10 +162,14 @@ GEM http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) naturally (~> 2.2) + nkf (~> 0.2.0) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.5) @@ -159,15 +180,17 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) + xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-plugin-firebase_app_distribution (0.9.1) + fastlane-plugin-firebase_app_distribution (0.10.1) google-apis-firebaseappdistribution_v1 (~> 0.3.0) google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) fastlane-plugin-load_json (0.0.1) fastlane-plugin-stream_actions (0.3.73) xctest_list (= 1.2.1) - ffi (1.17.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + ffi (1.17.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) @@ -191,12 +214,12 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.1) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.4.0) + google-cloud-errors (1.5.0) google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) @@ -212,81 +235,88 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.7) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) - i18n (1.14.5) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.7.2) - jwt (2.8.2) + json (2.18.0) + jwt (2.10.2) base64 - language_server-protocol (3.17.0.3) - logger (1.6.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.25.1) + minitest (6.0.1) + prism (~> 1.5) molinillo (0.8.0) - multi_json (1.15.0) + multi_json (1.19.1) multipart-post (2.4.1) - mutex_m (0.2.0) + mutex_m (0.3.0) nanaimo (0.3.0) nap (1.1.0) - naturally (2.2.1) + naturally (2.3.0) netrc (0.11.0) nkf (0.2.0) - optparse (0.5.0) + optparse (0.8.1) os (1.1.4) - parallel (1.26.3) - parser (3.3.4.2) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.10.1) ast (~> 2.4.1) racc - plist (3.7.1) + plist (3.7.2) + prism (1.8.0) public_suffix (4.0.7) racc (1.8.1) rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.9.2) + rake (13.3.1) + regexp_parser (2.11.3) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.3.5) - strscan - rouge (2.0.7) - rubocop (1.65.1) + rexml (3.4.4) + rouge (3.28.0) + rubocop (1.84.0) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.1) - parser (>= 3.3.1.0) - rubocop-performance (1.21.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) rubocop-require_tools (0.1.2) rubocop (>= 0.49.1) ruby-macho (2.5.1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) + rubyzip (2.4.1) + securerandom (0.4.1) security (0.1.5) - signet (0.19.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally - strscan (3.1.0) + sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -300,17 +330,17 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.25.0) + xcodeproj (1.25.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) - rexml (>= 3.3.2, < 4.0) - xcpretty (0.3.0) - rouge (~> 2.0.7) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) xctest_list (1.2.1) From 6d2c20c53e7225442846649f0d76d77f1f615a76 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:27:08 +0100 Subject: [PATCH 36/76] fix: context menu pressables not firing events on android (#3371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR fixes an issue with the new context menu, where `Pressable`s would not fire their `onPress` events on some specific devices on `Android`. Staying consistent and using the `RNGH` components fixes the issue. ## ๐Ÿ›  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 --- package/src/components/MessageMenu/MessageActionListItem.tsx | 4 +++- package/src/components/MessageMenu/MessageReactionPicker.tsx | 4 ++-- package/src/components/MessageMenu/ReactionButton.tsx | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx index 332dd0e6b4..df1333ca8f 100644 --- a/package/src/components/MessageMenu/MessageActionListItem.tsx +++ b/package/src/components/MessageMenu/MessageActionListItem.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { Pressable, StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native'; +import { StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native'; + +import { Pressable } from 'react-native-gesture-handler'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index 700ff4b50c..19b4200fea 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { FlatList } from 'react-native-gesture-handler'; +import { StyleSheet, Text, View } from 'react-native'; +import { FlatList, Pressable } from 'react-native-gesture-handler'; import { emojis } from './emojis'; import { ReactionButton } from './ReactionButton'; diff --git a/package/src/components/MessageMenu/ReactionButton.tsx b/package/src/components/MessageMenu/ReactionButton.tsx index 63fb815d1b..ce39dcd37f 100644 --- a/package/src/components/MessageMenu/ReactionButton.tsx +++ b/package/src/components/MessageMenu/ReactionButton.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Pressable, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; +import { Pressable } from 'react-native-gesture-handler'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { IconProps } from '../../icons'; From 3a3febd89b77beef7711b5b68d6f5e73ff99716f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:35:17 +0100 Subject: [PATCH 37/76] feat: introduce design tokens to the chat sdk (#3372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR introduces fully typed and generated design tokens to the React Native SDK. With the redesign happening, these are useful in keeping in sync and in line with all other SDKs design wise. It also makes sure that transitive design tokens (tokens within the same object that depend on each other) are also resolved properly and overrides are respected. Design tokens PR: https://github.com/GetStream/design-system-tokens/pull/10 ## ๐Ÿ›  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/ios/Podfile.lock | 2 +- .../SampleApp/src/components/ScreenHeader.tsx | 5 +- package/eslint.config.mjs | 13 +- .../Attachment/AttachmentActions.tsx | 5 +- .../src/components/ChannelList/Skeleton.tsx | 5 +- .../ChannelPreviewMessenger.tsx | 5 +- .../Message/MessageSimple/MessageAvatar.tsx | 2 + .../__tests__/MessageAvatar.test.js | 21 +- .../__snapshots__/MessageAvatar.test.js.snap | 4 +- .../components/MessageInput/MessageInput.tsx | 5 +- .../SendMessageDisallowedIndicator.tsx | 5 +- .../AttachmentRemoveControl.tsx | 26 +- .../AttachmentUploadPreviewList.tsx | 33 +- .../FileAttachmentUploadPreview.tsx | 35 +- .../ImageAttachmentUploadPreview.tsx | 14 +- .../MessageUserReactionsAvatar.test.tsx | 20 +- .../__snapshots__/Thread.test.js.snap | 18 +- package/src/components/ui/Avatar/Avatar.tsx | 24 +- .../components/ui/Avatar/ChannelAvatar.tsx | 17 +- .../src/components/ui/Avatar/UserAvatar.tsx | 17 +- package/src/components/ui/Avatar/constants.ts | 5 +- package/src/components/ui/BadgeCount.tsx | 20 +- .../src/components/ui/BadgeNotification.tsx | 33 +- package/src/components/ui/OnlineIndicator.tsx | 19 +- .../src/components/ui/VideoPlayIndicator.tsx | 24 +- .../contexts/themeContext/ThemeContext.tsx | 5 +- .../src/contexts/themeContext/utils/theme.ts | 21 +- package/src/theme/StreamTokens.types.ts | 494 +++++++++++++++ .../src/theme/generated/StreamTokens.types.ts | 494 +++++++++++++++ .../generated/dark/StreamTokens.android.ts | 563 ++++++++++++++++++ .../theme/generated/dark/StreamTokens.ios.ts | 563 ++++++++++++++++++ .../theme/generated/dark/StreamTokens.web.ts | 563 ++++++++++++++++++ .../StreamTokens.android.ts | 563 ++++++++++++++++++ .../high-contrast-light/StreamTokens.ios.ts | 563 ++++++++++++++++++ .../high-contrast-light/StreamTokens.web.ts | 563 ++++++++++++++++++ .../generated/light/StreamTokens.android.ts | 563 ++++++++++++++++++ .../theme/generated/light/StreamTokens.ios.ts | 563 ++++++++++++++++++ .../theme/generated/light/StreamTokens.web.ts | 563 ++++++++++++++++++ package/src/theme/index.ts | 2 + package/src/theme/primitives/colors.ts | 348 ----------- 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 - package/src/theme/topologicalResolution.ts | 69 +++ package/sync-theme.sh | 30 + package/tsconfig.json | 3 +- 47 files changed, 6373 insertions(+), 747 deletions(-) create mode 100644 package/src/theme/StreamTokens.types.ts create mode 100644 package/src/theme/generated/StreamTokens.types.ts create mode 100644 package/src/theme/generated/dark/StreamTokens.android.ts create mode 100644 package/src/theme/generated/dark/StreamTokens.ios.ts create mode 100644 package/src/theme/generated/dark/StreamTokens.web.ts create mode 100644 package/src/theme/generated/high-contrast-light/StreamTokens.android.ts create mode 100644 package/src/theme/generated/high-contrast-light/StreamTokens.ios.ts create mode 100644 package/src/theme/generated/high-contrast-light/StreamTokens.web.ts create mode 100644 package/src/theme/generated/light/StreamTokens.android.ts create mode 100644 package/src/theme/generated/light/StreamTokens.ios.ts create mode 100644 package/src/theme/generated/light/StreamTokens.web.ts create mode 100644 package/src/theme/index.ts delete mode 100644 package/src/theme/primitives/colors.ts delete mode 100644 package/src/theme/primitives/palette.ts delete mode 100644 package/src/theme/primitives/radius.ts delete mode 100644 package/src/theme/primitives/spacing.tsx delete mode 100644 package/src/theme/primitives/typography.ts create mode 100644 package/src/theme/topologicalResolution.ts create mode 100755 package/sync-theme.sh diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index e42179c14a..2238629946 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3584,7 +3584,7 @@ SPEC CHECKSUMS: op-sqlite: b9a4028bef60145d7b98fbbc4341c83420cdcfd5 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f diff --git a/examples/SampleApp/src/components/ScreenHeader.tsx b/examples/SampleApp/src/components/ScreenHeader.tsx index fda612e389..b8cf024d68 100644 --- a/examples/SampleApp/src/components/ScreenHeader.tsx +++ b/examples/SampleApp/src/components/ScreenHeader.tsx @@ -118,7 +118,8 @@ export const ScreenHeader: React.FC = (props) => { const { theme: { - colors: { black, border, grey, white }, + colors: { black, grey, white }, + semantics, }, } = useTheme(); const insets = useSafeAreaInsets(); @@ -129,7 +130,7 @@ export const ScreenHeader: React.FC = (props) => { styles.safeAreaContainer, { backgroundColor: white, - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreSubtle, height: HEADER_CONTENT_HEIGHT + (inSafeArea ? 0 : insets.top), }, style, diff --git a/package/eslint.config.mjs b/package/eslint.config.mjs index 0508bad26a..a154ab2d98 100644 --- a/package/eslint.config.mjs +++ b/package/eslint.config.mjs @@ -75,7 +75,18 @@ export default tsEslint.config( settings: { 'import/resolver': { node: { - extensions: ['.js', '.jsx', '.ts', '.tsx'], + extensions: [ + '.js', + '.jsx', + '.ts', + '.tsx', + '.ios.ts', + '.android.ts', + '.web.ts', + '.ios.tsx', + '.android.tsx', + '.web.tsx', + ], paths: ['src'], }, }, diff --git a/package/src/components/Attachment/AttachmentActions.tsx b/package/src/components/Attachment/AttachmentActions.tsx index 3418a38e35..158bdd441c 100644 --- a/package/src/components/Attachment/AttachmentActions.tsx +++ b/package/src/components/Attachment/AttachmentActions.tsx @@ -45,7 +45,8 @@ const AttachmentActionsWithContext = (props: AttachmentActionsPropsWithContext) const { theme: { - colors: { accent_blue, black, border, transparent, white }, + semantics, + colors: { accent_blue, black, transparent, white }, messageSimple: { actions: { button: { @@ -82,7 +83,7 @@ const AttachmentActionsWithContext = (props: AttachmentActionsPropsWithContext) ? primaryBackgroundColor || accent_blue : defaultBackgroundColor || white, borderColor: primary - ? primaryBorderColor || border.surfaceSubtle + ? primaryBorderColor || semantics.borderCoreDefault : defaultBorderColor || transparent, }, buttonStyle, diff --git a/package/src/components/ChannelList/Skeleton.tsx b/package/src/components/ChannelList/Skeleton.tsx index ec086a14b5..f709931e68 100644 --- a/package/src/components/ChannelList/Skeleton.tsx +++ b/package/src/components/ChannelList/Skeleton.tsx @@ -46,7 +46,8 @@ export const Skeleton = () => { height = 64, maskFillColor, }, - colors: { border, grey_gainsboro, white_snow }, + colors: { grey_gainsboro, white_snow }, + semantics, }, } = useTheme(); @@ -126,7 +127,7 @@ export const Skeleton = () => { return ( diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index 40a9b65f03..4193be9778 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -119,7 +119,8 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW const { theme: { channelPreview: { container, contentContainer, row, title }, - colors: { border, white_snow }, + colors: { white_snow }, + semantics, }, } = useTheme(); @@ -138,7 +139,7 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW style={[ // { opacity: pressed ? 0.5 : 1 }, styles.container, - { backgroundColor: white_snow, borderBottomColor: border.surfaceSubtle }, + { backgroundColor: white_snow, borderBottomColor: semantics.borderCoreDefault }, container, ]} testID='channel-preview-button' diff --git a/package/src/components/Message/MessageSimple/MessageAvatar.tsx b/package/src/components/Message/MessageSimple/MessageAvatar.tsx index eb02ed9ead..ec5e58c0b6 100644 --- a/package/src/components/Message/MessageSimple/MessageAvatar.tsx +++ b/package/src/components/Message/MessageSimple/MessageAvatar.tsx @@ -26,6 +26,8 @@ const MessageAvatarWithContext = (props: MessageAvatarPropsWithContext) => { const visible = typeof showAvatar === 'boolean' ? showAvatar : lastGroupMessage; + console.log(message); + return ( { + let chatClient; + + beforeEach(async () => { + chatClient = await getTestClientWithUser({ id: 'me' }); + }); + it('should render message avatar', async () => { const staticUser = generateStaticUser(0); const message = generateMessage({ user: { ...staticUser, image: undefined }, }); render( - + - , + , ); await waitFor(() => { @@ -30,9 +37,9 @@ describe('MessageAvatar', () => { }); screen.rerender( - + - , + , ); await waitFor(() => { @@ -45,14 +52,14 @@ describe('MessageAvatar', () => { }); screen.rerender( - + - , + , ); await waitFor(() => { diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap index 1fd9086fbc..c94f93477e 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageAvatar.test.js.snap @@ -29,10 +29,10 @@ exports[`MessageAvatar should render message avatar 1`] = ` "width": 24, }, { - "backgroundColor": "#D7F7FB", + "backgroundColor": "#d1f3f6", }, { - "borderColor": "hsla(0, 0%, 0%, 0.1)", + "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, ] diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 1f8d91e66c..b091d2b87a 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -264,7 +264,8 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); const { theme: { - colors: { border, grey_whisper, white, white_smoke }, + semantics, + colors: { grey_whisper, white, white_smoke }, messageInput: { attachmentSelectionBar, container, @@ -478,7 +479,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { styles.wrapper, { backgroundColor: white, - borderColor: border.surfaceSubtle, + borderColor: semantics.borderCoreDefault, paddingBottom: BOTTOM_OFFSET, }, wrapper, diff --git a/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx b/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx index d8dcf3c047..1c373b0fb5 100644 --- a/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx +++ b/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx @@ -21,7 +21,8 @@ export const SendMessageDisallowedIndicator = () => { const { t } = useTranslationContext(); const { theme: { - colors: { border, grey_dark, white }, + semantics, + colors: { grey_dark, white }, messageInput: { sendMessageDisallowedIndicator: { container, text }, }, @@ -34,7 +35,7 @@ export const SendMessageDisallowedIndicator = () => { styles.container, { backgroundColor: white, - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, height: 50, }, container, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx index 79fdbf3ca9..3ede593285 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx @@ -4,13 +4,14 @@ import { Pressable, PressableProps, StyleSheet } from 'react-native'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { NewClose } from '../../../../icons/NewClose'; +import { primitives } from '../../../../theme'; type AttachmentRemoveControlProps = PressableProps; export const AttachmentRemoveControl = ({ onPress }: AttachmentRemoveControlProps) => { const { theme: { - colors: { control }, + semantics, messageInput: { dismissAttachmentUpload: { dismiss, dismissIcon, dismissIconColor }, }, @@ -30,29 +31,34 @@ export const AttachmentRemoveControl = ({ onPress }: AttachmentRemoveControlProp ]} testID='remove-upload-preview' > - + ); }; const useStyles = () => { const { - theme: { - colors: { control }, - radius, - }, + theme: { semantics }, } = useTheme(); + + const { controlRemoveControlBg, controlRemoveControlBorder } = semantics; + return useMemo( () => StyleSheet.create({ dismiss: { - backgroundColor: control.bg, - borderColor: control.border, - borderRadius: radius.xl, + backgroundColor: controlRemoveControlBg, + borderColor: controlRemoveControlBorder, + borderRadius: primitives.radiusXl, borderWidth: 2, overflow: 'hidden', }, }), - [control, radius], + [controlRemoveControlBg, controlRemoveControlBorder], ); }; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index 60b2fa8ecc..9e0273548e 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, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { FlatList, StyleSheet, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -20,6 +20,7 @@ import { } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { isSoundPackageAvailable } from '../../../../native'; +import { primitives } from '../../../../theme'; const IMAGE_PREVIEW_SIZE = 72; const FILE_PREVIEW_HEIGHT = 224; @@ -33,7 +34,6 @@ export type AttachmentUploadListPreviewPropsWithContext = Pick< >; const ItemSeparatorComponent = () => { - const styles = useStyles(); const { theme: { messageInput: { @@ -76,7 +76,6 @@ const UnMemoizedAttachmentUploadPreviewList = ( const { attachmentManager } = useMessageComposer(); const { attachments } = useAttachmentManagerState(); - const styles = useStyles(); const { theme: { messageInput: { @@ -234,25 +233,15 @@ export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListPr ); }; -const useStyles = () => { - const { - theme: { spacing }, - } = useTheme(); - - return useMemo( - () => - StyleSheet.create({ - flatList: { - overflow: 'visible', - }, - itemSeparator: { - width: spacing.xs, - }, - wrapper: {}, - }), - [spacing.xs], - ); -}; +const styles = StyleSheet.create({ + flatList: { + overflow: 'visible', + }, + itemSeparator: { + width: primitives.spacingXs, + }, + wrapper: {}, +}); AttachmentUploadPreviewList.displayName = 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}'; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 06e6156161..91c2c6a0b4 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -13,6 +13,7 @@ import { WritingDirectionAwareText } from '../../../../components/RTLComponents/ import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useMessagesContext } from '../../../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getDurationLabelFromDuration, @@ -106,25 +107,23 @@ export const FileAttachmentUploadPreview = ({ const useStyles = () => { const { - theme: { - colors: { border, text }, - radius, - spacing, - typography: { fontSize, fontWeight }, - }, + theme: { semantics }, } = useTheme(); + + const { borderCoreDefault, textPrimary, textSecondary } = semantics; + return useMemo( () => StyleSheet.create({ dismissWrapper: { position: 'absolute', right: 0, top: 0 }, fileContainer: { - borderRadius: radius.lg, - borderColor: border.surfaceSubtle, + borderRadius: primitives.radiusLg, + borderColor: borderCoreDefault, borderWidth: 1, flexDirection: 'row', - gap: spacing.sm, + gap: primitives.spacingSm, width: 224, // TODO: Not sure how to omit this - padding: spacing.md, + padding: primitives.spacingMd, }, fileContent: { flexShrink: 1, @@ -136,21 +135,21 @@ const useStyles = () => { justifyContent: 'center', }, filenameText: { - color: text.primary, - fontSize: fontSize.xs, - fontWeight: fontWeight.semibold, + color: textPrimary, + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightSemiBold, }, fileSizeText: { - color: text.secondary, - fontSize: fontSize.xs, + color: textSecondary, + fontSize: primitives.typographyFontSizeXs, }, overlay: { - borderRadius: radius.lg, + borderRadius: primitives.radiusLg, }, wrapper: { - padding: spacing.xxs, + padding: primitives.spacingXxs, }, }), - [radius, border, spacing, text, fontSize, fontWeight], + [borderCoreDefault, textPrimary, textSecondary], ); }; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index f28c6a4896..552bfe9255 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -10,6 +10,7 @@ import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressInd import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; @@ -83,14 +84,17 @@ export const ImageAttachmentUploadPreview = ({ const useStyles = () => { const { - theme: { spacing, radius, colors }, + theme: { semantics }, } = useTheme(); + + const { borderCoreOpacity10 } = semantics; + return useMemo( () => StyleSheet.create({ container: { - borderColor: colors.border.image, - borderRadius: radius.lg, + borderColor: borderCoreOpacity10, + borderRadius: primitives.radiusLg, borderWidth: 1, flexDirection: 'row', overflow: 'hidden', @@ -101,9 +105,9 @@ const useStyles = () => { width: IMAGE_PREVIEW_SIZE, }, wrapper: { - padding: spacing.xxs, + padding: primitives.spacingXxs, }, }), - [colors, radius, spacing], + [borderCoreOpacity10], ); }; diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactionsAvatar.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactionsAvatar.test.tsx index b34f4d1577..dd8d675c0d 100644 --- a/package/src/components/MessageMenu/__tests__/MessageUserReactionsAvatar.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageUserReactionsAvatar.test.tsx @@ -2,18 +2,24 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Chat } from '../../Chat/Chat'; import { MessageUserReactionsAvatar } from '../MessageUserReactionsAvatar'; describe('MessageUserReactionsAvatar', () => { const reaction = { id: 'test-user', image: 'image-url', name: 'Test User', type: 'like' }; // Mock reaction data + let chatClient; + + beforeEach(async () => { + chatClient = await getTestClientWithUser({ id: 'me' }); + }); it('should render Avatar with correct image, name, and default size', () => { const { queryByTestId } = render( - + - , + , ); // Check if the mocked Avatar component is rendered with correct props @@ -21,12 +27,10 @@ describe('MessageUserReactionsAvatar', () => { }); it('should render Avatar with correct image, name, and custom size', () => { - const customSize = 40; - const { queryByTestId } = render( - - - , + + + , ); // Check if the mocked Avatar component is rendered with correct custom size 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 2975d9b1d3..dd198003b1 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -313,10 +313,10 @@ exports[`Thread should match thread snapshot 1`] = ` "width": 24, }, { - "backgroundColor": "#FFF1C2", + "backgroundColor": "#fcedb9", }, { - "borderColor": "hsla(0, 0%, 0%, 0.1)", + "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, ] @@ -689,10 +689,10 @@ exports[`Thread should match thread snapshot 1`] = ` "width": 24, }, { - "backgroundColor": "#C9FCE7", + "backgroundColor": "#bdfcdb", }, { - "borderColor": "hsla(0, 0%, 0%, 0.1)", + "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, ] @@ -1103,10 +1103,10 @@ exports[`Thread should match thread snapshot 1`] = ` "width": 24, }, { - "backgroundColor": "#FFF1C2", + "backgroundColor": "#fcedb9", }, { - "borderColor": "hsla(0, 0%, 0%, 0.1)", + "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, ] @@ -1483,10 +1483,10 @@ exports[`Thread should match thread snapshot 1`] = ` "width": 24, }, { - "backgroundColor": "#C9FCE7", + "backgroundColor": "#bdfcdb", }, { - "borderColor": "hsla(0, 0%, 0%, 0.1)", + "borderColor": "rgba(0, 0, 0, 0.1)", "borderWidth": 1, }, ] @@ -1887,7 +1887,7 @@ exports[`Thread should match thread snapshot 1`] = ` }, { "backgroundColor": "#FFFFFF", - "borderColor": "#E2E6EA", + "borderColor": "#d5dbe1", "paddingBottom": 16, }, {}, diff --git a/package/src/components/ui/Avatar/Avatar.tsx b/package/src/components/ui/Avatar/Avatar.tsx index 7a6b393f97..a869d095c3 100644 --- a/package/src/components/ui/Avatar/Avatar.tsx +++ b/package/src/components/ui/Avatar/Avatar.tsx @@ -1,26 +1,27 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { Image, StyleSheet, View } from 'react-native'; +import { ColorValue, StyleSheet, View } from 'react-native'; import { avatarSizes } from './constants'; +import { useChatContext } from '../../../contexts'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../theme'; export type AvatarProps = { size: 'xs' | 'sm' | 'md' | 'lg'; imageUrl?: string; placeholder?: React.ReactNode; showBorder?: boolean; - backgroundColor?: string; + backgroundColor?: ColorValue; }; export const Avatar = (props: AvatarProps) => { const [error, setError] = useState(false); const { - theme: { - colors: { avatarPalette }, - }, + theme: { semantics }, } = useTheme(); - const defaultAvatarBg = avatarPalette?.[0].bg; + const { ImageComponent } = useChatContext(); + const defaultAvatarBg = semantics.avatarPaletteBg1; const { backgroundColor = defaultAvatarBg, size, imageUrl, placeholder, showBorder } = props; const styles = useStyles(); @@ -39,7 +40,7 @@ export const Avatar = (props: AvatarProps) => { testID='avatar-image' > {imageUrl && !error ? ( - { const useStyles = () => { const { - theme: { colors, radius }, + theme: { semantics }, } = useTheme(); + const { borderCoreOpacity10 } = semantics; return useMemo( () => StyleSheet.create({ border: { - borderColor: colors.border.image, + borderColor: borderCoreOpacity10, borderWidth: 1, }, container: { alignItems: 'center', - borderRadius: radius.full, + borderRadius: primitives.radiusMax, justifyContent: 'center', overflow: 'hidden', }, image: {}, }), - [colors, radius], + [borderCoreOpacity10], ); }; diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index bc1c6dd2d0..9755528ba6 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -28,24 +28,21 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { const online = useChannelPreviewDisplayPresence(channel); const { - theme: { - colors: { avatarPalette }, - }, + theme: { semantics }, } = useTheme(); const hashedValue = hashStringToNumber(channel.cid); - const index = hashedValue % (avatarPalette?.length ?? 1); - const avatarColors = avatarPalette?.[index]; + const index = ((hashedValue % 5) + 1) as 1 | 2 | 3 | 4 | 5; + const avatarBackgroundColor = semantics[`avatarPaletteBg${index}`]; + const avatarTextColor = semantics[`avatarPaletteText${index}`]; const { size, showBorder = true, showOnlineIndicator = online } = props; const channelImage = channel.data?.image; const placeholder = useMemo(() => { - return ( - - ); - }, [size, avatarColors]); + return ; + }, [size, avatarTextColor]); if (!channelImage) { const otherMembers = members.filter((member) => member.user?.id !== client?.user?.id); @@ -66,7 +63,7 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { return ( { const { user, size, showBorder = !!user.image, showOnlineIndicator } = props; const { - theme: { - colors: { avatarPalette }, - }, + theme: { semantics }, } = useTheme(); const styles = useStyles(); const hashedValue = hashStringToNumber(user.id); - const index = hashedValue % (avatarPalette?.length ?? 1); - const avatarColors = avatarPalette?.[index]; + const index = ((hashedValue % 5) + 1) as 1 | 2 | 3 | 4 | 5; + const avatarBackgroundColor = semantics[`avatarPaletteBg${index}`]; + const avatarTextColor = semantics[`avatarPaletteText${index}`]; const placeholder = useMemo(() => { if (user.name) { return ( - + {getInitialsFromName(user.name, numberOfInitials[size])} ); } else { return ( - + ); } - }, [user.name, size, avatarColors]); + }, [user.name, size, avatarTextColor]); return ( = { const fontSizes: Record< UserAvatarProps['size'], - { fontSize: number; lineHeight: number; fontWeight: FontWeightType } + { fontSize: number; lineHeight: number; fontWeight: TextStyle['fontWeight'] } > = { xs: { fontSize: 12, lineHeight: 16, fontWeight: '600' }, sm: { fontSize: 13, lineHeight: 16, fontWeight: '600' }, diff --git a/package/src/components/ui/BadgeCount.tsx b/package/src/components/ui/BadgeCount.tsx index 87fb661361..f96653ab4f 100644 --- a/package/src/components/ui/BadgeCount.tsx +++ b/package/src/components/ui/BadgeCount.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; export type BadgeCountProps = { count: number; @@ -30,26 +31,25 @@ export const BadgeCount = (props: BadgeCountProps) => { const useStyles = () => { const { - theme: { - colors: { border, badge }, - typography, - }, + theme: { semantics }, } = useTheme(); + const { badgeBgInverse, badgeTextInverse, borderCoreSubtle } = semantics; + return useMemo( () => StyleSheet.create({ text: { - backgroundColor: badge.bgInverse, - borderColor: border.surfaceSubtle, + backgroundColor: badgeBgInverse, + borderColor: borderCoreSubtle, borderWidth: 1, - color: badge.textInverse, - fontSize: typography.fontSize.xs, - fontWeight: typography.fontWeight.bold, + color: badgeTextInverse, + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightBold, includeFontPadding: false, textAlign: 'center', }, }), - [border, badge, typography], + [badgeBgInverse, badgeTextInverse, borderCoreSubtle], ); }; diff --git a/package/src/components/ui/BadgeNotification.tsx b/package/src/components/ui/BadgeNotification.tsx index 36b81811f9..28227262ed 100644 --- a/package/src/components/ui/BadgeNotification.tsx +++ b/package/src/components/ui/BadgeNotification.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; export type BadgeNotificationProps = { type: 'primary' | 'error' | 'neutral'; @@ -29,15 +30,13 @@ export const BadgeNotification = (props: BadgeNotificationProps) => { const { type, count, size = 'md', testID } = props; const styles = useStyles(); const { - theme: { - colors: { accent }, - }, + theme: { semantics }, } = useTheme(); const colors = { - error: accent.error, - neutral: accent.neutral, - primary: accent.primary, + error: semantics.badgeBgError, + neutral: semantics.badgeBgNeutral, + primary: semantics.badgeBgPrimary, }; return ( @@ -49,26 +48,24 @@ export const BadgeNotification = (props: BadgeNotificationProps) => { const useStyles = () => { const { - theme: { - radius, - colors: { badge }, - typography, - spacing, - }, + theme: { semantics }, } = useTheme(); + + const { badgeText, badgeBorder } = semantics; + return useMemo( () => StyleSheet.create({ text: { - color: badge.text, - fontWeight: typography.fontWeight.bold, + color: badgeText, + fontWeight: primitives.typographyFontWeightBold, includeFontPadding: false, textAlign: 'center', - paddingHorizontal: spacing.xxs, - borderColor: badge.border, - borderRadius: radius.full, + paddingHorizontal: primitives.spacingXxs, + borderColor: badgeBorder, + borderRadius: primitives.radiusMax, }, }), - [radius, badge, typography, spacing], + [badgeText, badgeBorder], ); }; diff --git a/package/src/components/ui/OnlineIndicator.tsx b/package/src/components/ui/OnlineIndicator.tsx index 2dda8b0c3f..129aee3dcd 100644 --- a/package/src/components/ui/OnlineIndicator.tsx +++ b/package/src/components/ui/OnlineIndicator.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; export type OnlineIndicatorProps = { online: boolean; @@ -33,25 +34,25 @@ export const OnlineIndicator = ({ online, size = 'md' }: OnlineIndicatorProps) = const useStyles = () => { const { - theme: { - colors: { accent, presence }, - radius, - }, + theme: { semantics }, } = useTheme(); + + const { presenceBorder, presenceBgOffline, presenceBgOnline } = semantics; + return useMemo( () => StyleSheet.create({ indicator: { - borderColor: presence.border, - borderRadius: radius.full, + borderColor: presenceBorder, + borderRadius: primitives.radiusMax, }, online: { - backgroundColor: accent.success, + backgroundColor: presenceBgOnline, }, offline: { - backgroundColor: accent.neutral, + backgroundColor: presenceBgOffline, }, }), - [accent, presence, radius], + [presenceBgOnline, presenceBgOffline, presenceBorder], ); }; diff --git a/package/src/components/ui/VideoPlayIndicator.tsx b/package/src/components/ui/VideoPlayIndicator.tsx index 3bffc0dbaf..eea69aadce 100644 --- a/package/src/components/ui/VideoPlayIndicator.tsx +++ b/package/src/components/ui/VideoPlayIndicator.tsx @@ -1,9 +1,9 @@ import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTheme } from '../../contexts'; import { NewPlayIcon } from '../../icons/NewPlayIcon'; -import { palette } from '../../theme/primitives/palette'; +import { primitives } from '../../theme'; const sizes = { lg: { @@ -32,27 +32,37 @@ export type VideoPlayIndicatorProps = { export const VideoPlayIndicator = (props: VideoPlayIndicatorProps) => { const { size = 'md' } = props; + const { + theme: { semantics }, + } = useTheme(); const styles = useStyles(); return ( - + ); }; const useStyles = () => { const { - theme: { radius }, + theme: { semantics }, } = useTheme(); + + const { controlPlayControlBgInverse } = semantics; + return useMemo(() => { return StyleSheet.create({ container: { alignItems: 'center', - backgroundColor: palette.black, - borderRadius: radius.full, + backgroundColor: controlPlayControlBgInverse, + borderRadius: primitives.radiusMax, justifyContent: 'center', }, }); - }, [radius]); + }, [controlPlayControlBgInverse]); }; diff --git a/package/src/contexts/themeContext/ThemeContext.tsx b/package/src/contexts/themeContext/ThemeContext.tsx index 7df5f43283..3d7db92b7b 100644 --- a/package/src/contexts/themeContext/ThemeContext.tsx +++ b/package/src/contexts/themeContext/ThemeContext.tsx @@ -4,6 +4,7 @@ import merge from 'lodash/merge'; import { defaultTheme, Theme } from './utils/theme'; +import { resolveTokensTopologically } from '../../theme/topologicalResolution'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -37,7 +38,9 @@ export const mergeThemes = (params: MergedThemesParams) => { merge(finalTheme, style); } - return finalTheme; + const semantics = resolveTokensTopologically(finalTheme.semantics); + + return { ...finalTheme, semantics }; }; export const ThemeContext = React.createContext(DEFAULT_BASE_CONTEXT_VALUE as Theme); diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index c7cf91cab7..6bf086b5b7 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -2,10 +2,7 @@ import { type ColorValue, type ImageStyle, type TextStyle, type ViewStyle } from import type { CircleProps, StopProps } from 'react-native-svg'; import type { IconProps } from '../../../icons/utils/base'; -import { AvatarPalette, 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'; +import { semantics } from '../../../theme'; export const DEFAULT_STATUS_ICON_SIZE = 16; export const BASE_AVATAR_SIZE = 32; @@ -169,6 +166,7 @@ export type Theme = { height: number; maskFillColor?: ColorValue; }; + colors: typeof Colors; channelPreview: { avatar: { size: number; @@ -191,8 +189,6 @@ export type Theme = { unreadContainer: ViewStyle; unreadText: TextStyle; }; - colors: typeof Colors & - NewColors & { [key: string]: string | { [key: string]: string } | AvatarPalette }; dateHeader: { container: ViewStyle; text: TextStyle; @@ -890,15 +886,11 @@ export type Theme = { thumb: ViewStyle; waveform: ViewStyle; }; - spacing: typeof Spacing; - radius: typeof Radius; - typography: typeof Typography; + semantics: typeof semantics; }; export const defaultTheme: Theme = { - spacing: Spacing, - radius: Radius, - typography: Typography, + semantics, aiTypingIndicatorView: { container: {}, text: {}, @@ -1013,10 +1005,7 @@ export const defaultTheme: Theme = { unreadContainer: {}, unreadText: {}, }, - colors: { - ...Colors, - ...lightColors, - }, + colors: Colors, dateHeader: { container: {}, text: {}, diff --git a/package/src/theme/StreamTokens.types.ts b/package/src/theme/StreamTokens.types.ts new file mode 100644 index 0000000000..8375e80891 --- /dev/null +++ b/package/src/theme/StreamTokens.types.ts @@ -0,0 +1,494 @@ +// This file is autogenerated. Please do not edit it. + +import type { ColorValue, TextStyle, ViewStyle } from 'react-native'; + +export type RNShadowToken = Required< + Pick +>; + +export interface FoundationsColors { + baseBlack: ColorValue; + baseTransparent0: ColorValue; + baseTransparentBlack10: ColorValue; + baseTransparentBlack5: ColorValue; + baseTransparentBlack70: ColorValue; + baseTransparentWhite10: ColorValue; + baseTransparentWhite20: ColorValue; + baseTransparentWhite70: ColorValue; + baseWhite: ColorValue; + blue100: ColorValue; + blue150: ColorValue; + blue200: ColorValue; + blue300: ColorValue; + blue400: ColorValue; + blue50: ColorValue; + blue500: ColorValue; + blue600: ColorValue; + blue700: ColorValue; + blue800: ColorValue; + blue900: ColorValue; + cyan100: ColorValue; + cyan150: ColorValue; + cyan200: ColorValue; + cyan300: ColorValue; + cyan400: ColorValue; + cyan50: ColorValue; + cyan500: ColorValue; + cyan600: ColorValue; + cyan700: ColorValue; + cyan800: ColorValue; + cyan900: ColorValue; + green100: ColorValue; + green150: ColorValue; + green200: ColorValue; + green300: ColorValue; + green400: ColorValue; + green50: ColorValue; + green500: ColorValue; + green600: ColorValue; + green700: ColorValue; + green800: ColorValue; + green900: ColorValue; + lime100: ColorValue; + lime150: ColorValue; + lime200: ColorValue; + lime300: ColorValue; + lime400: ColorValue; + lime50: ColorValue; + lime500: ColorValue; + lime600: ColorValue; + lime700: ColorValue; + lime800: ColorValue; + lime900: ColorValue; + neutral100: ColorValue; + neutral150: ColorValue; + neutral200: ColorValue; + neutral300: ColorValue; + neutral400: ColorValue; + neutral50: ColorValue; + neutral500: ColorValue; + neutral600: ColorValue; + neutral700: ColorValue; + neutral800: ColorValue; + neutral900: ColorValue; + purple100: ColorValue; + purple150: ColorValue; + purple200: ColorValue; + purple300: ColorValue; + purple400: ColorValue; + purple50: ColorValue; + purple500: ColorValue; + purple600: ColorValue; + purple700: ColorValue; + purple800: ColorValue; + purple900: ColorValue; + red100: ColorValue; + red150: ColorValue; + red200: ColorValue; + red300: ColorValue; + red400: ColorValue; + red50: ColorValue; + red500: ColorValue; + red600: ColorValue; + red700: ColorValue; + red800: ColorValue; + red900: ColorValue; + slate100: ColorValue; + slate150: ColorValue; + slate200: ColorValue; + slate300: ColorValue; + slate400: ColorValue; + slate50: ColorValue; + slate500: ColorValue; + slate600: ColorValue; + slate700: ColorValue; + slate800: ColorValue; + slate900: ColorValue; + violet100: ColorValue; + violet150: ColorValue; + violet200: ColorValue; + violet300: ColorValue; + violet400: ColorValue; + violet50: ColorValue; + violet500: ColorValue; + violet600: ColorValue; + violet700: ColorValue; + violet800: ColorValue; + violet900: ColorValue; + yellow100: ColorValue; + yellow150: ColorValue; + yellow200: ColorValue; + yellow300: ColorValue; + yellow400: ColorValue; + yellow50: ColorValue; + yellow500: ColorValue; + yellow600: ColorValue; + yellow700: ColorValue; + yellow800: ColorValue; + yellow900: ColorValue; +} + +export interface FoundationsLayout { + size12: number; + size128: number; + size16: number; + size2: number; + size20: number; + size24: number; + size240: number; + size28: number; + size32: number; + size320: number; + size4: number; + size40: number; + size48: number; + size480: number; + size560: number; + size6: number; + size64: number; + size640: number; + size8: number; + size80: number; +} + +export interface FoundationsRadius { + radius0: number; + radius12: number; + radius16: number; + radius2: number; + radius20: number; + radius24: number; + radius32: number; + radius4: number; + radius6: number; + radius8: number; + radiusFull: number; +} + +export interface FoundationsSpacing { + space0: number; + space12: number; + space16: number; + space2: number; + space20: number; + space24: number; + space32: number; + space4: number; + space40: number; + space48: number; + space64: number; + space8: number; + space80: number; +} + +export interface FoundationsStroke { + w100: number; + w120: number; + w150: number; + w200: number; + w300: number; + w400: number; +} + +export interface FoundationsTypography { + fontFamilyGeist: TextStyle['fontFamily']; + fontFamilyGeistMono: TextStyle['fontFamily']; + fontFamilyRoboto: TextStyle['fontFamily']; + fontFamilyRobotoMono: TextStyle['fontFamily']; + fontFamilySfMono: TextStyle['fontFamily']; + fontFamilySfPro: TextStyle['fontFamily']; + fontSizeSize10: TextStyle['fontSize']; + fontSizeSize12: TextStyle['fontSize']; + fontSizeSize13: TextStyle['fontSize']; + fontSizeSize14: TextStyle['fontSize']; + fontSizeSize15: TextStyle['fontSize']; + fontSizeSize16: TextStyle['fontSize']; + fontSizeSize17: TextStyle['fontSize']; + fontSizeSize18: TextStyle['fontSize']; + fontSizeSize20: TextStyle['fontSize']; + fontSizeSize24: TextStyle['fontSize']; + fontSizeSize28: TextStyle['fontSize']; + fontSizeSize32: TextStyle['fontSize']; + fontSizeSize40: TextStyle['fontSize']; + fontSizeSize48: TextStyle['fontSize']; + fontSizeSize8: TextStyle['fontSize']; + fontWeightW400: TextStyle['fontWeight']; + fontWeightW500: TextStyle['fontWeight']; + fontWeightW600: TextStyle['fontWeight']; + fontWeightW700: TextStyle['fontWeight']; + lineHeightLineHeight10: TextStyle['lineHeight']; + lineHeightLineHeight12: TextStyle['lineHeight']; + lineHeightLineHeight14: TextStyle['lineHeight']; + lineHeightLineHeight15: TextStyle['lineHeight']; + lineHeightLineHeight16: TextStyle['lineHeight']; + lineHeightLineHeight17: TextStyle['lineHeight']; + lineHeightLineHeight18: TextStyle['lineHeight']; + lineHeightLineHeight20: TextStyle['lineHeight']; + lineHeightLineHeight24: TextStyle['lineHeight']; + lineHeightLineHeight28: TextStyle['lineHeight']; + lineHeightLineHeight32: TextStyle['lineHeight']; + lineHeightLineHeight40: TextStyle['lineHeight']; + lineHeightLineHeight48: TextStyle['lineHeight']; + lineHeightLineHeight8: TextStyle['lineHeight']; +} + +export interface ChatComponents { + buttonHitTargetMinHeight: number; + buttonHitTargetMinWidth: number; + buttonPaddingXIconOnlyLg: number; + buttonPaddingXIconOnlyMd: number; + buttonPaddingXIconOnlySm: number; + buttonPaddingXWithLabelLg: number; + buttonPaddingXWithLabelMd: number; + buttonPaddingXWithLabelSm: number; + buttonPaddingYLg: number; + buttonPaddingYMd: number; + buttonPaddingYSm: number; + buttonRadiusFull: number; + buttonRadiusLg: number; + buttonRadiusMd: number; + buttonRadiusSm: number; + buttonVisualHeightLg: number; + buttonVisualHeightMd: number; + buttonVisualHeightSm: number; + composerRadiusFixed: number; + composerRadiusFloating: number; + deviceRadius: number; + deviceSafeAreaBottom: number; + deviceSafeAreaTop: number; + iconSizeLg: number; + iconSizeMd: number; + iconSizeSm: number; + iconSizeXs: number; + iconStrokeDefault: number; + iconStrokeEmphasis: number; + iconStrokeSubtle: number; + messageBubbleRadiusAttachment: number; + messageBubbleRadiusAttachmentInline: number; + messageBubbleRadiusGroupBottom: number; + messageBubbleRadiusGroupMiddle: number; + messageBubbleRadiusGroupTop: number; + messageBubbleRadiusTail: number; +} + +export interface ChatPrimitives { + darkElevation0: RNShadowToken; + darkElevation1: RNShadowToken; + darkElevation2: RNShadowToken; + darkElevation3: RNShadowToken; + darkElevation4: RNShadowToken; + lightElevation0: RNShadowToken; + lightElevation1: RNShadowToken; + lightElevation2: RNShadowToken; + lightElevation3: RNShadowToken; + lightElevation4: RNShadowToken; + radius2xl: number; + radius3xl: number; + radius4xl: number; + radiusLg: number; + radiusMax: number; + radiusMd: number; + radiusNone: number; + radiusSm: number; + radiusXl: number; + radiusXs: number; + radiusXxs: number; + spacing2xl: number; + spacing3xl: number; + spacingLg: number; + spacingMd: number; + spacingNone: number; + spacingSm: number; + spacingXl: number; + spacingXs: number; + spacingXxs: number; + spacingXxxs: number; + typographyFontFamilyMono: TextStyle['fontFamily']; + typographyFontFamilySans: TextStyle['fontFamily']; + typographyFontSize2xl: TextStyle['fontSize']; + typographyFontSizeLg: TextStyle['fontSize']; + typographyFontSizeMd: TextStyle['fontSize']; + typographyFontSizeMicro: TextStyle['fontSize']; + typographyFontSizeSm: TextStyle['fontSize']; + typographyFontSizeXl: TextStyle['fontSize']; + typographyFontSizeXs: TextStyle['fontSize']; + typographyFontSizeXxs: TextStyle['fontSize']; + typographyFontWeightBold: TextStyle['fontWeight']; + typographyFontWeightMedium: TextStyle['fontWeight']; + typographyFontWeightRegular: TextStyle['fontWeight']; + typographyFontWeightSemiBold: TextStyle['fontWeight']; + typographyLineHeightNormal: TextStyle['lineHeight']; + typographyLineHeightRelaxed: TextStyle['lineHeight']; + typographyLineHeightTight: TextStyle['lineHeight']; +} + +export interface ChatSemantics { + accentBlack: ColorValue; + accentError: ColorValue; + accentNeutral: ColorValue; + accentPrimary: ColorValue; + accentSuccess: ColorValue; + accentWarning: ColorValue; + avatarBgDefault: ColorValue; + avatarBgPlaceholder: ColorValue; + avatarPaletteBg1: ColorValue; + avatarPaletteBg2: ColorValue; + avatarPaletteBg3: ColorValue; + avatarPaletteBg4: ColorValue; + avatarPaletteBg5: ColorValue; + avatarPaletteText1: ColorValue; + avatarPaletteText2: ColorValue; + avatarPaletteText3: ColorValue; + avatarPaletteText4: ColorValue; + avatarPaletteText5: ColorValue; + avatarTextDefault: ColorValue; + avatarTextPlaceholder: ColorValue; + backgroundCoreApp: ColorValue; + backgroundCoreDisabled: ColorValue; + backgroundCoreHover: ColorValue; + backgroundCoreOverlay: ColorValue; + backgroundCorePressed: ColorValue; + backgroundCoreScrim: ColorValue; + backgroundCoreSelected: ColorValue; + backgroundCoreSurface: ColorValue; + backgroundCoreSurfaceStrong: ColorValue; + backgroundCoreSurfaceSubtle: ColorValue; + backgroundElevationElevation0: ColorValue; + backgroundElevationElevation1: ColorValue; + backgroundElevationElevation2: ColorValue; + backgroundElevationElevation3: ColorValue; + backgroundElevationElevation4: ColorValue; + badgeBgDefault: ColorValue; + badgeBgError: ColorValue; + badgeBgInverse: ColorValue; + badgeBgNeutral: ColorValue; + badgeBgPrimary: ColorValue; + badgeBorder: ColorValue; + badgeText: ColorValue; + badgeTextInverse: ColorValue; + borderCoreDefault: ColorValue; + borderCoreOnAccent: ColorValue; + borderCoreOnDark: ColorValue; + borderCoreOpacity10: ColorValue; + borderCoreOpacity25: ColorValue; + borderCoreStrong: ColorValue; + borderCoreSubtle: ColorValue; + borderUtilityDisabled: ColorValue; + borderUtilityError: ColorValue; + borderUtilityFocus: ColorValue; + borderUtilitySelected: ColorValue; + borderUtilitySuccess: ColorValue; + borderUtilityWarning: ColorValue; + brand100: ColorValue; + brand150: ColorValue; + brand200: ColorValue; + brand300: ColorValue; + brand400: ColorValue; + brand50: ColorValue; + brand500: ColorValue; + brand600: ColorValue; + brand700: ColorValue; + brand800: ColorValue; + brand900: ColorValue; + buttonDestructiveBg: ColorValue; + buttonDestructiveBgLiquidGlass: ColorValue; + buttonDestructiveBorder: ColorValue; + buttonDestructiveText: ColorValue; + buttonDestructiveTextOnAccent: ColorValue; + buttonPrimaryBg: ColorValue; + buttonPrimaryBgLiquidGlass: ColorValue; + buttonPrimaryBorder: ColorValue; + buttonPrimaryText: ColorValue; + buttonPrimaryTextOnAccent: ColorValue; + buttonSecondaryBg: ColorValue; + buttonSecondaryBgLiquidGlass: ColorValue; + buttonSecondaryBorder: ColorValue; + buttonSecondaryText: ColorValue; + buttonSecondaryTextOnAccent: ColorValue; + chatBgAttachmentIncoming: ColorValue; + chatBgAttachmentOutgoing: ColorValue; + chatBgIncoming: ColorValue; + chatBgOutgoing: ColorValue; + chatBgTypingIndicator: ColorValue; + chatBorderIncoming: ColorValue; + chatBorderOnChatIncoming: ColorValue; + chatBorderOnChatOutgoing: ColorValue; + chatBorderOutgoing: ColorValue; + chatPollProgressFillIncoming: ColorValue; + chatPollProgressFillOutgoing: ColorValue; + chatPollProgressTrackIncoming: ColorValue; + chatPollProgressTrackOutgoing: ColorValue; + chatReplyIndicatorIncoming: ColorValue; + chatReplyIndicatorOutgoing: ColorValue; + chatTextLink: ColorValue; + chatTextMention: ColorValue; + chatTextMessage: ColorValue; + chatTextReaction: ColorValue; + chatTextSystem: ColorValue; + chatTextTimestamp: ColorValue; + chatTextUsername: ColorValue; + chatThreadConnectorIncoming: ColorValue; + chatThreadConnectorOutgoing: ColorValue; + chatWaveformBar: ColorValue; + chatWaveformBarPlaying: ColorValue; + composerBg: ColorValue; + controlPlayControlBg: ColorValue; + controlPlayControlBgInverse: ColorValue; + controlPlayControlBorder: ColorValue; + controlPlayControlIcon: ColorValue; + controlPlayControlIconInverse: ColorValue; + controlProgressBarFill: ColorValue; + controlProgressBarTrack: ColorValue; + controlRadiocheckBg: ColorValue; + controlRadiocheckBgSelected: ColorValue; + controlRadiocheckBorder: ColorValue; + controlRadiocheckIconSelected: ColorValue; + controlRemoveControlBg: ColorValue; + controlRemoveControlBorder: ColorValue; + controlRemoveControlIcon: ColorValue; + controlToggleSwitchBg: ColorValue; + controlToggleSwitchBgDisabled: ColorValue; + controlToggleSwitchBgSelected: ColorValue; + controlToggleSwitchKnob: ColorValue; + inputBorderDefault: ColorValue; + inputBorderFocus: ColorValue; + inputBorderHover: ColorValue; + inputSendIcon: ColorValue; + inputSendIconDisabled: ColorValue; + inputTextDefault: ColorValue; + inputTextDisabled: ColorValue; + inputTextIcon: ColorValue; + inputTextPlaceholder: ColorValue; + presenceBgOffline: ColorValue; + presenceBgOnline: ColorValue; + presenceBorder: ColorValue; + reactionBg: ColorValue; + reactionBorder: ColorValue; + reactionEmoji: ColorValue; + reactionText: ColorValue; + systemBgBlur: ColorValue; + systemCaret: ColorValue; + systemScrollbar: ColorValue; + systemText: ColorValue; + textDisabled: ColorValue; + textInverse: ColorValue; + textLink: ColorValue; + textOnAccent: ColorValue; + textPrimary: ColorValue; + textSecondary: ColorValue; + textTertiary: ColorValue; +} + +export interface IStreamTokens { + foundations: { + colors: FoundationsColors; + layout: FoundationsLayout; + radius: FoundationsRadius; + spacing: FoundationsSpacing; + stroke: FoundationsStroke; + typography: FoundationsTypography; + }; + components: ChatComponents; + primitives: ChatPrimitives; + semantics: ChatSemantics; +} diff --git a/package/src/theme/generated/StreamTokens.types.ts b/package/src/theme/generated/StreamTokens.types.ts new file mode 100644 index 0000000000..8375e80891 --- /dev/null +++ b/package/src/theme/generated/StreamTokens.types.ts @@ -0,0 +1,494 @@ +// This file is autogenerated. Please do not edit it. + +import type { ColorValue, TextStyle, ViewStyle } from 'react-native'; + +export type RNShadowToken = Required< + Pick +>; + +export interface FoundationsColors { + baseBlack: ColorValue; + baseTransparent0: ColorValue; + baseTransparentBlack10: ColorValue; + baseTransparentBlack5: ColorValue; + baseTransparentBlack70: ColorValue; + baseTransparentWhite10: ColorValue; + baseTransparentWhite20: ColorValue; + baseTransparentWhite70: ColorValue; + baseWhite: ColorValue; + blue100: ColorValue; + blue150: ColorValue; + blue200: ColorValue; + blue300: ColorValue; + blue400: ColorValue; + blue50: ColorValue; + blue500: ColorValue; + blue600: ColorValue; + blue700: ColorValue; + blue800: ColorValue; + blue900: ColorValue; + cyan100: ColorValue; + cyan150: ColorValue; + cyan200: ColorValue; + cyan300: ColorValue; + cyan400: ColorValue; + cyan50: ColorValue; + cyan500: ColorValue; + cyan600: ColorValue; + cyan700: ColorValue; + cyan800: ColorValue; + cyan900: ColorValue; + green100: ColorValue; + green150: ColorValue; + green200: ColorValue; + green300: ColorValue; + green400: ColorValue; + green50: ColorValue; + green500: ColorValue; + green600: ColorValue; + green700: ColorValue; + green800: ColorValue; + green900: ColorValue; + lime100: ColorValue; + lime150: ColorValue; + lime200: ColorValue; + lime300: ColorValue; + lime400: ColorValue; + lime50: ColorValue; + lime500: ColorValue; + lime600: ColorValue; + lime700: ColorValue; + lime800: ColorValue; + lime900: ColorValue; + neutral100: ColorValue; + neutral150: ColorValue; + neutral200: ColorValue; + neutral300: ColorValue; + neutral400: ColorValue; + neutral50: ColorValue; + neutral500: ColorValue; + neutral600: ColorValue; + neutral700: ColorValue; + neutral800: ColorValue; + neutral900: ColorValue; + purple100: ColorValue; + purple150: ColorValue; + purple200: ColorValue; + purple300: ColorValue; + purple400: ColorValue; + purple50: ColorValue; + purple500: ColorValue; + purple600: ColorValue; + purple700: ColorValue; + purple800: ColorValue; + purple900: ColorValue; + red100: ColorValue; + red150: ColorValue; + red200: ColorValue; + red300: ColorValue; + red400: ColorValue; + red50: ColorValue; + red500: ColorValue; + red600: ColorValue; + red700: ColorValue; + red800: ColorValue; + red900: ColorValue; + slate100: ColorValue; + slate150: ColorValue; + slate200: ColorValue; + slate300: ColorValue; + slate400: ColorValue; + slate50: ColorValue; + slate500: ColorValue; + slate600: ColorValue; + slate700: ColorValue; + slate800: ColorValue; + slate900: ColorValue; + violet100: ColorValue; + violet150: ColorValue; + violet200: ColorValue; + violet300: ColorValue; + violet400: ColorValue; + violet50: ColorValue; + violet500: ColorValue; + violet600: ColorValue; + violet700: ColorValue; + violet800: ColorValue; + violet900: ColorValue; + yellow100: ColorValue; + yellow150: ColorValue; + yellow200: ColorValue; + yellow300: ColorValue; + yellow400: ColorValue; + yellow50: ColorValue; + yellow500: ColorValue; + yellow600: ColorValue; + yellow700: ColorValue; + yellow800: ColorValue; + yellow900: ColorValue; +} + +export interface FoundationsLayout { + size12: number; + size128: number; + size16: number; + size2: number; + size20: number; + size24: number; + size240: number; + size28: number; + size32: number; + size320: number; + size4: number; + size40: number; + size48: number; + size480: number; + size560: number; + size6: number; + size64: number; + size640: number; + size8: number; + size80: number; +} + +export interface FoundationsRadius { + radius0: number; + radius12: number; + radius16: number; + radius2: number; + radius20: number; + radius24: number; + radius32: number; + radius4: number; + radius6: number; + radius8: number; + radiusFull: number; +} + +export interface FoundationsSpacing { + space0: number; + space12: number; + space16: number; + space2: number; + space20: number; + space24: number; + space32: number; + space4: number; + space40: number; + space48: number; + space64: number; + space8: number; + space80: number; +} + +export interface FoundationsStroke { + w100: number; + w120: number; + w150: number; + w200: number; + w300: number; + w400: number; +} + +export interface FoundationsTypography { + fontFamilyGeist: TextStyle['fontFamily']; + fontFamilyGeistMono: TextStyle['fontFamily']; + fontFamilyRoboto: TextStyle['fontFamily']; + fontFamilyRobotoMono: TextStyle['fontFamily']; + fontFamilySfMono: TextStyle['fontFamily']; + fontFamilySfPro: TextStyle['fontFamily']; + fontSizeSize10: TextStyle['fontSize']; + fontSizeSize12: TextStyle['fontSize']; + fontSizeSize13: TextStyle['fontSize']; + fontSizeSize14: TextStyle['fontSize']; + fontSizeSize15: TextStyle['fontSize']; + fontSizeSize16: TextStyle['fontSize']; + fontSizeSize17: TextStyle['fontSize']; + fontSizeSize18: TextStyle['fontSize']; + fontSizeSize20: TextStyle['fontSize']; + fontSizeSize24: TextStyle['fontSize']; + fontSizeSize28: TextStyle['fontSize']; + fontSizeSize32: TextStyle['fontSize']; + fontSizeSize40: TextStyle['fontSize']; + fontSizeSize48: TextStyle['fontSize']; + fontSizeSize8: TextStyle['fontSize']; + fontWeightW400: TextStyle['fontWeight']; + fontWeightW500: TextStyle['fontWeight']; + fontWeightW600: TextStyle['fontWeight']; + fontWeightW700: TextStyle['fontWeight']; + lineHeightLineHeight10: TextStyle['lineHeight']; + lineHeightLineHeight12: TextStyle['lineHeight']; + lineHeightLineHeight14: TextStyle['lineHeight']; + lineHeightLineHeight15: TextStyle['lineHeight']; + lineHeightLineHeight16: TextStyle['lineHeight']; + lineHeightLineHeight17: TextStyle['lineHeight']; + lineHeightLineHeight18: TextStyle['lineHeight']; + lineHeightLineHeight20: TextStyle['lineHeight']; + lineHeightLineHeight24: TextStyle['lineHeight']; + lineHeightLineHeight28: TextStyle['lineHeight']; + lineHeightLineHeight32: TextStyle['lineHeight']; + lineHeightLineHeight40: TextStyle['lineHeight']; + lineHeightLineHeight48: TextStyle['lineHeight']; + lineHeightLineHeight8: TextStyle['lineHeight']; +} + +export interface ChatComponents { + buttonHitTargetMinHeight: number; + buttonHitTargetMinWidth: number; + buttonPaddingXIconOnlyLg: number; + buttonPaddingXIconOnlyMd: number; + buttonPaddingXIconOnlySm: number; + buttonPaddingXWithLabelLg: number; + buttonPaddingXWithLabelMd: number; + buttonPaddingXWithLabelSm: number; + buttonPaddingYLg: number; + buttonPaddingYMd: number; + buttonPaddingYSm: number; + buttonRadiusFull: number; + buttonRadiusLg: number; + buttonRadiusMd: number; + buttonRadiusSm: number; + buttonVisualHeightLg: number; + buttonVisualHeightMd: number; + buttonVisualHeightSm: number; + composerRadiusFixed: number; + composerRadiusFloating: number; + deviceRadius: number; + deviceSafeAreaBottom: number; + deviceSafeAreaTop: number; + iconSizeLg: number; + iconSizeMd: number; + iconSizeSm: number; + iconSizeXs: number; + iconStrokeDefault: number; + iconStrokeEmphasis: number; + iconStrokeSubtle: number; + messageBubbleRadiusAttachment: number; + messageBubbleRadiusAttachmentInline: number; + messageBubbleRadiusGroupBottom: number; + messageBubbleRadiusGroupMiddle: number; + messageBubbleRadiusGroupTop: number; + messageBubbleRadiusTail: number; +} + +export interface ChatPrimitives { + darkElevation0: RNShadowToken; + darkElevation1: RNShadowToken; + darkElevation2: RNShadowToken; + darkElevation3: RNShadowToken; + darkElevation4: RNShadowToken; + lightElevation0: RNShadowToken; + lightElevation1: RNShadowToken; + lightElevation2: RNShadowToken; + lightElevation3: RNShadowToken; + lightElevation4: RNShadowToken; + radius2xl: number; + radius3xl: number; + radius4xl: number; + radiusLg: number; + radiusMax: number; + radiusMd: number; + radiusNone: number; + radiusSm: number; + radiusXl: number; + radiusXs: number; + radiusXxs: number; + spacing2xl: number; + spacing3xl: number; + spacingLg: number; + spacingMd: number; + spacingNone: number; + spacingSm: number; + spacingXl: number; + spacingXs: number; + spacingXxs: number; + spacingXxxs: number; + typographyFontFamilyMono: TextStyle['fontFamily']; + typographyFontFamilySans: TextStyle['fontFamily']; + typographyFontSize2xl: TextStyle['fontSize']; + typographyFontSizeLg: TextStyle['fontSize']; + typographyFontSizeMd: TextStyle['fontSize']; + typographyFontSizeMicro: TextStyle['fontSize']; + typographyFontSizeSm: TextStyle['fontSize']; + typographyFontSizeXl: TextStyle['fontSize']; + typographyFontSizeXs: TextStyle['fontSize']; + typographyFontSizeXxs: TextStyle['fontSize']; + typographyFontWeightBold: TextStyle['fontWeight']; + typographyFontWeightMedium: TextStyle['fontWeight']; + typographyFontWeightRegular: TextStyle['fontWeight']; + typographyFontWeightSemiBold: TextStyle['fontWeight']; + typographyLineHeightNormal: TextStyle['lineHeight']; + typographyLineHeightRelaxed: TextStyle['lineHeight']; + typographyLineHeightTight: TextStyle['lineHeight']; +} + +export interface ChatSemantics { + accentBlack: ColorValue; + accentError: ColorValue; + accentNeutral: ColorValue; + accentPrimary: ColorValue; + accentSuccess: ColorValue; + accentWarning: ColorValue; + avatarBgDefault: ColorValue; + avatarBgPlaceholder: ColorValue; + avatarPaletteBg1: ColorValue; + avatarPaletteBg2: ColorValue; + avatarPaletteBg3: ColorValue; + avatarPaletteBg4: ColorValue; + avatarPaletteBg5: ColorValue; + avatarPaletteText1: ColorValue; + avatarPaletteText2: ColorValue; + avatarPaletteText3: ColorValue; + avatarPaletteText4: ColorValue; + avatarPaletteText5: ColorValue; + avatarTextDefault: ColorValue; + avatarTextPlaceholder: ColorValue; + backgroundCoreApp: ColorValue; + backgroundCoreDisabled: ColorValue; + backgroundCoreHover: ColorValue; + backgroundCoreOverlay: ColorValue; + backgroundCorePressed: ColorValue; + backgroundCoreScrim: ColorValue; + backgroundCoreSelected: ColorValue; + backgroundCoreSurface: ColorValue; + backgroundCoreSurfaceStrong: ColorValue; + backgroundCoreSurfaceSubtle: ColorValue; + backgroundElevationElevation0: ColorValue; + backgroundElevationElevation1: ColorValue; + backgroundElevationElevation2: ColorValue; + backgroundElevationElevation3: ColorValue; + backgroundElevationElevation4: ColorValue; + badgeBgDefault: ColorValue; + badgeBgError: ColorValue; + badgeBgInverse: ColorValue; + badgeBgNeutral: ColorValue; + badgeBgPrimary: ColorValue; + badgeBorder: ColorValue; + badgeText: ColorValue; + badgeTextInverse: ColorValue; + borderCoreDefault: ColorValue; + borderCoreOnAccent: ColorValue; + borderCoreOnDark: ColorValue; + borderCoreOpacity10: ColorValue; + borderCoreOpacity25: ColorValue; + borderCoreStrong: ColorValue; + borderCoreSubtle: ColorValue; + borderUtilityDisabled: ColorValue; + borderUtilityError: ColorValue; + borderUtilityFocus: ColorValue; + borderUtilitySelected: ColorValue; + borderUtilitySuccess: ColorValue; + borderUtilityWarning: ColorValue; + brand100: ColorValue; + brand150: ColorValue; + brand200: ColorValue; + brand300: ColorValue; + brand400: ColorValue; + brand50: ColorValue; + brand500: ColorValue; + brand600: ColorValue; + brand700: ColorValue; + brand800: ColorValue; + brand900: ColorValue; + buttonDestructiveBg: ColorValue; + buttonDestructiveBgLiquidGlass: ColorValue; + buttonDestructiveBorder: ColorValue; + buttonDestructiveText: ColorValue; + buttonDestructiveTextOnAccent: ColorValue; + buttonPrimaryBg: ColorValue; + buttonPrimaryBgLiquidGlass: ColorValue; + buttonPrimaryBorder: ColorValue; + buttonPrimaryText: ColorValue; + buttonPrimaryTextOnAccent: ColorValue; + buttonSecondaryBg: ColorValue; + buttonSecondaryBgLiquidGlass: ColorValue; + buttonSecondaryBorder: ColorValue; + buttonSecondaryText: ColorValue; + buttonSecondaryTextOnAccent: ColorValue; + chatBgAttachmentIncoming: ColorValue; + chatBgAttachmentOutgoing: ColorValue; + chatBgIncoming: ColorValue; + chatBgOutgoing: ColorValue; + chatBgTypingIndicator: ColorValue; + chatBorderIncoming: ColorValue; + chatBorderOnChatIncoming: ColorValue; + chatBorderOnChatOutgoing: ColorValue; + chatBorderOutgoing: ColorValue; + chatPollProgressFillIncoming: ColorValue; + chatPollProgressFillOutgoing: ColorValue; + chatPollProgressTrackIncoming: ColorValue; + chatPollProgressTrackOutgoing: ColorValue; + chatReplyIndicatorIncoming: ColorValue; + chatReplyIndicatorOutgoing: ColorValue; + chatTextLink: ColorValue; + chatTextMention: ColorValue; + chatTextMessage: ColorValue; + chatTextReaction: ColorValue; + chatTextSystem: ColorValue; + chatTextTimestamp: ColorValue; + chatTextUsername: ColorValue; + chatThreadConnectorIncoming: ColorValue; + chatThreadConnectorOutgoing: ColorValue; + chatWaveformBar: ColorValue; + chatWaveformBarPlaying: ColorValue; + composerBg: ColorValue; + controlPlayControlBg: ColorValue; + controlPlayControlBgInverse: ColorValue; + controlPlayControlBorder: ColorValue; + controlPlayControlIcon: ColorValue; + controlPlayControlIconInverse: ColorValue; + controlProgressBarFill: ColorValue; + controlProgressBarTrack: ColorValue; + controlRadiocheckBg: ColorValue; + controlRadiocheckBgSelected: ColorValue; + controlRadiocheckBorder: ColorValue; + controlRadiocheckIconSelected: ColorValue; + controlRemoveControlBg: ColorValue; + controlRemoveControlBorder: ColorValue; + controlRemoveControlIcon: ColorValue; + controlToggleSwitchBg: ColorValue; + controlToggleSwitchBgDisabled: ColorValue; + controlToggleSwitchBgSelected: ColorValue; + controlToggleSwitchKnob: ColorValue; + inputBorderDefault: ColorValue; + inputBorderFocus: ColorValue; + inputBorderHover: ColorValue; + inputSendIcon: ColorValue; + inputSendIconDisabled: ColorValue; + inputTextDefault: ColorValue; + inputTextDisabled: ColorValue; + inputTextIcon: ColorValue; + inputTextPlaceholder: ColorValue; + presenceBgOffline: ColorValue; + presenceBgOnline: ColorValue; + presenceBorder: ColorValue; + reactionBg: ColorValue; + reactionBorder: ColorValue; + reactionEmoji: ColorValue; + reactionText: ColorValue; + systemBgBlur: ColorValue; + systemCaret: ColorValue; + systemScrollbar: ColorValue; + systemText: ColorValue; + textDisabled: ColorValue; + textInverse: ColorValue; + textLink: ColorValue; + textOnAccent: ColorValue; + textPrimary: ColorValue; + textSecondary: ColorValue; + textTertiary: ColorValue; +} + +export interface IStreamTokens { + foundations: { + colors: FoundationsColors; + layout: FoundationsLayout; + radius: FoundationsRadius; + spacing: FoundationsSpacing; + stroke: FoundationsStroke; + typography: FoundationsTypography; + }; + components: ChatComponents; + primitives: ChatPrimitives; + semantics: ChatSemantics; +} diff --git a/package/src/theme/generated/dark/StreamTokens.android.ts b/package/src/theme/generated/dark/StreamTokens.android.ts new file mode 100644 index 0000000000..c9cee27f17 --- /dev/null +++ b/package/src/theme/generated/dark/StreamTokens.android.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 1.4878048780487807, + }, + shadowOpacity: 0.42400000000000004, + shadowRadius: 3.6926829268292685, + }, + darkElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.30)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.44000000000000006, + shadowRadius: 5.630769230769231, + }, + darkElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.32)', + shadowOffset: { + width: 0, + height: 3.488372093023256, + }, + shadowOpacity: 0.4696, + shadowRadius: 11.079069767441862, + }, + darkElevation4: { + elevation: 8, + shadowColor: 'rgba(0,0,0,0.36)', + shadowOffset: { + width: 0, + height: 5.384615384615384, + }, + shadowOpacity: 0.5136000000000001, + shadowRadius: 16.892307692307693, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 1, + shadowColor: 'rgba(0,0,0,0.2)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.29599999999999993, + shadowRadius: 2.8285714285714287, + }, + lightElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.22)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.3136, + shadowRadius: 5.68, + }, + lightElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.24)', + shadowOffset: { + width: 0, + height: 3.5, + }, + shadowOpacity: 0.36160000000000003, + shadowRadius: 11.100000000000001, + }, + lightElevation4: { + elevation: 8, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 5.4, + }, + shadowOpacity: 0.40959999999999996, + shadowRadius: 16.919999999999998, + }, + radius2xl: foundations.radius.radius16, + radius3xl: foundations.radius.radius20, + radius4xl: foundations.radius.radius24, + radiusLg: foundations.radius.radius8, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius6, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius4, + radiusXl: foundations.radius.radius12, + radiusXs: foundations.radius.radius2, + radiusXxs: foundations.radius.radius0, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilyRobotoMono, + typographyFontFamilySans: foundations.typography.fontFamilyRoboto, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize18, + typographyFontSizeMd: foundations.typography.fontSizeSize16, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize14, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: primitives.radius4xl, + deviceSafeAreaBottom: foundations.spacing.space40, + deviceSafeAreaTop: foundations.spacing.space40, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.red500, + accentNeutral: foundations.colors.neutral600, + accentPrimary: '$brand500', + accentSuccess: foundations.colors.green300, + accentWarning: foundations.colors.yellow400, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: foundations.colors.neutral700, + avatarPaletteBg1: foundations.colors.blue800, + avatarPaletteBg2: foundations.colors.cyan800, + avatarPaletteBg3: foundations.colors.green800, + avatarPaletteBg4: foundations.colors.purple800, + avatarPaletteBg5: foundations.colors.yellow800, + avatarPaletteText1: foundations.colors.blue100, + avatarPaletteText2: foundations.colors.cyan100, + avatarPaletteText3: foundations.colors.green100, + avatarPaletteText4: foundations.colors.purple100, + avatarPaletteText5: foundations.colors.yellow100, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: foundations.colors.neutral400, + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: foundations.colors.neutral900, + backgroundCoreHover: 'rgba(255, 255, 255, 0.05)', + backgroundCoreOverlay: 'rgba(0, 0, 0, 0.75)', + backgroundCorePressed: 'rgba(255, 255, 255, 0.1)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.5)', + backgroundCoreSelected: 'rgba(255, 255, 255, 0.15)', + backgroundCoreSurface: foundations.colors.neutral800, + backgroundCoreSurfaceStrong: foundations.colors.neutral700, + backgroundCoreSurfaceSubtle: foundations.colors.neutral900, + backgroundElevationElevation0: foundations.colors.baseBlack, + backgroundElevationElevation1: foundations.colors.neutral900, + backgroundElevationElevation2: foundations.colors.neutral800, + backgroundElevationElevation3: foundations.colors.neutral700, + backgroundElevationElevation4: foundations.colors.neutral600, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: foundations.colors.neutral700, + borderCoreOnAccent: foundations.colors.baseWhite, + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: 'rgba(255, 255, 255, 0.2)', + borderCoreOpacity25: 'rgba(255, 255, 255, 0.25)', + borderCoreStrong: foundations.colors.neutral600, + borderCoreSubtle: foundations.colors.neutral800, + borderUtilityDisabled: foundations.colors.neutral800, + borderUtilityError: '$accentError', + borderUtilityFocus: '$brand700', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.blue900, + brand100: foundations.colors.blue800, + brand150: foundations.colors.blue700, + brand200: foundations.colors.blue700, + brand300: foundations.colors.blue600, + brand400: foundations.colors.blue500, + brand500: foundations.colors.blue400, + brand600: foundations.colors.blue300, + brand700: foundations.colors.blue200, + brand800: foundations.colors.blue150, + brand900: foundations.colors.blue100, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: foundations.colors.neutral700, + chatBgAttachmentOutgoing: '$brand300', + chatBgIncoming: foundations.colors.neutral800, + chatBgOutgoing: '$brand200', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: foundations.colors.baseTransparent0, + chatBorderOnChatIncoming: foundations.colors.slate600, + chatBorderOnChatOutgoing: '$brand600', + chatBorderOutgoing: foundations.colors.baseTransparent0, + chatPollProgressFillIncoming: foundations.colors.neutral600, + chatPollProgressFillOutgoing: '$brand400', + chatPollProgressTrackIncoming: foundations.colors.baseWhite, + chatPollProgressTrackOutgoing: foundations.colors.baseWhite, + chatReplyIndicatorIncoming: foundations.colors.neutral500, + chatReplyIndicatorOutgoing: '$brand700', + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: '$chatBgIncoming', + chatThreadConnectorOutgoing: '$chatBgOutgoing', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentPrimary', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: foundations.colors.neutral800, + controlProgressBarTrack: foundations.colors.neutral50, + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: foundations.colors.baseWhite, + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textOnAccent', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilitySelected', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: 'rgba(0, 0, 0, 0.01)', + systemCaret: foundations.colors.baseWhite, + systemScrollbar: 'rgba(255, 255, 255, 0.5)', + systemText: foundations.colors.baseWhite, + textDisabled: foundations.colors.neutral600, + textInverse: foundations.colors.baseBlack, + textLink: foundations.colors.baseWhite, + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.baseWhite, + textSecondary: foundations.colors.neutral300, + textTertiary: foundations.colors.neutral400, +} as const; diff --git a/package/src/theme/generated/dark/StreamTokens.ios.ts b/package/src/theme/generated/dark/StreamTokens.ios.ts new file mode 100644 index 0000000000..54f7b5df8b --- /dev/null +++ b/package/src/theme/generated/dark/StreamTokens.ios.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.20)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.19999999999999996, + shadowRadius: 3, + }, + darkElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.22)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.21999999999999997, + shadowRadius: 6, + }, + darkElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.24)', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.24, + shadowRadius: 12.000000000000002, + }, + darkElevation4: { + elevation: 12, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.28, + shadowRadius: 24.000000000000004, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.12, + shadowRadius: 3.0000000000000004, + }, + lightElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.14)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.14, + shadowRadius: 6.000000000000001, + }, + lightElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.16)', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.16000000000000003, + shadowRadius: 12, + }, + lightElevation4: { + elevation: 12, + shadowColor: 'rgba(0,0,0,0.20)', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.19999999999999996, + shadowRadius: 24, + }, + radius2xl: foundations.radius.radius20, + radius3xl: foundations.radius.radius24, + radius4xl: foundations.radius.radius32, + radiusLg: foundations.radius.radius12, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius8, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius6, + radiusXl: foundations.radius.radius16, + radiusXs: foundations.radius.radius4, + radiusXxs: foundations.radius.radius2, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilySfMono, + typographyFontFamilySans: foundations.typography.fontFamilySfPro, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize17, + typographyFontSizeMd: foundations.typography.fontSizeSize15, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize13, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: 62, + deviceSafeAreaBottom: foundations.spacing.space32, + deviceSafeAreaTop: 62, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.red500, + accentNeutral: foundations.colors.neutral600, + accentPrimary: '$brand500', + accentSuccess: foundations.colors.green300, + accentWarning: foundations.colors.yellow400, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: foundations.colors.neutral700, + avatarPaletteBg1: foundations.colors.blue800, + avatarPaletteBg2: foundations.colors.cyan800, + avatarPaletteBg3: foundations.colors.green800, + avatarPaletteBg4: foundations.colors.purple800, + avatarPaletteBg5: foundations.colors.yellow800, + avatarPaletteText1: foundations.colors.blue100, + avatarPaletteText2: foundations.colors.cyan100, + avatarPaletteText3: foundations.colors.green100, + avatarPaletteText4: foundations.colors.purple100, + avatarPaletteText5: foundations.colors.yellow100, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: foundations.colors.neutral400, + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: foundations.colors.neutral900, + backgroundCoreHover: 'rgba(255, 255, 255, 0.05)', + backgroundCoreOverlay: 'rgba(0, 0, 0, 0.75)', + backgroundCorePressed: 'rgba(255, 255, 255, 0.1)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.5)', + backgroundCoreSelected: 'rgba(255, 255, 255, 0.15)', + backgroundCoreSurface: foundations.colors.neutral800, + backgroundCoreSurfaceStrong: foundations.colors.neutral700, + backgroundCoreSurfaceSubtle: foundations.colors.neutral900, + backgroundElevationElevation0: foundations.colors.baseBlack, + backgroundElevationElevation1: foundations.colors.neutral900, + backgroundElevationElevation2: foundations.colors.neutral800, + backgroundElevationElevation3: foundations.colors.neutral700, + backgroundElevationElevation4: foundations.colors.neutral600, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: foundations.colors.neutral700, + borderCoreOnAccent: foundations.colors.baseWhite, + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: 'rgba(255, 255, 255, 0.2)', + borderCoreOpacity25: 'rgba(255, 255, 255, 0.25)', + borderCoreStrong: foundations.colors.neutral600, + borderCoreSubtle: foundations.colors.neutral800, + borderUtilityDisabled: foundations.colors.neutral800, + borderUtilityError: '$accentError', + borderUtilityFocus: '$brand700', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.blue900, + brand100: foundations.colors.blue800, + brand150: foundations.colors.blue700, + brand200: foundations.colors.blue700, + brand300: foundations.colors.blue600, + brand400: foundations.colors.blue500, + brand500: foundations.colors.blue400, + brand600: foundations.colors.blue300, + brand700: foundations.colors.blue200, + brand800: foundations.colors.blue150, + brand900: foundations.colors.blue100, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: foundations.colors.neutral700, + chatBgAttachmentOutgoing: '$brand300', + chatBgIncoming: foundations.colors.neutral800, + chatBgOutgoing: '$brand200', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: foundations.colors.baseTransparent0, + chatBorderOnChatIncoming: foundations.colors.slate600, + chatBorderOnChatOutgoing: '$brand600', + chatBorderOutgoing: foundations.colors.baseTransparent0, + chatPollProgressFillIncoming: foundations.colors.neutral600, + chatPollProgressFillOutgoing: '$brand400', + chatPollProgressTrackIncoming: foundations.colors.baseWhite, + chatPollProgressTrackOutgoing: foundations.colors.baseWhite, + chatReplyIndicatorIncoming: foundations.colors.neutral500, + chatReplyIndicatorOutgoing: '$brand700', + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: '$chatBgIncoming', + chatThreadConnectorOutgoing: '$chatBgOutgoing', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentPrimary', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: foundations.colors.neutral800, + controlProgressBarTrack: foundations.colors.neutral50, + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: foundations.colors.baseWhite, + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textOnAccent', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilitySelected', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: 'rgba(0, 0, 0, 0.01)', + systemCaret: foundations.colors.baseWhite, + systemScrollbar: 'rgba(255, 255, 255, 0.5)', + systemText: foundations.colors.baseWhite, + textDisabled: foundations.colors.neutral600, + textInverse: foundations.colors.baseBlack, + textLink: foundations.colors.baseWhite, + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.baseWhite, + textSecondary: foundations.colors.neutral300, + textTertiary: foundations.colors.neutral400, +} as const; diff --git a/package/src/theme/generated/dark/StreamTokens.web.ts b/package/src/theme/generated/dark/StreamTokens.web.ts new file mode 100644 index 0000000000..ba0715a779 --- /dev/null +++ b/package/src/theme/generated/dark/StreamTokens.web.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.1)', + shadowOffset: { + width: 0, + height: 2.6666666666666665, + }, + shadowOpacity: 0.3879999999999999, + shadowRadius: 6.4, + }, + darkElevation2: { + elevation: 7, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 4.5016949152542365, + }, + shadowOpacity: 0.41655999999999993, + shadowRadius: 13.364067796610168, + }, + darkElevation3: { + elevation: 10, + shadowColor: 'rgba(0,0,0,0.14)', + shadowOffset: { + width: 0, + height: 8.839779005524864, + }, + shadowOpacity: 0.44443999999999995, + shadowRadius: 20.207734806629837, + }, + darkElevation4: { + elevation: 13, + shadowColor: 'rgba(0,0,0,0.16)', + shadowOffset: { + width: 0, + height: 14.201622247972189, + }, + shadowOpacity: 0.48592, + shadowRadius: 26.994206257242176, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.06)', + shadowOffset: { + width: 0, + height: 2.904109589041096, + }, + shadowOpacity: 0.19630000000000003, + shadowRadius: 6.684931506849315, + }, + lightElevation2: { + elevation: 7, + shadowColor: 'rgba(0,0,0,0.06)', + shadowOffset: { + width: 0, + height: 4.510067114093959, + }, + shadowOpacity: 0.21416000000000013, + shadowRadius: 13.358389261744968, + }, + lightElevation3: { + elevation: 10, + shadowColor: 'rgba(0,0,0,0.1)', + shadowOffset: { + width: 0, + height: 9.322128851540615, + }, + shadowOpacity: 0.26470000000000005, + shadowRadius: 20.786554621848737, + }, + lightElevation4: { + elevation: 14, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 15.201376936316695, + }, + shadowOpacity: 0.29776, + shadowRadius: 27.86919104991394, + }, + radius2xl: foundations.radius.radius20, + radius3xl: foundations.radius.radius24, + radius4xl: foundations.radius.radius32, + radiusLg: foundations.radius.radius12, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius8, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius6, + radiusXl: foundations.radius.radius16, + radiusXs: foundations.radius.radius4, + radiusXxs: foundations.radius.radius2, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilyGeistMono, + typographyFontFamilySans: foundations.typography.fontFamilyGeist, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize18, + typographyFontSizeMd: foundations.typography.fontSizeSize16, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize14, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: primitives.radiusMd, + deviceSafeAreaBottom: foundations.spacing.space0, + deviceSafeAreaTop: foundations.spacing.space0, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.red500, + accentNeutral: foundations.colors.neutral600, + accentPrimary: '$brand500', + accentSuccess: foundations.colors.green300, + accentWarning: foundations.colors.yellow400, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: foundations.colors.neutral700, + avatarPaletteBg1: foundations.colors.blue800, + avatarPaletteBg2: foundations.colors.cyan800, + avatarPaletteBg3: foundations.colors.green800, + avatarPaletteBg4: foundations.colors.purple800, + avatarPaletteBg5: foundations.colors.yellow800, + avatarPaletteText1: foundations.colors.blue100, + avatarPaletteText2: foundations.colors.cyan100, + avatarPaletteText3: foundations.colors.green100, + avatarPaletteText4: foundations.colors.purple100, + avatarPaletteText5: foundations.colors.yellow100, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: foundations.colors.neutral400, + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: foundations.colors.neutral900, + backgroundCoreHover: 'rgba(255, 255, 255, 0.05)', + backgroundCoreOverlay: 'rgba(0, 0, 0, 0.75)', + backgroundCorePressed: 'rgba(255, 255, 255, 0.1)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.5)', + backgroundCoreSelected: 'rgba(255, 255, 255, 0.15)', + backgroundCoreSurface: foundations.colors.neutral800, + backgroundCoreSurfaceStrong: foundations.colors.neutral700, + backgroundCoreSurfaceSubtle: foundations.colors.neutral900, + backgroundElevationElevation0: foundations.colors.baseBlack, + backgroundElevationElevation1: foundations.colors.neutral900, + backgroundElevationElevation2: foundations.colors.neutral800, + backgroundElevationElevation3: foundations.colors.neutral700, + backgroundElevationElevation4: foundations.colors.neutral600, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: foundations.colors.neutral700, + borderCoreOnAccent: foundations.colors.baseWhite, + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: 'rgba(255, 255, 255, 0.2)', + borderCoreOpacity25: 'rgba(255, 255, 255, 0.25)', + borderCoreStrong: foundations.colors.neutral600, + borderCoreSubtle: foundations.colors.neutral800, + borderUtilityDisabled: foundations.colors.neutral800, + borderUtilityError: '$accentError', + borderUtilityFocus: '$brand700', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.blue900, + brand100: foundations.colors.blue800, + brand150: foundations.colors.blue700, + brand200: foundations.colors.blue700, + brand300: foundations.colors.blue600, + brand400: foundations.colors.blue500, + brand500: foundations.colors.blue400, + brand600: foundations.colors.blue300, + brand700: foundations.colors.blue200, + brand800: foundations.colors.blue150, + brand900: foundations.colors.blue100, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: foundations.colors.neutral700, + chatBgAttachmentOutgoing: '$brand300', + chatBgIncoming: foundations.colors.neutral800, + chatBgOutgoing: '$brand200', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: foundations.colors.baseTransparent0, + chatBorderOnChatIncoming: foundations.colors.slate600, + chatBorderOnChatOutgoing: '$brand600', + chatBorderOutgoing: foundations.colors.baseTransparent0, + chatPollProgressFillIncoming: foundations.colors.neutral600, + chatPollProgressFillOutgoing: '$brand400', + chatPollProgressTrackIncoming: foundations.colors.baseWhite, + chatPollProgressTrackOutgoing: foundations.colors.baseWhite, + chatReplyIndicatorIncoming: foundations.colors.neutral500, + chatReplyIndicatorOutgoing: '$brand700', + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: '$chatBgIncoming', + chatThreadConnectorOutgoing: '$chatBgOutgoing', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentPrimary', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: foundations.colors.neutral800, + controlProgressBarTrack: foundations.colors.neutral50, + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: foundations.colors.baseWhite, + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textOnAccent', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilitySelected', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: 'rgba(0, 0, 0, 0.01)', + systemCaret: foundations.colors.baseWhite, + systemScrollbar: 'rgba(255, 255, 255, 0.5)', + systemText: foundations.colors.baseWhite, + textDisabled: foundations.colors.neutral600, + textInverse: foundations.colors.baseBlack, + textLink: foundations.colors.baseWhite, + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.baseWhite, + textSecondary: foundations.colors.neutral300, + textTertiary: foundations.colors.neutral400, +} as const; diff --git a/package/src/theme/generated/high-contrast-light/StreamTokens.android.ts b/package/src/theme/generated/high-contrast-light/StreamTokens.android.ts new file mode 100644 index 0000000000..18f1180a3b --- /dev/null +++ b/package/src/theme/generated/high-contrast-light/StreamTokens.android.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 1.4878048780487807, + }, + shadowOpacity: 0.42400000000000004, + shadowRadius: 3.6926829268292685, + }, + darkElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.30)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.44000000000000006, + shadowRadius: 5.630769230769231, + }, + darkElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.32)', + shadowOffset: { + width: 0, + height: 3.488372093023256, + }, + shadowOpacity: 0.4696, + shadowRadius: 11.079069767441862, + }, + darkElevation4: { + elevation: 8, + shadowColor: 'rgba(0,0,0,0.36)', + shadowOffset: { + width: 0, + height: 5.384615384615384, + }, + shadowOpacity: 0.5136000000000001, + shadowRadius: 16.892307692307693, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 1, + shadowColor: 'rgba(0,0,0,0.2)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.29599999999999993, + shadowRadius: 2.8285714285714287, + }, + lightElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.22)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.3136, + shadowRadius: 5.68, + }, + lightElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.24)', + shadowOffset: { + width: 0, + height: 3.5, + }, + shadowOpacity: 0.36160000000000003, + shadowRadius: 11.100000000000001, + }, + lightElevation4: { + elevation: 8, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 5.4, + }, + shadowOpacity: 0.40959999999999996, + shadowRadius: 16.919999999999998, + }, + radius2xl: foundations.radius.radius16, + radius3xl: foundations.radius.radius20, + radius4xl: foundations.radius.radius24, + radiusLg: foundations.radius.radius8, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius6, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius4, + radiusXl: foundations.radius.radius12, + radiusXs: foundations.radius.radius2, + radiusXxs: foundations.radius.radius0, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilyRobotoMono, + typographyFontFamilySans: foundations.typography.fontFamilyRoboto, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize18, + typographyFontSizeMd: foundations.typography.fontSizeSize16, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize14, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: primitives.radius4xl, + deviceSafeAreaBottom: foundations.spacing.space40, + deviceSafeAreaTop: foundations.spacing.space40, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.baseBlack, + accentNeutral: foundations.colors.baseBlack, + accentPrimary: foundations.colors.baseBlack, + accentSuccess: foundations.colors.baseBlack, + accentWarning: foundations.colors.baseBlack, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: '$accentBlack', + avatarPaletteBg1: foundations.colors.blue900, + avatarPaletteBg2: foundations.colors.cyan900, + avatarPaletteBg3: foundations.colors.green900, + avatarPaletteBg4: foundations.colors.purple900, + avatarPaletteBg5: foundations.colors.yellow900, + avatarPaletteText1: foundations.colors.blue100, + avatarPaletteText2: foundations.colors.cyan100, + avatarPaletteText3: foundations.colors.green100, + avatarPaletteText4: foundations.colors.purple100, + avatarPaletteText5: foundations.colors.yellow100, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: '$textInverse', + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: '$backgroundElevationElevation0', + backgroundCoreHover: 'rgba(0, 0, 0, 0.1)', + backgroundCoreOverlay: 'rgba(255, 255, 255, 0.75)', + backgroundCorePressed: 'rgba(0, 0, 0, 0.2)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.25)', + backgroundCoreSelected: 'rgba(0, 0, 0, 0.2)', + backgroundCoreSurface: '$backgroundElevationElevation0', + backgroundCoreSurfaceStrong: '$backgroundElevationElevation0', + backgroundCoreSurfaceSubtle: '$backgroundElevationElevation0', + backgroundElevationElevation0: foundations.colors.baseWhite, + backgroundElevationElevation1: foundations.colors.baseWhite, + backgroundElevationElevation2: foundations.colors.baseWhite, + backgroundElevationElevation3: foundations.colors.baseWhite, + backgroundElevationElevation4: foundations.colors.baseWhite, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: '$accentBlack', + borderCoreOnAccent: '$accentBlack', + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: '$accentBlack', + borderCoreOpacity25: '$accentBlack', + borderCoreStrong: '$accentBlack', + borderCoreSubtle: '$accentBlack', + borderUtilityDisabled: foundations.colors.slate300, + borderUtilityError: '$accentError', + borderUtilityFocus: '$accentBlack', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.baseBlack, + brand100: foundations.colors.baseBlack, + brand150: foundations.colors.baseBlack, + brand200: foundations.colors.baseBlack, + brand300: foundations.colors.baseBlack, + brand400: foundations.colors.baseBlack, + brand500: foundations.colors.baseBlack, + brand600: foundations.colors.baseBlack, + brand700: foundations.colors.baseBlack, + brand800: foundations.colors.baseBlack, + brand900: foundations.colors.baseBlack, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: '$backgroundElevationElevation0', + chatBgAttachmentOutgoing: '$backgroundElevationElevation0', + chatBgIncoming: '$backgroundElevationElevation0', + chatBgOutgoing: '$backgroundElevationElevation0', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: '$accentBlack', + chatBorderOnChatIncoming: '$accentBlack', + chatBorderOnChatOutgoing: '$accentBlack', + chatBorderOutgoing: '$accentBlack', + chatPollProgressFillIncoming: foundations.colors.neutral300, + chatPollProgressFillOutgoing: foundations.colors.baseBlack, + chatPollProgressTrackIncoming: '$accentBlack', + chatPollProgressTrackOutgoing: '$accentBlack', + chatReplyIndicatorIncoming: foundations.colors.baseBlack, + chatReplyIndicatorOutgoing: foundations.colors.baseBlack, + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: '$accentBlack', + chatThreadConnectorOutgoing: '$accentBlack', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentBlack', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: '$backgroundCoreSurface', + controlProgressBarTrack: '$backgroundCoreSurfaceStrong', + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: '$accentPrimary', + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textInverse', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilityFocus', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: foundations.colors.baseWhite, + systemCaret: foundations.colors.baseBlack, + systemScrollbar: foundations.colors.baseBlack, + systemText: foundations.colors.baseBlack, + textDisabled: foundations.colors.neutral500, + textInverse: foundations.colors.baseWhite, + textLink: foundations.colors.baseBlack, + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.baseBlack, + textSecondary: foundations.colors.neutral800, + textTertiary: foundations.colors.neutral700, +} as const; diff --git a/package/src/theme/generated/high-contrast-light/StreamTokens.ios.ts b/package/src/theme/generated/high-contrast-light/StreamTokens.ios.ts new file mode 100644 index 0000000000..48ba5dc924 --- /dev/null +++ b/package/src/theme/generated/high-contrast-light/StreamTokens.ios.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.20)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.19999999999999996, + shadowRadius: 3, + }, + darkElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.22)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.21999999999999997, + shadowRadius: 6, + }, + darkElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.24)', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.24, + shadowRadius: 12.000000000000002, + }, + darkElevation4: { + elevation: 12, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.28, + shadowRadius: 24.000000000000004, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.12, + shadowRadius: 3.0000000000000004, + }, + lightElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.14)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.14, + shadowRadius: 6.000000000000001, + }, + lightElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.16)', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.16000000000000003, + shadowRadius: 12, + }, + lightElevation4: { + elevation: 12, + shadowColor: 'rgba(0,0,0,0.20)', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.19999999999999996, + shadowRadius: 24, + }, + radius2xl: foundations.radius.radius20, + radius3xl: foundations.radius.radius24, + radius4xl: foundations.radius.radius32, + radiusLg: foundations.radius.radius12, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius8, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius6, + radiusXl: foundations.radius.radius16, + radiusXs: foundations.radius.radius4, + radiusXxs: foundations.radius.radius2, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilySfMono, + typographyFontFamilySans: foundations.typography.fontFamilySfPro, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize17, + typographyFontSizeMd: foundations.typography.fontSizeSize15, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize13, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: 62, + deviceSafeAreaBottom: foundations.spacing.space32, + deviceSafeAreaTop: 62, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.baseBlack, + accentNeutral: foundations.colors.baseBlack, + accentPrimary: foundations.colors.baseBlack, + accentSuccess: foundations.colors.baseBlack, + accentWarning: foundations.colors.baseBlack, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: '$accentBlack', + avatarPaletteBg1: foundations.colors.blue900, + avatarPaletteBg2: foundations.colors.cyan900, + avatarPaletteBg3: foundations.colors.green900, + avatarPaletteBg4: foundations.colors.purple900, + avatarPaletteBg5: foundations.colors.yellow900, + avatarPaletteText1: foundations.colors.blue100, + avatarPaletteText2: foundations.colors.cyan100, + avatarPaletteText3: foundations.colors.green100, + avatarPaletteText4: foundations.colors.purple100, + avatarPaletteText5: foundations.colors.yellow100, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: '$textInverse', + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: '$backgroundElevationElevation0', + backgroundCoreHover: 'rgba(0, 0, 0, 0.1)', + backgroundCoreOverlay: 'rgba(255, 255, 255, 0.75)', + backgroundCorePressed: 'rgba(0, 0, 0, 0.2)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.25)', + backgroundCoreSelected: 'rgba(0, 0, 0, 0.2)', + backgroundCoreSurface: '$backgroundElevationElevation0', + backgroundCoreSurfaceStrong: '$backgroundElevationElevation0', + backgroundCoreSurfaceSubtle: '$backgroundElevationElevation0', + backgroundElevationElevation0: foundations.colors.baseWhite, + backgroundElevationElevation1: foundations.colors.baseWhite, + backgroundElevationElevation2: foundations.colors.baseWhite, + backgroundElevationElevation3: foundations.colors.baseWhite, + backgroundElevationElevation4: foundations.colors.baseWhite, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: '$accentBlack', + borderCoreOnAccent: '$accentBlack', + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: '$accentBlack', + borderCoreOpacity25: '$accentBlack', + borderCoreStrong: '$accentBlack', + borderCoreSubtle: '$accentBlack', + borderUtilityDisabled: foundations.colors.slate300, + borderUtilityError: '$accentError', + borderUtilityFocus: '$accentBlack', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.baseBlack, + brand100: foundations.colors.baseBlack, + brand150: foundations.colors.baseBlack, + brand200: foundations.colors.baseBlack, + brand300: foundations.colors.baseBlack, + brand400: foundations.colors.baseBlack, + brand500: foundations.colors.baseBlack, + brand600: foundations.colors.baseBlack, + brand700: foundations.colors.baseBlack, + brand800: foundations.colors.baseBlack, + brand900: foundations.colors.baseBlack, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: '$backgroundElevationElevation0', + chatBgAttachmentOutgoing: '$backgroundElevationElevation0', + chatBgIncoming: '$backgroundElevationElevation0', + chatBgOutgoing: '$backgroundElevationElevation0', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: '$accentBlack', + chatBorderOnChatIncoming: '$accentBlack', + chatBorderOnChatOutgoing: '$accentBlack', + chatBorderOutgoing: '$accentBlack', + chatPollProgressFillIncoming: foundations.colors.neutral300, + chatPollProgressFillOutgoing: foundations.colors.baseBlack, + chatPollProgressTrackIncoming: '$accentBlack', + chatPollProgressTrackOutgoing: '$accentBlack', + chatReplyIndicatorIncoming: foundations.colors.baseBlack, + chatReplyIndicatorOutgoing: foundations.colors.baseBlack, + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: '$accentBlack', + chatThreadConnectorOutgoing: '$accentBlack', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentBlack', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: '$backgroundCoreSurface', + controlProgressBarTrack: '$backgroundCoreSurfaceStrong', + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: '$accentPrimary', + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textInverse', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilityFocus', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: foundations.colors.baseWhite, + systemCaret: foundations.colors.baseBlack, + systemScrollbar: foundations.colors.baseBlack, + systemText: foundations.colors.baseBlack, + textDisabled: foundations.colors.neutral500, + textInverse: foundations.colors.baseWhite, + textLink: foundations.colors.baseBlack, + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.baseBlack, + textSecondary: foundations.colors.neutral800, + textTertiary: foundations.colors.neutral700, +} as const; diff --git a/package/src/theme/generated/high-contrast-light/StreamTokens.web.ts b/package/src/theme/generated/high-contrast-light/StreamTokens.web.ts new file mode 100644 index 0000000000..6ccc65ca0d --- /dev/null +++ b/package/src/theme/generated/high-contrast-light/StreamTokens.web.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.1)', + shadowOffset: { + width: 0, + height: 2.6666666666666665, + }, + shadowOpacity: 0.3879999999999999, + shadowRadius: 6.4, + }, + darkElevation2: { + elevation: 7, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 4.5016949152542365, + }, + shadowOpacity: 0.41655999999999993, + shadowRadius: 13.364067796610168, + }, + darkElevation3: { + elevation: 10, + shadowColor: 'rgba(0,0,0,0.14)', + shadowOffset: { + width: 0, + height: 8.839779005524864, + }, + shadowOpacity: 0.44443999999999995, + shadowRadius: 20.207734806629837, + }, + darkElevation4: { + elevation: 13, + shadowColor: 'rgba(0,0,0,0.16)', + shadowOffset: { + width: 0, + height: 14.201622247972189, + }, + shadowOpacity: 0.48592, + shadowRadius: 26.994206257242176, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.06)', + shadowOffset: { + width: 0, + height: 2.904109589041096, + }, + shadowOpacity: 0.19630000000000003, + shadowRadius: 6.684931506849315, + }, + lightElevation2: { + elevation: 7, + shadowColor: 'rgba(0,0,0,0.06)', + shadowOffset: { + width: 0, + height: 4.510067114093959, + }, + shadowOpacity: 0.21416000000000013, + shadowRadius: 13.358389261744968, + }, + lightElevation3: { + elevation: 10, + shadowColor: 'rgba(0,0,0,0.1)', + shadowOffset: { + width: 0, + height: 9.322128851540615, + }, + shadowOpacity: 0.26470000000000005, + shadowRadius: 20.786554621848737, + }, + lightElevation4: { + elevation: 14, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 15.201376936316695, + }, + shadowOpacity: 0.29776, + shadowRadius: 27.86919104991394, + }, + radius2xl: foundations.radius.radius20, + radius3xl: foundations.radius.radius24, + radius4xl: foundations.radius.radius32, + radiusLg: foundations.radius.radius12, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius8, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius6, + radiusXl: foundations.radius.radius16, + radiusXs: foundations.radius.radius4, + radiusXxs: foundations.radius.radius2, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilyGeistMono, + typographyFontFamilySans: foundations.typography.fontFamilyGeist, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize18, + typographyFontSizeMd: foundations.typography.fontSizeSize16, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize14, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: primitives.radiusMd, + deviceSafeAreaBottom: foundations.spacing.space0, + deviceSafeAreaTop: foundations.spacing.space0, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.baseBlack, + accentNeutral: foundations.colors.baseBlack, + accentPrimary: foundations.colors.baseBlack, + accentSuccess: foundations.colors.baseBlack, + accentWarning: foundations.colors.baseBlack, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: '$accentBlack', + avatarPaletteBg1: foundations.colors.blue900, + avatarPaletteBg2: foundations.colors.cyan900, + avatarPaletteBg3: foundations.colors.green900, + avatarPaletteBg4: foundations.colors.purple900, + avatarPaletteBg5: foundations.colors.yellow900, + avatarPaletteText1: foundations.colors.blue100, + avatarPaletteText2: foundations.colors.cyan100, + avatarPaletteText3: foundations.colors.green100, + avatarPaletteText4: foundations.colors.purple100, + avatarPaletteText5: foundations.colors.yellow100, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: '$textInverse', + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: '$backgroundElevationElevation0', + backgroundCoreHover: 'rgba(0, 0, 0, 0.1)', + backgroundCoreOverlay: 'rgba(255, 255, 255, 0.75)', + backgroundCorePressed: 'rgba(0, 0, 0, 0.2)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.25)', + backgroundCoreSelected: 'rgba(0, 0, 0, 0.2)', + backgroundCoreSurface: '$backgroundElevationElevation0', + backgroundCoreSurfaceStrong: '$backgroundElevationElevation0', + backgroundCoreSurfaceSubtle: '$backgroundElevationElevation0', + backgroundElevationElevation0: foundations.colors.baseWhite, + backgroundElevationElevation1: foundations.colors.baseWhite, + backgroundElevationElevation2: foundations.colors.baseWhite, + backgroundElevationElevation3: foundations.colors.baseWhite, + backgroundElevationElevation4: foundations.colors.baseWhite, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: '$accentBlack', + borderCoreOnAccent: '$accentBlack', + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: '$accentBlack', + borderCoreOpacity25: '$accentBlack', + borderCoreStrong: '$accentBlack', + borderCoreSubtle: '$accentBlack', + borderUtilityDisabled: foundations.colors.slate300, + borderUtilityError: '$accentError', + borderUtilityFocus: '$accentBlack', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.baseBlack, + brand100: foundations.colors.baseBlack, + brand150: foundations.colors.baseBlack, + brand200: foundations.colors.baseBlack, + brand300: foundations.colors.baseBlack, + brand400: foundations.colors.baseBlack, + brand500: foundations.colors.baseBlack, + brand600: foundations.colors.baseBlack, + brand700: foundations.colors.baseBlack, + brand800: foundations.colors.baseBlack, + brand900: foundations.colors.baseBlack, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: '$backgroundElevationElevation0', + chatBgAttachmentOutgoing: '$backgroundElevationElevation0', + chatBgIncoming: '$backgroundElevationElevation0', + chatBgOutgoing: '$backgroundElevationElevation0', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: '$accentBlack', + chatBorderOnChatIncoming: '$accentBlack', + chatBorderOnChatOutgoing: '$accentBlack', + chatBorderOutgoing: '$accentBlack', + chatPollProgressFillIncoming: foundations.colors.neutral300, + chatPollProgressFillOutgoing: foundations.colors.baseBlack, + chatPollProgressTrackIncoming: '$accentBlack', + chatPollProgressTrackOutgoing: '$accentBlack', + chatReplyIndicatorIncoming: foundations.colors.baseBlack, + chatReplyIndicatorOutgoing: foundations.colors.baseBlack, + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: '$accentBlack', + chatThreadConnectorOutgoing: '$accentBlack', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentBlack', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: '$backgroundCoreSurface', + controlProgressBarTrack: '$backgroundCoreSurfaceStrong', + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: '$accentPrimary', + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textInverse', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilityFocus', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: foundations.colors.baseWhite, + systemCaret: foundations.colors.baseBlack, + systemScrollbar: foundations.colors.baseBlack, + systemText: foundations.colors.baseBlack, + textDisabled: foundations.colors.neutral500, + textInverse: foundations.colors.baseWhite, + textLink: foundations.colors.baseBlack, + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.baseBlack, + textSecondary: foundations.colors.neutral800, + textTertiary: foundations.colors.neutral700, +} as const; diff --git a/package/src/theme/generated/light/StreamTokens.android.ts b/package/src/theme/generated/light/StreamTokens.android.ts new file mode 100644 index 0000000000..772b61409e --- /dev/null +++ b/package/src/theme/generated/light/StreamTokens.android.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 1.4878048780487807, + }, + shadowOpacity: 0.42400000000000004, + shadowRadius: 3.6926829268292685, + }, + darkElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.30)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.44000000000000006, + shadowRadius: 5.630769230769231, + }, + darkElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.32)', + shadowOffset: { + width: 0, + height: 3.488372093023256, + }, + shadowOpacity: 0.4696, + shadowRadius: 11.079069767441862, + }, + darkElevation4: { + elevation: 8, + shadowColor: 'rgba(0,0,0,0.36)', + shadowOffset: { + width: 0, + height: 5.384615384615384, + }, + shadowOpacity: 0.5136000000000001, + shadowRadius: 16.892307692307693, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 1, + shadowColor: 'rgba(0,0,0,0.2)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.29599999999999993, + shadowRadius: 2.8285714285714287, + }, + lightElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.22)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.3136, + shadowRadius: 5.68, + }, + lightElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.24)', + shadowOffset: { + width: 0, + height: 3.5, + }, + shadowOpacity: 0.36160000000000003, + shadowRadius: 11.100000000000001, + }, + lightElevation4: { + elevation: 8, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 5.4, + }, + shadowOpacity: 0.40959999999999996, + shadowRadius: 16.919999999999998, + }, + radius2xl: foundations.radius.radius16, + radius3xl: foundations.radius.radius20, + radius4xl: foundations.radius.radius24, + radiusLg: foundations.radius.radius8, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius6, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius4, + radiusXl: foundations.radius.radius12, + radiusXs: foundations.radius.radius2, + radiusXxs: foundations.radius.radius0, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilyRobotoMono, + typographyFontFamilySans: foundations.typography.fontFamilyRoboto, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize18, + typographyFontSizeMd: foundations.typography.fontSizeSize16, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize14, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: primitives.radius4xl, + deviceSafeAreaBottom: foundations.spacing.space40, + deviceSafeAreaTop: foundations.spacing.space40, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.red500, + accentNeutral: foundations.colors.slate500, + accentPrimary: '$brand500', + accentSuccess: foundations.colors.green300, + accentWarning: foundations.colors.yellow400, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: foundations.colors.slate100, + avatarPaletteBg1: foundations.colors.blue100, + avatarPaletteBg2: foundations.colors.cyan100, + avatarPaletteBg3: foundations.colors.green100, + avatarPaletteBg4: foundations.colors.purple100, + avatarPaletteBg5: foundations.colors.yellow100, + avatarPaletteText1: foundations.colors.blue800, + avatarPaletteText2: foundations.colors.cyan800, + avatarPaletteText3: foundations.colors.green800, + avatarPaletteText4: foundations.colors.purple800, + avatarPaletteText5: foundations.colors.yellow800, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: foundations.colors.slate500, + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: foundations.colors.slate100, + backgroundCoreHover: 'rgba(30, 37, 43, 0.05)', + backgroundCoreOverlay: 'rgba(255, 255, 255, 0.75)', + backgroundCorePressed: 'rgba(30, 37, 43, 0.1)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.25)', + backgroundCoreSelected: 'rgba(30, 37, 43, 0.15)', + backgroundCoreSurface: foundations.colors.slate200, + backgroundCoreSurfaceStrong: foundations.colors.slate300, + backgroundCoreSurfaceSubtle: foundations.colors.slate100, + backgroundElevationElevation0: foundations.colors.baseWhite, + backgroundElevationElevation1: foundations.colors.baseWhite, + backgroundElevationElevation2: foundations.colors.baseWhite, + backgroundElevationElevation3: foundations.colors.baseWhite, + backgroundElevationElevation4: foundations.colors.baseWhite, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: foundations.colors.slate150, + borderCoreOnAccent: foundations.colors.baseWhite, + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: 'rgba(0, 0, 0, 0.1)', + borderCoreOpacity25: 'rgba(0, 0, 0, 0.25)', + borderCoreStrong: foundations.colors.slate200, + borderCoreSubtle: foundations.colors.slate100, + borderUtilityDisabled: foundations.colors.slate200, + borderUtilityError: '$accentError', + borderUtilityFocus: '$brand300', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.blue50, + brand100: foundations.colors.blue100, + brand150: foundations.colors.blue150, + brand200: foundations.colors.blue200, + brand300: foundations.colors.blue300, + brand400: foundations.colors.blue400, + brand500: foundations.colors.blue500, + brand600: foundations.colors.blue600, + brand700: foundations.colors.blue700, + brand800: foundations.colors.blue800, + brand900: foundations.colors.blue900, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: foundations.colors.slate150, + chatBgAttachmentOutgoing: '$brand150', + chatBgIncoming: foundations.colors.slate100, + chatBgOutgoing: '$brand100', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: foundations.colors.baseTransparent0, + chatBorderOnChatIncoming: foundations.colors.slate400, + chatBorderOnChatOutgoing: '$brand300', + chatBorderOutgoing: foundations.colors.baseTransparent0, + chatPollProgressFillIncoming: foundations.colors.slate300, + chatPollProgressFillOutgoing: '$brand200', + chatPollProgressTrackIncoming: foundations.colors.slate600, + chatPollProgressTrackOutgoing: '$accentPrimary', + chatReplyIndicatorIncoming: foundations.colors.slate400, + chatReplyIndicatorOutgoing: '$brand400', + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: foundations.colors.slate200, + chatThreadConnectorOutgoing: '$chatBgOutgoing', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentPrimary', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: foundations.colors.slate200, + controlProgressBarTrack: foundations.colors.slate500, + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: '$accentPrimary', + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textOnAccent', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilitySelected', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: 'rgba(255, 255, 255, 0.01)', + systemCaret: '$accentPrimary', + systemScrollbar: 'rgba(0, 0, 0, 0.5)', + systemText: foundations.colors.baseBlack, + textDisabled: foundations.colors.slate400, + textInverse: foundations.colors.baseWhite, + textLink: '$accentPrimary', + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.slate900, + textSecondary: foundations.colors.slate700, + textTertiary: foundations.colors.slate500, +} as const; diff --git a/package/src/theme/generated/light/StreamTokens.ios.ts b/package/src/theme/generated/light/StreamTokens.ios.ts new file mode 100644 index 0000000000..c6bdb4d799 --- /dev/null +++ b/package/src/theme/generated/light/StreamTokens.ios.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.20)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.19999999999999996, + shadowRadius: 3, + }, + darkElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.22)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.21999999999999997, + shadowRadius: 6, + }, + darkElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.24)', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.24, + shadowRadius: 12.000000000000002, + }, + darkElevation4: { + elevation: 12, + shadowColor: 'rgba(0,0,0,0.28)', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.28, + shadowRadius: 24.000000000000004, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 2, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.12, + shadowRadius: 3.0000000000000004, + }, + lightElevation2: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.14)', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.14, + shadowRadius: 6.000000000000001, + }, + lightElevation3: { + elevation: 6, + shadowColor: 'rgba(0,0,0,0.16)', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.16000000000000003, + shadowRadius: 12, + }, + lightElevation4: { + elevation: 12, + shadowColor: 'rgba(0,0,0,0.20)', + shadowOffset: { + width: 0, + height: 8, + }, + shadowOpacity: 0.19999999999999996, + shadowRadius: 24, + }, + radius2xl: foundations.radius.radius20, + radius3xl: foundations.radius.radius24, + radius4xl: foundations.radius.radius32, + radiusLg: foundations.radius.radius12, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius8, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius6, + radiusXl: foundations.radius.radius16, + radiusXs: foundations.radius.radius4, + radiusXxs: foundations.radius.radius2, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilySfMono, + typographyFontFamilySans: foundations.typography.fontFamilySfPro, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize17, + typographyFontSizeMd: foundations.typography.fontSizeSize15, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize13, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: 62, + deviceSafeAreaBottom: foundations.spacing.space32, + deviceSafeAreaTop: 62, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.red500, + accentNeutral: foundations.colors.slate500, + accentPrimary: '$brand500', + accentSuccess: foundations.colors.green300, + accentWarning: foundations.colors.yellow400, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: foundations.colors.slate100, + avatarPaletteBg1: foundations.colors.blue100, + avatarPaletteBg2: foundations.colors.cyan100, + avatarPaletteBg3: foundations.colors.green100, + avatarPaletteBg4: foundations.colors.purple100, + avatarPaletteBg5: foundations.colors.yellow100, + avatarPaletteText1: foundations.colors.blue800, + avatarPaletteText2: foundations.colors.cyan800, + avatarPaletteText3: foundations.colors.green800, + avatarPaletteText4: foundations.colors.purple800, + avatarPaletteText5: foundations.colors.yellow800, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: foundations.colors.slate500, + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: foundations.colors.slate100, + backgroundCoreHover: 'rgba(30, 37, 43, 0.05)', + backgroundCoreOverlay: 'rgba(255, 255, 255, 0.75)', + backgroundCorePressed: 'rgba(30, 37, 43, 0.1)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.25)', + backgroundCoreSelected: 'rgba(30, 37, 43, 0.15)', + backgroundCoreSurface: foundations.colors.slate200, + backgroundCoreSurfaceStrong: foundations.colors.slate300, + backgroundCoreSurfaceSubtle: foundations.colors.slate100, + backgroundElevationElevation0: foundations.colors.baseWhite, + backgroundElevationElevation1: foundations.colors.baseWhite, + backgroundElevationElevation2: foundations.colors.baseWhite, + backgroundElevationElevation3: foundations.colors.baseWhite, + backgroundElevationElevation4: foundations.colors.baseWhite, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: foundations.colors.slate150, + borderCoreOnAccent: foundations.colors.baseWhite, + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: 'rgba(0, 0, 0, 0.1)', + borderCoreOpacity25: 'rgba(0, 0, 0, 0.25)', + borderCoreStrong: foundations.colors.slate200, + borderCoreSubtle: foundations.colors.slate100, + borderUtilityDisabled: foundations.colors.slate200, + borderUtilityError: '$accentError', + borderUtilityFocus: '$brand300', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.blue50, + brand100: foundations.colors.blue100, + brand150: foundations.colors.blue150, + brand200: foundations.colors.blue200, + brand300: foundations.colors.blue300, + brand400: foundations.colors.blue400, + brand500: foundations.colors.blue500, + brand600: foundations.colors.blue600, + brand700: foundations.colors.blue700, + brand800: foundations.colors.blue800, + brand900: foundations.colors.blue900, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: foundations.colors.slate150, + chatBgAttachmentOutgoing: '$brand150', + chatBgIncoming: foundations.colors.slate100, + chatBgOutgoing: '$brand100', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: foundations.colors.baseTransparent0, + chatBorderOnChatIncoming: foundations.colors.slate400, + chatBorderOnChatOutgoing: '$brand300', + chatBorderOutgoing: foundations.colors.baseTransparent0, + chatPollProgressFillIncoming: foundations.colors.slate300, + chatPollProgressFillOutgoing: '$brand200', + chatPollProgressTrackIncoming: foundations.colors.slate600, + chatPollProgressTrackOutgoing: '$accentPrimary', + chatReplyIndicatorIncoming: foundations.colors.slate400, + chatReplyIndicatorOutgoing: '$brand400', + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: foundations.colors.slate200, + chatThreadConnectorOutgoing: '$chatBgOutgoing', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentPrimary', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: foundations.colors.slate200, + controlProgressBarTrack: foundations.colors.slate500, + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: '$accentPrimary', + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textOnAccent', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilitySelected', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: 'rgba(255, 255, 255, 0.01)', + systemCaret: '$accentPrimary', + systemScrollbar: 'rgba(0, 0, 0, 0.5)', + systemText: foundations.colors.baseBlack, + textDisabled: foundations.colors.slate400, + textInverse: foundations.colors.baseWhite, + textLink: '$accentPrimary', + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.slate900, + textSecondary: foundations.colors.slate700, + textTertiary: foundations.colors.slate500, +} as const; diff --git a/package/src/theme/generated/light/StreamTokens.web.ts b/package/src/theme/generated/light/StreamTokens.web.ts new file mode 100644 index 0000000000..c46454929d --- /dev/null +++ b/package/src/theme/generated/light/StreamTokens.web.ts @@ -0,0 +1,563 @@ +// This file is autogenerated. Please do not edit it. + +import type { IStreamTokens } from '../StreamTokens.types'; + +export const foundations: IStreamTokens['foundations'] = { + colors: { + baseTransparent0: 'rgba(255, 255, 255, 0)', + baseTransparentBlack5: 'rgba(0, 0, 0, 0.05)', + baseTransparentBlack10: 'rgba(0, 0, 0, 0.1)', + baseTransparentWhite70: 'rgba(255, 255, 255, 0.7)', + baseTransparentWhite10: 'rgba(255, 255, 255, 0.1)', + baseTransparentWhite20: 'rgba(255, 255, 255, 0.2)', + baseTransparentBlack70: 'rgba(0, 0, 0, 0.7)', + baseBlack: '#000000', + baseWhite: '#ffffff', + slate50: '#f6f8fa', + slate100: '#ebeef1', + slate150: '#d5dbe1', + slate200: '#c0c8d2', + slate300: '#a3acba', + slate400: '#87909f', + slate500: '#687385', + slate600: '#545969', + slate700: '#414552', + slate800: '#30313d', + slate900: '#1a1b25', + neutral50: '#f8f8f8', + neutral100: '#efefef', + neutral150: '#d8d8d8', + neutral200: '#c4c4c4', + neutral300: '#ababab', + neutral400: '#8f8f8f', + neutral500: '#6a6a6a', + neutral600: '#565656', + neutral700: '#464646', + neutral800: '#323232', + neutral900: '#1c1c1c', + blue50: '#f3f7ff', + blue100: '#e3edff', + blue150: '#c3d9ff', + blue200: '#a5c5ff', + blue300: '#78a8ff', + blue400: '#4586ff', + blue500: '#005fff', + blue600: '#1b53bd', + blue700: '#19418d', + blue800: '#142f63', + blue900: '#091a3b', + cyan50: '#f1fbfc', + cyan100: '#d1f3f6', + cyan150: '#a9e4ea', + cyan200: '#72d7e0', + cyan300: '#45bcc7', + cyan400: '#1e9ea9', + cyan500: '#248088', + cyan600: '#006970', + cyan700: '#065056', + cyan800: '#003a3f', + cyan900: '#002124', + green50: '#e1ffee', + green100: '#bdfcdb', + green150: '#8febbd', + green200: '#59dea3', + green300: '#00c384', + green400: '#00a46e', + green500: '#277e59', + green600: '#006643', + green700: '#004f33', + green800: '#003a25', + green900: '#002213', + purple50: '#f7f8ff', + purple100: '#ecedff', + purple150: '#d4d7ff', + purple200: '#c1c5ff', + purple300: '#a1a3ff', + purple400: '#8482fc', + purple500: '#644af9', + purple600: '#553bd8', + purple700: '#4032a1', + purple800: '#2e2576', + purple900: '#1a114d', + yellow50: '#fef9da', + yellow100: '#fcedb9', + yellow150: '#fcd579', + yellow200: '#f6bf57', + yellow300: '#fa922b', + yellow400: '#f26d10', + yellow500: '#c84801', + yellow600: '#a82c00', + yellow700: '#842106', + yellow800: '#5f1a05', + yellow900: '#331302', + red50: '#fff5fa', + red100: '#ffe7f2', + red150: '#ffccdf', + red200: '#ffb1cd', + red300: '#fe87a1', + red400: '#fc526a', + red500: '#d90d10', + red600: '#b3093c', + red700: '#890d37', + red800: '#68052b', + red900: '#3e021a', + violet50: '#fef4ff', + violet100: '#fbe8fe', + violet150: '#f7cffc', + violet200: '#eeb5f4', + violet300: '#e68bec', + violet400: '#d75fe7', + violet500: '#b716ca', + violet600: '#9d00ae', + violet700: '#7c0089', + violet800: '#5c0066', + violet900: '#36003d', + lime50: '#f1fde8', + lime100: '#d4ffb0', + lime150: '#b1ee79', + lime200: '#9cda5d', + lime300: '#78c100', + lime400: '#639e11', + lime500: '#4b7a0a', + lime600: '#3e6213', + lime700: '#355315', + lime800: '#203a00', + lime900: '#112100', + }, + layout: { + size2: 2, + size4: 4, + size6: 6, + size8: 8, + size12: 12, + size16: 16, + size20: 20, + size24: 24, + size32: 32, + size40: 40, + size48: 48, + size64: 64, + size28: 28, + size80: 80, + size128: 128, + size240: 240, + size320: 320, + size480: 480, + size560: 560, + size640: 640, + }, + radius: { + radius0: 0, + radius2: 2, + radius4: 4, + radius6: 6, + radius8: 8, + radius12: 12, + radius16: 16, + radius20: 20, + radius24: 24, + radius32: 32, + radiusFull: 9999, + }, + spacing: { + space0: 0, + space2: 2, + space4: 4, + space8: 8, + space12: 12, + space16: 16, + space20: 20, + space24: 24, + space32: 32, + space40: 40, + space48: 48, + space64: 64, + space80: 80, + }, + stroke: { + w100: 1, + w150: 1.5, + w200: 2, + w300: 3, + w400: 4, + w120: 1.2, + }, + typography: { + fontFamilyGeist: '"Geist"', + fontFamilyGeistMono: '"Geist Mono"', + fontFamilySfPro: '"SF Pro"', + fontFamilySfMono: '"SF Mono"', + fontFamilyRoboto: '"Roboto"', + fontFamilyRobotoMono: '"Roboto Mono"', + fontWeightW400: '400', + fontWeightW500: '500', + fontWeightW600: '600', + fontWeightW700: '700', + fontSizeSize10: 10, + fontSizeSize12: 12, + fontSizeSize14: 14, + fontSizeSize16: 16, + fontSizeSize15: 15, + fontSizeSize17: 17, + fontSizeSize18: 18, + fontSizeSize20: 20, + fontSizeSize24: 24, + fontSizeSize28: 28, + fontSizeSize32: 32, + fontSizeSize40: 40, + fontSizeSize48: 48, + fontSizeSize13: 13, + fontSizeSize8: 8, + lineHeightLineHeight8: 8, + lineHeightLineHeight10: 10, + lineHeightLineHeight12: 12, + lineHeightLineHeight14: 14, + lineHeightLineHeight15: 15, + lineHeightLineHeight16: 16, + lineHeightLineHeight17: 17, + lineHeightLineHeight18: 18, + lineHeightLineHeight20: 20, + lineHeightLineHeight24: 24, + lineHeightLineHeight28: 28, + lineHeightLineHeight32: 32, + lineHeightLineHeight40: 40, + lineHeightLineHeight48: 48, + }, +}; + +export const primitives: IStreamTokens['primitives'] = { + darkElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + darkElevation1: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.1)', + shadowOffset: { + width: 0, + height: 2.6666666666666665, + }, + shadowOpacity: 0.3879999999999999, + shadowRadius: 6.4, + }, + darkElevation2: { + elevation: 7, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 4.5016949152542365, + }, + shadowOpacity: 0.41655999999999993, + shadowRadius: 13.364067796610168, + }, + darkElevation3: { + elevation: 10, + shadowColor: 'rgba(0,0,0,0.14)', + shadowOffset: { + width: 0, + height: 8.839779005524864, + }, + shadowOpacity: 0.44443999999999995, + shadowRadius: 20.207734806629837, + }, + darkElevation4: { + elevation: 13, + shadowColor: 'rgba(0,0,0,0.16)', + shadowOffset: { + width: 0, + height: 14.201622247972189, + }, + shadowOpacity: 0.48592, + shadowRadius: 26.994206257242176, + }, + lightElevation0: { + elevation: 0, + shadowColor: 'rgba(0,0,0,0)', + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0, + shadowRadius: 0, + }, + lightElevation1: { + elevation: 3, + shadowColor: 'rgba(0,0,0,0.06)', + shadowOffset: { + width: 0, + height: 2.904109589041096, + }, + shadowOpacity: 0.19630000000000003, + shadowRadius: 6.684931506849315, + }, + lightElevation2: { + elevation: 7, + shadowColor: 'rgba(0,0,0,0.06)', + shadowOffset: { + width: 0, + height: 4.510067114093959, + }, + shadowOpacity: 0.21416000000000013, + shadowRadius: 13.358389261744968, + }, + lightElevation3: { + elevation: 10, + shadowColor: 'rgba(0,0,0,0.1)', + shadowOffset: { + width: 0, + height: 9.322128851540615, + }, + shadowOpacity: 0.26470000000000005, + shadowRadius: 20.786554621848737, + }, + lightElevation4: { + elevation: 14, + shadowColor: 'rgba(0,0,0,0.12)', + shadowOffset: { + width: 0, + height: 15.201376936316695, + }, + shadowOpacity: 0.29776, + shadowRadius: 27.86919104991394, + }, + radius2xl: foundations.radius.radius20, + radius3xl: foundations.radius.radius24, + radius4xl: foundations.radius.radius32, + radiusLg: foundations.radius.radius12, + radiusMax: foundations.radius.radiusFull, + radiusMd: foundations.radius.radius8, + radiusNone: foundations.radius.radius0, + radiusSm: foundations.radius.radius6, + radiusXl: foundations.radius.radius16, + radiusXs: foundations.radius.radius4, + radiusXxs: foundations.radius.radius2, + spacing2xl: foundations.spacing.space32, + spacing3xl: foundations.spacing.space40, + spacingLg: foundations.spacing.space20, + spacingMd: foundations.spacing.space16, + spacingNone: foundations.spacing.space0, + spacingSm: foundations.spacing.space12, + spacingXl: foundations.spacing.space24, + spacingXs: foundations.spacing.space8, + spacingXxs: foundations.spacing.space4, + spacingXxxs: foundations.spacing.space2, + typographyFontFamilyMono: foundations.typography.fontFamilyGeistMono, + typographyFontFamilySans: foundations.typography.fontFamilyGeist, + typographyFontSize2xl: foundations.typography.fontSizeSize24, + typographyFontSizeLg: foundations.typography.fontSizeSize18, + typographyFontSizeMd: foundations.typography.fontSizeSize16, + typographyFontSizeMicro: foundations.typography.fontSizeSize8, + typographyFontSizeSm: foundations.typography.fontSizeSize14, + typographyFontSizeXl: foundations.typography.fontSizeSize20, + typographyFontSizeXs: foundations.typography.fontSizeSize12, + typographyFontSizeXxs: foundations.typography.fontSizeSize10, + typographyFontWeightBold: 700, + typographyFontWeightMedium: 500, + typographyFontWeightRegular: 400, + typographyFontWeightSemiBold: 600, + typographyLineHeightNormal: foundations.typography.lineHeightLineHeight20, + typographyLineHeightRelaxed: foundations.typography.lineHeightLineHeight24, + typographyLineHeightTight: foundations.typography.lineHeightLineHeight16, +} as const; + +export const components: IStreamTokens['components'] = { + buttonHitTargetMinHeight: foundations.layout.size48, + buttonHitTargetMinWidth: foundations.layout.size48, + buttonPaddingXIconOnlyLg: 14, + buttonPaddingXIconOnlyMd: 10, + buttonPaddingXIconOnlySm: 6, + buttonPaddingXWithLabelLg: 16, + buttonPaddingXWithLabelMd: 16, + buttonPaddingXWithLabelSm: 16, + buttonPaddingYLg: 14, + buttonPaddingYMd: 10, + buttonPaddingYSm: 6, + buttonRadiusFull: foundations.radius.radiusFull, + buttonRadiusLg: foundations.radius.radiusFull, + buttonRadiusMd: foundations.radius.radiusFull, + buttonRadiusSm: foundations.radius.radiusFull, + buttonVisualHeightLg: foundations.layout.size48, + buttonVisualHeightMd: foundations.layout.size40, + buttonVisualHeightSm: foundations.layout.size32, + composerRadiusFixed: primitives.radius3xl, + composerRadiusFloating: primitives.radius3xl, + deviceRadius: primitives.radiusMd, + deviceSafeAreaBottom: foundations.spacing.space0, + deviceSafeAreaTop: foundations.spacing.space0, + iconSizeLg: foundations.layout.size32, + iconSizeMd: foundations.layout.size20, + iconSizeSm: foundations.layout.size16, + iconSizeXs: foundations.layout.size12, + iconStrokeDefault: foundations.stroke.w150, + iconStrokeEmphasis: foundations.stroke.w200, + iconStrokeSubtle: foundations.stroke.w120, + messageBubbleRadiusAttachment: primitives.radiusLg, + messageBubbleRadiusAttachmentInline: primitives.radiusMd, + messageBubbleRadiusGroupBottom: primitives.radius2xl, + messageBubbleRadiusGroupMiddle: primitives.radius2xl, + messageBubbleRadiusGroupTop: primitives.radius2xl, + messageBubbleRadiusTail: primitives.radiusNone, +} as const; + +export const semantics: IStreamTokens['semantics'] = { + accentBlack: foundations.colors.baseBlack, + accentError: foundations.colors.red500, + accentNeutral: foundations.colors.slate500, + accentPrimary: '$brand500', + accentSuccess: foundations.colors.green300, + accentWarning: foundations.colors.yellow400, + avatarBgDefault: '$avatarPaletteBg1', + avatarBgPlaceholder: foundations.colors.slate100, + avatarPaletteBg1: foundations.colors.blue100, + avatarPaletteBg2: foundations.colors.cyan100, + avatarPaletteBg3: foundations.colors.green100, + avatarPaletteBg4: foundations.colors.purple100, + avatarPaletteBg5: foundations.colors.yellow100, + avatarPaletteText1: foundations.colors.blue800, + avatarPaletteText2: foundations.colors.cyan800, + avatarPaletteText3: foundations.colors.green800, + avatarPaletteText4: foundations.colors.purple800, + avatarPaletteText5: foundations.colors.yellow800, + avatarTextDefault: '$avatarPaletteText1', + avatarTextPlaceholder: foundations.colors.slate500, + backgroundCoreApp: '$backgroundElevationElevation0', + backgroundCoreDisabled: foundations.colors.slate100, + backgroundCoreHover: 'rgba(30, 37, 43, 0.05)', + backgroundCoreOverlay: 'rgba(255, 255, 255, 0.75)', + backgroundCorePressed: 'rgba(30, 37, 43, 0.1)', + backgroundCoreScrim: 'rgba(0, 0, 0, 0.25)', + backgroundCoreSelected: 'rgba(30, 37, 43, 0.15)', + backgroundCoreSurface: foundations.colors.slate200, + backgroundCoreSurfaceStrong: foundations.colors.slate300, + backgroundCoreSurfaceSubtle: foundations.colors.slate100, + backgroundElevationElevation0: foundations.colors.baseWhite, + backgroundElevationElevation1: foundations.colors.baseWhite, + backgroundElevationElevation2: foundations.colors.baseWhite, + backgroundElevationElevation3: foundations.colors.baseWhite, + backgroundElevationElevation4: foundations.colors.baseWhite, + badgeBgDefault: '$backgroundElevationElevation1', + badgeBgError: '$accentError', + badgeBgInverse: '$accentBlack', + badgeBgNeutral: '$accentNeutral', + badgeBgPrimary: '$accentPrimary', + badgeBorder: '$borderCoreOnDark', + badgeText: '$textOnAccent', + badgeTextInverse: '$textPrimary', + borderCoreDefault: foundations.colors.slate150, + borderCoreOnAccent: foundations.colors.baseWhite, + borderCoreOnDark: foundations.colors.baseWhite, + borderCoreOpacity10: 'rgba(0, 0, 0, 0.1)', + borderCoreOpacity25: 'rgba(0, 0, 0, 0.25)', + borderCoreStrong: foundations.colors.slate200, + borderCoreSubtle: foundations.colors.slate100, + borderUtilityDisabled: foundations.colors.slate200, + borderUtilityError: '$accentError', + borderUtilityFocus: '$brand300', + borderUtilitySelected: '$accentPrimary', + borderUtilitySuccess: '$accentSuccess', + borderUtilityWarning: '$accentWarning', + brand50: foundations.colors.blue50, + brand100: foundations.colors.blue100, + brand150: foundations.colors.blue150, + brand200: foundations.colors.blue200, + brand300: foundations.colors.blue300, + brand400: foundations.colors.blue400, + brand500: foundations.colors.blue500, + brand600: foundations.colors.blue600, + brand700: foundations.colors.blue700, + brand800: foundations.colors.blue800, + brand900: foundations.colors.blue900, + buttonDestructiveBg: '$accentError', + buttonDestructiveBgLiquidGlass: '$backgroundElevationElevation0', + buttonDestructiveBorder: '$accentError', + buttonDestructiveText: '$accentError', + buttonDestructiveTextOnAccent: '$textOnAccent', + buttonPrimaryBg: '$accentPrimary', + buttonPrimaryBgLiquidGlass: foundations.colors.baseTransparent0, + buttonPrimaryBorder: '$brand200', + buttonPrimaryText: '$accentPrimary', + buttonPrimaryTextOnAccent: '$textOnAccent', + buttonSecondaryBg: '$backgroundCoreSurfaceSubtle', + buttonSecondaryBgLiquidGlass: '$backgroundElevationElevation0', + buttonSecondaryBorder: '$borderCoreDefault', + buttonSecondaryText: '$textPrimary', + buttonSecondaryTextOnAccent: '$textPrimary', + chatBgAttachmentIncoming: foundations.colors.slate150, + chatBgAttachmentOutgoing: '$brand150', + chatBgIncoming: foundations.colors.slate100, + chatBgOutgoing: '$brand100', + chatBgTypingIndicator: '$accentNeutral', + chatBorderIncoming: foundations.colors.baseTransparent0, + chatBorderOnChatIncoming: foundations.colors.slate400, + chatBorderOnChatOutgoing: '$brand300', + chatBorderOutgoing: foundations.colors.baseTransparent0, + chatPollProgressFillIncoming: foundations.colors.slate300, + chatPollProgressFillOutgoing: '$brand200', + chatPollProgressTrackIncoming: foundations.colors.slate600, + chatPollProgressTrackOutgoing: '$accentPrimary', + chatReplyIndicatorIncoming: foundations.colors.slate400, + chatReplyIndicatorOutgoing: '$brand400', + chatTextLink: '$textLink', + chatTextMention: '$textLink', + chatTextMessage: '$textPrimary', + chatTextReaction: '$textSecondary', + chatTextSystem: '$textSecondary', + chatTextTimestamp: '$textTertiary', + chatTextUsername: '$textSecondary', + chatThreadConnectorIncoming: foundations.colors.slate200, + chatThreadConnectorOutgoing: '$chatBgOutgoing', + chatWaveformBar: '$borderCoreOpacity25', + chatWaveformBarPlaying: '$accentPrimary', + composerBg: '$backgroundElevationElevation1', + controlPlayControlBg: '$backgroundElevationElevation1', + controlPlayControlBgInverse: '$accentBlack', + controlPlayControlBorder: '$borderCoreDefault', + controlPlayControlIcon: '$textPrimary', + controlPlayControlIconInverse: '$textOnAccent', + controlProgressBarFill: foundations.colors.slate200, + controlProgressBarTrack: foundations.colors.slate500, + controlRadiocheckBg: foundations.colors.baseTransparent0, + controlRadiocheckBgSelected: '$accentPrimary', + controlRadiocheckBorder: '$borderCoreDefault', + controlRadiocheckIconSelected: '$textInverse', + controlRemoveControlBg: '$accentBlack', + controlRemoveControlBorder: '$borderCoreOnDark', + controlRemoveControlIcon: '$textOnAccent', + controlToggleSwitchBg: '$backgroundCoreSurfaceStrong', + controlToggleSwitchBgDisabled: '$backgroundCoreDisabled', + controlToggleSwitchBgSelected: '$accentPrimary', + controlToggleSwitchKnob: '$backgroundElevationElevation4', + inputBorderDefault: '$borderCoreDefault', + inputBorderFocus: '$borderUtilitySelected', + inputBorderHover: '$borderCoreStrong', + inputSendIcon: '$accentPrimary', + inputSendIconDisabled: '$textDisabled', + inputTextDefault: '$textPrimary', + inputTextDisabled: '$textDisabled', + inputTextIcon: '$textTertiary', + inputTextPlaceholder: '$textTertiary', + presenceBgOffline: '$accentNeutral', + presenceBgOnline: '$accentSuccess', + presenceBorder: '$borderCoreOnDark', + reactionBg: '$backgroundElevationElevation1', + reactionBorder: '$borderCoreDefault', + reactionEmoji: '$textPrimary', + reactionText: '$textPrimary', + systemBgBlur: 'rgba(255, 255, 255, 0.01)', + systemCaret: '$accentPrimary', + systemScrollbar: 'rgba(0, 0, 0, 0.5)', + systemText: foundations.colors.baseBlack, + textDisabled: foundations.colors.slate400, + textInverse: foundations.colors.baseWhite, + textLink: '$accentPrimary', + textOnAccent: foundations.colors.baseWhite, + textPrimary: foundations.colors.slate900, + textSecondary: foundations.colors.slate700, + textTertiary: foundations.colors.slate500, +} as const; diff --git a/package/src/theme/index.ts b/package/src/theme/index.ts new file mode 100644 index 0000000000..7412b9a202 --- /dev/null +++ b/package/src/theme/index.ts @@ -0,0 +1,2 @@ +// TODO: Handle color scheme here. +export * from './generated/light/StreamTokens'; diff --git a/package/src/theme/primitives/colors.ts b/package/src/theme/primitives/colors.ts deleted file mode 100644 index f54c7a1b88..0000000000 --- a/package/src/theme/primitives/colors.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { palette } from './palette'; - -type Pallete = { - 50: string; - 100: string; - 200: string; - 300: string; - 400: string; - 500: string; - 600: string; - 700: string; - 800: string; - 900: string; - 950: string; -}; - -export type AvatarColorPair = { - bg: string; - text: string; -}; - -export type AvatarPalette = AvatarColorPair[]; - -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; - avatarPalette?: AvatarPalette; - 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 avatarPalette = input.avatarPalette ?? [ - { - bg: palette.blue[100], - text: palette.blue[800], - }, - { - bg: palette.cyan[100], - text: palette.cyan[800], - }, - { - bg: palette.green[100], - text: palette.green[800], - }, - { - bg: palette.purple[100], - text: palette.purple[800], - }, - { - bg: palette.yellow[100], - text: palette.yellow[800], - }, - ]; - 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, - avatarPalette, - accent, - text, - state, - presence, - border, - badge, - control, - }; -} - -export const lightColors = { - brand: palette.blue, - avatarPalette: [ - { - bg: palette.blue[100], - text: palette.blue[800], - }, - { - bg: palette.cyan[100], - text: palette.cyan[800], - }, - { - bg: palette.green[100], - text: palette.green[800], - }, - { - bg: palette.purple[100], - text: palette.purple[800], - }, - { - bg: palette.yellow[100], - text: palette.yellow[800], - }, - ], - 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], - }, - 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: { - 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], - }, -}; - -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, - }, - avatarPalette: [ - { - bg: palette.blue[800], - text: palette.blue[100], - }, - { - bg: palette.cyan[800], - text: palette.cyan[100], - }, - { - bg: palette.green[800], - text: palette.green[100], - }, - { - bg: palette.purple[800], - text: palette.purple[100], - }, - { - bg: palette.yellow[800], - text: palette.yellow[100], - }, - ], - accent: { - primary: palette.blue[400], - 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], - }, - 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: { - 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], - }, -}; diff --git a/package/src/theme/primitives/palette.ts b/package/src/theme/primitives/palette.ts deleted file mode 100644 index f52105f3a9..0000000000 --- a/package/src/theme/primitives/palette.ts +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index a192a008bf..0000000000 --- a/package/src/theme/primitives/radius.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 6392225230..0000000000 --- a/package/src/theme/primitives/spacing.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 9cb3c056db..0000000000 --- a/package/src/theme/primitives/typography.ts +++ /dev/null @@ -1,33 +0,0 @@ -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, - }, -}; diff --git a/package/src/theme/topologicalResolution.ts b/package/src/theme/topologicalResolution.ts new file mode 100644 index 0000000000..384d7061f0 --- /dev/null +++ b/package/src/theme/topologicalResolution.ts @@ -0,0 +1,69 @@ +import { Theme } from '../contexts'; + +const isPlainObject = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && value.constructor === Object; +}; + +/** + * Resolves "$token" references in `dictionary` by performing a DFS. + * deps first, then node - i.e. a topological evaluation. + */ +export const resolveTokensTopologically = (dictionary: T): T => { + const resolvedMemo = new Map(); + + // Used purely for cycle detection (even though we do not expect + // cycles we want to assert early to make sure we raise an alarm). + const visiting = new Set(); + + const resolveValueDeep = (value: unknown): unknown => { + if (typeof value === 'string' && value.startsWith('$')) { + return dfs(value.slice(1), value); + } + + if (Array.isArray(value)) return value.map(resolveValueDeep); + + if (isPlainObject(value)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = resolveValueDeep(v); + return out; + } + + return value; + }; + + /** + * This is the topological sort part: + * - DFS into dependencies first + * - then memoize the current token + */ + const dfs = (tokenKeyOrPath: string, originalRef: string): unknown => { + const cacheKey = tokenKeyOrPath; + + if (resolvedMemo.has(cacheKey)) return resolvedMemo.get(cacheKey); + + if (visiting.has(cacheKey)) { + throw new Error(`Cycle detected while topo-evaluating "${originalRef}" (at "${cacheKey}")`); + } + + visiting.add(cacheKey); + + const raw = dictionary[tokenKeyOrPath as keyof Theme['semantics']]; + + if (raw === undefined) { + // is throwing maybe too strict here ? + throw new Error(`Unresolved reference: "${originalRef}" (missing "${tokenKeyOrPath}")`); + } + + // resolve dependencies inside `raw` BEFORE finalizing this token + // so that we can throw if there's a cycle or the dep graph is not + // connected) + const fullyResolved = resolveValueDeep(raw); + + resolvedMemo.set(cacheKey, fullyResolved); + visiting.delete(cacheKey); + + return fullyResolved; + }; + + return resolveValueDeep(dictionary) as T; +}; diff --git a/package/sync-theme.sh b/package/sync-theme.sh new file mode 100755 index 0000000000..71b37b39ea --- /dev/null +++ b/package/sync-theme.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEST="src/theme/generated" +DEFAULT_src="/open?url=https%3A%2F%2Fgithub.com%2FGetStream%2Fdesign-system-tokens%2Fbuild%2Freactnative" +FALLBACK_src="/open?url=https%3A%2F%2Fgithub.com%2FGetStream%2Fstream-chat-react-native%2Fcompare%2F%24%7B1%3A-%7D" + +# Pick a source, while preferring DEFAULT_SRC if it exists; otherwise use fallback arg (if provided) +if [[ -d "$DEFAULT_SRC" ]]; then + src="/open?url=https%3A%2F%2Fgithub.com%2FGetStream%2Fstream-chat-react-native%2Fcompare%2F%24DEFAULT_SRC" +elif [[ -n "$FALLBACK_SRC" && -d "$FALLBACK_SRC" ]]; then + src="/open?url=https%3A%2F%2Fgithub.com%2FGetStream%2Fstream-chat-react-native%2Fcompare%2F%24FALLBACK_SRC" +else + echo "Error: Source directory not found." + echo "Tried: $DEFAULT_SRC" + [[ -n "$FALLBACK_SRC" ]] && echo "Also tried fallback arg: $FALLBACK_SRC" + exit 1 +fi + +mkdir -p "$DEST" + +# Clear DEST contents (but keep the folder itself) +rm -rf "$DEST"/* "$DEST"/.[!.]* "$DEST"/..?* 2>/dev/null || true + +# Copy contents of SRC into DEST +cp -R "$SRC"/. "$DEST"/ + +prettier --write "$DEST" + +echo "Copied theme tokens from: $SRC -> $DEST" diff --git a/package/tsconfig.json b/package/tsconfig.json index 86ed10c83d..0464e38092 100644 --- a/package/tsconfig.json +++ b/package/tsconfig.json @@ -24,7 +24,8 @@ "noEmitOnError": false, "noImplicitAny": true, "allowJs": true, - "checkJs": false + "checkJs": false, + "moduleSuffixes": [".ios", ".android", "", ".web"] }, "include": ["./src/**/*"], "exclude": [ From edba2a282b59280097d40c9ff351c8761bfec526 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 29 Jan 2026 14:20:38 +0100 Subject: [PATCH 38/76] chore: bump xcode version to 26.2 --- .github/workflows/sample-distribution.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sample-distribution.yml b/.github/workflows/sample-distribution.yml index ecb42ecf80..5280534365 100644 --- a/.github/workflows/sample-distribution.yml +++ b/.github/workflows/sample-distribution.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v3 - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.0' # Update as needed + xcode-version: '26.2' # Update as needed - uses: ./.github/actions/ruby-cache - name: Install && Build - SDK and Sample App uses: ./.github/actions/install-and-build-sdk From a2a4bd5399007a7a4350558a682c83493a9a6117 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 30 Jan 2026 19:41:42 +0530 Subject: [PATCH 39/76] feat: async audio redesign, command ui and usage of theme (#3375) --- .../src/components/ChannelInfoOverlay.tsx | 100 ++-- .../components/ConfirmationBottomSheet.tsx | 5 +- .../MessageSearch/MessageSearchList.tsx | 17 +- .../src/components/UserInfoOverlay.tsx | 51 +- .../UserSearch/UserSearchResults.tsx | 4 +- .../src/screens/ChannelFilesScreen.tsx | 5 +- .../SampleApp/src/screens/ChannelScreen.tsx | 2 +- .../src/screens/GroupChannelDetailsScreen.tsx | 19 +- .../src/screens/NewDirectMessagingScreen.tsx | 5 +- .../NewGroupChannelAddMemberScreen.tsx | 5 +- .../NewGroupChannelAssignNameScreen.tsx | 9 +- .../screens/OneOnOneChannelDetailScreen.tsx | 27 +- .../src/screens/UserSelectorScreen.tsx | 7 +- .../src/optionalDependencies/Audio.ts | 1 - .../components/Attachment/AudioAttachment.tsx | 66 ++- .../Attachment/FileAttachmentGroup.tsx | 1 + .../AutoCompleteInput/AutoCompleteInput.tsx | 60 +- package/src/components/Channel/Channel.tsx | 5 +- .../useCreateInputMessageInputContext.ts | 2 - .../components/MessageInput/MessageInput.tsx | 535 ++++++++---------- .../AttachmentRemoveControl.tsx | 1 + .../AudioAttachmentUploadPreview.tsx | 37 +- .../FileAttachmentUploadPreview.tsx | 13 +- .../AudioRecorder/AudioRecorder.tsx | 337 +++++------ .../AudioRecorder/AudioRecordingButton.tsx | 275 +++++++-- .../AudioRecordingInProgress.tsx | 133 +++-- .../AudioRecordingLockIndicator.tsx | 80 ++- .../AudioRecorder/AudioRecordingPreview.tsx | 102 ++-- .../AudioRecorder/AudioRecordingWaveform.tsx | 53 +- .../MessageInput/components/CommandInput.tsx | 111 ---- .../components/InputButtons/index.tsx | 12 +- .../components/OutputButtons/index.tsx | 18 +- .../MessageInput/hooks/useAudioRecorder.tsx | 232 +++----- .../MessageList/MessageFlashList.tsx | 3 +- .../components/MessageList/MessageList.tsx | 3 +- .../ProgressControl/ProgressControl.tsx | 4 +- .../ProgressControl/WaveProgressBar.tsx | 92 +-- package/src/components/Reply/Reply.tsx | 217 ++++--- .../__snapshots__/Thread.test.js.snap | 220 +++---- package/src/components/index.ts | 1 - package/src/components/ui/GiphyBadge.tsx | 69 +++ package/src/components/ui/IconButton.tsx | 18 +- .../MessageInputContext.tsx | 21 +- .../hooks/useCreateMessageInputContext.ts | 12 +- .../src/contexts/themeContext/utils/theme.ts | 10 - package/src/icons/NewChevronLeft.tsx | 17 + package/src/icons/NewChevronTop.tsx | 17 + package/src/icons/NewCross.tsx | 16 + package/src/icons/NewGiphy.tsx | 14 + package/src/icons/NewLock.tsx | 17 + package/src/icons/NewPause.tsx | 18 + package/src/icons/NewPlay.tsx | 14 + package/src/icons/NewStop.tsx | 14 + package/src/icons/NewTrash.tsx | 17 + package/src/icons/NewUnlock.tsx | 17 + .../src/state-store/audio-recorder-manager.ts | 98 ++++ 56 files changed, 1849 insertions(+), 1410 deletions(-) delete mode 100644 package/src/components/MessageInput/components/CommandInput.tsx create mode 100644 package/src/components/ui/GiphyBadge.tsx create mode 100644 package/src/icons/NewChevronLeft.tsx create mode 100644 package/src/icons/NewChevronTop.tsx create mode 100644 package/src/icons/NewCross.tsx create mode 100644 package/src/icons/NewGiphy.tsx create mode 100644 package/src/icons/NewLock.tsx create mode 100644 package/src/icons/NewPause.tsx create mode 100644 package/src/icons/NewPlay.tsx create mode 100644 package/src/icons/NewStop.tsx create mode 100644 package/src/icons/NewTrash.tsx create mode 100644 package/src/icons/NewUnlock.tsx create mode 100644 package/src/state-store/audio-recorder-manager.ts diff --git a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx index 822a115556..228c715b87 100644 --- a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx +++ b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx @@ -110,7 +110,8 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { const { theme: { - colors: { accent_red, black, border, grey, white }, + colors: { accent_red, black, grey, white }, + semantics, }, } = useTheme(); @@ -127,19 +128,19 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { } showScreen.value = show ? withTiming(1, { - duration: 150, - easing: Easing.in(Easing.ease), - }) + duration: 150, + easing: Easing.in(Easing.ease), + }) : withTiming( - 0, - { - duration: 150, - easing: Easing.out(Easing.ease), - }, - () => { - runOnJS(reset)(); - }, - ); + 0, + { + duration: 150, + easing: Easing.out(Easing.ease), + }, + () => { + runOnJS(reset)(); + }, + ); }; useEffect(() => { @@ -185,12 +186,12 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { translateY.value = evt.velocityY > 1000 ? withDecay({ - velocity: evt.velocityY, - }) + velocity: evt.velocityY, + }) : withTiming(screenHeight, { - duration: 200, - easing: Easing.out(Easing.ease), - }); + duration: 200, + easing: Easing.out(Easing.ease), + }); } else { translateY.value = withTiming(0); overlayOpacity.value = withTiming(1); @@ -225,31 +226,31 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { : 0; const channelName = channel ? channel.data?.name || - Object.values(channel.state.members) - .slice(0) - .reduce((returnString, currentMember, index, originalArray) => { - const returnStringLength = returnString.length; - const currentMemberName = - currentMember.user?.name || currentMember.user?.id || 'Unknown User'; - // a rough approximation of when the +Number shows up - if (returnStringLength + (currentMemberName.length + 2) < maxWidth) { - if (returnStringLength) { - returnString += `, ${currentMemberName}`; - } else { - returnString = currentMemberName; - } + Object.values(channel.state.members) + .slice(0) + .reduce((returnString, currentMember, index, originalArray) => { + const returnStringLength = returnString.length; + const currentMemberName = + currentMember.user?.name || currentMember.user?.id || 'Unknown User'; + // a rough approximation of when the +Number shows up + if (returnStringLength + (currentMemberName.length + 2) < maxWidth) { + if (returnStringLength) { + returnString += `, ${currentMemberName}`; } else { - const remainingMembers = originalArray.length - index; - returnString += `, +${remainingMembers}`; - originalArray.splice(1); // exit early + returnString = currentMemberName; } - return returnString; - }, '') + } else { + const remainingMembers = originalArray.length - index; + returnString += `, +${remainingMembers}`; + originalArray.splice(1); // exit early + } + return returnString; + }, '') : ''; const otherMembers = channel ? Object.values(channel.state.members).filter( - (member) => member.user?.id !== clientId, - ) + (member) => member.user?.id !== clientId, + ) : []; const { viewInfo, pinUnpin, archiveUnarchive, leaveGroup, deleteConversation, cancel } = @@ -285,11 +286,10 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { ? otherMembers[0].user?.online ? 'Online' : `Last Seen ${dayjs(otherMembers[0].user?.last_active).fromNow()}` - : `${Object.keys(channel.state.members).length} Members, ${ - Object.values(channel.state.members).filter( - (member) => !!member.user?.online, - ).length - } Online`} + : `${Object.keys(channel.state.members).length} Members, ${Object.values(channel.state.members).filter( + (member) => !!member.user?.online, + ).length + } Online`} { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -344,7 +344,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -361,7 +361,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -376,7 +376,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { {otherMembers.length > 1 && ( - + @@ -389,7 +389,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -406,8 +406,8 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.lastRow, { - borderBottomColor: border.surfaceSubtle, - borderTopColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, + borderTopColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx index 581ac92ca0..3eab535667 100644 --- a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx +++ b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx @@ -52,7 +52,8 @@ export const ConfirmationBottomSheet: React.FC = () => { const { theme: { - colors: { accent_red, black, border, grey, white }, + colors: { accent_red, black, grey, white }, + semantics, }, } = useTheme(); const inset = useSafeAreaInsets(); @@ -86,7 +87,7 @@ export const ConfirmationBottomSheet: React.FC = () => { style={[ styles.actionButtonsContainer, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx index a087a9a3e8..ad2ec54ded 100644 --- a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx +++ b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx @@ -72,7 +72,8 @@ export const MessageSearchList: React.FC = React.forward } = props; const { theme: { - colors: { black, border, grey, white_snow }, + colors: { black, grey, white_snow }, + semantics, }, } = useTheme(); const { vw } = useViewport(); @@ -94,13 +95,11 @@ export const MessageSearchList: React.FC = React.forward }} > - {`${ - messages.length >= DEFAULT_PAGINATION_LIMIT - ? DEFAULT_PAGINATION_LIMIT - : messages.length - }${messages.length >= DEFAULT_PAGINATION_LIMIT ? '+ ' : ' '} result${ - messages.length === 1 ? '' : 's' - }`} + {`${messages.length >= DEFAULT_PAGINATION_LIMIT + ? DEFAULT_PAGINATION_LIMIT + : messages.length + }${messages.length >= DEFAULT_PAGINATION_LIMIT ? '+ ' : ' '} result${messages.length === 1 ? '' : 's' + }`} )} @@ -130,7 +129,7 @@ export const MessageSearchList: React.FC = React.forward messageId: item.id, }); }} - style={[styles.itemContainer, { borderBottomColor: border.surfaceSubtle }]} + style={[styles.itemContainer, { borderBottomColor: semantics.borderCoreDefault }]} testID='channel-preview-button' > {item.user ? : null} diff --git a/examples/SampleApp/src/components/UserInfoOverlay.tsx b/examples/SampleApp/src/components/UserInfoOverlay.tsx index dcaa84ae3e..91659ececf 100644 --- a/examples/SampleApp/src/components/UserInfoOverlay.tsx +++ b/examples/SampleApp/src/components/UserInfoOverlay.tsx @@ -105,7 +105,8 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { const { theme: { - colors: { accent_red, black, border, grey, white }, + colors: { accent_red, black, grey, white }, + semantics, }, } = useTheme(); @@ -122,19 +123,19 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { } showScreen.value = show ? withTiming(1, { - duration: 150, - easing: Easing.in(Easing.ease), - }) + duration: 150, + easing: Easing.in(Easing.ease), + }) : withTiming( - 0, - { - duration: 150, - easing: Easing.out(Easing.ease), - }, - () => { - runOnJS(reset)(); - }, - ); + 0, + { + duration: 150, + easing: Easing.out(Easing.ease), + }, + () => { + runOnJS(reset)(); + }, + ); }; useEffect(() => { @@ -180,12 +181,12 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { translateY.value = evt.velocityY > 1000 ? withDecay({ - velocity: evt.velocityY, - }) + velocity: evt.velocityY, + }) : withTiming(screenHeight, { - duration: 200, - easing: Easing.out(Easing.ease), - }); + duration: 200, + easing: Easing.out(Easing.ease), + }); } else { translateY.value = withTiming(0); overlayOpacity.value = withTiming(1); @@ -216,8 +217,8 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { const self = channel ? Object.values(channel.state.members).find( - (channelMember) => channelMember.user?.id === client.user?.id, - ) + (channelMember) => channelMember.user?.id === client.user?.id, + ) : undefined; const { viewInfo, messageUser, removeFromGroup, cancel } = useUserInfoOverlayActions(); @@ -277,7 +278,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -292,7 +293,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -308,7 +309,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -326,8 +327,8 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.lastRow, { - borderBottomColor: border.surfaceSubtle, - borderTopColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, + borderTopColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx index aac9764f79..4b1ab55040 100644 --- a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx +++ b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx @@ -97,12 +97,12 @@ export const UserSearchResults: React.FC = ({ bg_gradient_end, bg_gradient_start, black, - border, grey, grey_gainsboro, white_smoke, white_snow, }, + semantics, }, } = useTheme(); const { vw } = useViewport(); @@ -199,7 +199,7 @@ export const UserSearchResults: React.FC = ({ styles.searchResultContainer, { backgroundColor: white_snow, - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx index d657aeb696..5072e69f89 100644 --- a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx @@ -83,7 +83,8 @@ export const ChannelFilesScreen: React.FC = ({ const insets = useSafeAreaInsets(); const { theme: { - colors: { black, border, grey, white_snow }, + colors: { black, grey, white_snow }, + semantics, }, } = useTheme(); @@ -149,7 +150,7 @@ export const ChannelFilesScreen: React.FC = ({ Alert.alert('Not implemented.'); }} style={{ - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, borderBottomWidth: index === section.data.length - 1 ? 0 : 1, }} > diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 8c0a206d98..28b002c4f9 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -221,7 +221,7 @@ export const ChannelScreen: React.FC = ({ return ( = ({ const { setOverlay } = useOverlayContext(); const { theme: { - colors: { accent_blue, accent_green, black, border, grey, white, white_smoke }, + colors: { accent_blue, accent_green, black, grey, white, white_smoke }, + semantics, }, } = useTheme(); @@ -276,7 +277,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.memberContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -306,7 +307,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.loadMoreButton, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -330,7 +331,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.changeNameContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -382,7 +383,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -427,7 +428,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -457,7 +458,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -487,7 +488,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -513,7 +514,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx index fdbc9d6e5a..339ee4c998 100644 --- a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx +++ b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx @@ -118,7 +118,8 @@ export const NewDirectMessagingScreen: React.FC = }) => { const { theme: { - colors: { accent_blue, black, border, grey, white }, + colors: { accent_blue, black, grey, white }, + semantics, }, } = useTheme(); const { chatClient } = useAppContext(); @@ -208,7 +209,7 @@ export const NewDirectMessagingScreen: React.FC = styles.searchContainer, { backgroundColor: white, - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx index 79819b6de8..8897f77c36 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx @@ -77,7 +77,8 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) const { theme: { - colors: { black, border, grey, white }, + colors: { black, grey, white }, + semantics, }, } = useTheme(); @@ -111,7 +112,7 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) styles.inputBoxContainer, { backgroundColor: white, - borderColor: border.surfaceSubtle, + borderColor: semantics.borderCoreDefault, marginBottom: selectedUsers.length === 0 ? 8 : 16, }, ]} diff --git a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx index cf4c98aac5..270ce5e532 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx @@ -58,13 +58,13 @@ const ConfirmButton: React.FC = (props) => { const { disabled, onPress } = props; const { theme: { - colors: { accent_blue, grey }, + semantics, }, } = useTheme(); return ( - + ); }; @@ -86,7 +86,8 @@ export const NewGroupChannelAssignNameScreen: React.FC diff --git a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx index ccd83de704..36fd1d7de2 100644 --- a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx +++ b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx @@ -134,7 +134,8 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ }) => { const { theme: { - colors: { accent_green, accent_red, black, border, grey, white, white_smoke }, + colors: { accent_green, accent_red, black, grey, white, white_smoke }, + semantics, }, } = useTheme(); const { chatClient } = useAppContext(); @@ -148,13 +149,13 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ const user = member?.user; const [muted, setMuted] = useState( chatClient?.mutedUsers && - chatClient?.mutedUsers?.findIndex((mutedUser) => mutedUser.target.id === user?.id) > -1, + chatClient?.mutedUsers?.findIndex((mutedUser) => mutedUser.target.id === user?.id) > -1, ); const [notificationsEnabled, setNotificationsEnabled] = useState( chatClient?.mutedChannels && - chatClient.mutedChannels.findIndex( - (mutedChannel) => mutedChannel.channel?.id === channel.id, - ) > -1, + chatClient.mutedChannels.findIndex( + (mutedChannel) => mutedChannel.channel?.id === channel.id, + ) > -1, ); /** @@ -227,7 +228,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.userNameContainer, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -266,7 +267,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -305,7 +306,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -351,7 +352,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -381,7 +382,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -411,7 +412,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -441,7 +442,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -468,7 +469,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/screens/UserSelectorScreen.tsx b/examples/SampleApp/src/screens/UserSelectorScreen.tsx index 19987dc12a..49296412b4 100644 --- a/examples/SampleApp/src/screens/UserSelectorScreen.tsx +++ b/examples/SampleApp/src/screens/UserSelectorScreen.tsx @@ -90,7 +90,8 @@ type Props = { export const UserSelectorScreen: React.FC = ({ navigation }) => { const { theme: { - colors: { black, border, grey, grey_gainsboro, grey_whisper, white_snow }, + colors: { black, grey, grey_gainsboro, grey_whisper, white_snow }, + semantics, }, } = useTheme(); const { switchUser } = useAppContext(); @@ -125,7 +126,7 @@ export const UserSelectorScreen: React.FC = ({ navigation }) => { onPress={() => { switchUser(u.id); }} - style={[styles.userContainer, { borderBottomColor: border.surfaceSubtle }]} + style={[styles.userContainer, { borderBottomColor: semantics.borderCoreDefault }]} testID={`user-selector-button-${u.id}`} > = ({ navigation }) => { onPress={() => { navigation.navigate('AdvancedUserSelectorScreen'); }} - style={[styles.userContainer, { borderBottomColor: border.surfaceSubtle }]} + style={[styles.userContainer, { borderBottomColor: semantics.borderCoreDefault }]} > { try { - if (!audioRecorderPlayer._isRecording) return; await audioRecorderPlayer.stopRecorder(); audioRecorderPlayer.removeRecordBackListener(); } catch (error) { diff --git a/package/src/components/Attachment/AudioAttachment.tsx b/package/src/components/Attachment/AudioAttachment.tsx index cd3f2670ce..37b52a88e7 100644 --- a/package/src/components/Attachment/AudioAttachment.tsx +++ b/package/src/components/Attachment/AudioAttachment.tsx @@ -1,5 +1,5 @@ import React, { RefObject, useEffect, useMemo } from 'react'; -import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; +import { I18nManager, Pressable, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; @@ -14,7 +14,9 @@ import { import { useTheme } from '../../contexts'; import { useStateStore } from '../../hooks'; import { useAudioPlayer } from '../../hooks/useAudioPlayer'; -import { Audio, Pause, Play } from '../../icons'; +import { Audio } from '../../icons'; +import { NewPause } from '../../icons/NewPause'; +import { NewPlay } from '../../icons/NewPlay'; import { NativeHandlers, SoundReturnType, @@ -56,6 +58,8 @@ export type AudioAttachmentProps = { * If true, the audio attachment is in preview mode in the message input. */ isPreview?: boolean; + containerStyle?: StyleProp; + maxAmplitudesCount?: number; }; const audioPlayerSelector = (state: AudioPlayerState) => ({ @@ -81,6 +85,8 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { testID, titleMaxLength, isPreview = false, + containerStyle, + maxAmplitudesCount = 30, } = props; const isVoiceRecording = isVoiceRecordingAttachment(item); @@ -173,7 +179,8 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { speedChangeButton, speedChangeButtonText, }, - colors: { accent_blue, black, grey_dark, grey_whisper, static_black, static_white, white }, + colors: { black, static_white, white }, + semantics, messageInput: { fileAttachmentUploadPreview: { filenameText }, }, @@ -197,9 +204,10 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { styles.container, { backgroundColor: white, - borderColor: grey_whisper, + borderColor: semantics.borderCoreDefault, }, container, + containerStyle, ]} testID={testID} > @@ -209,14 +217,14 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { onPress={handlePlayPause} style={[ styles.playPauseButton, - { backgroundColor: static_white, shadowColor: black }, + { backgroundColor: static_white, borderColor: semantics.borderCoreDefault }, playPauseButton, ]} > {!isPlaying ? ( - + ) : ( - + )} @@ -227,25 +235,31 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { style={[ styles.filenameText, { - color: black, + color: semantics.textPrimary, }, I18nManager.isRTL ? { writingDirection: 'rtl' } : { writingDirection: 'ltr' }, filenameText, ]} > {isVoiceRecordingAttachment(item) - ? 'Recording' + ? 'Voice Message' : getTrimmedAttachmentTitle(item.title, titleMaxLength)}
- + {progressDuration} {!hideProgressBar && ( {item.waveform_data ? ( { /> ) : ( ) : ( diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index ac23dd1054..4146bafbf7 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -57,13 +57,23 @@ const MAX_NUMBER_OF_LINES = 5; const LINE_HEIGHT = 20; const PADDING_VERTICAL = 12; +const commandPlaceHolders: Record = { + giphy: 'Search GIFs', + ban: '@username', + unban: '@username', + mute: '@username', + unmute: '@username', +}; + const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => { + const styles = useStyles(); const { channel, cooldownRemainingSeconds, setInputBoxRef, t, TextInputComponent = RNTextInput, + placeholder, ...rest } = props; const [localText, setLocalText] = useState(''); @@ -109,18 +119,20 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) const { theme: { - colors: { black, grey }, messageInput: { inputBox }, + semantics, }, } = useTheme(); const placeholderText = useMemo(() => { - return command - ? t('Search') - : cooldownRemainingSeconds - ? `Slow mode, wait ${cooldownRemainingSeconds}s...` - : t('Send a message'); - }, [command, cooldownRemainingSeconds, t]); + return placeholder + ? placeholder + : command + ? commandPlaceHolders[command.name ?? ''] + : cooldownRemainingSeconds + ? `Slow mode, wait ${cooldownRemainingSeconds}s...` + : t('Send a message'); + }, [command, cooldownRemainingSeconds, t, placeholder]); return ( { ); }; -const styles = StyleSheet.create({ - inputBox: { - flex: 1, - fontSize: 16, - includeFontPadding: false, // for android vertical text centering - lineHeight: 20, - paddingLeft: 16, - paddingVertical: 12, - textAlignVertical: 'center', // for android vertical text centering - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + inputBox: { + color: semantics.inputTextDefault, + flex: 1, + fontSize: 16, + includeFontPadding: false, // for android vertical text centering + lineHeight: 20, + paddingLeft: 16, + paddingVertical: 12, + textAlignVertical: 'center', // for android vertical text centering + }, + }); + }, [semantics]); +}; AutoCompleteInput.displayName = 'AutoCompleteInput{messageInput{inputBox}}'; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 4b15de4d91..93a4a100d6 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -185,7 +185,6 @@ import { AudioRecordingInProgress as AudioRecordingInProgressDefault } from '../ import { AudioRecordingLockIndicator as AudioRecordingLockIndicatorDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingLockIndicator'; import { AudioRecordingPreview as AudioRecordingPreviewDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingPreview'; import { AudioRecordingWaveform as AudioRecordingWaveformDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingWaveform'; -import { CommandInput as CommandInputDefault } from '../MessageInput/components/CommandInput'; import { 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'; @@ -561,7 +560,7 @@ const ChannelWithContext = (props: PropsWithChildren) = asyncMessagesLockDistance = 50, asyncMessagesMinimumPressDuration = 500, asyncMessagesMultiSendEnabled = true, - asyncMessagesSlideToCancelDistance = 100, + asyncMessagesSlideToCancelDistance = 75, AttachButton = AttachButtonDefault, Attachment = AttachmentDefault, AttachmentActions = AttachmentActionsDefault, @@ -665,7 +664,6 @@ const ChannelWithContext = (props: PropsWithChildren) = InlineUnreadIndicator = InlineUnreadIndicatorDefault, Input, InputButtons = InputButtonsDefault, - CommandInput = CommandInputDefault, isAttachmentEqual, isMessageAIGenerated = () => false, keyboardBehavior, @@ -1857,7 +1855,6 @@ const ChannelWithContext = (props: PropsWithChildren) = AutoCompleteSuggestionList, CameraSelectorIcon, channelId, - CommandInput, compressImageQuality, CooldownTimer, CreatePollContent, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 5efbc41670..11f1f5a5be 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -29,7 +29,6 @@ export const useCreateInputMessageInputContext = ({ AutoCompleteSuggestionList, channelId, CameraSelectorIcon, - CommandInput, compressImageQuality, CooldownTimer, CreatePollContent, @@ -93,7 +92,6 @@ export const useCreateInputMessageInputContext = ({ AutoCompleteSuggestionItem, AutoCompleteSuggestionList, CameraSelectorIcon, - CommandInput, compressImageQuality, CooldownTimer, CreatePollContent, diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index b091d2b87a..cc62e6e60c 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -1,24 +1,15 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Modal, StyleSheet, TextInput, TextInputProps, View } from 'react-native'; -import { - Gesture, - GestureDetector, - GestureHandlerRootView, - PanGestureHandlerEventPayload, -} from 'react-native-gesture-handler'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { Extrapolation, FadeIn, FadeOut, interpolate, LinearTransition, - runOnJS, useAnimatedStyle, useSharedValue, - withSpring, - ZoomIn, - ZoomOut, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -26,7 +17,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { @@ -65,70 +55,87 @@ import { import { useKeyboardVisibility } from '../../hooks/useKeyboardVisibility'; import { useStateStore } from '../../hooks/useStateStore'; -import { isAudioRecorderAvailable, NativeHandlers } from '../../native'; +import { AudioRecorderManagerState } from '../../state-store/audio-recorder-manager'; import { MessageInputHeightState } from '../../state-store/message-input-height-store'; +import { primitives } from '../../theme'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; +import { GiphyBadge } from '../ui/GiphyBadge'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - gap: 8, - justifyContent: 'space-between', - }, - contentContainer: { - gap: 4, - overflow: 'hidden', - paddingHorizontal: 8, - }, - floatingWrapper: { - left: 0, - paddingHorizontal: 16, - position: 'absolute', - right: 0, - }, - inputBoxContainer: { - flex: 1, - }, - inputBoxWrapper: { - borderRadius: 24, - borderWidth: 1, - flex: 1, - flexDirection: 'row', - }, - 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, - }, - suggestionsListContainer: { - position: 'absolute', - width: '100%', - }, - wrapper: { - borderTopWidth: 1, - paddingHorizontal: 16, - paddingTop: 16, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXs, + justifyContent: 'space-between', + }, + contentContainer: { + gap: primitives.spacingXxs, + overflow: 'hidden', + paddingHorizontal: primitives.spacingXs, + }, + floatingWrapper: { + left: 0, + position: 'absolute', + right: 0, + }, + giphyContainer: { + padding: primitives.spacingXs, + }, + inputBoxContainer: { + flex: 1, + }, + inputBoxWrapper: { + borderRadius: 24, + borderWidth: 1, + flex: 1, + flexDirection: 'row', + backgroundColor: semantics.composerBg, + borderColor: semantics.borderCoreDefault, + }, + inputButtonsContainer: { + alignSelf: 'flex-end', + }, + inputContainer: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + }, + micButtonContainer: {}, + outputButtonsContainer: { + alignSelf: 'flex-end', + padding: primitives.spacingXs, + }, + shadow: { + elevation: 6, + + shadowColor: 'hsla(0, 0%, 0%, 0.24)', + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.24, + shadowRadius: 12, + }, + suggestionsListContainer: { + position: 'absolute', + width: '100%', + }, + wrapper: { + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacingMd, + }, + audioLockIndicatorWrapper: { + position: 'absolute', + right: primitives.spacingMd, + padding: 4, + }, + }); + }, [semantics]); +}; type MessageInputPropsWithContext = Pick< AttachmentPickerContextValue, @@ -138,6 +145,7 @@ type MessageInputPropsWithContext = Pick< Pick & Pick< MessageInputContextValue, + | 'audioRecorderManager' | 'additionalTextInputProps' | 'audioRecordingEnabled' | 'asyncMessagesLockDistance' @@ -167,7 +175,6 @@ type MessageInputPropsWithContext = Pick< | 'messageInputHeightStore' | 'ImageSelectorIcon' | 'VideoRecorderSelectorIcon' - | 'CommandInput' | 'SendButton' | 'ShowThreadMessageInChannelButton' | 'StartAudioRecordingButton' @@ -181,7 +188,8 @@ type MessageInputPropsWithContext = Pick< > & Pick & Pick & - Pick & { + Pick & + Pick & { editing: boolean; hasAttachments: boolean; isKeyboardVisible: boolean; @@ -190,6 +198,8 @@ type MessageInputPropsWithContext = Pick< ref: React.Ref | undefined; } >; + isRecordingStateIdle?: boolean; + recordingStatus?: string; }; const textComposerStateSelector = (state: TextComposerState) => ({ @@ -213,15 +223,11 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { attachmentSelectionBarHeight, bottomInset, selectedPicker, - additionalTextInputProps, asyncMessagesLockDistance, - asyncMessagesMinimumPressDuration, - asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachmentUploadPreviewList, AudioRecorder, - audioRecordingEnabled, AudioRecordingInProgress, AudioRecordingLockIndicator, AudioRecordingPreview, @@ -238,20 +244,21 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { Input, inputBoxRef, InputButtons, - CommandInput, isKeyboardVisible, - isOnline, members, Reply, threadList, sendMessage, showPollCreationDialog, ShowThreadMessageInChannelButton, - StartAudioRecordingButton, TextInputComponent, watchers, + micLocked, + isRecordingStateIdle, + recordingStatus, } = props; + const styles = useStyles(); const messageComposer = useMessageComposer(); const { clearEditingState } = useMessageComposerAPIContext(); const onDismissEditMessage = () => { @@ -265,7 +272,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { theme: { semantics, - colors: { grey_whisper, white, white_smoke }, messageInput: { attachmentSelectionBar, container, @@ -277,7 +283,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { inputContainer, inputButtonsContainer, inputFloatingContainer, - micButtonContainer, outputButtonsContainer, suggestionsListContainer: { container: suggestionListContainer }, wrapper, @@ -351,81 +356,11 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const isFocused = inputBoxRef.current?.isFocused(); - const { - deleteVoiceRecording, - micLocked, - permissionsGranted, - recording, - recordingDuration, - recordingStatus, - setMicLocked, - startVoiceRecording, - stopVoiceRecording, - uploadVoiceRecording, - waveformData, - } = useAudioRecorder(); - - const asyncAudioEnabled = audioRecordingEnabled && isAudioRecorderAvailable(); - const micPositionX = useSharedValue(0); const micPositionY = useSharedValue(0); const X_AXIS_POSITION = -asyncMessagesSlideToCancelDistance; const Y_AXIS_POSITION = -asyncMessagesLockDistance; - const resetAudioRecording = async () => { - await deleteVoiceRecording(); - }; - - const micLockHandler = () => { - setMicLocked(true); - NativeHandlers.triggerHaptic('impactMedium'); - }; - - const panGestureMic = Gesture.Pan() - .activateAfterLongPress(asyncMessagesMinimumPressDuration + 100) - .onChange((event: PanGestureHandlerEventPayload) => { - const newPositionX = event.translationX; - const newPositionY = event.translationY; - - if (newPositionX <= 0 && newPositionX >= X_AXIS_POSITION) { - micPositionX.value = newPositionX; - } - if (newPositionY <= 0 && newPositionY >= Y_AXIS_POSITION) { - micPositionY.value = newPositionY; - } - }) - .onEnd(() => { - const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2; - const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2; - - if (belowThresholdY && belowThresholdX) { - micPositionY.value = withSpring(0); - micPositionX.value = withSpring(0); - if (recordingStatus === 'recording') { - runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); - } - return; - } - - if (!belowThresholdY) { - micPositionY.value = withSpring(Y_AXIS_POSITION); - runOnJS(micLockHandler)(); - } - - if (!belowThresholdX) { - micPositionX.value = withSpring(X_AXIS_POSITION); - runOnJS(resetAudioRecording)(); - } - - micPositionX.value = 0; - micPositionY.value = 0; - }) - .onStart(() => { - micPositionX.value = 0; - micPositionY.value = 0; - runOnJS(setMicLocked)(false); - }); - const lockIndicatorAnimatedStyle = useAnimatedStyle(() => ({ transform: [ { @@ -438,22 +373,8 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { }, ], })); - const micButttonAnimatedStyle = useAnimatedStyle(() => ({ - opacity: interpolate(micPositionX.value, [0, X_AXIS_POSITION], [1, 0], Extrapolation.CLAMP), - transform: [{ translateX: micPositionX.value }, { translateY: micPositionY.value }], - })); const slideToCancelAnimatedStyle = useAnimatedStyle(() => ({ opacity: interpolate(micPositionX.value, [0, X_AXIS_POSITION], [1, 0], Extrapolation.CLAMP), - transform: [ - { - translateX: interpolate( - micPositionX.value, - [0, X_AXIS_POSITION], - [0, X_AXIS_POSITION / 2], - Extrapolation.CLAMP, - ), - }, - ], })); const { bottom } = useSafeAreaInsets(); @@ -471,14 +392,15 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { messageInputHeightStore.setHeight( messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight, ) - } // 24 is the position of the input from the bottom of the screen + } // BOTTOM OFFSET is the position of the input from the bottom of the screen style={ messageInputFloating - ? [styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] + ? [styles.wrapper, styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] : [ styles.wrapper, { - backgroundColor: white, + borderTopWidth: 1, + backgroundColor: semantics.composerBg, borderColor: semantics.borderCoreDefault, paddingBottom: BOTTOM_OFFSET, }, @@ -486,150 +408,122 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { ] } > - {recording && ( - <> - - {recordingStatus === 'stopped' ? ( - - ) : micLocked ? ( - + {Input ? ( + + ) : ( + + {isRecordingStateIdle ? ( + + {InputButtons && } + ) : null} - - )} - - - {Input ? ( - - ) : ( - <> - {recording ? ( - - ) : ( - <> + + + {recordingStatus === 'stopped' ? ( + + ) : micLocked ? ( + + ) : null} + {isRecordingStateIdle ? ( - {InputButtons && } - - - - + + + ) : null} + {quotedMessage ? ( + - {editing ? ( - - - - ) : null} - {quotedMessage ? ( - - - - ) : null} - - - - - {command ? ( - - ) : ( - - )} - - - + + + ) : null} + + + ) : null} + + + {!isRecordingStateIdle ? ( + + ) : ( + <> + {command ? ( + + - - - - - )} - - {asyncAudioEnabled && !micLocked ? ( - - - - - - - - ) : null} - - )} -
+ + )} + + {(recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( + + + + ) : null} +
+
+
+ + )} + {!isRecordingStateIdle ? ( + + + + ) : null} + { exiting={FadeOut.duration(200)} style={[ { - backgroundColor: white_smoke, + backgroundColor: semantics.composerBg, height: attachmentPickerBottomSheetHeight + attachmentSelectionBarHeight - bottomInset, }, @@ -700,6 +594,9 @@ const areEqual = ( showPollCreationDialog: prevShowPollCreationDialog, t: prevT, threadList: prevThreadList, + micLocked: prevMicLocked, + isRecordingStateIdle: prevIsRecordingStateIdle, + recordingStatus: prevRecordingStatus, } = prevProps; const { additionalTextInputProps: nextAdditionalTextInputProps, @@ -719,6 +616,9 @@ const areEqual = ( showPollCreationDialog: nextShowPollCreationDialog, t: nextT, threadList: nextThreadList, + micLocked: nextMicLocked, + isRecordingStateIdle: nextIsRecordingStateIdle, + recordingStatus: nextRecordingStatus, } = nextProps; const tEqual = prevT === nextT; @@ -803,6 +703,21 @@ const areEqual = ( return false; } + const micLockedEqual = prevMicLocked === nextMicLocked; + if (!micLockedEqual) { + return false; + } + + const isRecordingStateIdleEqual = prevIsRecordingStateIdle === nextIsRecordingStateIdle; + if (!isRecordingStateIdleEqual) { + return false; + } + + const recordingStatusEqual = prevRecordingStatus === nextRecordingStatus; + if (!recordingStatusEqual) { + return false; + } + return true; }; @@ -813,6 +728,12 @@ const MemoizedMessageInput = React.memo( export type MessageInputProps = Partial; +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + micLocked: state.micLocked, + isRecordingStateIdle: state.status === 'idle', + recordingStatus: state.status, +}); + /** * UI Component for message input * It's a consumer of @@ -829,6 +750,7 @@ export const MessageInput = (props: MessageInputProps) => { const { channel, members, threadList, watchers } = useChannelContext(); const { + audioRecorderManager, additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, @@ -860,7 +782,6 @@ export const MessageInput = (props: MessageInputProps) => { Input, inputBoxRef, InputButtons, - CommandInput, messageInputFloating, messageInputHeightStore, openPollCreationDialog, @@ -884,6 +805,11 @@ export const MessageInput = (props: MessageInputProps) => { const { attachments } = useAttachmentManagerState(); const isKeyboardVisible = useKeyboardVisibility(); + const { micLocked, isRecordingStateIdle, recordingStatus } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); + const { t } = useTranslationContext(); /** @@ -897,6 +823,10 @@ export const MessageInput = (props: MessageInputProps) => { return ( { clearEditingState, closeAttachmentPicker, closePollCreationDialog, - CommandInput, compressImageQuality, cooldownEndsAt, CooldownTimer, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx index 3ede593285..3096c593d2 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx @@ -21,6 +21,7 @@ export const AttachmentRemoveControl = ({ onPress }: AttachmentRemoveControlProp return ( [ styles.dismiss, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index 1313d6ecaf..26a42d4a55 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -11,6 +11,7 @@ import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressInd import { AudioAttachment } from '../../../../components/Attachment/AudioAttachment'; import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; @@ -24,6 +25,7 @@ export const AudioAttachmentUploadPreview = ({ handleRetry, removeAttachments, }: AudioAttachmentUploadPreviewProps) => { + const styles = useStyles(); const { enableOfflineSupport } = useChatContext(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, @@ -56,18 +58,18 @@ export const AudioAttachmentUploadPreview = ({ }, [attachment, removeAttachments]); return ( - + @@ -80,15 +82,22 @@ export const AudioAttachmentUploadPreview = ({ ); }; -const styles = StyleSheet.create({ - dismissWrapper: { - position: 'absolute', - right: 0, - top: 0, - }, - overlay: { - borderRadius: 12, - marginHorizontal: 8, - marginTop: 2, - }, -}); +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + overlay: { + borderRadius: primitives.radiusLg, + }, + wrapper: { + padding: primitives.spacingXxs, + }, + }), + [], + ); +}; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 91c2c6a0b4..72034890cb 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -117,6 +117,7 @@ const useStyles = () => { StyleSheet.create({ dismissWrapper: { position: 'absolute', right: 0, top: 0 }, fileContainer: { + alignItems: 'center', borderRadius: primitives.radiusLg, borderColor: borderCoreDefault, borderWidth: 1, @@ -125,23 +126,27 @@ const useStyles = () => { width: 224, // TODO: Not sure how to omit this padding: primitives.spacingMd, }, - fileContent: { - flexShrink: 1, - justifyContent: 'space-between', - }, fileIcon: { alignItems: 'center', alignSelf: 'center', justifyContent: 'center', }, + fileContent: { + flexShrink: 1, + justifyContent: 'space-between', + }, filenameText: { color: textPrimary, fontSize: primitives.typographyFontSizeXs, fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightTight, }, fileSizeText: { color: textSecondary, fontSize: primitives.typographyFontSizeXs, + lineHeight: primitives.typographyLineHeightTight, + fontWeight: primitives.typographyFontWeightRegular, + paddingTop: primitives.spacingXxs, }, overlay: { borderRadius: primitives.radiusLg, diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx index fd24d0b86f..1ddcefd811 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx @@ -1,58 +1,42 @@ -import React from 'react'; -import { Pressable, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; +import React, { useMemo } from 'react'; +import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; import dayjs from 'dayjs'; +import { IconButton } from '../../../../components/ui'; import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; -import { ArrowLeft, CircleStop, Delete, Mic, SendCheck } from '../../../../icons'; +import { useStateStore } from '../../../../hooks/useStateStore'; -import { AudioRecordingReturnType } from '../../../../native'; +import { NewChevronLeft } from '../../../../icons/NewChevronLeft'; +import { NewMic } from '../../../../icons/NewMic'; +import { NewStop } from '../../../../icons/NewStop'; +import { NewTick } from '../../../../icons/NewTick'; +import { NewTrash } from '../../../../icons/NewTrash'; +import { NativeHandlers } from '../../../../native'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; type AudioRecorderPropsWithContext = Pick< MessageInputContextValue, - 'asyncMessagesMultiSendEnabled' -> & { - /** - * Function to stop and delete the voice recording. - */ - deleteVoiceRecording: () => Promise; - /** - * Boolean used to show if the voice recording state is locked. This makes sure the mic button shouldn't be pressed any longer. - * When the mic is locked the `AudioRecordingInProgress` component shows up. - */ - micLocked: boolean; - /** - * The current voice recording that is in progress. - */ - recording: AudioRecordingReturnType; - /** - * Boolean to determine if the recording has been stopped. - */ - recordingStopped: boolean; - /** - * Function to stop the ongoing voice recording. - */ - stopVoiceRecording: () => Promise; - /** - * Function to upload the voice recording. - */ - uploadVoiceRecording: (multiSendEnabled: boolean) => Promise; - /** - * The duration of the voice recording. - */ - recordingDuration?: number; - /** - * Style used in slide to cancel container. - */ - slideToCancelStyle?: StyleProp; -}; + | 'audioRecorderManager' + | 'asyncMessagesMultiSendEnabled' + | 'stopVoiceRecording' + | 'deleteVoiceRecording' + | 'uploadVoiceRecording' +> & + Pick & { + /** + * Style used in slide to cancel container. + */ + slideToCancelStyle?: StyleProp; + }; const StopRecording = ({ stopVoiceRecordingHandler, @@ -60,20 +44,23 @@ const StopRecording = ({ stopVoiceRecordingHandler: () => Promise; }) => { const { - theme: { - colors: { accent_red }, - messageInput: { - audioRecorder: { circleStopIcon, pausedContainer }, - }, - }, + theme: { semantics }, } = useTheme(); + + const onStopVoiceRecording = () => { + NativeHandlers.triggerHaptic('impactMedium'); + stopVoiceRecordingHandler(); + }; + return ( - - - + ); }; @@ -84,23 +71,19 @@ const UploadRecording = ({ asyncMessagesMultiSendEnabled: boolean; uploadVoiceRecordingHandler: (multiSendEnabled: boolean) => Promise; }) => { - const { - theme: { - colors: { accent_blue }, - messageInput: { - audioRecorder: { checkContainer, sendCheckIcon }, - }, - }, - } = useTheme(); + const onUploadVoiceRecording = () => { + NativeHandlers.triggerHaptic('impactMedium'); + uploadVoiceRecordingHandler(asyncMessagesMultiSendEnabled); + }; + return ( - { - await uploadVoiceRecordingHandler(asyncMessagesMultiSendEnabled); - }} - style={[styles.checkContainer, checkContainer]} - > - - + ); }; @@ -109,191 +92,185 @@ const DeleteRecording = ({ }: { deleteVoiceRecordingHandler: () => Promise; }) => { - const { - theme: { - colors: { accent_blue }, - messageInput: { - audioRecorder: { deleteContainer, deleteIcon }, - }, - }, - } = useTheme(); + const onDeleteVoiceRecording = () => { + NativeHandlers.triggerHaptic('impactMedium'); + deleteVoiceRecordingHandler(); + }; return ( - - - + ); }; const AudioRecorderWithContext = (props: AudioRecorderPropsWithContext) => { const { asyncMessagesMultiSendEnabled, - deleteVoiceRecording, - micLocked, - recordingDuration, - recordingStopped, slideToCancelStyle, + deleteVoiceRecording, stopVoiceRecording, uploadVoiceRecording, + micLocked, + status, + duration, } = props; const { t } = useTranslationContext(); + const recordingStopped = status === 'stopped'; const { theme: { - colors: { accent_red, grey_dark }, + semantics, messageInput: { audioRecorder: { arrowLeftIcon, micContainer, micIcon, slideToCancelContainer }, }, }, } = useTheme(); + const styles = useStyles(); if (micLocked) { if (recordingStopped) { return ( - <> + - + ); } else { return ( - <> - - - + + - + ); } } else { return ( <> - - - {recordingDuration ? dayjs.duration(recordingDuration).format('mm:ss') : null} + + + {duration ? dayjs.duration(duration).format('mm:ss') : '00:00'} - {t('Slide to Cancel')} - + + {t('Slide to Cancel')} + + ); } }; -const areEqual = ( - prevProps: AudioRecorderPropsWithContext, - nextProps: AudioRecorderPropsWithContext, -) => { - const { - asyncMessagesMultiSendEnabled: prevAsyncMessagesMultiSendEnabled, - micLocked: prevMicLocked, - recording: prevRecording, - recordingDuration: prevRecordingDuration, - recordingStopped: prevRecordingStopped, - } = prevProps; - const { - asyncMessagesMultiSendEnabled: nextAsyncMessagesMultiSendEnabled, - micLocked: nextMicLocked, - recording: nextRecording, - recordingDuration: nextRecordingDuration, - recordingStopped: nextRecordingStopped, - } = nextProps; - - const asyncMessagesMultiSendEnabledEqual = - prevAsyncMessagesMultiSendEnabled === nextAsyncMessagesMultiSendEnabled; - if (!asyncMessagesMultiSendEnabledEqual) { - return false; - } - - const micLockedEqual = prevMicLocked === nextMicLocked; - if (!micLockedEqual) { - return false; - } - - const recordingEqual = prevRecording === nextRecording; - if (!recordingEqual) { - return false; - } - - const recordingDurationEqual = prevRecordingDuration === nextRecordingDuration; - if (!recordingDurationEqual) { - return false; - } - - const recordingStoppedEqual = prevRecordingStopped === nextRecordingStopped; - if (!recordingStoppedEqual) { - return false; - } - - return true; -}; - const MemoizedAudioRecorder = React.memo( AudioRecorderWithContext, - areEqual, ) as typeof AudioRecorderWithContext; -export type AudioRecorderProps = Partial & - Pick< - AudioRecorderPropsWithContext, - | 'deleteVoiceRecording' - | 'micLocked' - | 'recording' - | 'recordingStopped' - | 'stopVoiceRecording' - | 'uploadVoiceRecording' - >; +export type AudioRecorderProps = Partial; + +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + duration: state.duration, + micLocked: state.micLocked, + status: state.status, +}); /** * Component to display the Recording UI in the Message Input. */ export const AudioRecorder = (props: AudioRecorderProps) => { - const { asyncMessagesMultiSendEnabled } = useMessageInputContext(); + const { + audioRecorderManager, + asyncMessagesMultiSendEnabled, + stopVoiceRecording, + deleteVoiceRecording, + uploadVoiceRecording, + } = useMessageInputContext(); + + const { micLocked, duration, status } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); return ( ); }; -const styles = StyleSheet.create({ - checkContainer: {}, - deleteContainer: {}, - durationLabel: { - fontSize: 14, - }, - micContainer: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'center', - }, - pausedContainer: {}, - slideToCancel: { - fontSize: 18, - }, - slideToCancelContainer: { - alignItems: 'center', - flexDirection: 'row', - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + padding: primitives.spacingXs, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + checkContainer: {}, + deleteContainer: {}, + durationLabel: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + color: semantics.textPrimary, + }, + micContainer: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + gap: primitives.spacingSm, + paddingHorizontal: primitives.spacingMd, + }, + pausedContainer: {}, + slideToCancel: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + color: semantics.textPrimary, + }, + slideToCancelContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + }, + }), + [semantics.textPrimary], + ); +}; AudioRecorder.displayName = 'AudioRecorder{messageInput}'; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 39782431dd..430dd868a3 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -1,21 +1,44 @@ import React from 'react'; -import { Alert, Linking } from 'react-native'; +import { Alert, Linking, StyleSheet } from 'react-native'; + +import { + Gesture, + GestureDetector, + PanGestureHandlerEventPayload, +} from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + SharedValue, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; import { IconButton } from '../../../../components/ui/IconButton'; +import { useActiveAudioPlayer } from '../../../../contexts/audioPlayerContext/AudioPlayerContext'; import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../../hooks/useStateStore'; import { NewMic } from '../../../../icons/NewMic'; -import { AudioRecordingReturnType, NativeHandlers } from '../../../../native'; +import { NativeHandlers } from '../../../../native'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; -export type AudioRecordingButtonProps = Partial< - Pick & { - /** - * The current voice recording that is in progress. - */ - recording: AudioRecordingReturnType; +export type AudioRecordingButtonPropsWithContext = Pick< + MessageInputContextValue, + | 'asyncMessagesMinimumPressDuration' + | 'asyncMessagesSlideToCancelDistance' + | 'asyncMessagesLockDistance' + | 'asyncMessagesMultiSendEnabled' + | 'audioRecorderManager' + | 'startVoiceRecording' + | 'deleteVoiceRecording' + | 'uploadVoiceRecording' +> & + Pick & { /** * Size of the mic button. */ @@ -28,36 +51,41 @@ export type AudioRecordingButtonProps = Partial< * Handler to determine what should happen on press of the mic button. */ handlePress?: () => void; - /** - * Boolean to determine if the audio recording permissions are granted. - */ - permissionsGranted?: boolean; - /** - * Function to start the voice recording. - */ - startVoiceRecording?: () => Promise; - } ->; + micPositionX: SharedValue; + micPositionY: SharedValue; + }; /** * Component to display the mic button on the Message Input. */ -export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { +export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonPropsWithContext) => { const { - asyncMessagesMinimumPressDuration: propAsyncMessagesMinimumPressDuration, + audioRecorderManager, + asyncMessagesMinimumPressDuration, + asyncMessagesSlideToCancelDistance, + asyncMessagesLockDistance, + asyncMessagesMultiSendEnabled, + startVoiceRecording, + deleteVoiceRecording, + uploadVoiceRecording, handleLongPress, handlePress, + micPositionX, + micPositionY, permissionsGranted, + duration: recordingDuration, + status, recording, - startVoiceRecording, } = props; - const { asyncMessagesMinimumPressDuration: contextAsyncMessagesMinimumPressDuration } = - useMessageInputContext(); - - const asyncMessagesMinimumPressDuration = - propAsyncMessagesMinimumPressDuration || contextAsyncMessagesMinimumPressDuration; + const activeAudioPlayer = useActiveAudioPlayer(); + const scale = useSharedValue(1); const { t } = useTranslationContext(); + const { + theme: { + messageInput: { micButtonContainer }, + }, + } = useTheme(); const onPressHandler = () => { if (handlePress) { @@ -69,42 +97,189 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { } }; - const onLongPressHandler = () => { + const onLongPressHandler = async () => { if (handleLongPress) { handleLongPress(); return; } - if (!recording) { - NativeHandlers.triggerHaptic('impactHeavy'); - if (!permissionsGranted) { - Alert.alert(t('Please allow Audio permissions in settings.'), '', [ - { - onPress: () => { - Linking.openSettings(); - }, - text: t('Open Settings'), + if (recording) return; + NativeHandlers.triggerHaptic('impactHeavy'); + if (!permissionsGranted) { + Alert.alert(t('Please allow Audio permissions in settings.'), '', [ + { + onPress: () => { + Linking.openSettings(); }, - ]); - return; - } - if (startVoiceRecording) { - startVoiceRecording(); + text: t('Open Settings'), + }, + ]); + return; + } + if (startVoiceRecording) { + if (activeAudioPlayer?.isPlaying) { + await activeAudioPlayer?.pause(); } + await startVoiceRecording(); } }; + const X_AXIS_POSITION = -asyncMessagesSlideToCancelDistance; + const Y_AXIS_POSITION = -asyncMessagesLockDistance; + + const micUnlockHandler = () => { + audioRecorderManager.micLocked = false; + }; + + const micLockHandler = (value: boolean) => { + audioRecorderManager.micLocked = value; + }; + + const resetAudioRecording = async () => { + NativeHandlers.triggerHaptic('notificationSuccess'); + await deleteVoiceRecording(); + }; + + const onEarlyReleaseHandler = () => { + NativeHandlers.triggerHaptic('notificationError'); + resetAudioRecording(); + }; + + const tapGesture = Gesture.Tap() + .onBegin(() => { + scale.value = withSpring(0.8, { mass: 0.5 }); + }) + .onEnd(() => { + scale.value = withSpring(1, { mass: 0.5 }); + }); + + const panGesture = Gesture.Pan() + .activateAfterLongPress(asyncMessagesMinimumPressDuration + 100) + .onChange((event: PanGestureHandlerEventPayload) => { + const newPositionX = event.translationX; + const newPositionY = event.translationY; + + if (newPositionX <= 0 && newPositionX >= X_AXIS_POSITION) { + micPositionX.value = newPositionX; + } + if (newPositionY <= 0 && newPositionY >= Y_AXIS_POSITION) { + micPositionY.value = newPositionY; + } + }) + .onStart(() => { + micPositionX.value = 0; + micPositionY.value = 0; + runOnJS(micUnlockHandler)(); + }) + .onEnd(() => { + const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2; + const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2; + + if (belowThresholdY && belowThresholdX) { + micPositionY.value = withSpring(0); + micPositionX.value = withSpring(0); + if (status === 'recording') { + if (recordingDuration < 300) { + runOnJS(onEarlyReleaseHandler)(); + } else { + runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); + } + } + return; + } + + if (!belowThresholdY) { + micPositionY.value = withSpring(Y_AXIS_POSITION); + runOnJS(micLockHandler)(true); + } + + if (!belowThresholdX) { + micPositionX.value = withSpring(X_AXIS_POSITION); + runOnJS(resetAudioRecording)(); + } + + micPositionX.value = 0; + micPositionY.value = 0; + }); + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ scale: scale.value }], + }; + }); + + return ( + + + + + + ); +}; + +export type AudioRecordingButtonProps = Partial & { + micPositionX: SharedValue; + micPositionY: SharedValue; +}; + +const MemoizedAudioRecordingButton = React.memo( + AudioRecordingButtonWithContext, +) as typeof AudioRecordingButtonWithContext; + +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + duration: state.duration, + permissionsGranted: state.permissionsGranted, + recording: state.recording, + status: state.status, +}); + +export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { + const { + audioRecorderManager, + asyncMessagesMinimumPressDuration, + asyncMessagesSlideToCancelDistance, + asyncMessagesLockDistance, + asyncMessagesMultiSendEnabled, + startVoiceRecording, + deleteVoiceRecording, + uploadVoiceRecording, + } = useMessageInputContext(); + + const { duration, status, permissionsGranted, recording } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); return ( - ); }; AudioRecordingButton.displayName = 'AudioRecordingButton{messageInput}'; + +const styles = StyleSheet.create({ + container: {}, +}); diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx index d83d4f022d..0cabba7d7e 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; @@ -8,36 +8,30 @@ import { useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { NewMic } from '../../../../icons/NewMic'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; type AudioRecordingInProgressPropsWithContext = Pick< MessageInputContextValue, - 'AudioRecordingWaveform' -> & { - /** - * The waveform data to be presented to show the audio levels. - */ - waveformData: number[]; - /** - * Maximum number of waveform lines that should be rendered in the UI. - */ - maxDataPointsDrawn?: number; - /** - * The duration of the voice recording. - */ - recordingDuration?: number; -}; + 'audioRecorderManager' | 'AudioRecordingWaveform' +> & + Pick & { + /** + * Maximum number of waveform lines that should be rendered in the UI. + */ + maxDataPointsDrawn?: number; + }; const AudioRecordingInProgressWithContext = (props: AudioRecordingInProgressPropsWithContext) => { - const { - AudioRecordingWaveform, - maxDataPointsDrawn = 80, - recordingDuration, - waveformData, - } = props; + const { AudioRecordingWaveform, maxDataPointsDrawn = 60, duration, waveformData } = props; + + const styles = useStyles(); const { theme: { - colors: { grey_dark }, + semantics, messageInput: { audioRecordingInProgress: { container, durationText }, }, @@ -47,59 +41,78 @@ const AudioRecordingInProgressWithContext = (props: AudioRecordingInProgressProp return ( {/* `durationMillis` is for Expo apps, `currentPosition` is for Native CLI apps. */} - - {recordingDuration ? dayjs.duration(recordingDuration).format('mm:ss') : null} - + + + + {duration ? dayjs.duration(duration).format('mm:ss') : null} + + + + {/* TODO: Calculate the maxDataPointsDrawn based on the width of the container */} ); }; -const areEqual = ( - prevProps: AudioRecordingInProgressPropsWithContext, - nextProps: AudioRecordingInProgressPropsWithContext, -) => { - const { recordingDuration: prevRecordingDuration } = prevProps; - const { recordingDuration: nextRecordingDuration } = nextProps; - - const recordingDurationEqual = prevRecordingDuration === nextRecordingDuration; - - if (!recordingDurationEqual) { - return false; - } - - return true; -}; - const MemoizedAudioRecordingInProgress = React.memo( AudioRecordingInProgressWithContext, - areEqual, ) as typeof AudioRecordingInProgressWithContext; -export type AudioRecordingInProgressProps = Partial & { - waveformData: number[]; -}; +export type AudioRecordingInProgressProps = Partial; + +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + duration: state.duration, + waveformData: state.waveformData, +}); /** * Component displayed when the audio is in the recording state. */ export const AudioRecordingInProgress = (props: AudioRecordingInProgressProps) => { - const { AudioRecordingWaveform } = useMessageInputContext(); + const { audioRecorderManager, AudioRecordingWaveform } = useMessageInputContext(); + + const { duration, waveformData } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); - return ; + return ( + + ); }; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingBottom: 8, - paddingTop: 4, - }, - durationText: { - fontSize: 16, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: primitives.spacingSm, + paddingLeft: primitives.spacingSm, + paddingRight: primitives.spacingMd, + }, + durationText: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + color: semantics.textPrimary, + }, + micContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingSm, + }, + }), + [semantics.textPrimary], + ); +}; AudioRecordingInProgress.displayName = 'AudioRecordingInProgress{messageInput}'; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx index 368d48e9e5..2e30cfd060 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx @@ -1,17 +1,16 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; -import Animated from 'react-native-reanimated'; +import Animated, { LinearTransition } from 'react-native-reanimated'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; -import { ArrowUp, Lock } from '../../../../icons'; +import { NewChevronLeft } from '../../../../icons/NewChevronTop'; +import { NewLock } from '../../../../icons/NewLock'; +import { NewUnlock } from '../../../../icons/NewUnlock'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; -export type AudioRecordingLockIndicatorProps = { - /** - * Boolean used to show if the voice recording state is locked. This makes sure the mic button shouldn't be pressed any longer. - * When the mic is locked the `AudioRecordingInProgress` component shows up. - */ - micLocked: boolean; +export type AudioRecordingLockIndicatorProps = Pick & { /** * Height of the message input, to apply necessary position adjustments to this component. */ @@ -32,6 +31,7 @@ export const AudioRecordingLockIndicator = ({ }: AudioRecordingLockIndicatorProps) => { const [visible, setVisible] = useState(true); const timeoutRef = useRef(undefined); + const styles = useStyles(); useEffect(() => { timeoutRef.current = setTimeout(() => { @@ -47,7 +47,7 @@ export const AudioRecordingLockIndicator = ({ const { theme: { - colors: { accent_blue, grey, light_gray }, + semantics, messageInput: { audioRecordingLockIndicator: { arrowUpIcon, container, lockIcon }, }, @@ -60,25 +60,51 @@ export const AudioRecordingLockIndicator = ({ return ( - - {!micLocked && } + {micLocked ? ( + + ) : ( + + )} + {!micLocked && ( + + )} ); }; -const styles = StyleSheet.create({ - container: { - borderRadius: 50, - margin: 5, - padding: 8, - position: 'absolute', - right: 0, - }, -}); +const useStyles = () => { + const { + theme: { + colors: { white }, + semantics, + }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + backgroundColor: white, + borderColor: semantics.borderCoreDefault, + borderWidth: 1, + borderRadius: primitives.radiusMax, + padding: primitives.spacingXs, + gap: primitives.spacingXxs, + + // Replace shadow styles with theme-based tokens when available + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, + }, + }), + [white, semantics.borderCoreDefault], + ); +}; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx index 4bea5b3941..6dc63f7fb5 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx @@ -3,27 +3,22 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; +import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useAudioPlayer } from '../../../../hooks/useAudioPlayer'; import { useStateStore } from '../../../../hooks/useStateStore'; -import { Pause, Play } from '../../../../icons'; +import { NewPause } from '../../../../icons/NewPause'; +import { NewPlay } from '../../../../icons/NewPlay'; import { NativeHandlers } from '../../../../native'; import { AudioPlayerState } from '../../../../state-store/audio-player'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; import { WaveProgressBar } from '../../../ProgressControl/WaveProgressBar'; const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_HOUR_IN_MILLISECONDS = 3600 * 1000; -export type AudioRecordingPreviewProps = { - recordingDuration: number; - uri: string; - /** - * The waveform data to be presented to show the audio levels. - */ - waveformData: number[]; -}; - const audioPlayerSelector = (state: AudioPlayerState) => ({ duration: state.duration, isPlaying: state.isPlaying, @@ -31,11 +26,27 @@ const audioPlayerSelector = (state: AudioPlayerState) => ({ progress: state.progress, }); +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + duration: state.duration, + waveformData: state.waveformData, + recording: state.recording, +}); + /** * Component displayed when the audio is recorded and can be previewed. */ -export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { - const { recordingDuration, uri, waveformData } = props; +export const AudioRecordingPreview = () => { + const { audioRecorderManager } = useMessageInputContext(); + const styles = useStyles(); + + const { + duration: recordingDuration, + waveformData, + recording, + } = useStateStore(audioRecorderManager.state, audioRecorderSelector); + + const uri = + typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string); const audioPlayer = useAudioPlayer({ duration: recordingDuration / ONE_SECOND_IN_MILLISECONDS, @@ -61,7 +72,7 @@ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { const { theme: { - colors: { accent_blue, grey_dark }, + semantics, messageInput: { audioRecordingPreview: { container, @@ -92,47 +103,58 @@ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { return ( - + {!isPlaying ? ( - + ) : ( - + )} {/* `durationMillis` is for Expo apps, `currentPosition` is for Native CLI apps. */} - + {progressDuration} {/* Since the progress is in range 0-1 we convert it in terms of 100% */} - + ); }; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - display: 'flex', - flexDirection: 'row', - paddingBottom: 8, - paddingTop: 4, - }, - currentTime: { - fontSize: 16, - marginLeft: 4, - }, - infoContainer: { - alignItems: 'center', - display: 'flex', - flex: 1, - flexDirection: 'row', - }, - progressBar: { - flex: 3, - }, -}); +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: primitives.spacingSm, + paddingLeft: primitives.spacingSm, + paddingRight: primitives.spacingMd, + }, + durationText: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + infoContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingSm, + }, + progressBar: {}, + }), + [], + ); +}; AudioRecordingPreview.displayName = 'AudioRecordingPreview{messageInput}'; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx index f0691ac1dd..aec5ceab64 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx @@ -1,27 +1,27 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; -export type AudioRecordingWaveformProps = { +export type AudioRecordingWaveformProps = Pick & { /** * Maximum number of waveform lines that should be rendered in the UI. */ maxDataPointsDrawn: number; - /** - * The waveform data to be presented to show the audio levels. - */ - waveformData: number[]; }; +const WAVEFORM_MAX_HEIGHT = 20; + /** * Waveform Component displayed when the audio is in the recording state. */ export const AudioRecordingWaveform = (props: AudioRecordingWaveformProps) => { const { maxDataPointsDrawn, waveformData } = props; + const styles = useStyles(); const { theme: { - colors: { grey_dark }, messageInput: { audioRecordingWaveform: { container, waveform: waveformTheme }, }, @@ -35,8 +35,7 @@ export const AudioRecordingWaveform = (props: AudioRecordingWaveformProps) => { style={[ styles.waveform, { - backgroundColor: grey_dark, - height: waveform * 30 > 3 ? waveform * 30 : 3, + height: waveform * WAVEFORM_MAX_HEIGHT, }, waveformTheme, ]} @@ -46,17 +45,29 @@ export const AudioRecordingWaveform = (props: AudioRecordingWaveformProps) => { ); }; -const styles = StyleSheet.create({ - container: { - alignSelf: 'center', - flexDirection: 'row', - }, - waveform: { - alignSelf: 'center', - borderRadius: 2, - marginHorizontal: 1, - width: 2, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignSelf: 'center', + flexDirection: 'row', + }, + waveform: { + alignSelf: 'center', + borderRadius: primitives.radiusXxs, + marginHorizontal: 1, + width: 2, + minHeight: 2, + maxHeight: WAVEFORM_MAX_HEIGHT, + backgroundColor: semantics.chatWaveformBar, + }, + }), + [semantics.chatWaveformBar], + ); +}; AudioRecordingWaveform.displayName = 'AudioRecordingWaveform{messageInput}'; diff --git a/package/src/components/MessageInput/components/CommandInput.tsx b/package/src/components/MessageInput/components/CommandInput.tsx deleted file mode 100644 index f8c6c58f8b..0000000000 --- a/package/src/components/MessageInput/components/CommandInput.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { TextComposerState } from 'stream-chat'; - -import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../contexts/themeContext/ThemeContext'; - -import { useStateStore } from '../../../hooks/useStateStore'; -import { CircleClose, GiphyLightning } from '../../../icons'; - -import { AutoCompleteInput } from '../../AutoCompleteInput/AutoCompleteInput'; -import { useCountdown } from '../hooks/useCountdown'; - -export type CommandInputProps = Partial< - Pick -> & { - disabled: boolean; -}; - -const textComposerStateSelector = (state: TextComposerState) => ({ - command: state.command, -}); - -export const CommandInput = ({ - cooldownEndsAt: propCooldownEndsAt, - disabled, -}: CommandInputProps) => { - const { cooldownEndsAt: contextCooldownEndsAt } = useMessageInputContext(); - const messageComposer = useMessageComposer(); - const { textComposer } = messageComposer; - const { command } = useStateStore(textComposer.state, textComposerStateSelector); - - const cooldownEndsAt = propCooldownEndsAt || contextCooldownEndsAt; - - const { seconds: cooldownRemainingSeconds } = useCountdown(cooldownEndsAt); - - const { - theme: { - colors: { accent_blue, grey, white }, - messageInput: { - commandInput: { closeButton, container, text }, - inputContainer, - }, - }, - } = useTheme(); - - const onCloseHandler = () => { - textComposer.clearCommand(); - messageComposer?.restore(); - }; - - if (!command) { - return null; - } - - const commandName = (command.name ?? '').toUpperCase(); - - return ( - - - - {commandName} - - - - { - return [ - { - opacity: pressed ? 0.8 : 1, - }, - closeButton, - ]; - }} - testID='close-button' - > - - - - ); -}; - -CommandInput.displayName = 'CommandInput{messageInput}'; - -const styles = StyleSheet.create({ - giphyContainer: { - alignItems: 'center', - borderRadius: 12, - flexDirection: 'row', - marginRight: 8, - paddingHorizontal: 8, - paddingVertical: 4, - }, - giphyText: { - fontSize: 12, - fontWeight: 'bold', - }, - inputContainer: { - alignItems: 'center', - flexDirection: 'row', - paddingLeft: 8, - paddingRight: 10, - }, -}); diff --git a/package/src/components/MessageInput/components/InputButtons/index.tsx b/package/src/components/MessageInput/components/InputButtons/index.tsx index 4330aac3ef..9cf9a4a321 100644 --- a/package/src/components/MessageInput/components/InputButtons/index.tsx +++ b/package/src/components/MessageInput/components/InputButtons/index.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; + +import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; import { AttachmentPickerContextValue, @@ -51,9 +53,13 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => } return hasAttachmentUploadCapabilities ? ( - + - + ) : null; }; diff --git a/package/src/components/MessageInput/components/OutputButtons/index.tsx b/package/src/components/MessageInput/components/OutputButtons/index.tsx index dda8a74d56..58c9ece242 100644 --- a/package/src/components/MessageInput/components/OutputButtons/index.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/index.tsx @@ -22,10 +22,12 @@ import { } 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 OutputButtonsProps = Partial & { + micPositionX: Animated.SharedValue; + micPositionY: Animated.SharedValue; +}; export type OutputButtonsWithContextProps = Pick & Pick & @@ -41,7 +43,10 @@ export type OutputButtonsWithContextProps = Pick & | 'SendButton' | 'StopMessageStreamingButton' | 'StartAudioRecordingButton' - >; + > & { + micPositionX: Animated.SharedValue; + micPositionY: Animated.SharedValue; + }; const textComposerStateSelector = (state: TextComposerState) => ({ command: state.command, @@ -56,6 +61,9 @@ export const OutputButtonsWithContext = (props: OutputButtonsWithContextProps) = isOnline, SendButton, StopMessageStreamingButton, + StartAudioRecordingButton, + micPositionX, + micPositionY, } = props; const { theme: { @@ -88,7 +96,7 @@ export const OutputButtonsWithContext = (props: OutputButtonsWithContextProps) = return ; } - if (editing) { + if (editing || command) { return ( - + ); }; diff --git a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx index 86ca1c4866..2c53f82c47 100644 --- a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx +++ b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx @@ -1,47 +1,45 @@ -import { useEffect, useState } from 'react'; - -import { Alert, Platform } from 'react-native'; +import { useCallback, useEffect, useState } from 'react'; 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 { MessageInputContextValue } from '../../../contexts/messageInputContext/MessageInputContext'; 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([]); +export const useAudioRecorder = ({ + audioRecorderManager, + sendMessage, +}: Pick) => { 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(); + /** + * 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 stopVoiceRecording = useCallback(async () => { + const { status } = audioRecorderManager.state.getLatestValue(); + if (status !== 'recording') return; + await audioRecorderManager.stopRecording(); + }, [audioRecorderManager]); // This effect stop the player from playing and stops audio recording on // the audio SDK side on unmount. useEffect( () => () => { - stopSDKVoiceRecording(); + stopVoiceRecording(); }, - [], + [stopVoiceRecording], ); useEffect(() => { @@ -51,155 +49,85 @@ export const useAudioRecorder = () => { } }, [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([]); - }; + const startVoiceRecording = useCallback(async () => { + await audioRecorderManager.startRecording(); + }, [audioRecorderManager]); /** * Function to delete voice recording. */ - const deleteVoiceRecording = async () => { - if (recordingStatus === 'recording') { - await stopVoiceRecording(); - } - resetState(); - NativeHandlers.triggerHaptic('impactMedium'); - }; + const deleteVoiceRecording = useCallback(async () => { + await stopVoiceRecording(); + audioRecorderManager.reset(); + }, [audioRecorderManager, stopVoiceRecording]); /** * 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(); - }; + const uploadVoiceRecording = useCallback( + async (multiSendEnabled: boolean) => { + try { + const { recording, duration, waveformData } = audioRecorderManager.state.getLatestValue(); + await stopVoiceRecording(); + + const durationInSeconds = parseFloat((duration / 1000).toFixed(3)); + + const resampledWaveformData = + waveformData.length > 100 ? resampleWaveformData(waveformData, 100) : waveformData; + + 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, + }; + + audioRecorderManager.reset(); + + 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); + } + } catch (error) { + console.log('Error uploading voice recording: ', error); + } + }, + [audioRecorderManager, attachmentManager, stopVoiceRecording], + ); return { deleteVoiceRecording, - micLocked, - permissionsGranted, - recording, - recordingDuration, - recordingStatus, - setMicLocked, startVoiceRecording, stopVoiceRecording, uploadVoiceRecording, - waveformData, }; }; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 968c416894..5a0d646e93 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -1079,7 +1079,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => layout={LinearTransition.duration(200)} style={[ styles.scrollToBottomButtonContainer, - { bottom: messageInputFloating ? messageInputHeight + 8 : 8 }, + { bottom: messageInputFloating ? messageInputHeight : 16 }, scrollToBottomButtonContainer, ]} > @@ -1234,7 +1234,6 @@ const styles = StyleSheet.create({ width: '100%', }, scrollToBottomButtonContainer: { - bottom: 8, position: 'absolute', right: 16, }, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 5650861248..b614133134 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -83,7 +83,6 @@ const styles = StyleSheet.create({ width: '100%', }, scrollToBottomButtonContainer: { - bottom: 8, position: 'absolute', right: 16, }, @@ -1192,7 +1191,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { layout={LinearTransition.duration(200)} style={[ styles.scrollToBottomButtonContainer, - { bottom: messageInputFloating ? messageInputHeight + 8 : 8 }, + { bottom: messageInputFloating ? messageInputHeight : 16 }, scrollToBottomButtonContainer, ]} > diff --git a/package/src/components/ProgressControl/ProgressControl.tsx b/package/src/components/ProgressControl/ProgressControl.tsx index 0c7ee0c49c..270ba09fd0 100644 --- a/package/src/components/ProgressControl/ProgressControl.tsx +++ b/package/src/components/ProgressControl/ProgressControl.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { ColorValue, StyleSheet, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, @@ -14,7 +14,7 @@ export type ProgressControlProps = { /** * The color of the filled progress bar */ - filledColor: string; + filledColor: ColorValue; /** * The progress of the progress bar in percentage */ diff --git a/package/src/components/ProgressControl/WaveProgressBar.tsx b/package/src/components/ProgressControl/WaveProgressBar.tsx index 3a509a9060..a6d21ad8b8 100644 --- a/package/src/components/ProgressControl/WaveProgressBar.tsx +++ b/package/src/components/ProgressControl/WaveProgressBar.tsx @@ -9,6 +9,7 @@ import Animated, { } from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; import { resampleWaveformData } from '../MessageInput/utils/audioSampling'; export type WaveProgressBarProps = { @@ -43,27 +44,15 @@ export type WaveProgressBarProps = { }; const WAVEFORM_WIDTH = 2; -const WAVE_MAX_HEIGHT = 25; -const WAVE_MIN_HEIGHT = 3; +const WAVE_MAX_HEIGHT = 20; +const WAVE_MIN_HEIGHT = 2; const ProgressControlThumb = ({ style }: { style?: StyleProp }) => { - const { - theme: { - colors: { black, grey_dark, static_white }, - }, - } = useTheme(); + const styles = useStyles(); return ( ); }; @@ -86,6 +75,8 @@ export const WaveProgressBar = React.memo( const state = useSharedValue(progress); const [currentWaveformProgress, setCurrentWaveformProgress] = useState(0); + const styles = useStyles(); + const waveFormNumberFromProgress = useCallback( (progress: number) => { 'worklet'; @@ -108,7 +99,7 @@ export const WaveProgressBar = React.memo( const { theme: { - colors: { accent_blue, grey_dark }, + semantics, waveProgressBar: { container, thumb, waveform: waveformTheme }, }, } = useTheme(); @@ -162,7 +153,9 @@ export const WaveProgressBar = React.memo( styles.waveform, { backgroundColor: - index < currentWaveformProgress ? filledColor || accent_blue : grey_dark, + index < currentWaveformProgress + ? filledColor || semantics.chatWaveformBarPlaying + : semantics.chatWaveformBar, height: waveform * WAVE_MAX_HEIGHT > WAVE_MIN_HEIGHT ? waveform * WAVE_MAX_HEIGHT @@ -193,30 +186,41 @@ export const WaveProgressBar = React.memo( }, ); -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - }, - progressControlThumbStyle: { - borderRadius: 5, - borderWidth: 0.2, - elevation: 6, - height: 28, - shadowOffset: { - height: 3, - width: 0, - }, - shadowOpacity: 0.27, - shadowRadius: 4.65, - width: WAVEFORM_WIDTH * 2, - }, - waveform: { - alignSelf: 'center', - borderRadius: 2, - marginRight: WAVEFORM_WIDTH, - width: WAVEFORM_WIDTH, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + }, + progressControlThumbStyle: { + backgroundColor: semantics.accentPrimary, + borderColor: semantics.borderCoreOnAccent, + height: 12, + width: 12, + borderRadius: primitives.radiusMax, + borderWidth: 2, + elevation: 6, + shadowOffset: { + height: 3, + width: 0, + }, + shadowOpacity: 0.27, + shadowRadius: 4.65, + }, + waveform: { + alignSelf: 'center', + borderRadius: primitives.radiusXxs, + marginRight: WAVEFORM_WIDTH, + width: WAVEFORM_WIDTH, + }, + }), + [semantics], + ); +}; WaveProgressBar.displayName = 'WaveProgressBar'; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 4d303ee7d4..e005bf8d90 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -17,10 +17,10 @@ import { NewFile } from '../../icons/NewFile'; import { NewLink } from '../../icons/NewLink'; import { NewMapPin } from '../../icons/NewMapPin'; import { NewMic } from '../../icons/NewMic'; -import { NewPencil } from '../../icons/NewPencil'; import { NewPhoto } from '../../icons/NewPhoto'; import { NewPoll } from '../../icons/NewPoll'; import { NewVideo } from '../../icons/NewVideo'; +import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { checkQuotedMessageEquality } from '../../utils/utils'; import { FileIcon } from '../Attachment/FileIcon'; @@ -38,6 +38,7 @@ const selector = (nextValue: PollState) => ({ const RightContent = React.memo((props: { message: LocalMessage }) => { const { message } = props; const attachments = message?.attachments; + const styles = useStyles(); if (!attachments || attachments.length > 1) { return null; @@ -47,14 +48,14 @@ const RightContent = React.memo((props: { message: LocalMessage }) => { if (attachment?.type === FileTypes.Image) { return ( - + ); } if (attachment?.type === FileTypes.Video) { return ( - + @@ -79,6 +80,7 @@ const SubtitleText = React.memo(({ message }: { message?: LocalMessage | null }) reply: { subtitle: subtitleStyle }, }, } = useTheme(); + const styles = useStyles(); const subtitle = useMemo(() => { const attachments = message?.attachments; @@ -175,9 +177,12 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { const { message } = props; const { theme: { + semantics, reply: { pollIcon, locationIcon, linkIcon, audioIcon, fileIcon, videoIcon, photoIcon }, }, } = useTheme(); + const styles = useStyles(); + if (!message) { return null; } @@ -201,7 +206,13 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { if (message.poll_id) { return ( - + ); } @@ -209,7 +220,7 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { return ( { if (hasLink) { return ( - + ); } @@ -227,7 +244,7 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { return ( { if (onlyVideos) { return ( - + ); } if (onlyImages) { return ( - + ); } @@ -255,7 +284,13 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { audioAttachments?.length ) { return ( - + ); } @@ -275,7 +310,6 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { const { isMyMessage, message: messageFromContext, mode, onDismiss, quotedMessage, style } = props; const { theme: { - colors: { grey_whisper }, reply: { wrapper, container, @@ -285,8 +319,10 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { subtitleContainer, dismissWrapper, }, + semantics, }, } = useTheme(); + const styles = useStyles(); const title = useMemo( () => @@ -294,7 +330,9 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { ? 'Edit Message' : isMyMessage ? 'You' - : `Reply to ${quotedMessage?.user?.name}`, + : quotedMessage?.user?.name + ? `Reply to ${quotedMessage?.user?.name}` + : 'Reply', [mode, isMyMessage, quotedMessage?.user?.name], ); @@ -307,7 +345,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { { - {mode === 'edit' ? : null} {title} @@ -412,76 +453,76 @@ export const Reply = (props: ReplyProps) => { ); }; -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, - }, - titleContainer: { - alignItems: 'center', - flexDirection: 'row', - gap: 4, - }, - title: { - color: '#384047', - fontSize: 12, - fontWeight: 'bold', - includeFontPadding: false, - lineHeight: 16, - }, - wrapper: { - padding: 4, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + attachmentContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + container: { + borderRadius: primitives.radiusLg, + flexDirection: 'row', + padding: primitives.spacingXs, + }, + contentWrapper: { + borderRadius: primitives.radiusMd, + borderWidth: 1, + height: 40, + overflow: 'hidden', + width: 40, + }, + contentBorder: { + borderColor: semantics.borderCoreOpacity10, + }, + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + iconStyle: {}, + leftContainer: { + borderLeftWidth: 2, + flex: 1, + justifyContent: 'center', + paddingHorizontal: primitives.spacingXs, + }, + rightContainer: {}, + subtitle: { + color: semantics.textPrimary, + flexShrink: 1, + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightRegular, + includeFontPadding: false, + lineHeight: 16, + }, + subtitleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + paddingTop: primitives.spacingXxs, + }, + titleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + }, + title: { + color: semantics.textPrimary, + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightSemiBold, + includeFontPadding: false, + lineHeight: 16, + }, + wrapper: { + padding: primitives.spacingXxs, + }, + }), + [semantics], + ); +}; 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 dd198003b1..71f326a965 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1881,13 +1881,13 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { - "borderTopWidth": 1, "paddingHorizontal": 16, "paddingTop": 16, }, { - "backgroundColor": "#FFFFFF", + "backgroundColor": "#ffffff", "borderColor": "#d5dbe1", + "borderTopWidth": 1, "paddingBottom": 16, }, {}, @@ -1895,6 +1895,7 @@ exports[`Thread should match thread snapshot 1`] = ` } > - - - - - + > + + + + diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 898d0188b1..e69443a57d 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -133,7 +133,6 @@ export * from './MessageInput/components/AudioRecorder/AudioRecordingInProgress' export * from './MessageInput/components/AudioRecorder/AudioRecordingLockIndicator'; export * from './MessageInput/components/AudioRecorder/AudioRecordingPreview'; export * from './MessageInput/components/AudioRecorder/AudioRecordingWaveform'; -export * from './MessageInput/components/CommandInput'; export * from './MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator'; export * from './MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; diff --git a/package/src/components/ui/GiphyBadge.tsx b/package/src/components/ui/GiphyBadge.tsx new file mode 100644 index 0000000000..0b8b9e4ca2 --- /dev/null +++ b/package/src/components/ui/GiphyBadge.tsx @@ -0,0 +1,69 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View, Text, Pressable } from 'react-native'; + +import { TextComposerState } from 'stream-chat'; + +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; +import { NewCross } from '../../icons/NewCross'; +import { NewGiphy } from '../../icons/NewGiphy'; +import { primitives } from '../../theme'; + +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, +}); + +export const GiphyBadge = () => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + + const commandName = (command?.name ?? '').toUpperCase(); + + const onPressHandler = () => { + textComposer.clearCommand(); + messageComposer?.restore(); + }; + + return ( + + + {commandName} + + + + + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: semantics.badgeBgInverse, + borderRadius: primitives.radiusMax, + flexDirection: 'row', + paddingHorizontal: primitives.spacingXs, + paddingVertical: primitives.spacingXxs, + gap: primitives.spacingXxs, + }, + text: { + color: semantics.textInverse, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [semantics], + ); +}; diff --git a/package/src/components/ui/IconButton.tsx b/package/src/components/ui/IconButton.tsx index be00bc919f..dd6d9a6888 100644 --- a/package/src/components/ui/IconButton.tsx +++ b/package/src/components/ui/IconButton.tsx @@ -1,12 +1,20 @@ import React from 'react'; -import { Pressable, PressableProps, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { + ColorValue, + Pressable, + PressableProps, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { IconProps } from '../../icons/utils/base'; export type IconButtonProps = PressableProps & { Icon: React.FC | React.ReactNode; - iconColor?: string; + iconColor?: ColorValue; onPress?: () => void; size?: 'sm' | 'md' | 'lg'; status?: 'disabled' | 'pressed' | 'selected' | 'enabled'; @@ -77,8 +85,10 @@ export const IconButton = (props: IconButtonProps) => { ? selectedColor : pressed ? '#F5F6F7' - : getBackgroundColor({ status, type }), - borderColor: '#E2E6EA', + : category === 'outline' + ? 'none' + : getBackgroundColor({ status, type }), + borderColor: type === 'destructive' ? '#D92F26' : '#E2E6EA', borderWidth: category === 'outline' || category === 'filled' ? 1 : 0, }, style as StyleProp, diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 4c47757fcd..89a3220282 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -43,13 +43,12 @@ import type { AudioRecorderProps } from '../../components/MessageInput/component import type { AudioRecordingButtonProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingButton'; import type { AudioRecordingInProgressProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingInProgress'; import type { AudioRecordingLockIndicatorProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator'; -import type { AudioRecordingPreviewProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingPreview'; import type { AudioRecordingWaveformProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingWaveform'; -import type { CommandInputProps } from '../../components/MessageInput/components/CommandInput'; import type { 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 { useAudioRecorder } from '../../components/MessageInput/hooks/useAudioRecorder'; import { useCooldown } from '../../components/MessageInput/hooks/useCooldown'; import type { MessageInputProps } from '../../components/MessageInput/MessageInput'; import { useStableCallback } from '../../hooks/useStableCallback'; @@ -59,6 +58,7 @@ import { } from '../../middlewares/attachments'; import { isDocumentPickerAvailable, MediaTypes, NativeHandlers } from '../../native'; +import { AudioRecorderManager } from '../../state-store/audio-recorder-manager'; import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { File } from '../../types/types'; import { compressedImageURI } from '../../utils/compressImage'; @@ -100,6 +100,11 @@ export type LocalMessageInputContext = { takeAndUploadImage: (mediaType?: MediaTypes) => Promise; toggleAttachmentPicker: () => void; uploadNewFile: (file: File) => Promise; + audioRecorderManager: AudioRecorderManager; + startVoiceRecording: () => Promise; + deleteVoiceRecording: () => Promise; + uploadVoiceRecording: (multiSendEnabled: boolean) => Promise; + stopVoiceRecording: () => Promise; }; export type InputMessageInputContextValue = { @@ -161,7 +166,7 @@ export type InputMessageInputContextValue = { * **Default** * [AudioRecordingPreview](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx) */ - AudioRecordingPreview: React.ComponentType; + AudioRecordingPreview: React.ComponentType; /** * Custom UI component to render audio recording waveform. * @@ -272,7 +277,6 @@ export type InputMessageInputContextValue = { /** When false, ImageSelectorIcon will be hidden */ hasImagePicker: boolean; - CommandInput: React.ComponentType; /** * Custom UI component for send button. * @@ -405,6 +409,7 @@ export const MessageInputProvider = ({ useAttachmentPickerContext(); const { client } = useChatContext(); const channelCapabilities = useOwnCapabilitiesContext(); + const [audioRecorderManager] = useState(new AudioRecorderManager()); const { uploadAbortControllerRef } = useChannelContext(); const { clearEditingState } = useMessageComposerAPIContext(); @@ -669,6 +674,9 @@ export const MessageInputProvider = ({ defaultOpenPollCreationDialog(); }); + const { deleteVoiceRecording, startVoiceRecording, stopVoiceRecording, uploadVoiceRecording } = + useAudioRecorder({ audioRecorderManager, sendMessage }); + const messageInputContext = useCreateMessageInputContext({ closeAttachmentPicker, cooldownEndsAt, @@ -687,6 +695,11 @@ export const MessageInputProvider = ({ selectedPicker, sendMessage, // overriding the originally passed in sendMessage showPollCreationDialog, + audioRecorderManager, + startVoiceRecording, + deleteVoiceRecording, + uploadVoiceRecording, + stopVoiceRecording, }); return ( ) => { const threadId = thread?.id; @@ -102,7 +106,6 @@ export const useCreateMessageInputContext = ({ CameraSelectorIcon, closeAttachmentPicker, closePollCreationDialog, - CommandInput, compressImageQuality, cooldownEndsAt, CooldownTimer, @@ -142,6 +145,11 @@ export const useCreateMessageInputContext = ({ uploadNewFile, VideoAttachmentUploadPreview, VideoRecorderSelectorIcon, + audioRecorderManager, + startVoiceRecording, + deleteVoiceRecording, + uploadVoiceRecording, + stopVoiceRecording, }), // eslint-disable-next-line react-hooks/exhaustive-deps [cooldownEndsAt, threadId, showPollCreationDialog, selectedPicker], diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 6bf086b5b7..e9fbdc3219 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -317,11 +317,6 @@ export type Theme = { container: ViewStyle; waveform: ViewStyle; }; - commandInput: { - closeButton: ViewStyle; - container: ViewStyle; - text: TextStyle; - }; container: ViewStyle; contentContainer: ViewStyle; cooldownButtonContainer: ViewStyle; @@ -1122,11 +1117,6 @@ export const defaultTheme: Theme = { progressBar: {}, }, audioRecordingWaveform: { container: {}, waveform: {} }, - commandInput: { - closeButton: {}, - container: {}, - text: {}, - }, container: {}, contentContainer: {}, cooldownButtonContainer: {}, diff --git a/package/src/icons/NewChevronLeft.tsx b/package/src/icons/NewChevronLeft.tsx new file mode 100644 index 0000000000..badd824b18 --- /dev/null +++ b/package/src/icons/NewChevronLeft.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewChevronLeft = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewChevronTop.tsx b/package/src/icons/NewChevronTop.tsx new file mode 100644 index 0000000000..f02620efa0 --- /dev/null +++ b/package/src/icons/NewChevronTop.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewChevronLeft = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewCross.tsx b/package/src/icons/NewCross.tsx new file mode 100644 index 0000000000..9992b4811b --- /dev/null +++ b/package/src/icons/NewCross.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewCross = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewGiphy.tsx b/package/src/icons/NewGiphy.tsx new file mode 100644 index 0000000000..8eb44b4fad --- /dev/null +++ b/package/src/icons/NewGiphy.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewGiphy = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewLock.tsx b/package/src/icons/NewLock.tsx new file mode 100644 index 0000000000..b8c42033fa --- /dev/null +++ b/package/src/icons/NewLock.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewLock = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewPause.tsx b/package/src/icons/NewPause.tsx new file mode 100644 index 0000000000..a6e96e744a --- /dev/null +++ b/package/src/icons/NewPause.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPause = ({ height, width, ...rest }: IconProps) => ( + + + + +); diff --git a/package/src/icons/NewPlay.tsx b/package/src/icons/NewPlay.tsx new file mode 100644 index 0000000000..90fde4ed29 --- /dev/null +++ b/package/src/icons/NewPlay.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPlay = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewStop.tsx b/package/src/icons/NewStop.tsx new file mode 100644 index 0000000000..88f24f56c6 --- /dev/null +++ b/package/src/icons/NewStop.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewStop = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewTrash.tsx b/package/src/icons/NewTrash.tsx new file mode 100644 index 0000000000..b57bf1a7a1 --- /dev/null +++ b/package/src/icons/NewTrash.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewTrash = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewUnlock.tsx b/package/src/icons/NewUnlock.tsx new file mode 100644 index 0000000000..cfbd2492d8 --- /dev/null +++ b/package/src/icons/NewUnlock.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewUnlock = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/state-store/audio-recorder-manager.ts b/package/src/state-store/audio-recorder-manager.ts new file mode 100644 index 0000000000..cda1584900 --- /dev/null +++ b/package/src/state-store/audio-recorder-manager.ts @@ -0,0 +1,98 @@ +import { Alert, Platform } from 'react-native'; + +import { StateStore } from 'stream-chat'; + +import { normalizeAudioLevel } from '../components/MessageInput/utils/normalizeAudioLevel'; +import { AudioRecordingReturnType, NativeHandlers, RecordingStatus } from '../native'; + +export type RecordingStatusStates = 'idle' | 'recording' | 'stopped'; + +export type AudioRecorderManagerState = { + micLocked: boolean; + permissionsGranted: boolean; + recording: AudioRecordingReturnType; + waveformData: number[]; + duration: number; + status: RecordingStatusStates; +}; + +const INITIAL_STATE: AudioRecorderManagerState = { + micLocked: false, + permissionsGranted: true, + waveformData: [], + recording: undefined, + duration: 0, + status: 'idle', +}; + +export class AudioRecorderManager { + state: StateStore; + + constructor() { + this.state = new StateStore(INITIAL_STATE); + } + + reset() { + this.state.next(INITIAL_STATE); + } + + onRecordingStatusUpdate = (status: RecordingStatus) => { + if (status.isDoneRecording === true) { + return; + } + this.state.partialNext({ duration: 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, + ); + this.state.partialNext({ + waveformData: [...this.state.getLatestValue().waveformData, normalizedAudioLevel], + }); + }; + + async startRecording() { + if (!NativeHandlers.Audio) { + return; + } + this.state.partialNext({ + status: 'recording', + }); + const recordingInfo = await NativeHandlers.Audio.startRecording( + { + isMeteringEnabled: true, + }, + this.onRecordingStatusUpdate, + ); + const accessGranted = recordingInfo.accessGranted; + if (accessGranted) { + this.state.partialNext({ permissionsGranted: true }); + const recording = recordingInfo.recording; + if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) { + recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60); + } + this.state.partialNext({ recording }); + } else { + this.reset(); + this.state.partialNext({ permissionsGranted: false }); + Alert.alert('Please allow Audio permissions in settings.'); + } + } + + async stopRecording() { + if (!NativeHandlers.Audio) { + return; + } + await NativeHandlers.Audio.stopRecording(); + this.state.partialNext({ status: 'stopped' }); + } + + set micLocked(value: boolean) { + this.state.partialNext({ micLocked: value }); + } + + set status(value: RecordingStatusStates) { + this.state.partialNext({ status: value }); + } +} From 72ff661f31d55e3142520285ac0d3fb19323d45f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:13:07 +0100 Subject: [PATCH 40/76] feat: add link previews to message composer (#3376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR adds the missing link previews feature that we've been missing on the React Native SDK. It's essentially a way to prepopulate the message composer with a preview of the first link within the message we are composing, which is then going to be admitted as an attachment to the `message` we send. As this is an optional feature, it first needs to be enabled by doing: ``` composer.updateConfig({ linkPreviews: { enabled: true, } }); ``` when configuring the composer state. ## ๐Ÿ›  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 | 3 + examples/SampleApp/yarn.lock | 8 +- package/package.json | 2 +- .../components/MessageInput/MessageInput.tsx | 40 +++-- .../components/LinkPreviewList.tsx | 162 ++++++++++++++++++ .../MessageInput/hooks/useLinkPreviews.ts | 27 +++ .../__snapshots__/Thread.test.js.snap | 5 +- .../src/contexts/themeContext/utils/theme.ts | 24 +++ package/yarn.lock | 8 +- 9 files changed, 255 insertions(+), 24 deletions(-) create mode 100644 package/src/components/MessageInput/components/LinkPreviewList.tsx create mode 100644 package/src/components/MessageInput/hooks/useLinkPreviews.ts diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 4bdc24765c..60f518b389 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -196,6 +196,9 @@ const App = () => { drafts: { enabled: true, }, + linkPreviews: { + enabled: true, + } }); setupCommandUIMiddlewares(composer); diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 784af4a076..e365c6aa11 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -8349,10 +8349,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.27.2: - version "9.27.2" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968" - integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg== +stream-chat@^9.30.1: + version "9.30.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.30.1.tgz#86d152e4d0894854370512d17530854541f7990b" + integrity sha512-8f58tCo3QfgzaNhWHpRQzEfglSPPn4lGRn74FFTr/pn53dMJwtcKDSohV6NTHBrkYWTXYObRnHgh2IhGFUKckw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/package/package.json b/package/package.json index 57645fa6dc..75a84d3865 100644 --- a/package/package.json +++ b/package/package.json @@ -80,7 +80,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.27.2", + "stream-chat": "^9.30.1", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index cc62e6e60c..80636d8ca5 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -16,9 +16,12 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; +import { LinkPreviewList } from './components/LinkPreviewList'; import { OutputButtons } from './components/OutputButtons'; import { useCountdown } from './hooks/useCountdown'; +import { useHasLinkPreviews } from './hooks/useLinkPreviews'; + import { ChatContextValue, useAttachmentManagerState, @@ -269,6 +272,9 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); + + const hasLinkPreviews = useHasLinkPreviews(); + const { theme: { semantics, @@ -411,12 +417,10 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { {Input ? ( ) : ( - + {isRecordingStateIdle ? ( - { ]} > {InputButtons && } - + ) : null} - { ) : null} {isRecordingStateIdle ? ( - { ) : null} - + + ) : null} - + {!isRecordingStateIdle ? ( ) : ( @@ -499,10 +511,10 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { ) : null} - +
-
- + +
)} diff --git a/package/src/components/MessageInput/components/LinkPreviewList.tsx b/package/src/components/MessageInput/components/LinkPreviewList.tsx new file mode 100644 index 0000000000..61c9f784c9 --- /dev/null +++ b/package/src/components/MessageInput/components/LinkPreviewList.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useMemo } from 'react'; + +import { View, Text, StyleSheet } from 'react-native'; + +import Animated, { LinearTransition } from 'react-native-reanimated'; + +import type { LinkPreview } from 'stream-chat'; +import { LinkPreviewsManager } from 'stream-chat'; + +import { AttachmentRemoveControl } from './AttachmentPreview/AttachmentRemoveControl'; + +import { useChatContext, useMessageComposer, useTheme } from '../../../contexts'; +import { NewLink } from '../../../icons/NewLink'; +import { components, primitives } from '../../../theme'; +import { useLinkPreviews } from '../hooks/useLinkPreviews'; + +export type LinkPreviewListProps = { + displayLinkCount?: number; +}; + +export const LinkPreviewList = ({ displayLinkCount = 1 }: LinkPreviewListProps) => { + const linkPreviews = useLinkPreviews(); + + if (linkPreviews.length === 0) return null; + + return ( + <> + {linkPreviews.slice(0, displayLinkCount).map((linkPreview) => ( + + ))} + + ); +}; + +type LinkPreviewProps = { + linkPreview: LinkPreview; +}; + +export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { + const styles = useStyles(); + const { ImageComponent } = useChatContext(); + const { linkPreviewsManager } = useMessageComposer(); + const { image_url, thumb_url, title, text, og_scrape_url } = linkPreview; + + const dismissPreview = useCallback( + () => linkPreviewsManager.dismissPreview(og_scrape_url), + [linkPreviewsManager, og_scrape_url], + ); + + if ( + !LinkPreviewsManager.previewIsLoaded(linkPreview) && + !LinkPreviewsManager.previewIsLoading(linkPreview) + ) { + return null; + } + + return ( + + + + + + + {title ? ( + + {title} + + ) : null} + {text ? ( + + {text} + + ) : null} + {og_scrape_url ? ( + + + + {og_scrape_url} + + + ) : null} + + + + + + + ); +}; + +const useStyles = () => { + const { + theme: { + semantics, + messageInput: { linkPreviewList }, + }, + } = useTheme(); + + return useMemo( + () => + StyleSheet.create({ + linkContainer: { + flexDirection: 'row', + ...linkPreviewList.linkContainer, + }, + linkIcon: { alignSelf: 'center', marginRight: 4, ...linkPreviewList.linkIcon }, + container: { + flexDirection: 'row', + backgroundColor: semantics.chatBgOutgoing, + padding: primitives.spacingXs, + borderRadius: components.messageBubbleRadiusAttachment, + ...linkPreviewList.container, + }, + imageWrapper: { + flexDirection: 'row', + overflow: 'hidden', + ...linkPreviewList.imageWrapper, + }, + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + ...linkPreviewList.dismissWrapper, + }, + thumbnail: { + borderRadius: components.messageBubbleRadiusAttachment, + height: 40, + width: 40, + ...linkPreviewList.thumbnail, + }, + wrapper: { + paddingVertical: primitives.spacingXxs, + ...linkPreviewList.wrapper, + }, + metadataContainer: { + marginLeft: primitives.spacingXs, + flex: 1, + minWidth: 0, + ...linkPreviewList.metadataContainer, + }, + text: { + fontSize: primitives.typographyFontSizeXs, + // TODO: Change this to a better semantic once chatTextOutgoing is available + color: semantics.brand900, + ...linkPreviewList.text, + }, + titleText: { + fontWeight: primitives.typographyFontWeightBold, + fontSize: primitives.typographyFontSizeXs, + // TODO: Change this to a better semantic once chatTextOutgoing is available + color: semantics.brand900, + ...linkPreviewList.titleText, + }, + }), + [linkPreviewList, semantics.brand900, semantics.chatBgOutgoing], + ); +}; diff --git a/package/src/components/MessageInput/hooks/useLinkPreviews.ts b/package/src/components/MessageInput/hooks/useLinkPreviews.ts new file mode 100644 index 0000000000..4f4e7beb83 --- /dev/null +++ b/package/src/components/MessageInput/hooks/useLinkPreviews.ts @@ -0,0 +1,27 @@ +import { LinkPreviewsManager, LinkPreviewsManagerState } from 'stream-chat'; + +import { useMessageComposer } from '../../../contexts'; +import { useStateStore } from '../../../hooks'; + +const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ + linkPreviews: Array.from(state.previews.values()).filter((preview) => + LinkPreviewsManager.previewIsLoaded(preview), + ), +}); + +export const useLinkPreviews = () => { + const messageComposer = useMessageComposer(); + const { linkPreviewsManager } = messageComposer; + const { linkPreviews } = useStateStore( + linkPreviewsManager.state, + linkPreviewsManagerStateSelector, + ); + + return linkPreviews; +}; + +export const useHasLinkPreviews = () => { + const linkPreviews = useLinkPreviews(); + + return linkPreviews.length > 0; +}; 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 71f326a965..44aebeaea0 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1895,7 +1895,6 @@ exports[`Thread should match thread snapshot 1`] = ` } > Date: Mon, 2 Feb 2026 11:45:28 +0530 Subject: [PATCH 41/76] chore: update podfile.lock --- examples/SampleApp/ios/Podfile.lock | 158 ++++++++++++++-------------- 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 2238629946..61fc07eb9c 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3579,104 +3579,104 @@ SPEC CHECKSUMS: hermes-engine: bbc1152da7d2d40f9e59c28acc6576fcf5d28e2a libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - NitroModules: 8849240f6ee6d3b295514112437e3b09e855cb67 - NitroSound: fe46960c89410e62e05e9a709d8bf28a8202d1b3 - op-sqlite: b9a4028bef60145d7b98fbbc4341c83420cdcfd5 + NitroModules: 01ae20fc1e8fc9a3b088ab8ed06ab92527a04f0d + NitroSound: 347b6a21f2e7d5601c92ef81cec7836f8f8be44c + op-sqlite: a7e46cfdaebeef219fd0e939332967af9fe6d406 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f React: e7a4655b09d0e17e54be188cc34c2f3e2087318a React-callinvoker: 62192daaa2f30c3321fc531e4f776f7b09cf892b React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: c400b068fdb6172177f3b3fae00c10d1077244d7 - React-CoreModules: 8e911a5a504b45824374eec240a78de7a6db8ca2 - React-cxxreact: 06a91f55ac5f842219d6ca47e0f77187a5b5f4ac + React-Core: b23cdaaa9d76389d958c06af3c57aa6ad611c542 + React-CoreModules: 8e0f562e5695991e455abbebe1e968af71d52553 + React-cxxreact: 6ccbe0cc2c652b29409b14b23cfb3cd74e084691 React-debug: ab52e07f7148480ea61c5e9b68408d749c6e2b8f - React-defaultsnativemodule: fab7bf1b5ce1f3ed252aa4e949ec48a8df67d624 - React-domnativemodule: 735fa5238cceebceeecc18f9f4321016461178cf - React-Fabric: c75719fc8818049c3cf9071f0619af988b020c9f - React-FabricComponents: 74a381cc0dfaf2ec3ee29f39ef8533a7fd864b83 - React-FabricImage: 9a3ff143b1ac42e077c0b33ec790f3674ace5783 - React-featureflags: e1eca0727563a61c919131d57bbd0019c3bdb0f0 - React-featureflagsnativemodule: 692211fd48283b2ddee3817767021010e2f1788e - React-graphics: 759b02bde621f12426c1dc1ae2498aaa6b441cd7 - React-hermes: b6e33fcd21aa7523dc76e62acd7a547e68c28a5b - React-idlecallbacksnativemodule: 731552efc0815fc9d65a6931da55e722b1b3a397 - React-ImageManager: 2c510a480f2c358f56a82df823c66d5700949c96 - React-jserrorhandler: 411e18cbdcbdf546f8f8707091faeb00703527c1 - React-jsi: 3fde19aaf675c0607a0824c4d6002a4943820fd9 - React-jsiexecutor: 4f898228240cf261a02568e985dfa7e1d7ad1dfb - React-jsinspector: 2f0751e6a4fb840f4ed325384d0795a9e9afaf39 - React-jsinspectorcdp: 71c48944d97f5f20e8e144e419ddf04ffa931e93 - React-jsinspectornetwork: 102f347669b278644cc9bb4ebf2f90422bd5ccef - React-jsinspectortracing: 0f6f2ec7f3faa9dc73d591b24b460141612515eb - React-jsitooling: b557f8e12efdaf16997e43b0d07dbd8a3fce3a5b - React-jsitracing: f9a77561d99c0cd053a8230bab4829b100903949 - React-logger: ea80169d826e0cd112fa4d68f58b2b3b968f1ecb - React-Mapbuffer: 230c34b1cabd1c4815726c711b9df221c3d3fbfb - React-microtasksnativemodule: 29d62f132e4aba34ebb7f2b936dde754eb08971b - react-native-blob-util: cbd6b292d0f558f09dce85e6afe68074cd031f3e - react-native-cameraroll: 00057cc0ec595fdbdf282ecfb931d484b240565f - react-native-document-picker: c5fa18e9fc47b34cfbab3b0a4447d0df918a5621 - react-native-geolocation: eb39c815c9b58ddc3efb552cafdd4b035e4cf682 - react-native-image-picker: e479ec8884df9d99a62c1f53f2307055ad43ea85 - react-native-maps: ee1e65647460c3d41e778071be5eda10e3da6225 - react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac - react-native-safe-area-context: 7fd4c2c8023da8e18eaa3424cb49d52f626debee - react-native-video: 71973843c2c9ac154c54f95a5a408fd8b041790e - React-NativeModulesApple: d061f458c3febdf0ac99b1b0faf23b7305974b25 + React-defaultsnativemodule: 291d2b0a93c399056121f4f0acc7f46d155a38ec + React-domnativemodule: c4968302e857bd422df8eec50a3cd4d078bd4ac0 + React-Fabric: 7e3ba48433b87a416052c4077d5965aff64cb8c9 + React-FabricComponents: 21de255cf52232644d12d3288cced1f0c519b25d + React-FabricImage: 15a0961a0ab34178f1c803aa0a7d28f21322ffc3 + React-featureflags: 4e5dad365d57e3c3656447dfdad790f75878d9f4 + React-featureflagsnativemodule: 5eac59389131c2b87d165dac4094b5e86067fabb + React-graphics: 2f9b3db89f156afd793da99f23782f400f58c1ee + React-hermes: cc8c77acee1406c258622cd8abbee9049f6b5761 + React-idlecallbacksnativemodule: 1d7e1f73b624926d16db956e87c4885ef485664a + React-ImageManager: 8b6066f6638fba7d4a94fbd0b39b477ea8aced58 + React-jserrorhandler: e5a4626d65b0eda9a11c43a9f14d0423d8a7080d + React-jsi: ea5c640ea63c127080f158dac7f4f393d13d415c + React-jsiexecutor: cf7920f82e46fe9a484c15c9f31e67d7179aa826 + React-jsinspector: 094e3cb99952a0024fa977fa04706e68747cba18 + React-jsinspectorcdp: dca545979146e3ecbdc999c0789dab55beecc140 + React-jsinspectornetwork: 0a105fe74b0b1a93f70409d955c8a0556dc17c59 + React-jsinspectortracing: 76088dd78a2de3ea637a860cdf39a6d9c2637d6b + React-jsitooling: a2e1e87382aae2294bc94a6e9682b9bc83da1d36 + React-jsitracing: 45827be59e673f4c54175c150891301138846906 + React-logger: 7cfc7b1ae1f8e5fe5097f9c746137cc3a8fad4ce + React-Mapbuffer: 8f620d1794c6b59a8c3862c3ae820a2e9e6c9bb0 + React-microtasksnativemodule: dcf5321c9a41659a6718df8a5f202af1577c6825 + react-native-blob-util: a511afccff6511544ebf56928e6afdf837b037a7 + react-native-cameraroll: 8c3ba9b6f511cf645778de19d5039b61d922fdfb + react-native-document-picker: b37cf6660ad9087b782faa78a1e67687fac15bfd + react-native-geolocation: b7f68b8c04e36ee669c630dbc48dd42cf93a0a41 + react-native-image-picker: 9bceb747cd6cde22a3416ffdc819d11b5b11f156 + react-native-maps: 9febd31278b35cd21e4fad2cf6fa708993be5dab + react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 + react-native-safe-area-context: 32293dc61d1b92ccf892499ab6f8acfd609f9aef + react-native-video: 4da16bfca01a02aa2095e40683d74f2d6563207c + React-NativeModulesApple: 342e280bb9fc2aa5f61f6c257b309a86b995e12d React-oscompat: 56d6de59f9ae95cd006a1c40be2cde83bc06a4e1 - React-perflogger: 0633844e495d8b34798c9bf0cb32ce315f1d5c9f - React-performancetimeline: 53bdf62ff49a9b0c4bd4d66329fdcf28d77c1c9d + React-perflogger: 4008bd05a8b6c157b06608c0ea0b8bd5d9c5e6c9 + React-performancetimeline: 3ac316a346fe3d48801a746b754dd8f5b5146838 React-RCTActionSheet: 49138012280ec3bbb35193d8d09adb8bc61c982e - React-RCTAnimation: c7ed4a9d5a4e43c9b10f68bb43cd238c4a2e7e89 - React-RCTAppDelegate: ea2ab6f4aef1489f72025b7128d8ab645b40eafb - React-RCTBlob: c052799460b245e1fffe3d1dddea36fa88e998a0 - React-RCTFabric: fad6230640c42fb8cda29b1d0759f7a1fb8cc677 - React-RCTFBReactNativeSpec: ffb22c3ee3d359ae9245ca94af203845da9371ec - React-RCTImage: 59fc2571f4f109a77139924f5babee8f9cd639c9 - React-RCTLinking: a045cb58c08188dce6c6f4621de105114b1b16ce - React-RCTNetwork: fc7115a2f5e15ae0aa05e9a9be726817feefb482 - React-RCTRuntime: c69b86dc60dcc7297318097fc60bd8e40b050f74 - React-RCTSettings: 30d7dd7eae66290467a1e72bf42d927fa78c3884 - React-RCTText: 755d59284e66c7d33bb4f0ccc428fe69110c3e74 - React-RCTVibration: ffe019e588815df226f6f8ccdc65979f8b2bc440 + React-RCTAnimation: ebfe7c62016d4c17b56b2cab3a221908ae46288d + React-RCTAppDelegate: 0108657ba9a19f6a1cd62dcd19c2c0485b3fc251 + React-RCTBlob: 6cc309d1623f3c2679125a04a7425685b7219e6b + React-RCTFabric: 04d1cf11ee3747a699260492e319e92649d7ac88 + React-RCTFBReactNativeSpec: ff3e37e2456afc04211334e86d07bf20488df0ae + React-RCTImage: bb98a59aeed953a48be3f917b9b745b213b340ab + React-RCTLinking: d6e9795d4d75d154c1dd821fd0746cc3e05d6670 + React-RCTNetwork: 5c8a7a2dd26728323189362f149e788548ac72bc + React-RCTRuntime: 52b28e281aba881e1f94ee8b16611823b730d1c5 + React-RCTSettings: b6a02d545ce10dd936b39914b32674db6e865307 + React-RCTText: c7d9232da0e9b5082a99a617483d9164a9cd46e9 + React-RCTVibration: fe636c985c1bf25e4a5b5b4d9315a3b882468a72 React-rendererconsistency: aba18fa58a4d037004f6bed6bb201eb368016c56 - React-renderercss: c7c140782f5f21103b638abfde7b3f11d6a5fd7e - React-rendererdebug: 111519052db9610f1b93baf7350c800621df3d0c + React-renderercss: b490bd53486a6bae1e9809619735d1f2b2cabd7f + React-rendererdebug: 8db25b276b64d5a1dbf05677de0c4ff1039d5184 React-rncore: 22f344c7f9109b68c3872194b0b5081ca1aee655 - React-RuntimeApple: 30d20d804a216eb297ccc9ce1dc9e931738c03b1 - React-RuntimeCore: 6e1facd50e0b7aed1bc36b090015f33133958bb6 + React-RuntimeApple: 19bdabedda0eeb70acb71e68bfc42d61bbcbacd9 + React-RuntimeCore: 11bf03bdbd6e72857481c46d0f4eb9c19b14c754 React-runtimeexecutor: b35de9cb7f5d19c66ea9b067235f95b947697ba5 - React-RuntimeHermes: 222268a5931a23f095565c4d60e2673c04e2178a - React-runtimescheduler: aea93219348ba3069fe6c7685a84fe17d3a4b4ee + React-RuntimeHermes: d8f736d0a2d38233c7ec7bd36040eb9b0a3ccd8c + React-runtimescheduler: 0c95966d030c8ebbebddaab49630cda2889ca073 React-timing: 42e8212c479d1e956d3024be0a07f205a2e34d9d - React-utils: 0ebf25dd4eb1b5497933f4d8923b862d0fe9566f - ReactAppDependencyProvider: 6c9197c1f6643633012ab646d2bfedd1b0d25989 - ReactCodegen: f8ae44cfcb65af88f409f4b094b879591232f12c - ReactCommon: a237545f08f598b8b37dc3a9163c1c4b85817b0b - RNCAsyncStorage: afe7c3711dc256e492aa3a50dcac43eecebd0ae5 - RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 - RNFBApp: df5caad9f64b6bc87f8a0b110e6bc411fb00a12b - RNFBMessaging: 6586f18ab3411aeb3349088c19fe54283d39e529 - RNGestureHandler: 6f2448500f339bc93dc589a5fd4da00e6d0033b2 - RNNotifee: 4a6ee5c7deaf00e005050052d73ee6315dff7ec9 - RNReactNativeHapticFeedback: 8bd4a2ba7c3daeb5d2acfceb8b61743f203076d0 - RNReanimated: 0d996442e746ee9d947f13914fcc17b43b17c445 - RNScreens: 5ca475eb30f4362c5808f3ff4a1c7b786bcd878e - RNShare: 083f05646b9534305999cf20452bd87ca0e8b0b0 - RNSVG: fd433fe5da0f7fee8c78f5865f29ab37321dbd7f - RNWorklets: a382db09224b0f6fda1c72c4c9bf1abfac9b3db8 + React-utils: a185f723baa0c525c361e6c281a846d919623dbe + ReactAppDependencyProvider: 8df342c127fd0c1e30e8b9f71ff814c22414a7c0 + ReactCodegen: 4928682e20747464165effacc170019a18da953c + ReactCommon: ec1cdf708729338070f8c4ad746768a782fd9eb1 + RNCAsyncStorage: f30b3a83064e28b0fc46f1fbd3834589ed64c7b9 + RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 + RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e + RNFBMessaging: de62448d205095171915d622ed5fb45c2be5e075 + RNGestureHandler: 0c0d36c0f3c17fc755543fad1c182e1cd541f898 + RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 + RNReactNativeHapticFeedback: d39b9a5b334ce26f49ca6abe9eea8b3938532aee + RNReanimated: e3dd9527a9614e1c9e127018cca9486f2c13b2a9 + RNScreens: 6a2d1ff4d263d29d3d3db9f3c19aad2f99fdd162 + RNShare: 9d801eafd9ae835f51bcae6b5c8de9bf3389075b + RNSVG: bc7ccfe884848ac924d2279d9025d41b5f05cb0c + RNWorklets: 4bd2a43ae826633e5e0a92953fce2eb8265759d4 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a - Teleport: 9ae328b3384204b23236e47bd0d8e994ce6bdb48 + stream-chat-react-native: f42e234640869e0eafcdd354441414ad1818b9fe + Teleport: c089481dd2bb020e3dced39b7f8849b93d1499f6 Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 From f19e432f4b5683c535ec9b339162a44660d730ac Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:19:31 +0100 Subject: [PATCH 42/76] feat: implement video attachment upload previews with thumbnail (#3377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR implements the updated design video attachment upload preview for videos that have a thumbnail. If a thumbnail does not exist (was not able to be created), it will default to the old design. ## ๐Ÿ›  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/ios/Podfile.lock | 2 +- package/src/components/Channel/Channel.tsx | 3 +- .../VideoAttachmentUploadPreview.tsx | 91 +++++++++++++++++++ .../MessageInputContext.tsx | 3 +- .../src/contexts/themeContext/utils/theme.ts | 16 ++-- package/src/utils/utils.ts | 11 +++ 6 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 61fc07eb9c..48003e3ab3 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3584,7 +3584,7 @@ SPEC CHECKSUMS: op-sqlite: a7e46cfdaebeef219fd0e939332967af9fe6d406 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 93a4a100d6..bd88cac067 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -179,6 +179,7 @@ import { AttachmentUploadProgressIndicator as AttachmentUploadProgressIndicatorD import { AudioAttachmentUploadPreview as AudioAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; import { FileAttachmentUploadPreview as FileAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; import { ImageAttachmentUploadPreview as ImageAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview'; +import { VideoAttachmentUploadPreview as VideoAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview'; import { AudioRecorder as AudioRecorderDefault } from '../MessageInput/components/AudioRecorder/AudioRecorder'; import { AudioRecordingButton as AudioRecordingButtonDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingButton'; import { AudioRecordingInProgress as AudioRecordingInProgressDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingInProgress'; @@ -758,7 +759,7 @@ const ChannelWithContext = (props: PropsWithChildren) = UnreadMessagesNotification = UnreadMessagesNotificationDefault, AttachmentUploadProgressIndicator = AttachmentUploadProgressIndicatorDefault, UrlPreview = CardDefault, - VideoAttachmentUploadPreview = FileAttachmentUploadPreviewDefault, + VideoAttachmentUploadPreview = VideoAttachmentUploadPreviewDefault, VideoThumbnail = VideoThumbnailDefault, isOnline, maximumMessageLimit, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx new file mode 100644 index 0000000000..643e9fbb2c --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -0,0 +1,91 @@ +import React, { useMemo } from 'react'; + +import { StyleSheet, Text, View } from 'react-native'; + +import { LocalImageAttachment, LocalVideoAttachment } from 'stream-chat'; + +import { FileAttachmentUploadPreview } from './FileAttachmentUploadPreview'; +import { ImageAttachmentUploadPreview } from './ImageAttachmentUploadPreview'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { Recorder } from '../../../../icons'; +import { primitives } from '../../../../theme'; +import { UploadAttachmentPreviewProps } from '../../../../types/types'; +import { formatMsToMinSec } from '../../../../utils/utils'; + +export type VideoAttachmentUploadPreviewProps> = + UploadAttachmentPreviewProps>; + +export const VideoAttachmentUploadPreview = ({ + attachment, + handleRetry, + removeAttachments, +}: VideoAttachmentUploadPreviewProps) => { + const styles = useStyles(); + + const durationLabel = useMemo( + () => (attachment.duration ? formatMsToMinSec(attachment.duration) : undefined), + [attachment.duration], + ); + + return attachment.localMetadata.previewUri ? ( + <> + + {durationLabel ? ( + + + {durationLabel} + + ) : null} + + ) : ( + + ); +}; + +const useStyles = () => { + const { + theme: { + semantics, + messageInput: { + videoAttachmentUploadPreview: { durationContainer, durationText }, + }, + }, + } = useTheme(); + + const { badgeBgInverse, badgeText } = semantics; + + return useMemo( + () => + StyleSheet.create({ + durationContainer: { + position: 'absolute', + left: 8, + bottom: 8, + borderRadius: primitives.radiusMax, + backgroundColor: badgeBgInverse, + paddingVertical: primitives.spacingXxs, + paddingHorizontal: primitives.spacingXs, + flexDirection: 'row', + alignItems: 'center', + ...durationContainer, + }, + durationText: { + fontSize: primitives.typographyFontSizeXxs, + fontWeight: primitives.typographyFontWeightBold, + color: badgeText, + marginLeft: primitives.spacingXxs, + ...durationText, + }, + }), + [badgeBgInverse, badgeText, durationContainer, durationText], + ); +}; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 89a3220282..5bcb403e3a 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -39,6 +39,7 @@ import type { AttachmentUploadProgressIndicatorProps } from '../../components/Me import { AudioAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; import { FileAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; import { ImageAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview'; +import { VideoAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview'; import type { AudioRecorderProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecorder'; import type { AudioRecordingButtonProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingButton'; import type { AudioRecordingInProgressProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingInProgress'; @@ -251,7 +252,7 @@ export type InputMessageInputContextValue = { AudioAttachmentUploadPreview: React.ComponentType; ImageAttachmentUploadPreview: React.ComponentType; FileAttachmentUploadPreview: React.ComponentType; - VideoAttachmentUploadPreview: React.ComponentType; + VideoAttachmentUploadPreview: React.ComponentType; /** * Custom UI component to display the remaining cooldown a user will have to wait before diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index bf6467abd0..5c1ba99a34 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -411,10 +411,8 @@ export type Theme = { overlay: ViewStyle; }; videoAttachmentUploadPreview: { - recorderIconContainer: ViewStyle; - recorderIcon: IconProps; - itemContainer: ViewStyle; - upload: ImageStyle; + durationContainer: ViewStyle; + durationText: TextStyle; }; wrapper: ViewStyle; linkPreviewList: { @@ -1160,6 +1158,10 @@ export const defaultTheme: Theme = { upload: {}, wrapper: {}, }, + videoAttachmentUploadPreview: { + durationContainer: {}, + durationText: {}, + }, inputBox: {}, inputBoxContainer: {}, inputBoxWrapper: {}, @@ -1222,12 +1224,6 @@ export const defaultTheme: Theme = { indicatorColor: '', overlay: {}, }, - videoAttachmentUploadPreview: { - itemContainer: {}, - recorderIcon: {}, - recorderIconContainer: {}, - upload: {}, - }, wrapper: {}, linkPreviewList: { linkContainer: {}, diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 22a0d6766a..99b9f054b8 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -234,6 +234,17 @@ export const getDurationLabelFromDuration = (duration: number) => { return durationLabel; }; +export const formatMsToMinSec = (ms: number) => { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + const mm = minutes; // no padding for minutes + const ss = String(seconds).padStart(2, '0'); + + return `${mm}m ${ss}s`.replace(/^0m\s/, ''); +}; + /** * Utility to escape special characters in a string. * @param text From 37a254321d4224bfa52c40f0024b8bcb835b26ba Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 2 Feb 2026 17:38:26 +0530 Subject: [PATCH 43/76] feat: avatar component design refinements (#3378) Co-authored-by: Ivan Sekovanikj --- .../src/components/ui/Avatar/AvatarGroup.tsx | 233 ++++++++++++++++++ .../components/ui/Avatar/ChannelAvatar.tsx | 39 +-- package/src/components/ui/BadgeCount.tsx | 57 +++-- .../src/components/ui/BadgeNotification.tsx | 39 ++- package/src/components/ui/OnlineIndicator.tsx | 7 +- 5 files changed, 319 insertions(+), 56 deletions(-) create mode 100644 package/src/components/ui/Avatar/AvatarGroup.tsx diff --git a/package/src/components/ui/Avatar/AvatarGroup.tsx b/package/src/components/ui/Avatar/AvatarGroup.tsx new file mode 100644 index 0000000000..81d338f4e5 --- /dev/null +++ b/package/src/components/ui/Avatar/AvatarGroup.tsx @@ -0,0 +1,233 @@ +import React, { useCallback, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { UserResponse } from 'stream-chat'; + +import { Avatar } from './Avatar'; + +import { iconSizes } from './constants'; + +import { UserAvatar } from './UserAvatar'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { PeopleIcon } from '../../../icons/PeopleIcon'; +import { primitives } from '../../../theme'; +import { BadgeCount } from '../BadgeCount'; +import { OnlineIndicator } from '../OnlineIndicator'; + +export type AvatarGroupProps = { + /** + * The size of the avatar group. + */ + size: 'lg' | 'xl'; + /** + * The items to display in the avatar group. + */ + items: React.ReactNode[]; +}; + +const sizes = { + lg: { + width: 40, + height: 40, + }, + xl: { + width: 64, + height: 64, + }, +}; + +const buildForTwo = (items: React.ReactNode[]) => { + return ( + <> + {items[0]} + {items[1]} + + ); +}; + +const buildForThree = (items: React.ReactNode[]) => { + return ( + <> + {items[0]} + {items[1]} + {items[2]} + + ); +}; + +const buildForFour = (items: React.ReactNode[]) => { + return ( + <> + {items[0]} + {items[1]} + {items[2]} + {items[3]} + + ); +}; + +export const AvatarGroup = (props: AvatarGroupProps) => { + const { size, items = [] } = props; + const { + theme: { semantics }, + } = useTheme(); + + const avatarSize = size === 'lg' ? 'sm' : 'lg'; + const badgeCountSize = size === 'lg' ? 'xs' : 'md'; + + const buildForOne = useCallback( + (item: React.ReactNode) => { + return buildForTwo([ + + } + size={avatarSize} + />, + item, + ]); + }, + [semantics.avatarTextDefault, avatarSize], + ); + + const buildForMore = useCallback( + (items: React.ReactNode[]) => { + const remainingItems = items.length - 2; + return ( + <> + {items[0]} + {items[1]} + + + + + ); + }, + [badgeCountSize], + ); + + const renderItems = useMemo(() => { + const length = items.length; + if (length === 1) { + return buildForOne(items[0]); + } + if (length === 2) { + return buildForTwo(items); + } + if (length === 3) { + return buildForThree(items); + } + if (length === 4) { + return buildForFour(items); + } + return buildForMore(items); + }, [buildForMore, buildForOne, items]); + + return ( + + {renderItems} + + ); +}; + +export type UserAvatarGroupProps = Pick & { + /** + * The users to display in the avatar group. + */ + users: UserResponse[]; + /** + * Whether to show the online indicator. + */ + showOnlineIndicator?: boolean; +}; + +export const UserAvatarGroup = ({ + users, + showOnlineIndicator = true, + size, +}: UserAvatarGroupProps) => { + const styles = useUserAvatarGroupStyles(); + const userAvatarSize = size === 'lg' ? 'sm' : 'lg'; + const onlineIndicatorSize = size === 'xl' ? 'xl' : 'lg'; + return ( + + ( + + + + ))} + /> + {showOnlineIndicator ? ( + + + + ) : null} + + ); +}; + +const useUserAvatarGroupStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + + return useMemo( + () => + StyleSheet.create({ + userAvatarWrapper: { + borderWidth: 2, + borderColor: semantics.borderCoreOnAccent, + borderRadius: primitives.radiusMax, + }, + onlineIndicatorWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + }), + [semantics], + ); +}; + +// TODO V9: Add theming support here. +const styles = StyleSheet.create({ + container: { + padding: 2, + }, + topStart: { + position: 'absolute', + top: 0, + left: 0, + }, + bottomEnd: { + position: 'absolute', + bottom: 0, + right: 0, + }, + topEnd: { + position: 'absolute', + top: 0, + right: 0, + }, + bottomStart: { + position: 'absolute', + bottom: 0, + left: 0, + }, + topCenter: { + alignItems: 'center', + }, + bottomCenter: { + position: 'absolute', + bottom: 0, + alignSelf: 'center', + }, +}); diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 9755528ba6..335e22c840 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -1,17 +1,13 @@ import React, { useMemo } from 'react'; -import { Channel } from 'stream-chat'; +import { Channel, UserResponse } from 'stream-chat'; import { Avatar } from './Avatar'; -import { iconSizes } from './constants'; - -import { UserAvatar } from './UserAvatar'; +import { UserAvatarGroup } from './AvatarGroup'; import { useChannelPreviewDisplayPresence } from '../../../components/ChannelPreview/hooks/useChannelPreviewDisplayPresence'; -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -import { GroupIcon } from '../../../icons/GroupIcon'; import { hashStringToNumber } from '../../../utils/utils'; export type ChannelAvatarProps = { @@ -22,10 +18,9 @@ export type ChannelAvatarProps = { }; export const ChannelAvatar = (props: ChannelAvatarProps) => { - const { client } = useChatContext(); const { channel } = props; - const members = Object.values(channel.state.members); const online = useChannelPreviewDisplayPresence(channel); + const { showOnlineIndicator = online, size, showBorder = true } = props; const { theme: { semantics }, @@ -34,38 +29,24 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { const hashedValue = hashStringToNumber(channel.cid); const index = ((hashedValue % 5) + 1) as 1 | 2 | 3 | 4 | 5; const avatarBackgroundColor = semantics[`avatarPaletteBg${index}`]; - const avatarTextColor = semantics[`avatarPaletteText${index}`]; - - const { size, showBorder = true, showOnlineIndicator = online } = props; const channelImage = channel.data?.image; - const placeholder = useMemo(() => { - return ; - }, [size, avatarTextColor]); + const usersForGroup = useMemo( + () => Object.values(channel.state.members).map((member) => member.user as UserResponse), + [channel.state.members], + ); if (!channelImage) { - const otherMembers = members.filter((member) => member.user?.id !== client?.user?.id); - const otherUser = otherMembers?.[0]?.user; - - const user = members.length === 1 ? client.user : members.length === 2 ? otherUser : null; - if (user) { - return ( - - ); - } + return ( + + ); } return ( diff --git a/package/src/components/ui/BadgeCount.tsx b/package/src/components/ui/BadgeCount.tsx index f96653ab4f..202e7f4877 100644 --- a/package/src/components/ui/BadgeCount.tsx +++ b/package/src/components/ui/BadgeCount.tsx @@ -1,32 +1,59 @@ import React, { useMemo } from 'react'; -import { StyleSheet, Text } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; export type BadgeCountProps = { - count: number; - size: 'sm' | 'xs'; + count: string | number; + size: 'sm' | 'xs' | 'md'; }; const sizes = { + md: { + minWidth: 32, + height: 32, + }, sm: { - borderRadius: 12, minWidth: 24, - lineHeight: 22, + height: 24, }, xs: { - borderRadius: 10, minWidth: 20, - lineHeight: 18, + height: 20, + }, +}; + +const textStyles = { + md: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 14, + }, + sm: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 14, + }, + xs: { + fontSize: primitives.typographyFontSizeXxs, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 10, }, }; export const BadgeCount = (props: BadgeCountProps) => { const { count, size = 'sm' } = props; const styles = useStyles(); + const paddingHorizontal = size === 'xs' ? primitives.spacingXxs : primitives.spacingXs; - return {count}; + return ( + + {count} + + ); }; const useStyles = () => { @@ -34,22 +61,24 @@ const useStyles = () => { theme: { semantics }, } = useTheme(); - const { badgeBgInverse, badgeTextInverse, borderCoreSubtle } = semantics; + const { badgeBgDefault, badgeTextInverse, borderCoreSubtle } = semantics; return useMemo( () => StyleSheet.create({ - text: { - backgroundColor: badgeBgInverse, + container: { + backgroundColor: badgeBgDefault, borderColor: borderCoreSubtle, borderWidth: 1, + borderRadius: primitives.radiusMax, + justifyContent: 'center', + }, + text: { color: badgeTextInverse, - fontSize: primitives.typographyFontSizeXs, - fontWeight: primitives.typographyFontWeightBold, includeFontPadding: false, textAlign: 'center', }, }), - [badgeBgInverse, badgeTextInverse, borderCoreSubtle], + [badgeBgDefault, badgeTextInverse, borderCoreSubtle], ); }; diff --git a/package/src/components/ui/BadgeNotification.tsx b/package/src/components/ui/BadgeNotification.tsx index 28227262ed..cfd2632c6d 100644 --- a/package/src/components/ui/BadgeNotification.tsx +++ b/package/src/components/ui/BadgeNotification.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { StyleSheet, Text } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; @@ -13,19 +13,30 @@ export type BadgeNotificationProps = { const sizes = { md: { - fontSize: 12, - lineHeight: 16, + height: 20, minWidth: 20, borderWidth: 2, }, sm: { - fontSize: 10, - lineHeight: 14, + height: 16, minWidth: 16, borderWidth: 1, }, }; +const textStyles = { + md: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 14, + }, + sm: { + fontSize: primitives.typographyFontSizeXxs, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: 10, + }, +}; + export const BadgeNotification = (props: BadgeNotificationProps) => { const { type, count, size = 'md', testID } = props; const styles = useStyles(); @@ -40,9 +51,11 @@ export const BadgeNotification = (props: BadgeNotificationProps) => { }; return ( - - {count} - + + + {count} + + ); }; @@ -56,14 +69,16 @@ const useStyles = () => { return useMemo( () => StyleSheet.create({ + container: { + paddingHorizontal: primitives.spacingXxs, + borderColor: badgeBorder, + borderRadius: primitives.radiusMax, + justifyContent: 'center', + }, text: { color: badgeText, - fontWeight: primitives.typographyFontWeightBold, includeFontPadding: false, textAlign: 'center', - paddingHorizontal: primitives.spacingXxs, - borderColor: badgeBorder, - borderRadius: primitives.radiusMax, }, }), [badgeText, badgeBorder], diff --git a/package/src/components/ui/OnlineIndicator.tsx b/package/src/components/ui/OnlineIndicator.tsx index 129aee3dcd..7f493d817f 100644 --- a/package/src/components/ui/OnlineIndicator.tsx +++ b/package/src/components/ui/OnlineIndicator.tsx @@ -6,10 +6,15 @@ import { primitives } from '../../theme'; export type OnlineIndicatorProps = { online: boolean; - size: 'lg' | 'sm' | 'md'; + size: 'xl' | 'lg' | 'sm' | 'md'; }; const sizes = { + xl: { + borderWidth: 2, + height: 16, + width: 16, + }, lg: { borderWidth: 2, height: 14, From ff44253835ddfa44663bfd3a753334ba8e5115c4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:58:57 +0100 Subject: [PATCH 44/76] fix: image gallery crash when many videos are present (#3380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR fixes an issue only present in the in-development V9 version, where we rely on videos refs to determine our interactions with a video component. As our clean-up function within the video player pool depends on the `videoRef` being up to date, we have to respect React's lifecycle when we want to run it (and make sure that the `videoRef` is always up to date accordingly). ## ๐Ÿ›  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 --- .../ImageGallery/components/AnimatedGalleryVideo.tsx | 4 ++++ .../ImageGallery/components/ImageGalleryHeader.tsx | 9 +++++++-- .../ImageGallery/hooks/useImageGalleryGestures.tsx | 1 - package/src/state-store/image-gallery-state-store.ts | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx index 9941168f78..8978ff5fb9 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx @@ -85,6 +85,10 @@ export const AnimatedGalleryVideo = React.memo( if (videoRef.current) { videoPlayer.initPlayer({ playerRef: videoRef.current }); } + + return () => { + videoPlayer.playerRef = null; + }; }, [videoPlayer]); const { isPlaying } = useStateStore(videoPlayer.state, videoPlayerSelector); diff --git a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx index e49549009b..f077674d6f 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native'; @@ -92,9 +92,14 @@ export const ImageGalleryHeader = (props: Props) => { const hideOverlay = () => { setOverlay('none'); - imageGalleryStateStore.clear(); }; + useEffect(() => { + return () => { + imageGalleryStateStore.clear(); + }; + }, [imageGalleryStateStore]); + return ( setHeight(event.nativeEvent.layout.height)} diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx index 733b3be696..b7a400bec4 100644 --- a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx +++ b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx @@ -159,7 +159,6 @@ export const useImageGalleryGestures = ({ const assetsLength = imageGalleryStateStore.assets.length; const clearImageGallery = () => { - runOnJS(imageGalleryStateStore.clear)(); runOnJS(setOverlay)('none'); }; diff --git a/package/src/state-store/image-gallery-state-store.ts b/package/src/state-store/image-gallery-state-store.ts index 1ba675d4f4..997892f1d5 100644 --- a/package/src/state-store/image-gallery-state-store.ts +++ b/package/src/state-store/image-gallery-state-store.ts @@ -219,7 +219,7 @@ export class ImageGalleryStateStore { }; clear = () => { - this.state.partialNext(INITIAL_STATE); this.videoPlayerPool.clear(); + this.state.partialNext(INITIAL_STATE); }; } From 0acaa8c60866dc2b80c7e7dd8bee8750833cd53a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:02:27 +0100 Subject: [PATCH 45/76] fix: voice recording race conditions (#3382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR fixes a couple of race conditions and locked stale state issues with regards to voice recording in the SDK. Most notably, the following: - Disabling permissions prevents voice recording from being played ever again - Quickly jumping from recording to prevented recording breaks the recording state - Mismatch between when we're actually recording and the state updating (possible missed recording by up to a second) - The underlying `Pressable`'s gesture not being recognized sometimes as the `Tap` gesture from RNGH took precedence It also includes various optimizations for the animations to make sure we are not rerendering heavily when the animations are running (or recreating gesture detector primitives). ## ๐Ÿ›  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 --- .../AudioRecorder/AudioRecordingButton.tsx | 207 ++++++++++-------- .../MessageInput/hooks/useAudioRecorder.tsx | 6 +- .../__snapshots__/Thread.test.js.snap | 2 +- .../MessageInputContext.tsx | 2 +- .../src/state-store/audio-recorder-manager.ts | 19 +- 5 files changed, 123 insertions(+), 113 deletions(-) diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 430dd868a3..6950f4370c 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -1,12 +1,9 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Alert, Linking, StyleSheet } from 'react-native'; -import { - Gesture, - GestureDetector, - PanGestureHandlerEventPayload, -} from 'react-native-gesture-handler'; +import { Gesture, GestureDetector, State } from 'react-native-gesture-handler'; import Animated, { + clamp, runOnJS, SharedValue, useAnimatedStyle, @@ -22,6 +19,7 @@ import { } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { useStableCallback } from '../../../../hooks'; import { useStateStore } from '../../../../hooks/useStateStore'; import { NewMic } from '../../../../icons/NewMic'; import { NativeHandlers } from '../../../../native'; @@ -38,7 +36,7 @@ export type AudioRecordingButtonPropsWithContext = Pick< | 'deleteVoiceRecording' | 'uploadVoiceRecording' > & - Pick & { + Pick & { /** * Size of the mic button. */ @@ -53,6 +51,7 @@ export type AudioRecordingButtonPropsWithContext = Pick< handlePress?: () => void; micPositionX: SharedValue; micPositionY: SharedValue; + cancellableDuration: boolean; }; /** @@ -72,8 +71,7 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps handlePress, micPositionX, micPositionY, - permissionsGranted, - duration: recordingDuration, + cancellableDuration, status, recording, } = props; @@ -87,7 +85,7 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps }, } = useTheme(); - const onPressHandler = () => { + const onPressHandler = useStableCallback(() => { if (handlePress) { handlePress(); } @@ -95,110 +93,131 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps NativeHandlers.triggerHaptic('notificationError'); Alert.alert(t('Hold to start recording.')); } - }; + }); - const onLongPressHandler = async () => { + const onLongPressHandler = useStableCallback(async () => { if (handleLongPress) { handleLongPress(); return; } if (recording) return; - NativeHandlers.triggerHaptic('impactHeavy'); - if (!permissionsGranted) { - Alert.alert(t('Please allow Audio permissions in settings.'), '', [ - { - onPress: () => { - Linking.openSettings(); - }, - text: t('Open Settings'), - }, - ]); - return; - } if (startVoiceRecording) { if (activeAudioPlayer?.isPlaying) { - await activeAudioPlayer?.pause(); + activeAudioPlayer?.pause(); + } + const permissionsGranted = await startVoiceRecording(); + if (!permissionsGranted) { + Alert.alert(t('Please allow Audio permissions in settings.'), '', [ + { + onPress: () => { + Linking.openSettings(); + }, + text: t('Open Settings'), + }, + { + text: t('Cancel'), + style: 'cancel', + }, + ]); + return; } - await startVoiceRecording(); + NativeHandlers.triggerHaptic('impactHeavy'); } - }; + }); + const X_AXIS_POSITION = -asyncMessagesSlideToCancelDistance; const Y_AXIS_POSITION = -asyncMessagesLockDistance; - const micUnlockHandler = () => { - audioRecorderManager.micLocked = false; - }; - - const micLockHandler = (value: boolean) => { - audioRecorderManager.micLocked = value; - }; + const micLockHandler = useStableCallback((value: boolean) => { + if (status === 'recording') { + audioRecorderManager.micLocked = value; + } + }); - const resetAudioRecording = async () => { + const resetAudioRecording = useStableCallback(async () => { NativeHandlers.triggerHaptic('notificationSuccess'); await deleteVoiceRecording(); - }; + }); - const onEarlyReleaseHandler = () => { + const onEarlyReleaseHandler = useStableCallback(() => { NativeHandlers.triggerHaptic('notificationError'); resetAudioRecording(); - }; - - const tapGesture = Gesture.Tap() - .onBegin(() => { - scale.value = withSpring(0.8, { mass: 0.5 }); - }) - .onEnd(() => { - scale.value = withSpring(1, { mass: 0.5 }); - }); - - const panGesture = Gesture.Pan() - .activateAfterLongPress(asyncMessagesMinimumPressDuration + 100) - .onChange((event: PanGestureHandlerEventPayload) => { - const newPositionX = event.translationX; - const newPositionY = event.translationY; + }); - if (newPositionX <= 0 && newPositionX >= X_AXIS_POSITION) { - micPositionX.value = newPositionX; - } - if (newPositionY <= 0 && newPositionY >= Y_AXIS_POSITION) { - micPositionY.value = newPositionY; + const onTouchGestureEnd = useStableCallback(() => { + if (status === 'recording') { + if (cancellableDuration) { + runOnJS(onEarlyReleaseHandler)(); + } else { + runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); } - }) - .onStart(() => { - micPositionX.value = 0; - micPositionY.value = 0; - runOnJS(micUnlockHandler)(); - }) - .onEnd(() => { - const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2; - const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2; + } + }); - if (belowThresholdY && belowThresholdX) { - micPositionY.value = withSpring(0); - micPositionX.value = withSpring(0); - if (status === 'recording') { - if (recordingDuration < 300) { - runOnJS(onEarlyReleaseHandler)(); - } else { - runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); + const tapGesture = useMemo( + () => + Gesture.LongPress() + .minDuration(asyncMessagesMinimumPressDuration) + .onBegin(() => { + scale.value = withSpring(0.8, { mass: 0.5 }); + }) + .onStart(() => { + runOnJS(onLongPressHandler)(); + }) + .onFinalize((e) => { + scale.value = withSpring(1, { mass: 0.5 }); + if (e.state === State.FAILED) { + runOnJS(onPressHandler)(); } - } - return; - } + }), + [asyncMessagesMinimumPressDuration, onLongPressHandler, onPressHandler, scale], + ); - if (!belowThresholdY) { - micPositionY.value = withSpring(Y_AXIS_POSITION); - runOnJS(micLockHandler)(true); - } + const panGesture = useMemo( + () => + Gesture.Pan() + .activateAfterLongPress(asyncMessagesMinimumPressDuration) + .onUpdate((e) => { + micPositionX.value = clamp(e.translationX, X_AXIS_POSITION, 0); + micPositionY.value = clamp(e.translationY, Y_AXIS_POSITION, 0); + }) + .onStart(() => { + micPositionX.value = 0; + micPositionY.value = 0; + }) + .onEnd(() => { + const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2; + const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2; - if (!belowThresholdX) { - micPositionX.value = withSpring(X_AXIS_POSITION); - runOnJS(resetAudioRecording)(); - } + if (belowThresholdY && belowThresholdX) { + micPositionY.value = withSpring(0); + micPositionX.value = withSpring(0); + runOnJS(onTouchGestureEnd)(); + return; + } + + if (!belowThresholdX) { + micPositionX.value = withSpring(X_AXIS_POSITION); + runOnJS(resetAudioRecording)(); + } else if (!belowThresholdY) { + micPositionY.value = withSpring(Y_AXIS_POSITION); + runOnJS(micLockHandler)(true); + } - micPositionX.value = 0; - micPositionY.value = 0; - }); + micPositionX.value = 0; + micPositionY.value = 0; + }), + [ + X_AXIS_POSITION, + Y_AXIS_POSITION, + asyncMessagesMinimumPressDuration, + micLockHandler, + micPositionX, + micPositionY, + onTouchGestureEnd, + resetAudioRecording, + ], + ); const animatedStyle = useAnimatedStyle(() => { return { @@ -210,12 +229,10 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps @@ -234,8 +251,7 @@ const MemoizedAudioRecordingButton = React.memo( ) as typeof AudioRecordingButtonWithContext; const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ - duration: state.duration, - permissionsGranted: state.permissionsGranted, + cancellableDuration: state.duration < 300, recording: state.recording, status: state.status, }); @@ -252,7 +268,7 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { uploadVoiceRecording, } = useMessageInputContext(); - const { duration, status, permissionsGranted, recording } = useStateStore( + const { cancellableDuration, status, recording } = useStateStore( audioRecorderManager.state, audioRecorderSelector, ); @@ -268,9 +284,8 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { startVoiceRecording, deleteVoiceRecording, uploadVoiceRecording, - duration, + cancellableDuration, status, - permissionsGranted, recording, }} {...props} diff --git a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx index 2c53f82c47..b847f6237f 100644 --- a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx +++ b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx @@ -50,10 +50,12 @@ export const useAudioRecorder = ({ }, [isScheduledForSubmit, sendMessage]); /** - * Function to start voice recording. + * Function to start voice recording. Will return whether access is granted + * with regards to the microphone permission as that's how the underlying + * library works on iOS. */ const startVoiceRecording = useCallback(async () => { - await audioRecorderManager.startRecording(); + return await audioRecorderManager.startRecording(); }, [audioRecorderManager]); /** 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 44aebeaea0..76dc288597 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -2195,7 +2195,7 @@ exports[`Thread should match thread snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": false, + "disabled": true, "expanded": undefined, "selected": undefined, } diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 5bcb403e3a..ab3e8f3e49 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -102,7 +102,7 @@ export type LocalMessageInputContext = { toggleAttachmentPicker: () => void; uploadNewFile: (file: File) => Promise; audioRecorderManager: AudioRecorderManager; - startVoiceRecording: () => Promise; + startVoiceRecording: () => Promise; deleteVoiceRecording: () => Promise; uploadVoiceRecording: (multiSendEnabled: boolean) => Promise; stopVoiceRecording: () => Promise; diff --git a/package/src/state-store/audio-recorder-manager.ts b/package/src/state-store/audio-recorder-manager.ts index cda1584900..e355191be7 100644 --- a/package/src/state-store/audio-recorder-manager.ts +++ b/package/src/state-store/audio-recorder-manager.ts @@ -1,4 +1,4 @@ -import { Alert, Platform } from 'react-native'; +import { Platform } from 'react-native'; import { StateStore } from 'stream-chat'; @@ -9,7 +9,6 @@ export type RecordingStatusStates = 'idle' | 'recording' | 'stopped'; export type AudioRecorderManagerState = { micLocked: boolean; - permissionsGranted: boolean; recording: AudioRecordingReturnType; waveformData: number[]; duration: number; @@ -18,7 +17,6 @@ export type AudioRecorderManagerState = { const INITIAL_STATE: AudioRecorderManagerState = { micLocked: false, - permissionsGranted: true, waveformData: [], recording: undefined, duration: 0, @@ -56,28 +54,23 @@ export class AudioRecorderManager { if (!NativeHandlers.Audio) { return; } - this.state.partialNext({ - status: 'recording', - }); const recordingInfo = await NativeHandlers.Audio.startRecording( { isMeteringEnabled: true, }, this.onRecordingStatusUpdate, ); - const accessGranted = recordingInfo.accessGranted; - if (accessGranted) { - this.state.partialNext({ permissionsGranted: true }); - const recording = recordingInfo.recording; + const { accessGranted, recording } = recordingInfo; + if (accessGranted && recording) { if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) { recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60); } - this.state.partialNext({ recording }); + this.state.partialNext({ recording, status: 'recording' }); } else { this.reset(); - this.state.partialNext({ permissionsGranted: false }); - Alert.alert('Please allow Audio permissions in settings.'); } + + return accessGranted; } async stopRecording() { From 0437a4b9851fa6029f0a3f39d43f55cfa85c9a04 Mon Sep 17 00:00:00 2001 From: Norman Wilde <68503084+normanwilde@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:39:31 +0100 Subject: [PATCH 46/76] chore: pass contentContainerStyle to MessageFlashList (#3348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR enables passing `additionalFlashListProps.contentContainerStyle` to the `` component similar to how it works on ``. ## ๐Ÿ›  Implementation details Extends `flatListContentContainerStyle` with `additionalFlashListProps?.contentContainerStyle`. ## ๐ŸŽจ UI Changes n/a ## ๐Ÿงช Testing Pass the following as `additionalFlashListProps` to ``: ``` { contentContainerStyle: { backgroundColor: "blue", }, } ``` ## โ˜‘๏ธ Checklist - [X] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [X] 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 --- package/src/components/MessageList/MessageFlashList.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 5a0d646e93..b245ff7006 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -988,8 +988,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => styles.contentContainer, { paddingBottom: messageInputFloating ? messageInputHeight : 0 }, contentContainer, + additionalFlashListProps?.contentContainerStyle, + ], + [ + additionalFlashListProps?.contentContainerStyle, + contentContainer, + messageInputFloating, + messageInputHeight, ], - [contentContainer, messageInputFloating, messageInputHeight], ); const currentListHeightRef = useRef(undefined); From 0dea5334108c088d736a0d19b5cada50da46f5c2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:40:04 +0100 Subject: [PATCH 47/76] fix: bump vulnerable lodash and linkify versions (#3383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal ## ๐Ÿ›  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 --- package/package.json | 4 ++-- package/yarn.lock | 13 ++++--------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/package/package.json b/package/package.json index 75a84d3865..9f9b961f9c 100644 --- a/package/package.json +++ b/package/package.json @@ -74,8 +74,8 @@ "emoji-regex": "^10.4.0", "i18next": "^25.2.1", "intl-pluralrules": "^2.0.1", - "linkifyjs": "^4.3.1", - "lodash-es": "4.17.21", + "linkifyjs": "^4.3.2", + "lodash-es": "4.17.23", "mime-types": "^2.1.35", "path": "0.12.7", "react-native-markdown-package": "1.8.2", diff --git a/package/yarn.lock b/package/yarn.lock index 88861a9ccc..2132a204b2 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -6320,11 +6320,6 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkifyjs@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.1.tgz#1f246ebf4be040002accd1f4535b6af7c7e37898" - integrity sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg== - linkifyjs@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1" @@ -6352,10 +6347,10 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash-es@4.17.23: + version "4.17.23" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.23.tgz#58c4360fd1b5d33afc6c0bbd3d1149349b1138e0" + integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== lodash.debounce@^4.0.8: version "4.0.8" From a4610cc8d9667e30ae9c8ec162588df3266ac7b9 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 4 Feb 2026 13:11:36 +0530 Subject: [PATCH 48/76] feat: use countdown timer from channel in client JS (#3381) This pull request refactors how cooldown logic is handled in the message input components. The main improvement is centralizing cooldown state management using new hooks, which simplifies component props and reduces duplicated logic by using the Cooldown state from Channel in LLC. **Cooldown logic refactor:** * Removed the old `useCooldown` hook and its associated prop threading, consolidating cooldown state management. (`package/src/components/MessageInput/hooks/useCooldown.ts` [[1]](diffhunk://#diff-1efea63ff2f1cac9aa7eac0d913a66bb2000c06be2fa4ec15c4029d6e087bd9fL1-L46) `package/src/components/MessageInput/MessageInput.tsx` [[2]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL21) [[3]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL167-L168) [[4]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL240) [[5]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL299-L300) [[6]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL502) [[7]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL599) [[8]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL621) [[9]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL703-L707) [[10]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL788-L789) [[11]](diffhunk://#diff-86dc374aab4800be48d530a2bc910453ba27e3e5a23c15bd04538c6262cac9eaL868-L869) * Introduced a new `useCooldownRemaining` hook to provide the current cooldown state from the channel context, and updated all relevant components to use this hook. (`package/src/components/MessageInput/hooks/useCooldownRemaining.tsx` [[1]](diffhunk://#diff-611c7fb4e6e32490d00001b4d4551afd0595486ee825af6d2e456855d6c820c9R1-R21) `package/src/components/AutoCompleteInput/AutoCompleteInput.tsx` [[2]](diffhunk://#diff-a679894aeadc4b2f5acbe224d3096db6570563706734eb9196fc3b99dec6fbeaR28) [[3]](diffhunk://#diff-a679894aeadc4b2f5acbe224d3096db6570563706734eb9196fc3b99dec6fbeaR209-R217) * Updated `CooldownTimer` to use the new hook and improved its styling to use theme primitives, ensuring consistent appearance. (`package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx` [[1]](diffhunk://#diff-12161af2dfb48393df5318ce11366665f1a457aa3e308abff439b3c68a197de9L1-R14) [[2]](diffhunk://#diff-12161af2dfb48393df5318ce11366665f1a457aa3e308abff439b3c68a197de9L33-R29) [[3]](diffhunk://#diff-12161af2dfb48393df5318ce11366665f1a457aa3e308abff439b3c68a197de9L47-R59) **Output button logic improvements:** * Refactored `OutputButtons` to use a new `useIsCooldownActive` hook and simplified the rendering logic for send, edit, and cooldown buttons, making use of `useMemo` for performance and clarity. (`package/src/components/MessageInput/components/OutputButtons/index.tsx` [[1]](diffhunk://#diff-90be63eed21504db3919c735eb064f08aeaf5dede1943e3afd6303ce6e182c3cL1-R1) [[2]](diffhunk://#diff-90be63eed21504db3919c735eb064f08aeaf5dede1943e3afd6303ce6e182c3cL17-R25) [[3]](diffhunk://#diff-90be63eed21504db3919c735eb064f08aeaf5dede1943e3afd6303ce6e182c3cL41-R60) [[4]](diffhunk://#diff-90be63eed21504db3919c735eb064f08aeaf5dede1943e3afd6303ce6e182c3cL82-R128) [[5]](diffhunk://#diff-90be63eed21504db3919c735eb064f08aeaf5dede1943e3afd6303ce6e182c3cR138-R180) [[6]](diffhunk://#diff-90be63eed21504db3919c735eb064f08aeaf5dede1943e3afd6303ce6e182c3cL184-R206) [[7]](diffhunk://#diff-90be63eed21504db3919c735eb064f08aeaf5dede1943e3afd6303ce6e182c3cL200-R217) These changes make the codebase easier to maintain and extend, and ensure that cooldown logic is handled consistently across components. --- .../AutoCompleteInput/AutoCompleteInput.tsx | 5 +- .../components/MessageInput/MessageInput.tsx | 18 -- .../OutputButtons/CooldownTimer.tsx | 38 ++-- .../components/OutputButtons/index.tsx | 66 +++--- .../MessageInput/hooks/useCooldown.ts | 46 ---- .../hooks/useCooldownRemaining.tsx | 21 ++ .../MessageInput/hooks/useCountdown.ts | 48 ----- .../MessageInput/hooks/useIsCooldownActive.ts | 13 ++ .../__snapshots__/Thread.test.js.snap | 202 ++++++++---------- .../MessageInputContext.tsx | 12 +- .../hooks/useCreateMessageInputContext.ts | 4 +- .../hooks/useMessageCompositionIsEmpty.ts | 13 ++ package/src/i18n/en.json | 1 + package/src/i18n/es.json | 1 + package/src/i18n/fr.json | 1 + package/src/i18n/he.json | 1 + package/src/i18n/hi.json | 1 + package/src/i18n/it.json | 1 + package/src/i18n/ja.json | 1 + package/src/i18n/ko.json | 1 + package/src/i18n/nl.json | 1 + package/src/i18n/pt-br.json | 1 + package/src/i18n/ru.json | 1 + package/src/i18n/tr.json | 1 + 24 files changed, 210 insertions(+), 288 deletions(-) delete mode 100644 package/src/components/MessageInput/hooks/useCooldown.ts create mode 100644 package/src/components/MessageInput/hooks/useCooldownRemaining.tsx delete mode 100644 package/src/components/MessageInput/hooks/useCountdown.ts create mode 100644 package/src/components/MessageInput/hooks/useIsCooldownActive.ts create mode 100644 package/src/contexts/messageInputContext/hooks/useMessageCompositionIsEmpty.ts diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index 4146bafbf7..b37232e496 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -25,6 +25,7 @@ import { } from '../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../hooks/useStateStore'; +import { useCooldownRemaining } from '../MessageInput/hooks/useCooldownRemaining'; type AutoCompleteInputPropsWithContext = TextInputProps & Pick & @@ -130,7 +131,7 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) : command ? commandPlaceHolders[command.name ?? ''] : cooldownRemainingSeconds - ? `Slow mode, wait ${cooldownRemainingSeconds}s...` + ? t('Slow mode, wait {{seconds}}s...', { seconds: cooldownRemainingSeconds }) : t('Send a message'); }, [command, cooldownRemainingSeconds, t, placeholder]); @@ -205,6 +206,7 @@ export const AutoCompleteInput = (props: AutoCompleteInputProps) => { const { setInputBoxRef } = useMessageInputContext(); const { t } = useTranslationContext(); const { channel } = useChannelContext(); + const cooldownRemainingSeconds = useCooldownRemaining(); return ( { channel, setInputBoxRef, t, + cooldownRemainingSeconds, }} {...props} /> diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 80636d8ca5..2f7c4706ea 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -18,7 +18,6 @@ import { type MessageComposerState, type TextComposerState, type UserResponse } import { LinkPreviewList } from './components/LinkPreviewList'; import { OutputButtons } from './components/OutputButtons'; -import { useCountdown } from './hooks/useCountdown'; import { useHasLinkPreviews } from './hooks/useLinkPreviews'; @@ -164,8 +163,6 @@ type MessageInputPropsWithContext = Pick< | 'AudioRecordingLockIndicator' | 'AudioRecordingPreview' | 'AutoCompleteSuggestionList' - | 'cooldownEndsAt' - | 'CooldownTimer' | 'closeAttachmentPicker' | 'compressImageQuality' | 'Input' @@ -237,7 +234,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { AutoCompleteSuggestionList, closeAttachmentPicker, closePollCreationDialog, - cooldownEndsAt, CreatePollContent, disableAttachmentPicker, editing, @@ -296,8 +292,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { }, } = useTheme(); - const { seconds: cooldownRemainingSeconds } = useCountdown(cooldownEndsAt); - // Close the attachment picker state when the component unmounts useEffect( () => () => { @@ -499,7 +493,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { ) : null} @@ -596,7 +589,6 @@ const areEqual = ( audioRecordingEnabled: prevAsyncMessagesEnabled, channel: prevChannel, closePollCreationDialog: prevClosePollCreationDialog, - cooldownEndsAt: prevCooldownEndsAt, editing: prevEditing, hasAttachments: prevHasAttachments, isKeyboardVisible: prevIsKeyboardVisible, @@ -618,7 +610,6 @@ const areEqual = ( audioRecordingEnabled: nextAsyncMessagesEnabled, channel: nextChannel, closePollCreationDialog: nextClosePollCreationDialog, - cooldownEndsAt: nextCooldownEndsAt, editing: nextEditing, isKeyboardVisible: nextIsKeyboardVisible, isOnline: nextIsOnline, @@ -700,11 +691,6 @@ const areEqual = ( return false; } - const cooldownEndsAtEqual = prevCooldownEndsAt === nextCooldownEndsAt; - if (!cooldownEndsAtEqual) { - return false; - } - const threadListEqual = prevThreadList === nextThreadList; if (!threadListEqual) { return false; @@ -785,8 +771,6 @@ export const MessageInput = (props: MessageInputProps) => { closeAttachmentPicker, closePollCreationDialog, compressImageQuality, - cooldownEndsAt, - CooldownTimer, CreatePollContent, CreatePollIcon, FileSelectorIcon, @@ -865,8 +849,6 @@ export const MessageInput = (props: MessageInputProps) => { closeAttachmentPicker, closePollCreationDialog, compressImageQuality, - cooldownEndsAt, - CooldownTimer, CreatePollContent, CreatePollIcon, disableAttachmentPicker, diff --git a/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx index ecf6105a2e..44342b2a65 100644 --- a/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx @@ -1,21 +1,17 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, Text } from 'react-native'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../../theme'; import { IconButton } from '../../../ui/IconButton'; - -export type CooldownTimerProps = { - seconds: number; -}; +import { useCooldownRemaining } from '../../hooks/useCooldownRemaining'; /** * 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; +export const CooldownTimer = () => { + const seconds = useCooldownRemaining(); + const styles = useStyles(); const { theme: { messageInput: { @@ -30,7 +26,7 @@ export const CooldownTimer = (props: CooldownTimerProps) => { {seconds}
); - }, [seconds, text]); + }, [seconds, text, styles]); return ( { ); }; -const styles = StyleSheet.create({ - text: { color: '#B8BEC4', fontSize: 16, fontWeight: '600' }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + text: { + color: semantics.textDisabled, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [semantics.textDisabled], + ); +}; diff --git a/package/src/components/MessageInput/components/OutputButtons/index.tsx b/package/src/components/MessageInput/components/OutputButtons/index.tsx index 58c9ece242..7ca7e51376 100644 --- a/package/src/components/MessageInput/components/OutputButtons/index.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/index.tsx @@ -14,15 +14,15 @@ import { useMessageComposerHasSendableData, useTheme, } from '../../../../contexts'; -import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState'; import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useMessageCompositionIsEmpty } from '../../../../contexts/messageInputContext/hooks/useMessageCompositionIsEmpty'; import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useStateStore } from '../../../../hooks/useStateStore'; import { AIStates, useAIState } from '../../../AITypingIndicatorView'; -import { useCountdown } from '../../hooks/useCountdown'; +import { useIsCooldownActive } from '../../hooks/useIsCooldownActive'; export type OutputButtonsProps = Partial & { micPositionX: Animated.SharedValue; @@ -38,7 +38,6 @@ export type OutputButtonsWithContextProps = Pick & | 'asyncMessagesLockDistance' | 'asyncMessagesMultiSendEnabled' | 'audioRecordingEnabled' - | 'cooldownEndsAt' | 'CooldownTimer' | 'SendButton' | 'StopMessageStreamingButton' @@ -46,18 +45,19 @@ export type OutputButtonsWithContextProps = Pick & > & { micPositionX: Animated.SharedValue; micPositionY: Animated.SharedValue; + cooldownIsActive: boolean; }; const textComposerStateSelector = (state: TextComposerState) => ({ command: state.command, - hasText: !!state.text, }); export const OutputButtonsWithContext = (props: OutputButtonsWithContextProps) => { const { + audioRecordingEnabled, channel, - cooldownEndsAt, CooldownTimer, + cooldownIsActive, isOnline, SendButton, StopMessageStreamingButton, @@ -79,13 +79,9 @@ export const OutputButtonsWithContext = (props: OutputButtonsWithContextProps) = const messageComposer = useMessageComposer(); const editing = !!messageComposer.editedMessage; const { textComposer } = messageComposer; - const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); - const { attachments } = useAttachmentManagerState(); + const { command } = useStateStore(textComposer.state, textComposerStateSelector); const hasSendableData = useMessageComposerHasSendableData(); - - const showSendingButton = hasText || attachments.length || command; - - const { seconds: cooldownRemainingSeconds } = useCountdown(cooldownEndsAt); + const compositionIsEmpty = useMessageCompositionIsEmpty(); const { aiState } = useAIState(channel); const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); @@ -94,9 +90,7 @@ export const OutputButtonsWithContext = (props: OutputButtonsWithContextProps) = if (shouldDisplayStopAIGeneration) { return ; - } - - if (editing || command) { + } else if (editing || command) { return ( ); - } - - if (cooldownRemainingSeconds) { + } else if (cooldownIsActive) { return ( - + ); - } - - if (showSendingButton) { + } else if (audioRecordingEnabled && compositionIsEmpty) { + return ( + + + + ); + } else { return ( ); } - - return ( - - - - ); }; const areEqual = ( prevProps: OutputButtonsWithContextProps, nextProps: OutputButtonsWithContextProps, ) => { - const { channel: prevChannel, cooldownEndsAt: prevCooldownEndsAt } = prevProps; + const { channel: prevChannel, cooldownIsActive: prevCooldownIsActive } = prevProps; - const { channel: nextChannel, cooldownEndsAt: nextCooldownEndsAt } = nextProps; + const { channel: nextChannel, cooldownIsActive: nextCooldownIsActive } = nextProps; if (prevChannel?.cid !== nextChannel?.cid) { return false; } - const cooldownEndsAtEqual = prevCooldownEndsAt === nextCooldownEndsAt; - if (!cooldownEndsAtEqual) { + const cooldownIsActiveEqual = prevCooldownIsActive === nextCooldownIsActive; + if (!cooldownIsActiveEqual) { return false; } @@ -181,12 +171,12 @@ export const OutputButtons = (props: OutputButtonsProps) => { asyncMessagesSlideToCancelDistance, asyncMessagesLockDistance, asyncMessagesMultiSendEnabled, - cooldownEndsAt, CooldownTimer, SendButton, StopMessageStreamingButton, StartAudioRecordingButton, } = useMessageInputContext(); + const cooldownIsActive = useIsCooldownActive(); return ( { asyncMessagesSlideToCancelDistance, audioRecordingEnabled, channel, - cooldownEndsAt, + cooldownIsActive, CooldownTimer, isOnline, SendButton, diff --git a/package/src/components/MessageInput/hooks/useCooldown.ts b/package/src/components/MessageInput/hooks/useCooldown.ts deleted file mode 100644 index 7e77d4cfbe..0000000000 --- a/package/src/components/MessageInput/hooks/useCooldown.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useState } from 'react'; - -import { BuiltinRoles, ChannelResponse, Role } from 'stream-chat'; - -import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; - -import { ONE_SECOND_IN_MS } from '../../../utils/date'; - -type Roles = Array; - -/** - * useCooldown can be used to start a cooldown defined - * for a Channel by setting an end time for - **/ -export const useCooldown = () => { - const [endsAt, setEndsAt] = useState(new Date()); - - const { client } = useChatContext(); - const { channel } = useChannelContext(); - const { cooldown } = (channel?.data || {}) as ChannelResponse; - const interval: number = cooldown ?? 0; - - /** - * We get the roles a user has globally set on the Client, and the role - * they have locally within the chat. If any of the predefined - * `disabledRoles` are matched to either of the users roles, the user - * will not have a cooldown at all. - **/ - const disabledRoles = [BuiltinRoles.Admin, BuiltinRoles.ChannelModerator]; - const userClientRole = client?.user?.role || ''; - const userChannelRole = channel?.state.members[client.userID || '']?.role || ''; - - const disabledFor = (roles: Roles) => - disabledRoles.some((roleToSkip) => roles.includes(roleToSkip)); - - const enabled = interval > 0 && !disabledFor([userClientRole, userChannelRole]); - - const start = () => { - if (enabled) { - setEndsAt(new Date(Date.now() + interval * ONE_SECOND_IN_MS)); - } - }; - - return { endsAt, start }; -}; diff --git a/package/src/components/MessageInput/hooks/useCooldownRemaining.tsx b/package/src/components/MessageInput/hooks/useCooldownRemaining.tsx new file mode 100644 index 0000000000..e3ec0ea0eb --- /dev/null +++ b/package/src/components/MessageInput/hooks/useCooldownRemaining.tsx @@ -0,0 +1,21 @@ +import { type CooldownTimerState } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +const cooldownTimerStateSelector = (state: CooldownTimerState) => ({ + cooldownRemaining: state.cooldownRemaining, +}); + +/** + * Provides and initial value of cooldown, from which the countdown should start, e.g.: + * + * The value of channel.data.cooldown is 100s but 30s has already elapsed, user reloads the page, + * the initial value is now 70s from which the countdown will continue using useTimer() hook. + */ +export const useCooldownRemaining = (): number => { + const { channel } = useChannelContext(); + return ( + useStateStore(channel.cooldownTimer.state, cooldownTimerStateSelector).cooldownRemaining ?? 0 + ); +}; diff --git a/package/src/components/MessageInput/hooks/useCountdown.ts b/package/src/components/MessageInput/hooks/useCountdown.ts deleted file mode 100644 index a62038cc20..0000000000 --- a/package/src/components/MessageInput/hooks/useCountdown.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { ONE_SECOND_IN_MS, secondsUntil } from '../../../utils/date'; - -/** - * Start a countdown to a set date, in seconds. - * The date passed in as an argument to useCountdown - * will be rounded up to the nearest second. - **/ -export const useCountdown = (end: Date) => { - const [seconds, setSeconds] = useState(0); - - /** - * When a new `end` is set for the countdown, start the counter if - * the `end` is in the future. - **/ - useEffect(() => { - let intervalId: ReturnType; - - const startCountdown = (seconds: number) => { - setSeconds(seconds); - intervalId = setInterval(() => { - setSeconds((previous: number) => { - const next = previous - 1; - if (next < 1) { - /* Don't trigger an unnecessary rerender when done */ - clearInterval(intervalId); - return 0; - } - return next; - }); - }, ONE_SECOND_IN_MS); - }; - - const secondsUntilEnd = secondsUntil(end); - if (secondsUntilEnd > 0) { - startCountdown(secondsUntilEnd); - } - - return () => { - if (intervalId) { - clearInterval(intervalId); - } - }; - }, [end]); - - return { seconds }; -}; diff --git a/package/src/components/MessageInput/hooks/useIsCooldownActive.ts b/package/src/components/MessageInput/hooks/useIsCooldownActive.ts new file mode 100644 index 0000000000..6c6494b8fc --- /dev/null +++ b/package/src/components/MessageInput/hooks/useIsCooldownActive.ts @@ -0,0 +1,13 @@ +import { CooldownTimerState } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +const cooldownTimerStateSelector = (state: CooldownTimerState) => ({ + isCooldownActive: !!state.cooldownRemaining, +}); + +export const useIsCooldownActive = () => { + const { channel } = useChannelContext(); + return useStateStore(channel.cooldownTimer.state, cooldownTimerStateSelector).isCooldownActive; +}; 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 76dc288597..bcf852d878 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -2174,108 +2174,119 @@ exports[`Thread should match thread snapshot 1`] = ` style={{}} > - - - - - - - + /> + +
diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index ab3e8f3e49..43d7c8c2e8 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -47,10 +47,8 @@ import type { AudioRecordingLockIndicatorProps } from '../../components/MessageI import type { AudioRecordingWaveformProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingWaveform'; 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 { useAudioRecorder } from '../../components/MessageInput/hooks/useAudioRecorder'; -import { useCooldown } from '../../components/MessageInput/hooks/useCooldown'; import type { MessageInputProps } from '../../components/MessageInput/MessageInput'; import { useStableCallback } from '../../hooks/useStableCallback'; import { @@ -79,9 +77,6 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type LocalMessageInputContext = { closeAttachmentPicker: () => void; - /** The time at which the active cooldown will end */ - cooldownEndsAt: Date; - inputBoxRef: React.RefObject; openAttachmentPicker: () => void; /** @@ -262,7 +257,7 @@ export type InputMessageInputContextValue = { * **default** * [CooldownTimer](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/CooldownTimer.tsx) */ - CooldownTimer: React.ComponentType; + CooldownTimer: React.ComponentType; editMessage: (params: { localMessage: LocalMessage; options?: UpdateMessageOptions; @@ -428,8 +423,6 @@ export const MessageInputProvider = ({ allowSendBeforeAttachmentsUpload, } = value; - const { endsAt: cooldownEndsAt, start: startCooldown } = useCooldown(); - const messageComposer = useMessageComposer(); const { attachmentManager, editedMessage } = messageComposer; const { availableUploadSlots } = useAttachmentManagerState(); @@ -577,8 +570,6 @@ export const MessageInputProvider = ({ }, [closeAttachmentPicker, openAttachmentPicker, selectedPicker]); const sendMessage = useStableCallback(async () => { - startCooldown(); - if (inputBoxRef.current) { inputBoxRef.current.clear(); } @@ -680,7 +671,6 @@ export const MessageInputProvider = ({ const messageInputContext = useCreateMessageInputContext({ closeAttachmentPicker, - cooldownEndsAt, inputBoxRef, openAttachmentPicker, pickAndUploadImageFromNativePicker, diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 20e99ceacb..2d393941ff 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, compressImageQuality, - cooldownEndsAt, CooldownTimer, CreatePollContent, CreatePollIcon, @@ -107,7 +106,6 @@ export const useCreateMessageInputContext = ({ closeAttachmentPicker, closePollCreationDialog, compressImageQuality, - cooldownEndsAt, CooldownTimer, CreatePollContent, CreatePollIcon, @@ -152,7 +150,7 @@ export const useCreateMessageInputContext = ({ stopVoiceRecording, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [cooldownEndsAt, threadId, showPollCreationDialog, selectedPicker], + [threadId, showPollCreationDialog, selectedPicker], ); return messageInputContext; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageCompositionIsEmpty.ts b/package/src/contexts/messageInputContext/hooks/useMessageCompositionIsEmpty.ts new file mode 100644 index 0000000000..15f49d65ad --- /dev/null +++ b/package/src/contexts/messageInputContext/hooks/useMessageCompositionIsEmpty.ts @@ -0,0 +1,13 @@ +import type { EditingAuditState } from 'stream-chat'; + +import { useMessageComposer } from './useMessageComposer'; + +import { useStateStore } from '../../../hooks/useStateStore'; + +const editingAuditStateStateSelector = (state: EditingAuditState) => state; + +export const useMessageCompositionIsEmpty = () => { + const messageComposer = useMessageComposer(); + useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); + return messageComposer.compositionIsEmpty; +}; diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 9837a87b65..0610ca467c 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -106,6 +106,7 @@ "Show All": "Show All", "Slide to Cancel": "Slide to Cancel", "Slow mode ON": "Slow mode ON", + "Slow mode, wait {{seconds}}s...": "Slow mode, wait {{seconds}}s...", "Suggest an option": "Suggest an option", "The message has been reported to a moderator.": "The message has been reported to a moderator.", "The source message was deleted": "The source message was deleted", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index 6c29a9cbe6..34dc0d1d46 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -106,6 +106,7 @@ "Show All": "Mostrar todo", "Slide to Cancel": "Desliza para cancelar", "Slow mode ON": "Modo lento ACTIVADO", + "Slow mode, wait {{seconds}}s...": "Modo lento, espera {{seconds}}s...", "Suggest an option": "Sugerir una opciรณn", "The message has been reported to a moderator.": "El mensaje ha sido reportado a un moderador.", "The source message was deleted": "El mensaje original fue eliminado", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 3227ab9290..dd99db9648 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -106,6 +106,7 @@ "Show All": "Afficher tout", "Slide to Cancel": "Glisser pour annuler", "Slow mode ON": "Mode lent activรฉ", + "Slow mode, wait {{seconds}}s...": "Mode lent, attendez {{seconds}}s...", "Suggest an option": "Suggรฉrer une option", "The message has been reported to a moderator.": "Le message a รฉtรฉ signalรฉ ร  un modรฉrateur.", "The source message was deleted": "Le message source a รฉtรฉ supprimรฉ", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 8742f2d3fe..66dfc4bea6 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -106,6 +106,7 @@ "Show All": "ื”ืฆื’ ื”ื›ืœ", "Slide to Cancel": "ื’ืœื’ืœ/ื™ ื›ื“ื™ ืœื‘ื˜ืœ", "Slow mode ON": "ืžืฆื‘ ืื™ื˜ื™ ืžื•ืคืขืœ", + "Slow mode, wait {{seconds}}s...": "ืžืฆื‘ ืื™ื˜ื™, ื—ื›ื” {{seconds}}s...", "Suggest an option": "ื”ืฆืข ืืคืฉืจื•ืช", "The message has been reported to a moderator.": "ื”ื”ื•ื“ืขื” ื“ื•ื•ื—ื” ืœืžื ื”ืœ", "The source message was deleted": "ื”ื”ื•ื“ืขื” ื”ืžืงื•ืจื™ืช ื ืžื—ืงื”", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 2ea6b2a80d..417fb29b82 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -106,6 +106,7 @@ "Show All": "เคธเคญเฅ€ เคฆเคฟเค–เคพเคเค‚", "Slide to Cancel": "เคธเฅเคฒเคพเค‡เคก เค•เคฐเค•เฅ‡ เคฐเคฆเฅเคฆ เค•เคฐเฅ‡เค‚", "Slow mode ON": "เคธเฅเคฒเฅ‹ เคฎเฅ‹เคก เคšเคพเคฒเฅ‚", + "Slow mode, wait {{seconds}}s...": "เคธเฅเคฒเฅ‹ เคฎเฅ‹เคก, {{seconds}}s เค‡เค‚เคคเคœเคพเคฐ เค•เคฐเฅ‡เค‚...", "Suggest an option": "เคเค• เคตเคฟเค•เคฒเฅเคช เคธเฅเคเคพเคเค‚", "The message has been reported to a moderator.": "เคธเค‚เคฆเฅ‡เคถ เคเค• เคฎเฅ‰เคกเคฐเฅ‡เคŸเคฐ เค•เฅ‹ เคธเฅ‚เคšเคฟเคค เค•เคฟเคฏเคพ เค—เคฏเคพ เคนเฅˆเฅค", "The source message was deleted": "เคธเฅเคฐเฅ‹เคค เคธเค‚เคฆเฅ‡เคถ เคนเคŸเคพ เคฆเคฟเคฏเคพ เค—เคฏเคพ เคนเฅˆ", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index e89c76d4ba..aec7e7a420 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -106,6 +106,7 @@ "Show All": "Mostra tutto", "Slide to Cancel": "Scorri per annullare", "Slow mode ON": "Slowmode attiva", + "Slow mode, wait {{seconds}}s...": "Slowmode, attendi {{seconds}}s...", "Suggest an option": "Suggerisci un'opzione", "The message has been reported to a moderator.": "Il messaggio รจ stato segnalato a un moderatore.", "The source message was deleted": "Il messaggio originale รจ stato eliminato", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 4394088f31..c26566fa78 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -106,6 +106,7 @@ "Show All": "ใ™ในใฆ่กจ็คบ", "Slide to Cancel": "ใ‚นใƒฉใ‚คใƒ‰ใ—ใฆใ‚ญใƒฃใƒณใ‚ปใƒซ", "Slow mode ON": "ใ‚นใƒญใƒผใƒขใƒผใƒ‰ใ‚ชใƒณ", + "Slow mode, wait {{seconds}}s...": "ใ‚นใƒญใƒผใƒขใƒผใƒ‰, {{seconds}}s ๅพ…ใฃใฆใใ ใ•ใ„...", "Suggest an option": "ใ‚ชใƒ—ใ‚ทใƒงใƒณใ‚’ๆๆกˆ", "The message has been reported to a moderator.": "ใƒกใƒƒใ‚ปใƒผใ‚ธใฏใƒขใƒ‡ใƒฌใƒผใ‚ฟใƒผใซๅ ฑๅ‘Šใ•ใ‚Œใพใ—ใŸใ€‚", "The source message was deleted": "ๅ…ƒใฎใƒกใƒƒใ‚ปใƒผใ‚ธใŒๅ‰Š้™คใ•ใ‚Œใพใ—ใŸ", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index a9d61ef72a..4e93d1d3ac 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -106,6 +106,7 @@ "Show All": "๋ชจ๋‘ ๋ณด๊ธฐ", "Slide to Cancel": "์Šฌ๋ผ์ด๋“œํ•˜์—ฌ ์ทจ์†Œ", "Slow mode ON": "์Šฌ๋กœ๋ชจ๋“œ ์ผœ์ง", + "Slow mode, wait {{seconds}}s...": "์Šฌ๋กœ๋ชจ๋“œ, {{seconds}}s ๋Œ€๊ธฐ...", "Suggest an option": "์˜ต์…˜ ์ œ์•ˆ", "The message has been reported to a moderator.": "๋ฉ”์‹œ์ง€๋Š” ์šด์˜์ž์—๋ณด๊ณ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", "The source message was deleted": "์›๋ณธ ๋ฉ”์‹œ์ง€๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index eada070bd6..9bb3a46470 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -106,6 +106,7 @@ "Show All": "Alles weergeven", "Slide to Cancel": "Slide om te annuleren", "Slow mode ON": "Langzame modus aan", + "Slow mode, wait {{seconds}}s...": "Langzame modus, wacht {{seconds}}s...", "Suggest an option": "Stel een optie voor", "The message has been reported to a moderator.": "Het bericht is gerapporteerd aan een moderator.", "The source message was deleted": "Het oorspronkelijke bericht is verwijderd", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 4fb3d21991..fd2ac9ae0d 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -106,6 +106,7 @@ "Show All": "Mostrar tudo", "Slide to Cancel": "Deslize para cancelar", "Slow mode ON": "Modo Lento ATIVADO", + "Slow mode, wait {{seconds}}s...": "Modo lento, aguarde {{seconds}}s...", "Suggest an option": "Sugerir uma opรงรฃo", "The message has been reported to a moderator.": "A mensagem foi relatada a um moderador.", "The source message was deleted": "A mensagem original foi excluรญda", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 1042e82e53..01fbf4592b 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -106,6 +106,7 @@ "Show All": "ะŸะพะบะฐะทะฐั‚ัŒ ะฒัะต", "Slide to Cancel": "ะกะปะฐะนะด ะดะปั ะพั‚ะผะตะฝั‹", "Slow mode ON": "ะœะตะดะปะตะฝะฝั‹ะน ั€ะตะถะธะผ ะฒะบะปัŽั‡ะตะฝ", + "Slow mode, wait {{seconds}}s...": "ะœะตะดะปะตะฝะฝั‹ะน ั€ะตะถะธะผ, ะพะถะธะดะฐะนั‚ะต {{seconds}}s...", "Suggest an option": "ะŸั€ะตะดะปะพะถะธั‚ัŒ ะฒะฐั€ะธะฐะฝั‚", "The message has been reported to a moderator.": "ะกะพะพะฑั‰ะตะฝะธะต ะพั‚ะฟั€ะฐะฒะปะตะฝะพ ะผะพะดะตั€ะฐั‚ะพั€ัƒ.", "The source message was deleted": "ะ˜ัั…ะพะดะฝะพะต ัะพะพะฑั‰ะตะฝะธะต ะฑั‹ะปะพ ัƒะดะฐะปะตะฝะพ", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 6f2b955938..eb555200ae 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -106,6 +106,7 @@ "Show All": "Hepsini gรถster", "Slide to Cancel": "Silmek iรงin kaydฤฑrฤฑn", "Slow mode ON": "YavaลŸ Mod Aรงฤฑk", + "Slow mode, wait {{seconds}}s...": "YavaลŸ mod, {{seconds}}s bekleyin...", "Suggest an option": "Bir seรงenek รถner", "The message has been reported to a moderator.": "Mesaj moderatรถre bildirildi.", "The source message was deleted": "Kaynak mesaj silindi", From f338de549732f67a9dcb734e13988b120be21767 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 4 Feb 2026 18:44:04 +0530 Subject: [PATCH 49/76] feat: add button component implementation (#3386) https://github.com/user-attachments/assets/68ba9a81-4fd9-4a6a-9f58-ca6306cd4bd9 --------- Co-authored-by: Ivan Sekovanikj --- .../__snapshots__/AttachButton.test.js.snap | 558 +++++++++--------- .../__snapshots__/SendButton.test.js.snap | 386 ++++++------ .../AudioRecorder/AudioRecorder.tsx | 43 +- .../AudioRecorder/AudioRecordingButton.tsx | 16 +- .../components/InputButtons/AttachButton.tsx | 22 +- .../OutputButtons/CooldownTimer.tsx | 49 +- .../components/OutputButtons/EditButton.tsx | 20 +- .../components/OutputButtons/SendButton.tsx | 18 +- .../MessageList/ScrollToBottomButton.tsx | 70 ++- .../ScrollToBottomButton.test.js.snap | 220 ++++--- .../__snapshots__/Thread.test.js.snap | 380 ++++++------ package/src/components/ui/Avatar/index.ts | 1 + package/src/components/ui/Button/Button.tsx | 168 ++++++ package/src/components/ui/Button/constants.ts | 22 + .../ui/Button/hooks/useButtonStyles.ts | 118 ++++ package/src/components/ui/Button/index.ts | 1 + package/src/components/ui/IconButton.tsx | 119 ---- package/src/components/ui/index.ts | 2 +- 18 files changed, 1221 insertions(+), 992 deletions(-) create mode 100644 package/src/components/ui/Button/Button.tsx create mode 100644 package/src/components/ui/Button/constants.ts create mode 100644 package/src/components/ui/Button/hooks/useButtonStyles.ts create mode 100644 package/src/components/ui/Button/index.ts delete mode 100644 package/src/components/ui/IconButton.tsx 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 8c62fffa57..4733e48b26 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -19,114 +19,101 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli } > - - - - - + > + + + +
- - - - - + > + + + +
- - - - - + > + + + +
- - - - - + > + + + +
- - - - - + > + + + +
{ NativeHandlers.triggerHaptic('impactMedium'); stopVoiceRecordingHandler(); }; + const StopIcon = useCallback( + (props: IconProps) => , + [semantics.buttonDestructiveBg], + ); + return ( - ); }; @@ -77,11 +82,12 @@ const UploadRecording = ({ }; return ( - ); @@ -97,12 +103,13 @@ const DeleteRecording = ({ deleteVoiceRecordingHandler(); }; return ( - ); }; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 6950f4370c..82f95e7385 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -11,7 +11,6 @@ import Animated, { withSpring, } from 'react-native-reanimated'; -import { IconButton } from '../../../../components/ui/IconButton'; import { useActiveAudioPlayer } from '../../../../contexts/audioPlayerContext/AudioPlayerContext'; import { MessageInputContextValue, @@ -24,6 +23,7 @@ import { useStateStore } from '../../../../hooks/useStateStore'; import { NewMic } from '../../../../icons/NewMic'; import { NativeHandlers } from '../../../../native'; import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { Button } from '../../../ui'; export type AudioRecordingButtonPropsWithContext = Pick< MessageInputContextValue, @@ -228,13 +228,17 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps return ( - diff --git a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx index 9f8640be40..cce437b1e1 100644 --- a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx +++ b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx @@ -9,9 +9,8 @@ import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { NewPlus } from '../../../../icons/NewPlus'; -import { IconButton } from '../../../ui/IconButton'; +import { Button } from '../../../ui/'; import { NativeAttachmentPicker } from '../NativeAttachmentPicker'; type AttachButtonPropsWithContext = Pick< @@ -35,11 +34,6 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { selectedPicker, toggleAttachmentPicker, } = props; - const { - theme: { - messageInput: { attachButton }, - }, - } = useTheme(); const onAttachButtonLayout = (event: LayoutChangeEvent) => { const layout = event.nativeEvent.layout; @@ -82,15 +76,17 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { return ( <> - {showAttachButtonPicker ? ( { const seconds = useCooldownRemaining(); - const styles = useStyles(); - const { - theme: { - messageInput: { - cooldownTimer: { text }, - }, - }, - } = useTheme(); - - const icon = useCallback(() => { - return ( - - {seconds} - - ); - }, [seconds, text, styles]); return ( - ); }; - -const useStyles = () => { - const { - theme: { semantics }, - } = useTheme(); - return useMemo( - () => - StyleSheet.create({ - text: { - color: semantics.textDisabled, - fontSize: primitives.typographyFontSizeMd, - fontWeight: primitives.typographyFontWeightSemiBold, - lineHeight: primitives.typographyLineHeightNormal, - }, - }), - [semantics.textDisabled], - ); -}; diff --git a/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx index 48adc924f5..fbae9135b6 100644 --- a/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx @@ -4,9 +4,8 @@ import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { NewTick } from '../../../../icons/NewTick'; -import { IconButton } from '../../../ui/IconButton'; +import { Button } from '../../../ui'; export type EditButtonProps = Partial> & { /** Disables the button */ @@ -18,12 +17,6 @@ export const EditButton = (props: EditButtonProps) => { const { sendMessage: sendMessageFromContext } = useMessageInputContext(); const sendMessage = propsSendMessage || sendMessageFromContext; - const { - theme: { - messageInput: { editButton }, - }, - } = useTheme(); - const onPressHandler = useCallback(() => { if (disabled) { return; @@ -32,13 +25,14 @@ export const EditButton = (props: EditButtonProps) => { }, [disabled, sendMessage]); return ( - ); diff --git a/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx index b0ccabad26..3a9f6962b9 100644 --- a/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx @@ -4,9 +4,8 @@ import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { SendRight } from '../../../../icons/SendRight'; -import { IconButton } from '../../../ui/IconButton'; +import { Button } from '../../../ui'; export type SendButtonProps = Partial> & { /** Disables the button */ @@ -18,12 +17,6 @@ export const SendButton = (props: SendButtonProps) => { const { sendMessage: sendMessageFromContext } = useMessageInputContext(); const sendMessage = propsSendMessage || sendMessageFromContext; - const { - theme: { - messageInput: { sendButton }, - }, - } = useTheme(); - const onPressHandler = useCallback(() => { if (disabled) { return; @@ -32,14 +25,15 @@ export const SendButton = (props: SendButtonProps) => { }, [disabled, sendMessage]); return ( - ); }; diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index c8614c1656..3c867275d1 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -1,23 +1,13 @@ import React from 'react'; -import { StyleSheet, 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/BadgeNotification'; -import { IconButton } from '../ui/IconButton'; - -const styles = StyleSheet.create({ - unreadCountNotificationContainer: { - position: 'absolute', - right: 0, - top: 0, - }, - container: { - padding: 4, - }, -}); +import { primitives } from '../../theme'; +import { BadgeNotification } from '../ui'; +import { Button } from '../ui/Button'; export type ScrollToBottomButtonProps = { /** onPress handler */ @@ -29,13 +19,8 @@ export type ScrollToBottomButtonProps = { export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { const { onPress, showNotification = true, unreadCount } = props; - const { - theme: { - messageList: { - scrollToBottomButton: { container }, - }, - }, + theme: { semantics }, } = useTheme(); if (!showNotification) { @@ -43,28 +28,51 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { } return ( - - + - - + + {unreadCount ? ( ) : null} - + ); }; +const styles = StyleSheet.create({ + unreadCountNotificationContainer: { + position: 'absolute', + right: 0, + top: 0, + }, + floatingButtonContainer: { + borderRadius: primitives.radiusMax, + }, + container: { + padding: primitives.spacingXxs, + }, +}); + ScrollToBottomButton.displayName = 'ScrollToBottomButton{messageList{scrollToBottomButton}}'; 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 ea4ebf392b..c18d5de3c1 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap @@ -2,6 +2,8 @@ exports[`ScrollToBottomButton should render the message notification and match snapshot 1`] = ` - + - - - - - + > + + + + - - - - - + > + + + +
@@ -2174,137 +2180,143 @@ exports[`Thread should match thread snapshot 1`] = ` style={{}} > - - - - - + > + + + +
diff --git a/package/src/components/ui/Avatar/index.ts b/package/src/components/ui/Avatar/index.ts index 17e92462dd..d4bd64f670 100644 --- a/package/src/components/ui/Avatar/index.ts +++ b/package/src/components/ui/Avatar/index.ts @@ -2,3 +2,4 @@ export * from './Avatar'; export * from './ChannelAvatar'; export * from './UserAvatar'; export * from './AvatarStack'; +export * from './AvatarGroup'; diff --git a/package/src/components/ui/Button/Button.tsx b/package/src/components/ui/Button/Button.tsx new file mode 100644 index 0000000000..c9051c3ed6 --- /dev/null +++ b/package/src/components/ui/Button/Button.tsx @@ -0,0 +1,168 @@ +import React, { useMemo } from 'react'; +import { I18nManager, Pressable, PressableProps, StyleSheet, Text, View } from 'react-native'; + +import { buttonPadding, buttonSizes } from './constants'; +import { useButtonStyles } from './hooks/useButtonStyles'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { IconProps } from '../../../icons/utils/base'; +import { primitives } from '../../../theme'; + +type IconRenderer = (props: IconProps) => React.ReactNode; + +export type ButtonProps = PressableProps & { + /** + * The style of the button. + */ + variant: 'primary' | 'secondary' | 'destructive'; + /** + * The type of the button. + */ + type: 'solid' | 'outline' | 'ghost' | 'liquidGlass'; + /** + * The size of the button. + */ + size?: 'sm' | 'md' | 'lg'; + /** + * Whether the button is selected. + */ + selected?: boolean; + /** + * The icon to display on the leading side of the button. + */ + LeadingIcon?: IconRenderer; + /** + * The content to display on the center of the button. + */ + label?: React.ReactNode; + /** + * The icon to display on the trailing side of the button. + */ + TrailingIcon?: IconRenderer; + /** + * Whether the button is only an icon. + */ + iconOnly?: boolean; +}; + +export const Button = ({ + variant, + type, + selected = false, + size = 'md', + LeadingIcon, + TrailingIcon, + iconOnly = false, + label, + onLayout, + disabled = false, + ...rest +}: ButtonProps) => { + const { + theme: { semantics }, + } = useTheme(); + const buttonStyles = useButtonStyles({ variant, type }); + const styles = useStyles(); + + const isRTL = I18nManager.isRTL; + + const LeftIcon = isRTL ? TrailingIcon : LeadingIcon; + const RightIcon = isRTL ? LeadingIcon : TrailingIcon; + + return ( + + [ + { + backgroundColor: pressed + ? semantics.backgroundCorePressed + : selected + ? semantics.backgroundCoreSelected + : 'transparent', + }, + styles.container, + { paddingHorizontal: buttonPadding[size] }, + ]} + {...rest} + > + {LeftIcon ? ( + + ) : null} + {!iconOnly ? ( + <> + {typeof label === 'string' ? ( + + {label} + + ) : ( + label + )} + {RightIcon ? ( + + ) : null} + + ) : null} + + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + wrapper: { + borderRadius: primitives.radiusMax, + }, + container: { + borderRadius: primitives.radiusMax, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + gap: primitives.spacingXs, + }, + label: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [], + ); +}; diff --git a/package/src/components/ui/Button/constants.ts b/package/src/components/ui/Button/constants.ts new file mode 100644 index 0000000000..718b576046 --- /dev/null +++ b/package/src/components/ui/Button/constants.ts @@ -0,0 +1,22 @@ +import { primitives } from '../../../theme'; + +export const buttonSizes = { + sm: { + height: 32, + width: 32, + }, + md: { + height: 40, + width: 40, + }, + lg: { + height: 48, + width: 48, + }, +}; + +export const buttonPadding = { + sm: primitives.spacingSm, + md: primitives.spacingMd, + lg: primitives.spacingLg, +}; diff --git a/package/src/components/ui/Button/hooks/useButtonStyles.ts b/package/src/components/ui/Button/hooks/useButtonStyles.ts new file mode 100644 index 0000000000..86f3bbb32c --- /dev/null +++ b/package/src/components/ui/Button/hooks/useButtonStyles.ts @@ -0,0 +1,118 @@ +import { useMemo } from 'react'; +import { ColorValue } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { ButtonProps } from '../Button'; + +export type ButtonStyleCategory = + | 'primarySolid' + | 'primaryOutline' + | 'primaryGhost' + | 'secondarySolid' + | 'secondaryOutline' + | 'secondaryGhost' + | 'destructiveSolid' + | 'destructiveOutline' + | 'destructiveGhost'; + +export type ButtonStyle = { + foregroundColor?: ColorValue; + backgroundColor?: ColorValue; + borderColor?: ColorValue; + disabledForegroundColor?: ColorValue; + disabledBackgroundColor?: ColorValue; + disabledBorderColor?: ColorValue; +}; + +/** + * Returns the styles for the button based on the button style and type. + * @param buttonStyle - The style of the button. + * @param type - The type of the button. + * @returns The styles for the button. + */ +export const useButtonStyles = ({ variant, type }: Pick) => { + const { + theme: { semantics }, + } = useTheme(); + + const category = variant.concat(type[0].toUpperCase() + type.slice(1)) as ButtonStyleCategory; + + const defaultButtonStyles: Record = useMemo(() => { + return { + primarySolid: { + foregroundColor: semantics.buttonPrimaryTextOnAccent, + backgroundColor: semantics.buttonPrimaryBg, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: semantics.backgroundCoreDisabled, + disabledBorderColor: undefined, + }, + primaryOutline: { + foregroundColor: semantics.buttonPrimaryText, + backgroundColor: undefined, + borderColor: semantics.buttonPrimaryBorder, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: semantics.borderUtilityDisabled, + }, + primaryGhost: { + foregroundColor: semantics.buttonPrimaryText, + backgroundColor: undefined, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: undefined, + }, + secondarySolid: { + foregroundColor: semantics.buttonSecondaryTextOnAccent, + backgroundColor: semantics.buttonSecondaryBg, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: semantics.backgroundCoreDisabled, + disabledBorderColor: undefined, + }, + secondaryOutline: { + foregroundColor: semantics.buttonSecondaryText, + backgroundColor: undefined, + borderColor: semantics.buttonSecondaryBorder, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: semantics.buttonSecondaryBorder, + }, + secondaryGhost: { + foregroundColor: semantics.buttonSecondaryText, + backgroundColor: undefined, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: undefined, + }, + destructiveSolid: { + foregroundColor: semantics.buttonDestructiveTextOnAccent, + backgroundColor: semantics.buttonDestructiveBg, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: semantics.backgroundCoreDisabled, + disabledBorderColor: undefined, + }, + destructiveOutline: { + foregroundColor: semantics.buttonDestructiveText, + backgroundColor: undefined, + borderColor: semantics.buttonDestructiveBorder, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: semantics.borderUtilityDisabled, + }, + destructiveGhost: { + foregroundColor: semantics.buttonDestructiveText, + backgroundColor: undefined, + borderColor: undefined, + disabledForegroundColor: semantics.textDisabled, + disabledBackgroundColor: undefined, + disabledBorderColor: undefined, + }, + }; + }, [semantics]); + + return defaultButtonStyles[category]; +}; diff --git a/package/src/components/ui/Button/index.ts b/package/src/components/ui/Button/index.ts new file mode 100644 index 0000000000..8b166a86e4 --- /dev/null +++ b/package/src/components/ui/Button/index.ts @@ -0,0 +1 @@ +export * from './Button'; diff --git a/package/src/components/ui/IconButton.tsx b/package/src/components/ui/IconButton.tsx deleted file mode 100644 index dd6d9a6888..0000000000 --- a/package/src/components/ui/IconButton.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React from 'react'; -import { - ColorValue, - Pressable, - PressableProps, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; - -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { IconProps } from '../../icons/utils/base'; - -export type IconButtonProps = PressableProps & { - Icon: React.FC | React.ReactNode; - iconColor?: ColorValue; - 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' - : category === 'outline' - ? 'none' - : getBackgroundColor({ status, type }), - borderColor: type === 'destructive' ? '#D92F26' : '#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/components/ui/index.ts b/package/src/components/ui/index.ts index 6c1291a195..d9d379b842 100644 --- a/package/src/components/ui/index.ts +++ b/package/src/components/ui/index.ts @@ -1,6 +1,6 @@ export * from './Avatar'; export * from './BadgeCount'; export * from './BadgeNotification'; -export * from './IconButton'; export * from './OnlineIndicator'; export * from './VideoPlayIndicator'; +export * from './Button'; From 23ac6cac224d853bfa77bb59a47cfef55a4d8ca0 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:04:11 +0100 Subject: [PATCH 50/76] feat: implement message composer slots (#3387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR implements message composer slots as discussed with the other SDKs. The composer slots are meant to be an easy way to override larger chunks of the composer while maintaining the other bits intact. ## ๐Ÿ›  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 --- .../MessageComposerLeadingView.tsx | 51 +++ .../MessageComposerTrailingView.tsx | 3 + .../components/MessageInput/MessageInput.tsx | 366 +++++++----------- .../MessageInput/MessageInputHeaderView.tsx | 80 ++++ .../MessageInput/MessageInputLeadingView.tsx | 27 ++ .../MessageInput/MessageInputTrailingView.tsx | 36 ++ .../__snapshots__/AttachButton.test.js.snap | 6 +- .../__snapshots__/SendButton.test.js.snap | 4 +- .../AudioRecorder/AudioRecordingButton.tsx | 53 +-- .../components/OutputButtons/index.tsx | 11 +- .../contexts/MicPositionContext.tsx | 34 ++ .../utils/audioRecorderSelectors.ts | 11 + .../utils/messageComposerSelectors.ts | 9 + .../ScrollToBottomButton.test.js.snap | 2 +- .../__snapshots__/Thread.test.js.snap | 4 +- package/src/components/index.ts | 6 + package/src/components/ui/Button/Button.tsx | 1 + .../ui/Button/hooks/useButtonStyles.ts | 4 +- .../hooks/useHasAttachments.ts | 15 + 19 files changed, 451 insertions(+), 272 deletions(-) create mode 100644 package/src/components/MessageInput/MessageComposerLeadingView.tsx create mode 100644 package/src/components/MessageInput/MessageComposerTrailingView.tsx create mode 100644 package/src/components/MessageInput/MessageInputHeaderView.tsx create mode 100644 package/src/components/MessageInput/MessageInputLeadingView.tsx create mode 100644 package/src/components/MessageInput/MessageInputTrailingView.tsx create mode 100644 package/src/components/MessageInput/contexts/MicPositionContext.tsx create mode 100644 package/src/components/MessageInput/utils/audioRecorderSelectors.ts create mode 100644 package/src/components/MessageInput/utils/messageComposerSelectors.ts create mode 100644 package/src/contexts/messageInputContext/hooks/useHasAttachments.ts diff --git a/package/src/components/MessageInput/MessageComposerLeadingView.tsx b/package/src/components/MessageInput/MessageComposerLeadingView.tsx new file mode 100644 index 0000000000..e45e927757 --- /dev/null +++ b/package/src/components/MessageInput/MessageComposerLeadingView.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import Animated, { LinearTransition } from 'react-native-reanimated'; + +import { InputButtons } from './components/InputButtons'; +import { idleRecordingStateSelector } from './utils/audioRecorderSelectors'; + +import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; + +export const MessageComposerLeadingView = () => { + const { + theme: { + messageInput: { inputButtonsContainer }, + }, + } = useTheme(); + const { audioRecorderManager, messageInputFloating } = useMessageInputContext(); + const { isRecordingStateIdle } = useStateStore( + audioRecorderManager.state, + idleRecordingStateSelector, + ); + + return isRecordingStateIdle ? ( + + + + ) : null; +}; + +const styles = StyleSheet.create({ + inputButtonsContainer: { + alignSelf: 'flex-end', + }, + shadow: { + elevation: 6, + + shadowColor: 'hsla(0, 0%, 0%, 0.24)', + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.24, + shadowRadius: 12, + }, +}); diff --git a/package/src/components/MessageInput/MessageComposerTrailingView.tsx b/package/src/components/MessageInput/MessageComposerTrailingView.tsx new file mode 100644 index 0000000000..0ff4c0341a --- /dev/null +++ b/package/src/components/MessageInput/MessageComposerTrailingView.tsx @@ -0,0 +1,3 @@ +export const MessageComposerTrailingView = () => { + return null; +}; diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 2f7c4706ea..ad5d2cff7e 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -14,19 +14,18 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; +import { type UserResponse } from 'stream-chat'; -import { LinkPreviewList } from './components/LinkPreviewList'; -import { OutputButtons } from './components/OutputButtons'; +import { MicPositionProvider } from './contexts/MicPositionContext'; +import { MessageComposerLeadingView } from './MessageComposerLeadingView'; +import { MessageComposerTrailingView } from './MessageComposerTrailingView'; +import { MessageInputHeaderView } from './MessageInputHeaderView'; +import { MessageInputLeadingView } from './MessageInputLeadingView'; +import { MessageInputTrailingView } from './MessageInputTrailingView'; -import { useHasLinkPreviews } from './hooks/useLinkPreviews'; +import { audioRecorderSelector } from './utils/audioRecorderSelectors'; -import { - ChatContextValue, - useAttachmentManagerState, - useChatContext, - useOwnCapabilitiesContext, -} from '../../contexts'; +import { ChatContextValue, useChatContext, useOwnCapabilitiesContext } from '../../contexts'; import { AttachmentPickerContextValue, useAttachmentPickerContext, @@ -62,7 +61,6 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; -import { GiphyBadge } from '../ui/GiphyBadge'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; const useStyles = () => { @@ -191,7 +189,6 @@ type MessageInputPropsWithContext = Pick< Pick & Pick & { editing: boolean; - hasAttachments: boolean; isKeyboardVisible: boolean; TextInputComponent?: React.ComponentType< TextInputProps & { @@ -202,16 +199,6 @@ type MessageInputPropsWithContext = Pick< recordingStatus?: string; }; -const textComposerStateSelector = (state: TextComposerState) => ({ - command: state.command, - mentionedUsers: state.mentionedUsers, - suggestions: state.suggestions, -}); - -const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ - quotedMessage: state.quotedMessage, -}); - const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); @@ -226,7 +213,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesSlideToCancelDistance, - AttachmentUploadPreviewList, AudioRecorder, AudioRecordingInProgress, AudioRecordingLockIndicator, @@ -237,15 +223,12 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { CreatePollContent, disableAttachmentPicker, editing, - hasAttachments, messageInputFloating, messageInputHeightStore, Input, inputBoxRef, - InputButtons, isKeyboardVisible, members, - Reply, threadList, sendMessage, showPollCreationDialog, @@ -259,33 +242,21 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const styles = useStyles(); const messageComposer = useMessageComposer(); - const { clearEditingState } = useMessageComposerAPIContext(); - const onDismissEditMessage = () => { - clearEditingState(); - }; - const { textComposer } = messageComposer; - const { command } = useStateStore(textComposer.state, textComposerStateSelector); - const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); - const hasLinkPreviews = useHasLinkPreviews(); - const { theme: { semantics, messageInput: { attachmentSelectionBar, container, - contentContainer, floatingWrapper, focusedInputBoxContainer, inputBoxContainer, inputBoxWrapper, inputContainer, - inputButtonsContainer, inputFloatingContainer, - outputButtonsContainer, suggestionsListContainer: { container: suggestionListContainer }, wrapper, }, @@ -380,200 +351,154 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const BOTTOM_OFFSET = isKeyboardVisible ? 16 : bottom ? bottom : 16; + const micPositionContextValue = useMemo( + () => ({ micPositionX, micPositionY }), + [micPositionX, micPositionY], + ); + return ( - <> - - messageInputHeightStore.setHeight( - messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight, - ) - } // BOTTOM OFFSET is the position of the input from the bottom of the screen - style={ - messageInputFloating - ? [styles.wrapper, styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] - : [ - styles.wrapper, - { - borderTopWidth: 1, - backgroundColor: semantics.composerBg, - borderColor: semantics.borderCoreDefault, - paddingBottom: BOTTOM_OFFSET, - }, - wrapper, - ] - } - > - {Input ? ( - - ) : ( - - {isRecordingStateIdle ? ( + + <> + + messageInputHeightStore.setHeight( + messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight, + ) + } // BOTTOM OFFSET is the position of the input from the bottom of the screen + style={ + messageInputFloating + ? [styles.wrapper, styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] + : [ + styles.wrapper, + { + borderTopWidth: 1, + backgroundColor: semantics.composerBg, + borderColor: semantics.borderCoreDefault, + paddingBottom: BOTTOM_OFFSET, + }, + wrapper, + ] + } + > + {Input ? ( + + ) : ( + + - {InputButtons && } - - ) : null} - - - {recordingStatus === 'stopped' ? ( - - ) : micLocked ? ( - - ) : null} - {isRecordingStateIdle ? ( + + {recordingStatus === 'stopped' ? ( + + ) : micLocked ? ( + + ) : null} + + + - {editing ? ( - - + ) : ( + <> + + + - - ) : null} - {quotedMessage ? ( - - - - ) : null} - - + + )} + + - ) : null} - - - {!isRecordingStateIdle ? ( - - ) : ( - <> - {command ? ( - - - - ) : null} - - - - )} - - {(recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( - - - - ) : null} - - - + + + + )} + + + + {!isRecordingStateIdle ? ( + + + ) : ( + )} - - - - {!isRecordingStateIdle ? ( - - - - ) : null} - - - - - {!disableAttachmentPicker && selectedPicker ? ( + - + - ) : null} - - {showPollCreationDialog ? ( - - - - - - - - - - ) : null} - + + + ) : null} + + {showPollCreationDialog ? ( + + + + + + + + + + ) : null} + + ); }; @@ -590,7 +515,6 @@ const areEqual = ( channel: prevChannel, closePollCreationDialog: prevClosePollCreationDialog, editing: prevEditing, - hasAttachments: prevHasAttachments, isKeyboardVisible: prevIsKeyboardVisible, isOnline: prevIsOnline, openPollCreationDialog: prevOpenPollCreationDialog, @@ -613,7 +537,6 @@ const areEqual = ( editing: nextEditing, isKeyboardVisible: nextIsKeyboardVisible, isOnline: nextIsOnline, - hasAttachments: nextHasAttachments, openPollCreationDialog: nextOpenPollCreationDialog, selectedPicker: nextSelectedPicker, showPollCreationDialog: nextShowPollCreationDialog, @@ -676,11 +599,6 @@ const areEqual = ( return false; } - const hasAttachmentsEqual = prevHasAttachments === nextHasAttachments; - if (!hasAttachmentsEqual) { - return false; - } - const isKeyboardVisibleEqual = prevIsKeyboardVisible === nextIsKeyboardVisible; if (!isKeyboardVisibleEqual) { return false; @@ -726,12 +644,6 @@ const MemoizedMessageInput = React.memo( export type MessageInputProps = Partial; -const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ - micLocked: state.micLocked, - isRecordingStateIdle: state.status === 'idle', - recordingStatus: state.status, -}); - /** * UI Component for message input * It's a consumer of @@ -798,7 +710,6 @@ export const MessageInput = (props: MessageInputProps) => { const { clearEditingState } = useMessageComposerAPIContext(); const { Reply } = useMessagesContext(); - const { attachments } = useAttachmentManagerState(); const isKeyboardVisible = useKeyboardVisibility(); const { micLocked, isRecordingStateIdle, recordingStatus } = useStateStore( @@ -854,7 +765,6 @@ export const MessageInput = (props: MessageInputProps) => { disableAttachmentPicker, editing, FileSelectorIcon, - hasAttachments: attachments.length > 0, ImageSelectorIcon, Input, inputBoxRef, diff --git a/package/src/components/MessageInput/MessageInputHeaderView.tsx b/package/src/components/MessageInput/MessageInputHeaderView.tsx new file mode 100644 index 0000000000..44d55bd6ad --- /dev/null +++ b/package/src/components/MessageInput/MessageInputHeaderView.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; + +import { LinkPreviewList } from './components/LinkPreviewList'; +import { useHasLinkPreviews } from './hooks/useLinkPreviews'; + +import { idleRecordingStateSelector } from './utils/audioRecorderSelectors'; +import { messageComposerStateStoreSelector } from './utils/messageComposerSelectors'; + +import { useMessageComposerAPIContext } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; +import { useHasAttachments } from '../../contexts/messageInputContext/hooks/useHasAttachments'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext'; +import { useMessagesContext } from '../../contexts/messagesContext/MessagesContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; +import { primitives } from '../../theme'; + +export const MessageInputHeaderView = () => { + const { + theme: { + messageInput: { contentContainer }, + }, + } = useTheme(); + const messageComposer = useMessageComposer(); + const editing = !!messageComposer.editedMessage; + const { clearEditingState } = useMessageComposerAPIContext(); + const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); + const hasLinkPreviews = useHasLinkPreviews(); + const { audioRecorderManager, AttachmentUploadPreviewList } = useMessageInputContext(); + const { Reply } = useMessagesContext(); + const { isRecordingStateIdle } = useStateStore( + audioRecorderManager.state, + idleRecordingStateSelector, + ); + const hasAttachments = useHasAttachments(); + + return isRecordingStateIdle ? ( + + {editing ? ( + + + + ) : null} + {quotedMessage ? ( + + + + ) : null} + + + + ) : null; +}; + +const styles = StyleSheet.create({ + contentContainer: { + gap: primitives.spacingXxs, + overflow: 'hidden', + paddingHorizontal: primitives.spacingXs, + }, +}); diff --git a/package/src/components/MessageInput/MessageInputLeadingView.tsx b/package/src/components/MessageInput/MessageInputLeadingView.tsx new file mode 100644 index 0000000000..2037de40d2 --- /dev/null +++ b/package/src/components/MessageInput/MessageInputLeadingView.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { textComposerStateSelector } from './utils/messageComposerSelectors'; + +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useStateStore } from '../../hooks/useStateStore'; +import { primitives } from '../../theme'; +import { GiphyBadge } from '../ui/GiphyBadge'; + +export const MessageInputLeadingView = () => { + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + + return command ? ( + + + + ) : null; +}; + +const styles = StyleSheet.create({ + giphyContainer: { + padding: primitives.spacingXs, + }, +}); diff --git a/package/src/components/MessageInput/MessageInputTrailingView.tsx b/package/src/components/MessageInput/MessageInputTrailingView.tsx new file mode 100644 index 0000000000..d0a7531f64 --- /dev/null +++ b/package/src/components/MessageInput/MessageInputTrailingView.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { OutputButtons } from './components/OutputButtons'; + +import { audioRecorderSelector } from './utils/audioRecorderSelectors'; + +import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; +import { primitives } from '../../theme'; + +export const MessageInputTrailingView = () => { + const { + theme: { + messageInput: { outputButtonsContainer }, + }, + } = useTheme(); + const { audioRecorderManager } = useMessageInputContext(); + const { micLocked, recordingStatus } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); + return (recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( + + + + ) : null; +}; + +const styles = StyleSheet.create({ + outputButtonsContainer: { + alignSelf: 'flex-end', + padding: primitives.spacingXs, + }, +}); 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 4733e48b26..0f384672ee 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -40,7 +40,7 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -393,7 +393,7 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -746,7 +746,7 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": true, "expanded": undefined, "selected": undefined, } 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 ce5c623954..364869e599 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap @@ -39,7 +39,7 @@ exports[`SendButton should render a SendButton 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -389,7 +389,7 @@ exports[`SendButton should render a disabled SendButton 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": true, "expanded": undefined, "selected": undefined, } diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 82f95e7385..63dd611ac9 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -5,7 +5,6 @@ import { Gesture, GestureDetector, State } from 'react-native-gesture-handler'; import Animated, { clamp, runOnJS, - SharedValue, useAnimatedStyle, useSharedValue, withSpring, @@ -23,7 +22,9 @@ import { useStateStore } from '../../../../hooks/useStateStore'; import { NewMic } from '../../../../icons/NewMic'; import { NativeHandlers } from '../../../../native'; import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; -import { Button } from '../../../ui'; +import { primitives } from '../../../../theme'; +import { ButtonStylesConfig, useButtonStyles } from '../../../ui/Button/hooks/useButtonStyles'; +import { useMicPositionContext } from '../../contexts/MicPositionContext'; export type AudioRecordingButtonPropsWithContext = Pick< MessageInputContextValue, @@ -49,11 +50,14 @@ export type AudioRecordingButtonPropsWithContext = Pick< * Handler to determine what should happen on press of the mic button. */ handlePress?: () => void; - micPositionX: SharedValue; - micPositionY: SharedValue; cancellableDuration: boolean; }; +const buttonStylesConfig: ButtonStylesConfig = { + variant: 'secondary', + type: 'ghost', +}; + /** * Component to display the mic button on the Message Input. */ @@ -69,21 +73,23 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps uploadVoiceRecording, handleLongPress, handlePress, - micPositionX, - micPositionY, cancellableDuration, status, recording, } = props; + const { micPositionX, micPositionY } = useMicPositionContext(); const activeAudioPlayer = useActiveAudioPlayer(); const scale = useSharedValue(1); + const pressed = useSharedValue(false); const { t } = useTranslationContext(); const { theme: { messageInput: { micButtonContainer }, + semantics, }, } = useTheme(); + const buttonStyles = useButtonStyles(buttonStylesConfig); const onPressHandler = useStableCallback(() => { if (handlePress) { @@ -147,9 +153,9 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps const onTouchGestureEnd = useStableCallback(() => { if (status === 'recording') { if (cancellableDuration) { - runOnJS(onEarlyReleaseHandler)(); + onEarlyReleaseHandler(); } else { - runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); + uploadVoiceRecording(asyncMessagesMultiSendEnabled); } } }); @@ -160,17 +166,19 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps .minDuration(asyncMessagesMinimumPressDuration) .onBegin(() => { scale.value = withSpring(0.8, { mass: 0.5 }); + pressed.value = true; }) .onStart(() => { runOnJS(onLongPressHandler)(); }) .onFinalize((e) => { scale.value = withSpring(1, { mass: 0.5 }); + pressed.value = false; if (e.state === State.FAILED) { runOnJS(onPressHandler)(); } }), - [asyncMessagesMinimumPressDuration, onLongPressHandler, onPressHandler, scale], + [asyncMessagesMinimumPressDuration, onLongPressHandler, onPressHandler, scale, pressed], ); const panGesture = useMemo( @@ -222,33 +230,20 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps const animatedStyle = useAnimatedStyle(() => { return { transform: [{ scale: scale.value }], + backgroundColor: pressed.value ? semantics.backgroundCorePressed : 'transparent', }; }); return ( -