From edae5decc57eaa70622145425591a8b677238110 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 30 Jan 2026 16:15:23 +0530 Subject: [PATCH 1/2] feat: async audio redesign and message input ui fixes --- .../src/components/ChannelInfoOverlay.tsx | 14 +- .../components/ConfirmationBottomSheet.tsx | 2 +- .../MessageSearch/MessageSearchList.tsx | 2 +- .../SampleApp/src/components/ScreenHeader.tsx | 2 +- .../src/components/UserInfoOverlay.tsx | 10 +- .../UserSearch/UserSearchResults.tsx | 2 +- .../src/screens/ChannelFilesScreen.tsx | 2 +- .../SampleApp/src/screens/ChannelScreen.tsx | 2 +- .../src/screens/GroupChannelDetailsScreen.tsx | 16 +- .../src/screens/NewDirectMessagingScreen.tsx | 2 +- .../NewGroupChannelAddMemberScreen.tsx | 2 +- .../NewGroupChannelAssignNameScreen.tsx | 2 +- .../screens/OneOnOneChannelDetailScreen.tsx | 16 +- .../src/screens/UserSelectorScreen.tsx | 4 +- .../src/optionalDependencies/Audio.ts | 1 - .../Attachment/AttachmentActions.tsx | 2 +- .../components/Attachment/AudioAttachment.tsx | 65 +-- .../Attachment/FileAttachmentGroup.tsx | 1 + .../AutoCompleteInput/AutoCompleteInput.tsx | 24 +- package/src/components/Channel/Channel.tsx | 5 +- .../useCreateInputMessageInputContext.ts | 2 - .../src/components/ChannelList/Skeleton.tsx | 2 +- .../ChannelPreviewMessenger.tsx | 2 +- .../components/MessageInput/MessageInput.tsx | 412 +++++++----------- .../SendMessageDisallowedIndicator.tsx | 2 +- .../AttachmentRemoveControl.tsx | 1 + .../AudioAttachmentUploadPreview.tsx | 40 +- .../FileAttachmentUploadPreview.tsx | 17 +- .../ImageAttachmentUploadPreview.tsx | 2 +- .../AudioRecorder/AudioRecorder.tsx | 325 +++++++------- .../AudioRecorder/AudioRecordingButton.tsx | 275 +++++++++--- .../AudioRecordingInProgress.tsx | 131 +++--- .../AudioRecordingLockIndicator.tsx | 80 ++-- .../AudioRecorder/AudioRecordingPreview.tsx | 104 +++-- .../AudioRecorder/AudioRecordingWaveform.tsx | 52 ++- .../MessageInput/components/CommandInput.tsx | 111 ----- .../components/InputButtons/index.tsx | 12 +- .../components/OutputButtons/index.tsx | 18 +- .../MessageInput/hooks/useAudioRecorder.tsx | 211 +++------ .../MessageList/MessageFlashList.tsx | 3 +- .../components/MessageList/MessageList.tsx | 3 +- .../ProgressControl/WaveProgressBar.tsx | 22 +- package/src/components/Reply/Reply.tsx | 209 +++++---- package/src/components/index.ts | 1 - package/src/components/ui/Avatar/Avatar.tsx | 2 +- package/src/components/ui/BadgeCount.tsx | 2 +- package/src/components/ui/GiphyBadge.tsx | 68 +++ package/src/components/ui/IconButton.tsx | 6 +- .../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 +++++ package/src/theme/primitives/colors.ts | 52 ++- package/src/theme/primitives/palette.ts | 46 +- package/src/theme/primitives/spacing.tsx | 1 + package/src/theme/primitives/typography.ts | 4 +- 66 files changed, 1536 insertions(+), 1160 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..c805bb137d 100644 --- a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx +++ b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx @@ -329,7 +329,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: border.default, }, ]} > @@ -344,7 +344,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: border.default, }, ]} > @@ -361,7 +361,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: border.default, }, ]} > @@ -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: border.default, }, ]} > @@ -406,8 +406,8 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.lastRow, { - borderBottomColor: border.surfaceSubtle, - borderTopColor: border.surfaceSubtle, + borderBottomColor: border.default, + borderTopColor: border.default, }, ]} > diff --git a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx index 581ac92ca0..6a453fa4c5 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.surfaceSubtle, + borderTopColor: border.default, }, ]} > diff --git a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx index a087a9a3e8..1d7c26c7a1 100644 --- a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx +++ b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx @@ -130,7 +130,7 @@ export const MessageSearchList: React.FC = React.forward messageId: item.id, }); }} - style={[styles.itemContainer, { borderBottomColor: border.surfaceSubtle }]} + style={[styles.itemContainer, { borderBottomColor: border.default }]} testID='channel-preview-button' > {item.user ? : null} diff --git a/examples/SampleApp/src/components/ScreenHeader.tsx b/examples/SampleApp/src/components/ScreenHeader.tsx index fda612e389..32a2606c2c 100644 --- a/examples/SampleApp/src/components/ScreenHeader.tsx +++ b/examples/SampleApp/src/components/ScreenHeader.tsx @@ -129,7 +129,7 @@ export const ScreenHeader: React.FC = (props) => { styles.safeAreaContainer, { backgroundColor: white, - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, height: HEADER_CONTENT_HEIGHT + (inSafeArea ? 0 : insets.top), }, style, diff --git a/examples/SampleApp/src/components/UserInfoOverlay.tsx b/examples/SampleApp/src/components/UserInfoOverlay.tsx index dcaa84ae3e..9eb073ee29 100644 --- a/examples/SampleApp/src/components/UserInfoOverlay.tsx +++ b/examples/SampleApp/src/components/UserInfoOverlay.tsx @@ -277,7 +277,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: border.default, }, ]} > @@ -292,7 +292,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: border.default, }, ]} > @@ -308,7 +308,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: border.default, }, ]} > @@ -326,8 +326,8 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.lastRow, { - borderBottomColor: border.surfaceSubtle, - borderTopColor: border.surfaceSubtle, + borderBottomColor: border.default, + borderTopColor: border.default, }, ]} > diff --git a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx index aac9764f79..686e091225 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.surfaceSubtle, + borderBottomColor: border.default, }, ]} > diff --git a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx index d657aeb696..7c18cb7843 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.surfaceSubtle, + borderBottomColor: border.default, 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 ( = ({ style={[ styles.memberContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -306,7 +306,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.loadMoreButton, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -330,7 +330,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.changeNameContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -382,7 +382,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -427,7 +427,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -457,7 +457,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -487,7 +487,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -513,7 +513,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > diff --git a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx index fdbc9d6e5a..806ec2c50d 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.surfaceSubtle, + borderBottomColor: border.default, }, ]} > diff --git a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx index 79819b6de8..094de3561d 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.surfaceSubtle, + borderColor: border.default, marginBottom: selectedUsers.length === 0 ? 8 : 16, }, ]} diff --git a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx index cf4c98aac5..83457f2caa 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 ccd83de704..b99c2fc01d 100644 --- a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx +++ b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx @@ -227,7 +227,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.userNameContainer, { - borderTopColor: border.surfaceSubtle, + borderTopColor: border.default, }, ]} > @@ -266,7 +266,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -305,7 +305,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -351,7 +351,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -381,7 +381,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -411,7 +411,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -441,7 +441,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > @@ -468,7 +468,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: border.default, }, ]} > diff --git a/examples/SampleApp/src/screens/UserSelectorScreen.tsx b/examples/SampleApp/src/screens/UserSelectorScreen.tsx index 19987dc12a..1130156050 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.surfaceSubtle }]} + style={[styles.userContainer, { borderBottomColor: border.default }]} testID={`user-selector-button-${u.id}`} > = ({ navigation }) => { onPress={() => { navigation.navigate('AdvancedUserSelectorScreen'); }} - style={[styles.userContainer, { borderBottomColor: border.surfaceSubtle }]} + style={[styles.userContainer, { borderBottomColor: border.default }]} > { try { - if (!audioRecorderPlayer._isRecording) return; await audioRecorderPlayer.stopRecorder(); audioRecorderPlayer.removeRecordBackListener(); } catch (error) { diff --git a/package/src/components/Attachment/AttachmentActions.tsx b/package/src/components/Attachment/AttachmentActions.tsx index 3418a38e35..1e7d21b4b6 100644 --- a/package/src/components/Attachment/AttachmentActions.tsx +++ b/package/src/components/Attachment/AttachmentActions.tsx @@ -82,7 +82,7 @@ const AttachmentActionsWithContext = (props: AttachmentActionsPropsWithContext) ? primaryBackgroundColor || accent_blue : defaultBackgroundColor || white, borderColor: primary - ? primaryBorderColor || border.surfaceSubtle + ? primaryBorderColor || border.default : defaultBorderColor || transparent, }, buttonStyle, diff --git a/package/src/components/Attachment/AudioAttachment.tsx b/package/src/components/Attachment/AudioAttachment.tsx index cd3f2670ce..61e3e123fc 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,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { speedChangeButton, speedChangeButtonText, }, - colors: { accent_blue, black, grey_dark, grey_whisper, static_black, static_white, white }, + colors: { accent, border, text, black, static_white, white }, messageInput: { fileAttachmentUploadPreview: { filenameText }, }, @@ -197,9 +203,10 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { styles.container, { backgroundColor: white, - borderColor: grey_whisper, + borderColor: border.default, }, container, + containerStyle, ]} testID={testID} > @@ -209,14 +216,14 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { onPress={handlePlayPause} style={[ styles.playPauseButton, - { backgroundColor: static_white, shadowColor: black }, + { backgroundColor: static_white, borderColor: border.default }, playPauseButton, ]} > {!isPlaying ? ( - + ) : ( - + )} @@ -227,25 +234,31 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { style={[ styles.filenameText, { - color: black, + color: text.primary, }, 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..5acb4cb8ea 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -57,6 +57,14 @@ 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 { channel, @@ -64,6 +72,7 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) setInputBoxRef, t, TextInputComponent = RNTextInput, + placeholder, ...rest } = props; const [localText, setLocalText] = useState(''); @@ -115,12 +124,14 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) } = 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 ( ) = 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/ChannelList/Skeleton.tsx b/package/src/components/ChannelList/Skeleton.tsx index ec086a14b5..4c3f9b389a 100644 --- a/package/src/components/ChannelList/Skeleton.tsx +++ b/package/src/components/ChannelList/Skeleton.tsx @@ -126,7 +126,7 @@ export const Skeleton = () => { return ( diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index 40a9b65f03..a151546559 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -138,7 +138,7 @@ const ChannelPreviewMessengerWithContext = (props: ChannelPreviewMessengerPropsW style={[ // { opacity: pressed ? 0.5 : 1 }, styles.container, - { backgroundColor: white_snow, borderBottomColor: border.surfaceSubtle }, + { backgroundColor: white_snow, borderBottomColor: border.default }, container, ]} testID='channel-preview-button' diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 1f8d91e66c..6e014fbf65 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 { 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,10 +55,11 @@ 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 { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; +import { GiphyBadge } from '../ui/GiphyBadge'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; const styles = StyleSheet.create({ @@ -85,10 +76,12 @@ const styles = StyleSheet.create({ }, floatingWrapper: { left: 0, - paddingHorizontal: 16, position: 'absolute', right: 0, }, + giphyContainer: { + padding: 8, + }, inputBoxContainer: { flex: 1, }, @@ -124,10 +117,14 @@ const styles = StyleSheet.create({ width: '100%', }, wrapper: { - borderTopWidth: 1, paddingHorizontal: 16, paddingTop: 16, }, + audioLockIndicatorWrapper: { + position: 'absolute', + right: 16, + padding: 4, + }, }); type MessageInputPropsWithContext = Pick< @@ -138,6 +135,7 @@ type MessageInputPropsWithContext = Pick< Pick & Pick< MessageInputContextValue, + | 'audioRecorderManager' | 'additionalTextInputProps' | 'audioRecordingEnabled' | 'asyncMessagesLockDistance' @@ -167,7 +165,6 @@ type MessageInputPropsWithContext = Pick< | 'messageInputHeightStore' | 'ImageSelectorIcon' | 'VideoRecorderSelectorIcon' - | 'CommandInput' | 'SendButton' | 'ShowThreadMessageInChannelButton' | 'StartAudioRecordingButton' @@ -181,7 +178,8 @@ type MessageInputPropsWithContext = Pick< > & Pick & Pick & - Pick & { + Pick & + Pick & { editing: boolean; hasAttachments: boolean; isKeyboardVisible: boolean; @@ -190,6 +188,8 @@ type MessageInputPropsWithContext = Pick< ref: React.Ref | undefined; } >; + isRecordingStateIdle?: boolean; + recordingStatus?: string; }; const textComposerStateSelector = (state: TextComposerState) => ({ @@ -213,15 +213,11 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { attachmentSelectionBarHeight, bottomInset, selectedPicker, - additionalTextInputProps, asyncMessagesLockDistance, - asyncMessagesMinimumPressDuration, - asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachmentUploadPreviewList, AudioRecorder, - audioRecordingEnabled, AudioRecordingInProgress, AudioRecordingLockIndicator, AudioRecordingPreview, @@ -238,18 +234,18 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { Input, inputBoxRef, InputButtons, - CommandInput, isKeyboardVisible, - isOnline, members, Reply, threadList, sendMessage, showPollCreationDialog, ShowThreadMessageInChannelButton, - StartAudioRecordingButton, TextInputComponent, watchers, + micLocked, + isRecordingStateIdle, + recordingStatus, } = props; const messageComposer = useMessageComposer(); @@ -264,7 +260,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); const { theme: { - colors: { border, grey_whisper, white, white_smoke }, + colors: { border, white, white_smoke }, messageInput: { attachmentSelectionBar, container, @@ -276,7 +272,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { inputContainer, inputButtonsContainer, inputFloatingContainer, - micButtonContainer, outputButtonsContainer, suggestionsListContainer: { container: suggestionListContainer }, wrapper, @@ -350,81 +345,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: [ { @@ -437,22 +362,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(); @@ -470,165 +381,141 @@ 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, { + borderTopWidth: 1, backgroundColor: white, - borderColor: border.surfaceSubtle, + borderColor: border.default, paddingBottom: BOTTOM_OFFSET, }, wrapper, ] } > - {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} + ; +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + micLocked: state.micLocked, + isRecordingStateIdle: state.status === 'idle', + recordingStatus: state.status, +}); + /** * UI Component for message input * It's a consumer of @@ -828,6 +742,7 @@ export const MessageInput = (props: MessageInputProps) => { const { channel, members, threadList, watchers } = useChannelContext(); const { + audioRecorderManager, additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, @@ -859,7 +774,6 @@ export const MessageInput = (props: MessageInputProps) => { Input, inputBoxRef, InputButtons, - CommandInput, messageInputFloating, messageInputHeightStore, openPollCreationDialog, @@ -883,6 +797,11 @@ export const MessageInput = (props: MessageInputProps) => { const { attachments } = useAttachmentManagerState(); const isKeyboardVisible = useKeyboardVisibility(); + const { micLocked, isRecordingStateIdle, recordingStatus } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); + const { t } = useTranslationContext(); /** @@ -896,6 +815,10 @@ export const MessageInput = (props: MessageInputProps) => { return ( { clearEditingState, closeAttachmentPicker, closePollCreationDialog, - CommandInput, compressImageQuality, cooldownEndsAt, CooldownTimer, diff --git a/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx b/package/src/components/MessageInput/SendMessageDisallowedIndicator.tsx index d8dcf3c047..f55f49ef1d 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.surfaceSubtle, + borderTopColor: border.default, 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..9800dc95e7 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx @@ -20,6 +20,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..119a4a6cd1 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 { useTheme } from '../../../../contexts/themeContext/ThemeContext'; 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,25 @@ export const AudioAttachmentUploadPreview = ({ ); }; -const styles = StyleSheet.create({ - dismissWrapper: { - position: 'absolute', - right: 0, - top: 0, - }, - overlay: { - borderRadius: 12, - marginHorizontal: 8, - marginTop: 2, - }, -}); +const useStyles = () => { + const { + theme: { radius, spacing }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + overlay: { + borderRadius: radius.lg, + }, + wrapper: { + padding: spacing.xxs, + }, + }), + [radius, spacing], + ); +}; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 06e6156161..46428c2153 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -110,7 +110,7 @@ const useStyles = () => { colors: { border, text }, radius, spacing, - typography: { fontSize, fontWeight }, + typography: { fontSize, fontWeight, lineHeight }, }, } = useTheme(); return useMemo( @@ -118,31 +118,34 @@ const useStyles = () => { StyleSheet.create({ dismissWrapper: { position: 'absolute', right: 0, top: 0 }, fileContainer: { + alignItems: 'center', borderRadius: radius.lg, - borderColor: border.surfaceSubtle, + borderColor: border.default, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, width: 224, // TODO: Not sure how to omit this padding: spacing.md, }, - fileContent: { - flexShrink: 1, - justifyContent: 'space-between', - }, fileIcon: { alignItems: 'center', alignSelf: 'center', justifyContent: 'center', }, + fileContent: { + flexShrink: 1, + justifyContent: 'space-between', + }, filenameText: { color: text.primary, fontSize: fontSize.xs, fontWeight: fontWeight.semibold, + lineHeight: lineHeight.tight, }, fileSizeText: { color: text.secondary, fontSize: fontSize.xs, + paddingTop: 4, }, overlay: { borderRadius: radius.lg, @@ -151,6 +154,6 @@ const useStyles = () => { padding: spacing.xxs, }, }), - [radius, border, spacing, text, fontSize, fontWeight], + [radius, border, spacing, text, fontSize, fontWeight, lineHeight], ); }; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index f28c6a4896..4538b10e91 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.border.image, + borderColor: colors.border.opacity10, borderRadius: radius.lg, borderWidth: 1, flexDirection: 'row', diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx index fd24d0b86f..7a843cff7a 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx @@ -1,58 +1,41 @@ -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'; 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, @@ -61,19 +44,24 @@ const StopRecording = ({ }) => { const { theme: { - colors: { accent_red }, - messageInput: { - audioRecorder: { circleStopIcon, pausedContainer }, - }, + colors: { accent }, }, } = useTheme(); + + const onStopVoiceRecording = () => { + NativeHandlers.triggerHaptic('impactMedium'); + stopVoiceRecordingHandler(); + }; + return ( - - - + ); }; @@ -84,23 +72,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 +93,178 @@ 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 }, + colors: { accent, grey_dark }, 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')} - + ); } }; -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: { colors, spacing, typography }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + padding: spacing.xs, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + checkContainer: {}, + deleteContainer: {}, + durationLabel: { + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.semibold, + lineHeight: typography.lineHeight.normal, + color: colors.text.primary, + }, + micContainer: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + }, + pausedContainer: {}, + slideToCancel: { + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.regular, + lineHeight: typography.lineHeight.normal, + color: colors.text.primary, + }, + slideToCancelContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.xxs, + }, + }), + [colors, spacing, typography], + ); +}; 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..8bfe866b84 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,29 @@ 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'; 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 }, + colors: { accent }, messageInput: { audioRecordingInProgress: { container, durationText }, }, @@ -47,59 +40,77 @@ 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} + + + ); }; -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: { colors, spacing, typography }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + paddingLeft: spacing.sm, + paddingRight: spacing.md, + }, + durationText: { + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.semibold, + lineHeight: typography.lineHeight.normal, + color: colors.text.primary, + }, + micContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + }), + [colors, spacing, typography], + ); +}; 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..e2287156fd 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx @@ -1,17 +1,15 @@ -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'; -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 +30,7 @@ export const AudioRecordingLockIndicator = ({ }: AudioRecordingLockIndicatorProps) => { const [visible, setVisible] = useState(true); const timeoutRef = useRef(undefined); + const styles = useStyles(); useEffect(() => { timeoutRef.current = setTimeout(() => { @@ -47,7 +46,7 @@ export const AudioRecordingLockIndicator = ({ const { theme: { - colors: { accent_blue, grey, light_gray }, + colors: { text, accent }, messageInput: { audioRecordingLockIndicator: { arrowUpIcon, container, lockIcon }, }, @@ -60,25 +59,52 @@ 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, border }, + radius, + spacing, + }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + backgroundColor: white, + borderColor: border.default, + borderWidth: 1, + borderRadius: radius.full, + padding: spacing.xs, + gap: spacing.xxs, + + // 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, border, radius, spacing], + ); +}; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx index 4bea5b3941..500e62ca28 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx @@ -3,27 +3,21 @@ 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 { 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 +25,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 +71,7 @@ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { const { theme: { - colors: { accent_blue, grey_dark }, + colors: { accent, text }, messageInput: { audioRecordingPreview: { container, @@ -92,47 +102,61 @@ 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 = () => { + const { + theme: { spacing, typography }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + paddingLeft: spacing.sm, + paddingRight: spacing.md, + }, + durationText: { + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeight.semibold, + lineHeight: typography.lineHeight.normal, + }, + infoContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + progressBar: {}, + }), + [spacing, typography], + ); +}; 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..d79b0f7e0a 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx @@ -1,27 +1,26 @@ -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'; -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 +34,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 +44,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: { colors, radius }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignSelf: 'center', + flexDirection: 'row', + }, + waveform: { + alignSelf: 'center', + borderRadius: radius.xxs, + marginHorizontal: 1, + width: 2, + minHeight: 2, + maxHeight: WAVEFORM_MAX_HEIGHT, + backgroundColor: colors.border.opacity25, + }, + }), + [radius, colors], + ); +}; 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..280882320d 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,91 +49,19 @@ 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([]); + await audioRecorderManager.startRecording(); }; /** * Function to delete voice recording. */ const deleteVoiceRecording = async () => { - if (recordingStatus === 'recording') { - await stopVoiceRecording(); - } - resetState(); - NativeHandlers.triggerHaptic('impactMedium'); + await stopVoiceRecording(); + audioRecorderManager.reset(); }; /** @@ -143,63 +69,62 @@ export const useAudioRecorder = () => { * @param multiSendEnabled boolean */ const uploadVoiceRecording = async (multiSendEnabled: boolean) => { - if (recordingStatus === 'recording') { + try { + const { recording, duration, waveformData } = audioRecorderManager.state.getLatestValue(); 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); + 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); } - resetState(); }; 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/WaveProgressBar.tsx b/package/src/components/ProgressControl/WaveProgressBar.tsx index 3a509a9060..b7e368cbca 100644 --- a/package/src/components/ProgressControl/WaveProgressBar.tsx +++ b/package/src/components/ProgressControl/WaveProgressBar.tsx @@ -43,13 +43,13 @@ 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 }, + colors: { accent, border, black }, }, } = useTheme(); return ( @@ -58,8 +58,8 @@ const ProgressControlThumb = ({ style }: { style?: StyleProp }) => { style={[ styles.progressControlThumbStyle, { - backgroundColor: static_white, - borderColor: grey_dark, + backgroundColor: accent.primary, + borderColor: border.onAccent, shadowColor: black, }, style, @@ -108,7 +108,7 @@ export const WaveProgressBar = React.memo( const { theme: { - colors: { accent_blue, grey_dark }, + colors: { accent, grey_dark }, waveProgressBar: { container, thumb, waveform: waveformTheme }, }, } = useTheme(); @@ -162,7 +162,7 @@ export const WaveProgressBar = React.memo( styles.waveform, { backgroundColor: - index < currentWaveformProgress ? filledColor || accent_blue : grey_dark, + index < currentWaveformProgress ? filledColor || accent.primary : grey_dark, height: waveform * WAVE_MAX_HEIGHT > WAVE_MIN_HEIGHT ? waveform * WAVE_MAX_HEIGHT @@ -199,17 +199,17 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, progressControlThumbStyle: { - borderRadius: 5, - borderWidth: 0.2, + height: 12, + width: 12, + borderRadius: 6, + borderWidth: 2, elevation: 6, - height: 28, shadowOffset: { height: 3, width: 0, }, shadowOpacity: 0.27, shadowRadius: 4.65, - width: WAVEFORM_WIDTH * 2, }, waveform: { alignSelf: 'center', diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 4d303ee7d4..5f8e60f461 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -17,7 +17,6 @@ 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'; @@ -38,6 +37,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 +47,14 @@ const RightContent = React.memo((props: { message: LocalMessage }) => { if (attachment?.type === FileTypes.Image) { return ( - + ); } if (attachment?.type === FileTypes.Video) { return ( - + @@ -79,6 +79,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 +176,12 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { const { message } = props; const { theme: { + colors, reply: { pollIcon, locationIcon, linkIcon, audioIcon, fileIcon, videoIcon, photoIcon }, }, } = useTheme(); + const styles = useStyles(); + if (!message) { return null; } @@ -201,7 +205,13 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { if (message.poll_id) { return ( - + ); } @@ -209,7 +219,7 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { return ( { if (hasLink) { return ( - + ); } @@ -227,7 +243,7 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { return ( { if (onlyVideos) { return ( - + ); } if (onlyImages) { return ( - + ); } @@ -255,7 +283,13 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { audioAttachments?.length ) { return ( - + ); } @@ -275,7 +309,6 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { const { isMyMessage, message: messageFromContext, mode, onDismiss, quotedMessage, style } = props; const { theme: { - colors: { grey_whisper }, reply: { wrapper, container, @@ -287,6 +320,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { }, }, } = useTheme(); + const styles = useStyles(); const title = useMemo( () => @@ -294,7 +328,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 +343,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { { ]} > - {mode === 'edit' ? : null} {title} @@ -412,76 +447,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: { colors, radius, spacing, typography }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + attachmentContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + container: { + borderRadius: radius.lg, + flexDirection: 'row', + padding: spacing.xs, + }, + contentWrapper: { + borderRadius: radius.md, + borderWidth: 1, + height: 40, + overflow: 'hidden', + width: 40, + }, + contentBorder: { + borderColor: colors.border.opacity10, + }, + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + iconStyle: {}, + leftContainer: { + borderLeftWidth: 2, + flex: 1, + justifyContent: 'center', + paddingHorizontal: spacing.xs, + }, + rightContainer: {}, + subtitle: { + color: colors.text.primary, + flexShrink: 1, + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.regular, + includeFontPadding: false, + lineHeight: 16, + }, + subtitleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.xxs, + paddingTop: spacing.xxs, + }, + titleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.xxs, + }, + title: { + color: colors.text.primary, + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeight.semibold, + includeFontPadding: false, + lineHeight: 16, + }, + wrapper: { + padding: spacing.xxs, + }, + }), + [colors, radius, spacing, typography], + ); +}; 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/Avatar/Avatar.tsx b/package/src/components/ui/Avatar/Avatar.tsx index 7a6b393f97..aff26ee045 100644 --- a/package/src/components/ui/Avatar/Avatar.tsx +++ b/package/src/components/ui/Avatar/Avatar.tsx @@ -59,7 +59,7 @@ const useStyles = () => { () => StyleSheet.create({ border: { - borderColor: colors.border.image, + borderColor: colors.border.opacity10, borderWidth: 1, }, container: { diff --git a/package/src/components/ui/BadgeCount.tsx b/package/src/components/ui/BadgeCount.tsx index 87fb661361..fe50b33fd3 100644 --- a/package/src/components/ui/BadgeCount.tsx +++ b/package/src/components/ui/BadgeCount.tsx @@ -41,7 +41,7 @@ const useStyles = () => { StyleSheet.create({ text: { backgroundColor: badge.bgInverse, - borderColor: border.surfaceSubtle, + borderColor: border.subtle, borderWidth: 1, color: badge.textInverse, fontSize: typography.fontSize.xs, diff --git a/package/src/components/ui/GiphyBadge.tsx b/package/src/components/ui/GiphyBadge.tsx new file mode 100644 index 0000000000..93c28ca6b4 --- /dev/null +++ b/package/src/components/ui/GiphyBadge.tsx @@ -0,0 +1,68 @@ +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'; + +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, +}); + +export const GiphyBadge = () => { + const { + theme: { colors }, + } = 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: { colors, spacing, radius, typography }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: colors.accent.black, + borderRadius: radius.full, + flexDirection: 'row', + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xxs, + gap: spacing.xxs, + }, + text: { + color: colors.text.onAccent, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeight.semibold, + lineHeight: typography.lineHeight.normal, + }, + }), + [radius, spacing, typography, colors], + ); +}; diff --git a/package/src/components/ui/IconButton.tsx b/package/src/components/ui/IconButton.tsx index be00bc919f..0346c71c20 100644 --- a/package/src/components/ui/IconButton.tsx +++ b/package/src/components/ui/IconButton.tsx @@ -77,8 +77,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 c7cf91cab7..17a60aae91 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -321,11 +321,6 @@ export type Theme = { container: ViewStyle; waveform: ViewStyle; }; - commandInput: { - closeButton: ViewStyle; - container: ViewStyle; - text: TextStyle; - }; container: ViewStyle; contentContainer: ViewStyle; cooldownButtonContainer: ViewStyle; @@ -1133,11 +1128,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 }); + } +} diff --git a/package/src/theme/primitives/colors.ts b/package/src/theme/primitives/colors.ts index f54c7a1b88..e18130020f 100644 --- a/package/src/theme/primitives/colors.ts +++ b/package/src/theme/primitives/colors.ts @@ -27,6 +27,7 @@ type AccentColors = { warning: string; error: string; neutral: string; + black: string; }; type StateColors = { @@ -55,13 +56,13 @@ type PresenceColors = { }; type BorderCore = { - surface: string; - surfaceSubtle: string; - surfaceStrong: string; + default: string; + subtle: string; + strong: string; onDark: string; onAccent: string; - subtle: string; - image: string; + opacity10: string; + opacity25: string; }; export type BadgeColors = { @@ -122,14 +123,15 @@ export function resolveTheme(input: NewColors) { warning: palette.yellow[500], error: palette.red[500], neutral: palette.slate[500], + black: palette.black, }; const text = input.text ?? { - primary: brand[900], - secondary: brand[700], - tertiary: brand[500], + primary: palette.slate[900], + secondary: palette.slate[700], + tertiary: palette.slate[500], inverse: palette.white, onAccent: palette.white, - disabled: brand[400], + disabled: palette.slate[400], link: accent.primary, }; const state = input.state ?? { @@ -211,6 +213,7 @@ export const lightColors = { warning: palette.yellow[500], error: palette.red[500], neutral: palette.slate[500], + black: palette.black, }, state: { hover: palette.black5, @@ -230,13 +233,13 @@ export const lightColors = { link: palette.blue[500], }, border: { - surface: palette.slate[400], - surfaceSubtle: palette.slate[200], - surfaceStrong: palette.slate[600], + default: palette.slate[150], + subtle: palette.slate[100], + strong: palette.slate[200], onDark: palette.white, onAccent: palette.white, - subtle: palette.slate[100], - image: palette.black10, + opacity10: palette.black10, + opacity25: palette.black25, }, control: { bg: palette.slate[900], @@ -301,6 +304,7 @@ export const darkColors = { warning: palette.yellow[400], error: palette.red[400], neutral: palette.neutral[500], + black: palette.black, }, state: { hover: palette.black5, @@ -311,22 +315,22 @@ export const darkColors = { textDisabled: palette.neutral[600], }, text: { - primary: palette.neutral[50], - secondary: palette.neutral[300], - tertiary: palette.neutral[400], + primary: palette.white, + secondary: palette.neutral[100], + tertiary: palette.neutral[300], inverse: palette.black, onAccent: palette.white, - disabled: palette.neutral[600], - link: palette.blue[500], + disabled: palette.neutral[400], + link: palette.white, }, border: { - surface: palette.neutral[500], - surfaceSubtle: palette.neutral[700], - surfaceStrong: palette.neutral[400], + default: palette.neutral[600], + subtle: palette.neutral[700], + strong: palette.neutral[500], onDark: palette.white, onAccent: palette.white, - subtle: palette.neutral[800], - image: palette.white20, + opacity10: palette.white10, + opacity25: palette.white25, }, control: { bg: palette.neutral[800], diff --git a/package/src/theme/primitives/palette.ts b/package/src/theme/primitives/palette.ts index f52105f3a9..4bebf8de22 100644 --- a/package/src/theme/primitives/palette.ts +++ b/package/src/theme/primitives/palette.ts @@ -4,35 +4,37 @@ export const palette = { white: '#FFFFFF', white10: 'hsla(0, 0%, 100%, 0.1)', white20: 'hsla(0, 0%, 100%, 0.2)', + white25: 'hsla(0, 0%, 100%, 0.25)', white70: 'hsla(0, 0%, 100%, 0.7)', black5: 'hsla(0, 0%, 0%, 0.05)', black10: 'hsla(0, 0%, 0%, 0.1)', + black25: 'hsla(0, 0%, 0%, 0.25)', 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', + 50: '#F6F8FA', + 100: '#EBEEF1', + 150: '#D5DBE1', + 200: '#C0C8D2', + 300: '#A3ACBA', + 400: '#87909F', + 500: '#687385', + 600: '#545969', + 700: '#414552', + 800: '#30313D', + 900: '#1A1B25', }, neutral: { - 50: '#F7F7F7', - 100: '#EDEDED', - 200: '#D9D9D9', - 300: '#C1C1C1', - 400: '#A3A3A3', - 500: '#7F7F7F', - 600: '#636363', - 700: '#4A4A4A', - 800: '#383838', - 900: '#262626', - 950: '#151515', + 50: '#F8F8F8', + 100: '#EFEFEF', + 200: '#D8D8D8', + 300: '#C4C4C4', + 400: '#ABABAB', + 500: '#8F8F8F', + 600: '#6A6A6A', + 700: '#565656', + 800: '#464646', + 900: '#323232', + 950: '#1C1C1C', }, blue: { 50: '#EBF3FF', diff --git a/package/src/theme/primitives/spacing.tsx b/package/src/theme/primitives/spacing.tsx index 6392225230..9fd8934669 100644 --- a/package/src/theme/primitives/spacing.tsx +++ b/package/src/theme/primitives/spacing.tsx @@ -1,5 +1,6 @@ export const Spacing = { none: 0, + xxxs: 2, xxs: 4, xs: 8, sm: 12, diff --git a/package/src/theme/primitives/typography.ts b/package/src/theme/primitives/typography.ts index 9cb3c056db..7cdbc12874 100644 --- a/package/src/theme/primitives/typography.ts +++ b/package/src/theme/primitives/typography.ts @@ -17,8 +17,8 @@ export const Typography: TypographyType = { }, lineHeight: { tight: 16, - normal: 24, - relaxed: 32, + normal: 20, + relaxed: 24, }, fontSize: { micro: 8, From 45dfc606c8b40cb428cd7faa66f7738c679f151e Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 30 Jan 2026 19:35:45 +0530 Subject: [PATCH 2/2] fix: lint issues --- .../src/components/MessageSearch/MessageSearchList.tsx | 2 +- examples/SampleApp/src/components/UserInfoOverlay.tsx | 2 +- examples/SampleApp/src/screens/UserSelectorScreen.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx index 1df1d6c4f6..ad2ec54ded 100644 --- a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx +++ b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx @@ -72,7 +72,7 @@ export const MessageSearchList: React.FC = React.forward } = props; const { theme: { - colors: { black, border, grey, white_snow }, + colors: { black, grey, white_snow }, semantics, }, } = useTheme(); diff --git a/examples/SampleApp/src/components/UserInfoOverlay.tsx b/examples/SampleApp/src/components/UserInfoOverlay.tsx index 41fbeeb589..91659ececf 100644 --- a/examples/SampleApp/src/components/UserInfoOverlay.tsx +++ b/examples/SampleApp/src/components/UserInfoOverlay.tsx @@ -105,7 +105,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { const { theme: { - colors: { accent_red, black, border, grey, white }, + colors: { accent_red, black, grey, white }, semantics, }, } = useTheme(); diff --git a/examples/SampleApp/src/screens/UserSelectorScreen.tsx b/examples/SampleApp/src/screens/UserSelectorScreen.tsx index 1aa4016a4c..49296412b4 100644 --- a/examples/SampleApp/src/screens/UserSelectorScreen.tsx +++ b/examples/SampleApp/src/screens/UserSelectorScreen.tsx @@ -90,7 +90,7 @@ 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();