diff --git a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx index 822a11555..228c715b8 100644 --- a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx +++ b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx @@ -110,7 +110,8 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { const { theme: { - colors: { accent_red, black, border, grey, white }, + colors: { accent_red, black, grey, white }, + semantics, }, } = useTheme(); @@ -127,19 +128,19 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { } showScreen.value = show ? withTiming(1, { - duration: 150, - easing: Easing.in(Easing.ease), - }) + duration: 150, + easing: Easing.in(Easing.ease), + }) : withTiming( - 0, - { - duration: 150, - easing: Easing.out(Easing.ease), - }, - () => { - runOnJS(reset)(); - }, - ); + 0, + { + duration: 150, + easing: Easing.out(Easing.ease), + }, + () => { + runOnJS(reset)(); + }, + ); }; useEffect(() => { @@ -185,12 +186,12 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { translateY.value = evt.velocityY > 1000 ? withDecay({ - velocity: evt.velocityY, - }) + velocity: evt.velocityY, + }) : withTiming(screenHeight, { - duration: 200, - easing: Easing.out(Easing.ease), - }); + duration: 200, + easing: Easing.out(Easing.ease), + }); } else { translateY.value = withTiming(0); overlayOpacity.value = withTiming(1); @@ -225,31 +226,31 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { : 0; const channelName = channel ? channel.data?.name || - Object.values(channel.state.members) - .slice(0) - .reduce((returnString, currentMember, index, originalArray) => { - const returnStringLength = returnString.length; - const currentMemberName = - currentMember.user?.name || currentMember.user?.id || 'Unknown User'; - // a rough approximation of when the +Number shows up - if (returnStringLength + (currentMemberName.length + 2) < maxWidth) { - if (returnStringLength) { - returnString += `, ${currentMemberName}`; - } else { - returnString = currentMemberName; - } + Object.values(channel.state.members) + .slice(0) + .reduce((returnString, currentMember, index, originalArray) => { + const returnStringLength = returnString.length; + const currentMemberName = + currentMember.user?.name || currentMember.user?.id || 'Unknown User'; + // a rough approximation of when the +Number shows up + if (returnStringLength + (currentMemberName.length + 2) < maxWidth) { + if (returnStringLength) { + returnString += `, ${currentMemberName}`; } else { - const remainingMembers = originalArray.length - index; - returnString += `, +${remainingMembers}`; - originalArray.splice(1); // exit early + returnString = currentMemberName; } - return returnString; - }, '') + } else { + const remainingMembers = originalArray.length - index; + returnString += `, +${remainingMembers}`; + originalArray.splice(1); // exit early + } + return returnString; + }, '') : ''; const otherMembers = channel ? Object.values(channel.state.members).filter( - (member) => member.user?.id !== clientId, - ) + (member) => member.user?.id !== clientId, + ) : []; const { viewInfo, pinUnpin, archiveUnarchive, leaveGroup, deleteConversation, cancel } = @@ -285,11 +286,10 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { ? otherMembers[0].user?.online ? 'Online' : `Last Seen ${dayjs(otherMembers[0].user?.last_active).fromNow()}` - : `${Object.keys(channel.state.members).length} Members, ${ - Object.values(channel.state.members).filter( - (member) => !!member.user?.online, - ).length - } Online`} + : `${Object.keys(channel.state.members).length} Members, ${Object.values(channel.state.members).filter( + (member) => !!member.user?.online, + ).length + } Online`} { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -344,7 +344,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -361,7 +361,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -376,7 +376,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { {otherMembers.length > 1 && ( - + @@ -389,7 +389,7 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -406,8 +406,8 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { style={[ styles.lastRow, { - borderBottomColor: border.surfaceSubtle, - borderTopColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, + borderTopColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx index 581ac92ca..3eab53566 100644 --- a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx +++ b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx @@ -52,7 +52,8 @@ export const ConfirmationBottomSheet: React.FC = () => { const { theme: { - colors: { accent_red, black, border, grey, white }, + colors: { accent_red, black, grey, white }, + semantics, }, } = useTheme(); const inset = useSafeAreaInsets(); @@ -86,7 +87,7 @@ export const ConfirmationBottomSheet: React.FC = () => { style={[ styles.actionButtonsContainer, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx index a087a9a3e..ad2ec54de 100644 --- a/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx +++ b/examples/SampleApp/src/components/MessageSearch/MessageSearchList.tsx @@ -72,7 +72,8 @@ export const MessageSearchList: React.FC = React.forward } = props; const { theme: { - colors: { black, border, grey, white_snow }, + colors: { black, grey, white_snow }, + semantics, }, } = useTheme(); const { vw } = useViewport(); @@ -94,13 +95,11 @@ export const MessageSearchList: React.FC = React.forward }} > - {`${ - messages.length >= DEFAULT_PAGINATION_LIMIT - ? DEFAULT_PAGINATION_LIMIT - : messages.length - }${messages.length >= DEFAULT_PAGINATION_LIMIT ? '+ ' : ' '} result${ - messages.length === 1 ? '' : 's' - }`} + {`${messages.length >= DEFAULT_PAGINATION_LIMIT + ? DEFAULT_PAGINATION_LIMIT + : messages.length + }${messages.length >= DEFAULT_PAGINATION_LIMIT ? '+ ' : ' '} result${messages.length === 1 ? '' : 's' + }`} )} @@ -130,7 +129,7 @@ export const MessageSearchList: React.FC = React.forward messageId: item.id, }); }} - style={[styles.itemContainer, { borderBottomColor: border.surfaceSubtle }]} + style={[styles.itemContainer, { borderBottomColor: semantics.borderCoreDefault }]} testID='channel-preview-button' > {item.user ? : null} diff --git a/examples/SampleApp/src/components/UserInfoOverlay.tsx b/examples/SampleApp/src/components/UserInfoOverlay.tsx index dcaa84ae3..91659ecec 100644 --- a/examples/SampleApp/src/components/UserInfoOverlay.tsx +++ b/examples/SampleApp/src/components/UserInfoOverlay.tsx @@ -105,7 +105,8 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { const { theme: { - colors: { accent_red, black, border, grey, white }, + colors: { accent_red, black, grey, white }, + semantics, }, } = useTheme(); @@ -122,19 +123,19 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { } showScreen.value = show ? withTiming(1, { - duration: 150, - easing: Easing.in(Easing.ease), - }) + duration: 150, + easing: Easing.in(Easing.ease), + }) : withTiming( - 0, - { - duration: 150, - easing: Easing.out(Easing.ease), - }, - () => { - runOnJS(reset)(); - }, - ); + 0, + { + duration: 150, + easing: Easing.out(Easing.ease), + }, + () => { + runOnJS(reset)(); + }, + ); }; useEffect(() => { @@ -180,12 +181,12 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { translateY.value = evt.velocityY > 1000 ? withDecay({ - velocity: evt.velocityY, - }) + velocity: evt.velocityY, + }) : withTiming(screenHeight, { - duration: 200, - easing: Easing.out(Easing.ease), - }); + duration: 200, + easing: Easing.out(Easing.ease), + }); } else { translateY.value = withTiming(0); overlayOpacity.value = withTiming(1); @@ -216,8 +217,8 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { const self = channel ? Object.values(channel.state.members).find( - (channelMember) => channelMember.user?.id === client.user?.id, - ) + (channelMember) => channelMember.user?.id === client.user?.id, + ) : undefined; const { viewInfo, messageUser, removeFromGroup, cancel } = useUserInfoOverlayActions(); @@ -277,7 +278,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -292,7 +293,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -308,7 +309,7 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.row, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -326,8 +327,8 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { style={[ styles.lastRow, { - borderBottomColor: border.surfaceSubtle, - borderTopColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, + borderTopColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx index aac9764f7..4b1ab5504 100644 --- a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx +++ b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx @@ -97,12 +97,12 @@ export const UserSearchResults: React.FC = ({ bg_gradient_end, bg_gradient_start, black, - border, grey, grey_gainsboro, white_smoke, white_snow, }, + semantics, }, } = useTheme(); const { vw } = useViewport(); @@ -199,7 +199,7 @@ export const UserSearchResults: React.FC = ({ styles.searchResultContainer, { backgroundColor: white_snow, - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx index d657aeb69..5072e69f8 100644 --- a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx @@ -83,7 +83,8 @@ export const ChannelFilesScreen: React.FC = ({ const insets = useSafeAreaInsets(); const { theme: { - colors: { black, border, grey, white_snow }, + colors: { black, grey, white_snow }, + semantics, }, } = useTheme(); @@ -149,7 +150,7 @@ export const ChannelFilesScreen: React.FC = ({ Alert.alert('Not implemented.'); }} style={{ - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, borderBottomWidth: index === section.data.length - 1 ? 0 : 1, }} > diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 8c0a206d9..28b002c4f 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -221,7 +221,7 @@ export const ChannelScreen: React.FC = ({ return ( = ({ const { setOverlay } = useOverlayContext(); const { theme: { - colors: { accent_blue, accent_green, black, border, grey, white, white_smoke }, + colors: { accent_blue, accent_green, black, grey, white, white_smoke }, + semantics, }, } = useTheme(); @@ -276,7 +277,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.memberContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -306,7 +307,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.loadMoreButton, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -330,7 +331,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.changeNameContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -382,7 +383,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -427,7 +428,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -457,7 +458,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -487,7 +488,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -513,7 +514,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx index fdbc9d6e5..339ee4c99 100644 --- a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx +++ b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx @@ -118,7 +118,8 @@ export const NewDirectMessagingScreen: React.FC = }) => { const { theme: { - colors: { accent_blue, black, border, grey, white }, + colors: { accent_blue, black, grey, white }, + semantics, }, } = useTheme(); const { chatClient } = useAppContext(); @@ -208,7 +209,7 @@ export const NewDirectMessagingScreen: React.FC = styles.searchContainer, { backgroundColor: white, - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx index 79819b6de..8897f77c3 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx @@ -77,7 +77,8 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) const { theme: { - colors: { black, border, grey, white }, + colors: { black, grey, white }, + semantics, }, } = useTheme(); @@ -111,7 +112,7 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) styles.inputBoxContainer, { backgroundColor: white, - borderColor: border.surfaceSubtle, + borderColor: semantics.borderCoreDefault, marginBottom: selectedUsers.length === 0 ? 8 : 16, }, ]} diff --git a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx index cf4c98aac..270ce5e53 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx @@ -58,13 +58,13 @@ const ConfirmButton: React.FC = (props) => { const { disabled, onPress } = props; const { theme: { - colors: { accent_blue, grey }, + semantics, }, } = useTheme(); return ( - + ); }; @@ -86,7 +86,8 @@ export const NewGroupChannelAssignNameScreen: React.FC diff --git a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx index ccd83de70..36fd1d7de 100644 --- a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx +++ b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx @@ -134,7 +134,8 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ }) => { const { theme: { - colors: { accent_green, accent_red, black, border, grey, white, white_smoke }, + colors: { accent_green, accent_red, black, grey, white, white_smoke }, + semantics, }, } = useTheme(); const { chatClient } = useAppContext(); @@ -148,13 +149,13 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ const user = member?.user; const [muted, setMuted] = useState( chatClient?.mutedUsers && - chatClient?.mutedUsers?.findIndex((mutedUser) => mutedUser.target.id === user?.id) > -1, + chatClient?.mutedUsers?.findIndex((mutedUser) => mutedUser.target.id === user?.id) > -1, ); const [notificationsEnabled, setNotificationsEnabled] = useState( chatClient?.mutedChannels && - chatClient.mutedChannels.findIndex( - (mutedChannel) => mutedChannel.channel?.id === channel.id, - ) > -1, + chatClient.mutedChannels.findIndex( + (mutedChannel) => mutedChannel.channel?.id === channel.id, + ) > -1, ); /** @@ -227,7 +228,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.userNameContainer, { - borderTopColor: border.surfaceSubtle, + borderTopColor: semantics.borderCoreDefault, }, ]} > @@ -266,7 +267,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -305,7 +306,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -351,7 +352,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -381,7 +382,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -411,7 +412,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -441,7 +442,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > @@ -468,7 +469,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ style={[ styles.actionContainer, { - borderBottomColor: border.surfaceSubtle, + borderBottomColor: semantics.borderCoreDefault, }, ]} > diff --git a/examples/SampleApp/src/screens/UserSelectorScreen.tsx b/examples/SampleApp/src/screens/UserSelectorScreen.tsx index 19987dc12..49296412b 100644 --- a/examples/SampleApp/src/screens/UserSelectorScreen.tsx +++ b/examples/SampleApp/src/screens/UserSelectorScreen.tsx @@ -90,7 +90,8 @@ type Props = { export const UserSelectorScreen: React.FC = ({ navigation }) => { const { theme: { - colors: { black, border, grey, grey_gainsboro, grey_whisper, white_snow }, + colors: { black, grey, grey_gainsboro, grey_whisper, white_snow }, + semantics, }, } = useTheme(); const { switchUser } = useAppContext(); @@ -125,7 +126,7 @@ export const UserSelectorScreen: React.FC = ({ navigation }) => { onPress={() => { switchUser(u.id); }} - style={[styles.userContainer, { borderBottomColor: border.surfaceSubtle }]} + style={[styles.userContainer, { borderBottomColor: semantics.borderCoreDefault }]} testID={`user-selector-button-${u.id}`} > = ({ navigation }) => { onPress={() => { navigation.navigate('AdvancedUserSelectorScreen'); }} - style={[styles.userContainer, { borderBottomColor: border.surfaceSubtle }]} + style={[styles.userContainer, { borderBottomColor: semantics.borderCoreDefault }]} > { try { - if (!audioRecorderPlayer._isRecording) return; await audioRecorderPlayer.stopRecorder(); audioRecorderPlayer.removeRecordBackListener(); } catch (error) { diff --git a/package/src/components/Attachment/AudioAttachment.tsx b/package/src/components/Attachment/AudioAttachment.tsx index cd3f2670c..37b52a88e 100644 --- a/package/src/components/Attachment/AudioAttachment.tsx +++ b/package/src/components/Attachment/AudioAttachment.tsx @@ -1,5 +1,5 @@ import React, { RefObject, useEffect, useMemo } from 'react'; -import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; +import { I18nManager, Pressable, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; @@ -14,7 +14,9 @@ import { import { useTheme } from '../../contexts'; import { useStateStore } from '../../hooks'; import { useAudioPlayer } from '../../hooks/useAudioPlayer'; -import { Audio, Pause, Play } from '../../icons'; +import { Audio } from '../../icons'; +import { NewPause } from '../../icons/NewPause'; +import { NewPlay } from '../../icons/NewPlay'; import { NativeHandlers, SoundReturnType, @@ -56,6 +58,8 @@ export type AudioAttachmentProps = { * If true, the audio attachment is in preview mode in the message input. */ isPreview?: boolean; + containerStyle?: StyleProp; + maxAmplitudesCount?: number; }; const audioPlayerSelector = (state: AudioPlayerState) => ({ @@ -81,6 +85,8 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { testID, titleMaxLength, isPreview = false, + containerStyle, + maxAmplitudesCount = 30, } = props; const isVoiceRecording = isVoiceRecordingAttachment(item); @@ -173,7 +179,8 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { speedChangeButton, speedChangeButtonText, }, - colors: { accent_blue, black, grey_dark, grey_whisper, static_black, static_white, white }, + colors: { black, static_white, white }, + semantics, messageInput: { fileAttachmentUploadPreview: { filenameText }, }, @@ -197,9 +204,10 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { styles.container, { backgroundColor: white, - borderColor: grey_whisper, + borderColor: semantics.borderCoreDefault, }, container, + containerStyle, ]} testID={testID} > @@ -209,14 +217,14 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { onPress={handlePlayPause} style={[ styles.playPauseButton, - { backgroundColor: static_white, shadowColor: black }, + { backgroundColor: static_white, borderColor: semantics.borderCoreDefault }, playPauseButton, ]} > {!isPlaying ? ( - + ) : ( - + )} @@ -227,25 +235,31 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { style={[ styles.filenameText, { - color: black, + color: semantics.textPrimary, }, I18nManager.isRTL ? { writingDirection: 'rtl' } : { writingDirection: 'ltr' }, filenameText, ]} > {isVoiceRecordingAttachment(item) - ? 'Recording' + ? 'Voice Message' : getTrimmedAttachmentTitle(item.title, titleMaxLength)} - + {progressDuration} {!hideProgressBar && ( {item.waveform_data ? ( { /> ) : ( ) : ( diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index ac23dd105..4146bafbf 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -57,13 +57,23 @@ const MAX_NUMBER_OF_LINES = 5; const LINE_HEIGHT = 20; const PADDING_VERTICAL = 12; +const commandPlaceHolders: Record = { + giphy: 'Search GIFs', + ban: '@username', + unban: '@username', + mute: '@username', + unmute: '@username', +}; + const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => { + const styles = useStyles(); const { channel, cooldownRemainingSeconds, setInputBoxRef, t, TextInputComponent = RNTextInput, + placeholder, ...rest } = props; const [localText, setLocalText] = useState(''); @@ -109,18 +119,20 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) const { theme: { - colors: { black, grey }, messageInput: { inputBox }, + semantics, }, } = useTheme(); const placeholderText = useMemo(() => { - return command - ? t('Search') - : cooldownRemainingSeconds - ? `Slow mode, wait ${cooldownRemainingSeconds}s...` - : t('Send a message'); - }, [command, cooldownRemainingSeconds, t]); + return placeholder + ? placeholder + : command + ? commandPlaceHolders[command.name ?? ''] + : cooldownRemainingSeconds + ? `Slow mode, wait ${cooldownRemainingSeconds}s...` + : t('Send a message'); + }, [command, cooldownRemainingSeconds, t, placeholder]); return ( { ); }; -const styles = StyleSheet.create({ - inputBox: { - flex: 1, - fontSize: 16, - includeFontPadding: false, // for android vertical text centering - lineHeight: 20, - paddingLeft: 16, - paddingVertical: 12, - textAlignVertical: 'center', // for android vertical text centering - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + inputBox: { + color: semantics.inputTextDefault, + flex: 1, + fontSize: 16, + includeFontPadding: false, // for android vertical text centering + lineHeight: 20, + paddingLeft: 16, + paddingVertical: 12, + textAlignVertical: 'center', // for android vertical text centering + }, + }); + }, [semantics]); +}; AutoCompleteInput.displayName = 'AutoCompleteInput{messageInput{inputBox}}'; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 4b15de4d9..93a4a100d 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -185,7 +185,6 @@ import { AudioRecordingInProgress as AudioRecordingInProgressDefault } from '../ import { AudioRecordingLockIndicator as AudioRecordingLockIndicatorDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingLockIndicator'; import { AudioRecordingPreview as AudioRecordingPreviewDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingPreview'; import { AudioRecordingWaveform as AudioRecordingWaveformDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingWaveform'; -import { CommandInput as CommandInputDefault } from '../MessageInput/components/CommandInput'; import { InputButtons as InputButtonsDefault } from '../MessageInput/components/InputButtons'; import { AttachButton as AttachButtonDefault } from '../MessageInput/components/InputButtons/AttachButton'; import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/components/OutputButtons/CooldownTimer'; @@ -561,7 +560,7 @@ const ChannelWithContext = (props: PropsWithChildren) = asyncMessagesLockDistance = 50, asyncMessagesMinimumPressDuration = 500, asyncMessagesMultiSendEnabled = true, - asyncMessagesSlideToCancelDistance = 100, + asyncMessagesSlideToCancelDistance = 75, AttachButton = AttachButtonDefault, Attachment = AttachmentDefault, AttachmentActions = AttachmentActionsDefault, @@ -665,7 +664,6 @@ const ChannelWithContext = (props: PropsWithChildren) = InlineUnreadIndicator = InlineUnreadIndicatorDefault, Input, InputButtons = InputButtonsDefault, - CommandInput = CommandInputDefault, isAttachmentEqual, isMessageAIGenerated = () => false, keyboardBehavior, @@ -1857,7 +1855,6 @@ const ChannelWithContext = (props: PropsWithChildren) = AutoCompleteSuggestionList, CameraSelectorIcon, channelId, - CommandInput, compressImageQuality, CooldownTimer, CreatePollContent, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 5efbc4167..11f1f5a5b 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -29,7 +29,6 @@ export const useCreateInputMessageInputContext = ({ AutoCompleteSuggestionList, channelId, CameraSelectorIcon, - CommandInput, compressImageQuality, CooldownTimer, CreatePollContent, @@ -93,7 +92,6 @@ export const useCreateInputMessageInputContext = ({ AutoCompleteSuggestionItem, AutoCompleteSuggestionList, CameraSelectorIcon, - CommandInput, compressImageQuality, CooldownTimer, CreatePollContent, diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index b091d2b87..cc62e6e60 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -1,24 +1,15 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Modal, StyleSheet, TextInput, TextInputProps, View } from 'react-native'; -import { - Gesture, - GestureDetector, - GestureHandlerRootView, - PanGestureHandlerEventPayload, -} from 'react-native-gesture-handler'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { Extrapolation, FadeIn, FadeOut, interpolate, LinearTransition, - runOnJS, useAnimatedStyle, useSharedValue, - withSpring, - ZoomIn, - ZoomOut, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -26,7 +17,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; import { OutputButtons } from './components/OutputButtons'; -import { useAudioRecorder } from './hooks/useAudioRecorder'; import { useCountdown } from './hooks/useCountdown'; import { @@ -65,70 +55,87 @@ import { import { useKeyboardVisibility } from '../../hooks/useKeyboardVisibility'; import { useStateStore } from '../../hooks/useStateStore'; -import { isAudioRecorderAvailable, NativeHandlers } from '../../native'; +import { AudioRecorderManagerState } from '../../state-store/audio-recorder-manager'; import { MessageInputHeightState } from '../../state-store/message-input-height-store'; +import { primitives } from '../../theme'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; +import { GiphyBadge } from '../ui/GiphyBadge'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - gap: 8, - justifyContent: 'space-between', - }, - contentContainer: { - gap: 4, - overflow: 'hidden', - paddingHorizontal: 8, - }, - floatingWrapper: { - left: 0, - paddingHorizontal: 16, - position: 'absolute', - right: 0, - }, - inputBoxContainer: { - flex: 1, - }, - inputBoxWrapper: { - borderRadius: 24, - borderWidth: 1, - flex: 1, - flexDirection: 'row', - }, - inputButtonsContainer: { - alignSelf: 'flex-end', - }, - inputContainer: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - }, - micButtonContainer: {}, - outputButtonsContainer: { - alignSelf: 'flex-end', - padding: 8, - }, - shadow: { - elevation: 6, - - shadowColor: 'hsla(0, 0%, 0%, 0.24)', - shadowOffset: { height: 4, width: 0 }, - shadowOpacity: 0.24, - shadowRadius: 12, - }, - suggestionsListContainer: { - position: 'absolute', - width: '100%', - }, - wrapper: { - borderTopWidth: 1, - paddingHorizontal: 16, - paddingTop: 16, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXs, + justifyContent: 'space-between', + }, + contentContainer: { + gap: primitives.spacingXxs, + overflow: 'hidden', + paddingHorizontal: primitives.spacingXs, + }, + floatingWrapper: { + left: 0, + position: 'absolute', + right: 0, + }, + giphyContainer: { + padding: primitives.spacingXs, + }, + inputBoxContainer: { + flex: 1, + }, + inputBoxWrapper: { + borderRadius: 24, + borderWidth: 1, + flex: 1, + flexDirection: 'row', + backgroundColor: semantics.composerBg, + borderColor: semantics.borderCoreDefault, + }, + inputButtonsContainer: { + alignSelf: 'flex-end', + }, + inputContainer: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + }, + micButtonContainer: {}, + outputButtonsContainer: { + alignSelf: 'flex-end', + padding: primitives.spacingXs, + }, + shadow: { + elevation: 6, + + shadowColor: 'hsla(0, 0%, 0%, 0.24)', + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.24, + shadowRadius: 12, + }, + suggestionsListContainer: { + position: 'absolute', + width: '100%', + }, + wrapper: { + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacingMd, + }, + audioLockIndicatorWrapper: { + position: 'absolute', + right: primitives.spacingMd, + padding: 4, + }, + }); + }, [semantics]); +}; type MessageInputPropsWithContext = Pick< AttachmentPickerContextValue, @@ -138,6 +145,7 @@ type MessageInputPropsWithContext = Pick< Pick & Pick< MessageInputContextValue, + | 'audioRecorderManager' | 'additionalTextInputProps' | 'audioRecordingEnabled' | 'asyncMessagesLockDistance' @@ -167,7 +175,6 @@ type MessageInputPropsWithContext = Pick< | 'messageInputHeightStore' | 'ImageSelectorIcon' | 'VideoRecorderSelectorIcon' - | 'CommandInput' | 'SendButton' | 'ShowThreadMessageInChannelButton' | 'StartAudioRecordingButton' @@ -181,7 +188,8 @@ type MessageInputPropsWithContext = Pick< > & Pick & Pick & - Pick & { + Pick & + Pick & { editing: boolean; hasAttachments: boolean; isKeyboardVisible: boolean; @@ -190,6 +198,8 @@ type MessageInputPropsWithContext = Pick< ref: React.Ref | undefined; } >; + isRecordingStateIdle?: boolean; + recordingStatus?: string; }; const textComposerStateSelector = (state: TextComposerState) => ({ @@ -213,15 +223,11 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { attachmentSelectionBarHeight, bottomInset, selectedPicker, - additionalTextInputProps, asyncMessagesLockDistance, - asyncMessagesMinimumPressDuration, - asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, AttachmentUploadPreviewList, AudioRecorder, - audioRecordingEnabled, AudioRecordingInProgress, AudioRecordingLockIndicator, AudioRecordingPreview, @@ -238,20 +244,21 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { Input, inputBoxRef, InputButtons, - CommandInput, isKeyboardVisible, - isOnline, members, Reply, threadList, sendMessage, showPollCreationDialog, ShowThreadMessageInChannelButton, - StartAudioRecordingButton, TextInputComponent, watchers, + micLocked, + isRecordingStateIdle, + recordingStatus, } = props; + const styles = useStyles(); const messageComposer = useMessageComposer(); const { clearEditingState } = useMessageComposerAPIContext(); const onDismissEditMessage = () => { @@ -265,7 +272,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { theme: { semantics, - colors: { grey_whisper, white, white_smoke }, messageInput: { attachmentSelectionBar, container, @@ -277,7 +283,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { inputContainer, inputButtonsContainer, inputFloatingContainer, - micButtonContainer, outputButtonsContainer, suggestionsListContainer: { container: suggestionListContainer }, wrapper, @@ -351,81 +356,11 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const isFocused = inputBoxRef.current?.isFocused(); - const { - deleteVoiceRecording, - micLocked, - permissionsGranted, - recording, - recordingDuration, - recordingStatus, - setMicLocked, - startVoiceRecording, - stopVoiceRecording, - uploadVoiceRecording, - waveformData, - } = useAudioRecorder(); - - const asyncAudioEnabled = audioRecordingEnabled && isAudioRecorderAvailable(); - const micPositionX = useSharedValue(0); const micPositionY = useSharedValue(0); const X_AXIS_POSITION = -asyncMessagesSlideToCancelDistance; const Y_AXIS_POSITION = -asyncMessagesLockDistance; - const resetAudioRecording = async () => { - await deleteVoiceRecording(); - }; - - const micLockHandler = () => { - setMicLocked(true); - NativeHandlers.triggerHaptic('impactMedium'); - }; - - const panGestureMic = Gesture.Pan() - .activateAfterLongPress(asyncMessagesMinimumPressDuration + 100) - .onChange((event: PanGestureHandlerEventPayload) => { - const newPositionX = event.translationX; - const newPositionY = event.translationY; - - if (newPositionX <= 0 && newPositionX >= X_AXIS_POSITION) { - micPositionX.value = newPositionX; - } - if (newPositionY <= 0 && newPositionY >= Y_AXIS_POSITION) { - micPositionY.value = newPositionY; - } - }) - .onEnd(() => { - const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2; - const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2; - - if (belowThresholdY && belowThresholdX) { - micPositionY.value = withSpring(0); - micPositionX.value = withSpring(0); - if (recordingStatus === 'recording') { - runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); - } - return; - } - - if (!belowThresholdY) { - micPositionY.value = withSpring(Y_AXIS_POSITION); - runOnJS(micLockHandler)(); - } - - if (!belowThresholdX) { - micPositionX.value = withSpring(X_AXIS_POSITION); - runOnJS(resetAudioRecording)(); - } - - micPositionX.value = 0; - micPositionY.value = 0; - }) - .onStart(() => { - micPositionX.value = 0; - micPositionY.value = 0; - runOnJS(setMicLocked)(false); - }); - const lockIndicatorAnimatedStyle = useAnimatedStyle(() => ({ transform: [ { @@ -438,22 +373,8 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { }, ], })); - const micButttonAnimatedStyle = useAnimatedStyle(() => ({ - opacity: interpolate(micPositionX.value, [0, X_AXIS_POSITION], [1, 0], Extrapolation.CLAMP), - transform: [{ translateX: micPositionX.value }, { translateY: micPositionY.value }], - })); const slideToCancelAnimatedStyle = useAnimatedStyle(() => ({ opacity: interpolate(micPositionX.value, [0, X_AXIS_POSITION], [1, 0], Extrapolation.CLAMP), - transform: [ - { - translateX: interpolate( - micPositionX.value, - [0, X_AXIS_POSITION], - [0, X_AXIS_POSITION / 2], - Extrapolation.CLAMP, - ), - }, - ], })); const { bottom } = useSafeAreaInsets(); @@ -471,14 +392,15 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { messageInputHeightStore.setHeight( messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight, ) - } // 24 is the position of the input from the bottom of the screen + } // BOTTOM OFFSET is the position of the input from the bottom of the screen style={ messageInputFloating - ? [styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] + ? [styles.wrapper, styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] : [ styles.wrapper, { - backgroundColor: white, + borderTopWidth: 1, + backgroundColor: semantics.composerBg, borderColor: semantics.borderCoreDefault, paddingBottom: BOTTOM_OFFSET, }, @@ -486,150 +408,122 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { ] } > - {recording && ( - <> - - {recordingStatus === 'stopped' ? ( - - ) : micLocked ? ( - + {Input ? ( + + ) : ( + + {isRecordingStateIdle ? ( + + {InputButtons && } + ) : null} - - )} - - - {Input ? ( - - ) : ( - <> - {recording ? ( - - ) : ( - <> + + + {recordingStatus === 'stopped' ? ( + + ) : micLocked ? ( + + ) : null} + {isRecordingStateIdle ? ( - {InputButtons && } - - - - + + + ) : null} + {quotedMessage ? ( + - {editing ? ( - - - - ) : null} - {quotedMessage ? ( - - - - ) : null} - - - - - {command ? ( - - ) : ( - - )} - - - + + + ) : null} + + + ) : null} + + + {!isRecordingStateIdle ? ( + + ) : ( + <> + {command ? ( + + - - - - - )} - - {asyncAudioEnabled && !micLocked ? ( - - - - - - - - ) : null} - - )} - + + )} + + {(recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( + + + + ) : null} + + + + + )} + {!isRecordingStateIdle ? ( + + + + ) : null} + { exiting={FadeOut.duration(200)} style={[ { - backgroundColor: white_smoke, + backgroundColor: semantics.composerBg, height: attachmentPickerBottomSheetHeight + attachmentSelectionBarHeight - bottomInset, }, @@ -700,6 +594,9 @@ const areEqual = ( showPollCreationDialog: prevShowPollCreationDialog, t: prevT, threadList: prevThreadList, + micLocked: prevMicLocked, + isRecordingStateIdle: prevIsRecordingStateIdle, + recordingStatus: prevRecordingStatus, } = prevProps; const { additionalTextInputProps: nextAdditionalTextInputProps, @@ -719,6 +616,9 @@ const areEqual = ( showPollCreationDialog: nextShowPollCreationDialog, t: nextT, threadList: nextThreadList, + micLocked: nextMicLocked, + isRecordingStateIdle: nextIsRecordingStateIdle, + recordingStatus: nextRecordingStatus, } = nextProps; const tEqual = prevT === nextT; @@ -803,6 +703,21 @@ const areEqual = ( return false; } + const micLockedEqual = prevMicLocked === nextMicLocked; + if (!micLockedEqual) { + return false; + } + + const isRecordingStateIdleEqual = prevIsRecordingStateIdle === nextIsRecordingStateIdle; + if (!isRecordingStateIdleEqual) { + return false; + } + + const recordingStatusEqual = prevRecordingStatus === nextRecordingStatus; + if (!recordingStatusEqual) { + return false; + } + return true; }; @@ -813,6 +728,12 @@ const MemoizedMessageInput = React.memo( export type MessageInputProps = Partial; +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + micLocked: state.micLocked, + isRecordingStateIdle: state.status === 'idle', + recordingStatus: state.status, +}); + /** * UI Component for message input * It's a consumer of @@ -829,6 +750,7 @@ export const MessageInput = (props: MessageInputProps) => { const { channel, members, threadList, watchers } = useChannelContext(); const { + audioRecorderManager, additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, @@ -860,7 +782,6 @@ export const MessageInput = (props: MessageInputProps) => { Input, inputBoxRef, InputButtons, - CommandInput, messageInputFloating, messageInputHeightStore, openPollCreationDialog, @@ -884,6 +805,11 @@ export const MessageInput = (props: MessageInputProps) => { const { attachments } = useAttachmentManagerState(); const isKeyboardVisible = useKeyboardVisibility(); + const { micLocked, isRecordingStateIdle, recordingStatus } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); + const { t } = useTranslationContext(); /** @@ -897,6 +823,10 @@ export const MessageInput = (props: MessageInputProps) => { return ( { clearEditingState, closeAttachmentPicker, closePollCreationDialog, - CommandInput, compressImageQuality, cooldownEndsAt, CooldownTimer, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx index 3ede59328..3096c593d 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx @@ -21,6 +21,7 @@ export const AttachmentRemoveControl = ({ onPress }: AttachmentRemoveControlProp return ( [ styles.dismiss, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index 1313d6eca..26a42d4a5 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -11,6 +11,7 @@ import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressInd import { AudioAttachment } from '../../../../components/Attachment/AudioAttachment'; import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; @@ -24,6 +25,7 @@ export const AudioAttachmentUploadPreview = ({ handleRetry, removeAttachments, }: AudioAttachmentUploadPreviewProps) => { + const styles = useStyles(); const { enableOfflineSupport } = useChatContext(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, @@ -56,18 +58,18 @@ export const AudioAttachmentUploadPreview = ({ }, [attachment, removeAttachments]); return ( - + @@ -80,15 +82,22 @@ export const AudioAttachmentUploadPreview = ({ ); }; -const styles = StyleSheet.create({ - dismissWrapper: { - position: 'absolute', - right: 0, - top: 0, - }, - overlay: { - borderRadius: 12, - marginHorizontal: 8, - marginTop: 2, - }, -}); +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + overlay: { + borderRadius: primitives.radiusLg, + }, + wrapper: { + padding: primitives.spacingXxs, + }, + }), + [], + ); +}; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 91c2c6a0b..72034890c 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -117,6 +117,7 @@ const useStyles = () => { StyleSheet.create({ dismissWrapper: { position: 'absolute', right: 0, top: 0 }, fileContainer: { + alignItems: 'center', borderRadius: primitives.radiusLg, borderColor: borderCoreDefault, borderWidth: 1, @@ -125,23 +126,27 @@ const useStyles = () => { width: 224, // TODO: Not sure how to omit this padding: primitives.spacingMd, }, - fileContent: { - flexShrink: 1, - justifyContent: 'space-between', - }, fileIcon: { alignItems: 'center', alignSelf: 'center', justifyContent: 'center', }, + fileContent: { + flexShrink: 1, + justifyContent: 'space-between', + }, filenameText: { color: textPrimary, fontSize: primitives.typographyFontSizeXs, fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightTight, }, fileSizeText: { color: textSecondary, fontSize: primitives.typographyFontSizeXs, + lineHeight: primitives.typographyLineHeightTight, + fontWeight: primitives.typographyFontWeightRegular, + paddingTop: primitives.spacingXxs, }, overlay: { borderRadius: primitives.radiusLg, diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx index fd24d0b86..1ddcefd81 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx @@ -1,58 +1,42 @@ -import React from 'react'; -import { Pressable, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; +import React, { useMemo } from 'react'; +import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; import dayjs from 'dayjs'; +import { IconButton } from '../../../../components/ui'; import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; -import { ArrowLeft, CircleStop, Delete, Mic, SendCheck } from '../../../../icons'; +import { useStateStore } from '../../../../hooks/useStateStore'; -import { AudioRecordingReturnType } from '../../../../native'; +import { NewChevronLeft } from '../../../../icons/NewChevronLeft'; +import { NewMic } from '../../../../icons/NewMic'; +import { NewStop } from '../../../../icons/NewStop'; +import { NewTick } from '../../../../icons/NewTick'; +import { NewTrash } from '../../../../icons/NewTrash'; +import { NativeHandlers } from '../../../../native'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; type AudioRecorderPropsWithContext = Pick< MessageInputContextValue, - 'asyncMessagesMultiSendEnabled' -> & { - /** - * Function to stop and delete the voice recording. - */ - deleteVoiceRecording: () => Promise; - /** - * Boolean used to show if the voice recording state is locked. This makes sure the mic button shouldn't be pressed any longer. - * When the mic is locked the `AudioRecordingInProgress` component shows up. - */ - micLocked: boolean; - /** - * The current voice recording that is in progress. - */ - recording: AudioRecordingReturnType; - /** - * Boolean to determine if the recording has been stopped. - */ - recordingStopped: boolean; - /** - * Function to stop the ongoing voice recording. - */ - stopVoiceRecording: () => Promise; - /** - * Function to upload the voice recording. - */ - uploadVoiceRecording: (multiSendEnabled: boolean) => Promise; - /** - * The duration of the voice recording. - */ - recordingDuration?: number; - /** - * Style used in slide to cancel container. - */ - slideToCancelStyle?: StyleProp; -}; + | 'audioRecorderManager' + | 'asyncMessagesMultiSendEnabled' + | 'stopVoiceRecording' + | 'deleteVoiceRecording' + | 'uploadVoiceRecording' +> & + Pick & { + /** + * Style used in slide to cancel container. + */ + slideToCancelStyle?: StyleProp; + }; const StopRecording = ({ stopVoiceRecordingHandler, @@ -60,20 +44,23 @@ const StopRecording = ({ stopVoiceRecordingHandler: () => Promise; }) => { const { - theme: { - colors: { accent_red }, - messageInput: { - audioRecorder: { circleStopIcon, pausedContainer }, - }, - }, + theme: { semantics }, } = useTheme(); + + const onStopVoiceRecording = () => { + NativeHandlers.triggerHaptic('impactMedium'); + stopVoiceRecordingHandler(); + }; + return ( - - - + ); }; @@ -84,23 +71,19 @@ const UploadRecording = ({ asyncMessagesMultiSendEnabled: boolean; uploadVoiceRecordingHandler: (multiSendEnabled: boolean) => Promise; }) => { - const { - theme: { - colors: { accent_blue }, - messageInput: { - audioRecorder: { checkContainer, sendCheckIcon }, - }, - }, - } = useTheme(); + const onUploadVoiceRecording = () => { + NativeHandlers.triggerHaptic('impactMedium'); + uploadVoiceRecordingHandler(asyncMessagesMultiSendEnabled); + }; + return ( - { - await uploadVoiceRecordingHandler(asyncMessagesMultiSendEnabled); - }} - style={[styles.checkContainer, checkContainer]} - > - - + ); }; @@ -109,191 +92,185 @@ const DeleteRecording = ({ }: { deleteVoiceRecordingHandler: () => Promise; }) => { - const { - theme: { - colors: { accent_blue }, - messageInput: { - audioRecorder: { deleteContainer, deleteIcon }, - }, - }, - } = useTheme(); + const onDeleteVoiceRecording = () => { + NativeHandlers.triggerHaptic('impactMedium'); + deleteVoiceRecordingHandler(); + }; return ( - - - + ); }; const AudioRecorderWithContext = (props: AudioRecorderPropsWithContext) => { const { asyncMessagesMultiSendEnabled, - deleteVoiceRecording, - micLocked, - recordingDuration, - recordingStopped, slideToCancelStyle, + deleteVoiceRecording, stopVoiceRecording, uploadVoiceRecording, + micLocked, + status, + duration, } = props; const { t } = useTranslationContext(); + const recordingStopped = status === 'stopped'; const { theme: { - colors: { accent_red, grey_dark }, + semantics, messageInput: { audioRecorder: { arrowLeftIcon, micContainer, micIcon, slideToCancelContainer }, }, }, } = useTheme(); + const styles = useStyles(); if (micLocked) { if (recordingStopped) { return ( - <> + - + ); } else { return ( - <> - - - + + - + ); } } else { return ( <> - - - {recordingDuration ? dayjs.duration(recordingDuration).format('mm:ss') : null} + + + {duration ? dayjs.duration(duration).format('mm:ss') : '00:00'} - {t('Slide to Cancel')} - + + {t('Slide to Cancel')} + + ); } }; -const areEqual = ( - prevProps: AudioRecorderPropsWithContext, - nextProps: AudioRecorderPropsWithContext, -) => { - const { - asyncMessagesMultiSendEnabled: prevAsyncMessagesMultiSendEnabled, - micLocked: prevMicLocked, - recording: prevRecording, - recordingDuration: prevRecordingDuration, - recordingStopped: prevRecordingStopped, - } = prevProps; - const { - asyncMessagesMultiSendEnabled: nextAsyncMessagesMultiSendEnabled, - micLocked: nextMicLocked, - recording: nextRecording, - recordingDuration: nextRecordingDuration, - recordingStopped: nextRecordingStopped, - } = nextProps; - - const asyncMessagesMultiSendEnabledEqual = - prevAsyncMessagesMultiSendEnabled === nextAsyncMessagesMultiSendEnabled; - if (!asyncMessagesMultiSendEnabledEqual) { - return false; - } - - const micLockedEqual = prevMicLocked === nextMicLocked; - if (!micLockedEqual) { - return false; - } - - const recordingEqual = prevRecording === nextRecording; - if (!recordingEqual) { - return false; - } - - const recordingDurationEqual = prevRecordingDuration === nextRecordingDuration; - if (!recordingDurationEqual) { - return false; - } - - const recordingStoppedEqual = prevRecordingStopped === nextRecordingStopped; - if (!recordingStoppedEqual) { - return false; - } - - return true; -}; - const MemoizedAudioRecorder = React.memo( AudioRecorderWithContext, - areEqual, ) as typeof AudioRecorderWithContext; -export type AudioRecorderProps = Partial & - Pick< - AudioRecorderPropsWithContext, - | 'deleteVoiceRecording' - | 'micLocked' - | 'recording' - | 'recordingStopped' - | 'stopVoiceRecording' - | 'uploadVoiceRecording' - >; +export type AudioRecorderProps = Partial; + +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + duration: state.duration, + micLocked: state.micLocked, + status: state.status, +}); /** * Component to display the Recording UI in the Message Input. */ export const AudioRecorder = (props: AudioRecorderProps) => { - const { asyncMessagesMultiSendEnabled } = useMessageInputContext(); + const { + audioRecorderManager, + asyncMessagesMultiSendEnabled, + stopVoiceRecording, + deleteVoiceRecording, + uploadVoiceRecording, + } = useMessageInputContext(); + + const { micLocked, duration, status } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); return ( ); }; -const styles = StyleSheet.create({ - checkContainer: {}, - deleteContainer: {}, - durationLabel: { - fontSize: 14, - }, - micContainer: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'center', - }, - pausedContainer: {}, - slideToCancel: { - fontSize: 18, - }, - slideToCancelContainer: { - alignItems: 'center', - flexDirection: 'row', - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + padding: primitives.spacingXs, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + checkContainer: {}, + deleteContainer: {}, + durationLabel: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + color: semantics.textPrimary, + }, + micContainer: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + gap: primitives.spacingSm, + paddingHorizontal: primitives.spacingMd, + }, + pausedContainer: {}, + slideToCancel: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + color: semantics.textPrimary, + }, + slideToCancelContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + }, + }), + [semantics.textPrimary], + ); +}; AudioRecorder.displayName = 'AudioRecorder{messageInput}'; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 39782431d..430dd868a 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 d83d4f022..0cabba7d7 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; @@ -8,36 +8,30 @@ import { useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { NewMic } from '../../../../icons/NewMic'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; type AudioRecordingInProgressPropsWithContext = Pick< MessageInputContextValue, - 'AudioRecordingWaveform' -> & { - /** - * The waveform data to be presented to show the audio levels. - */ - waveformData: number[]; - /** - * Maximum number of waveform lines that should be rendered in the UI. - */ - maxDataPointsDrawn?: number; - /** - * The duration of the voice recording. - */ - recordingDuration?: number; -}; + 'audioRecorderManager' | 'AudioRecordingWaveform' +> & + Pick & { + /** + * Maximum number of waveform lines that should be rendered in the UI. + */ + maxDataPointsDrawn?: number; + }; const AudioRecordingInProgressWithContext = (props: AudioRecordingInProgressPropsWithContext) => { - const { - AudioRecordingWaveform, - maxDataPointsDrawn = 80, - recordingDuration, - waveformData, - } = props; + const { AudioRecordingWaveform, maxDataPointsDrawn = 60, duration, waveformData } = props; + + const styles = useStyles(); const { theme: { - colors: { grey_dark }, + semantics, messageInput: { audioRecordingInProgress: { container, durationText }, }, @@ -47,59 +41,78 @@ const AudioRecordingInProgressWithContext = (props: AudioRecordingInProgressProp return ( {/* `durationMillis` is for Expo apps, `currentPosition` is for Native CLI apps. */} - - {recordingDuration ? dayjs.duration(recordingDuration).format('mm:ss') : null} - + + + + {duration ? dayjs.duration(duration).format('mm:ss') : null} + + + + {/* TODO: Calculate the maxDataPointsDrawn based on the width of the container */} ); }; -const areEqual = ( - prevProps: AudioRecordingInProgressPropsWithContext, - nextProps: AudioRecordingInProgressPropsWithContext, -) => { - const { recordingDuration: prevRecordingDuration } = prevProps; - const { recordingDuration: nextRecordingDuration } = nextProps; - - const recordingDurationEqual = prevRecordingDuration === nextRecordingDuration; - - if (!recordingDurationEqual) { - return false; - } - - return true; -}; - const MemoizedAudioRecordingInProgress = React.memo( AudioRecordingInProgressWithContext, - areEqual, ) as typeof AudioRecordingInProgressWithContext; -export type AudioRecordingInProgressProps = Partial & { - waveformData: number[]; -}; +export type AudioRecordingInProgressProps = Partial; + +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + duration: state.duration, + waveformData: state.waveformData, +}); /** * Component displayed when the audio is in the recording state. */ export const AudioRecordingInProgress = (props: AudioRecordingInProgressProps) => { - const { AudioRecordingWaveform } = useMessageInputContext(); + const { audioRecorderManager, AudioRecordingWaveform } = useMessageInputContext(); + + const { duration, waveformData } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); - return ; + return ( + + ); }; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingBottom: 8, - paddingTop: 4, - }, - durationText: { - fontSize: 16, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: primitives.spacingSm, + paddingLeft: primitives.spacingSm, + paddingRight: primitives.spacingMd, + }, + durationText: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + color: semantics.textPrimary, + }, + micContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingSm, + }, + }), + [semantics.textPrimary], + ); +}; AudioRecordingInProgress.displayName = 'AudioRecordingInProgress{messageInput}'; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx index 368d48e9e..2e30cfd06 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx @@ -1,17 +1,16 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; -import Animated from 'react-native-reanimated'; +import Animated, { LinearTransition } from 'react-native-reanimated'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; -import { ArrowUp, Lock } from '../../../../icons'; +import { NewChevronLeft } from '../../../../icons/NewChevronTop'; +import { NewLock } from '../../../../icons/NewLock'; +import { NewUnlock } from '../../../../icons/NewUnlock'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; -export type AudioRecordingLockIndicatorProps = { - /** - * Boolean used to show if the voice recording state is locked. This makes sure the mic button shouldn't be pressed any longer. - * When the mic is locked the `AudioRecordingInProgress` component shows up. - */ - micLocked: boolean; +export type AudioRecordingLockIndicatorProps = Pick & { /** * Height of the message input, to apply necessary position adjustments to this component. */ @@ -32,6 +31,7 @@ export const AudioRecordingLockIndicator = ({ }: AudioRecordingLockIndicatorProps) => { const [visible, setVisible] = useState(true); const timeoutRef = useRef(undefined); + const styles = useStyles(); useEffect(() => { timeoutRef.current = setTimeout(() => { @@ -47,7 +47,7 @@ export const AudioRecordingLockIndicator = ({ const { theme: { - colors: { accent_blue, grey, light_gray }, + semantics, messageInput: { audioRecordingLockIndicator: { arrowUpIcon, container, lockIcon }, }, @@ -60,25 +60,51 @@ export const AudioRecordingLockIndicator = ({ return ( - - {!micLocked && } + {micLocked ? ( + + ) : ( + + )} + {!micLocked && ( + + )} ); }; -const styles = StyleSheet.create({ - container: { - borderRadius: 50, - margin: 5, - padding: 8, - position: 'absolute', - right: 0, - }, -}); +const useStyles = () => { + const { + theme: { + colors: { white }, + semantics, + }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + backgroundColor: white, + borderColor: semantics.borderCoreDefault, + borderWidth: 1, + borderRadius: primitives.radiusMax, + padding: primitives.spacingXs, + gap: primitives.spacingXxs, + + // Replace shadow styles with theme-based tokens when available + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + + elevation: 5, + }, + }), + [white, semantics.borderCoreDefault], + ); +}; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx index 4bea5b394..6dc63f7fb 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx @@ -3,27 +3,22 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; +import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useAudioPlayer } from '../../../../hooks/useAudioPlayer'; import { useStateStore } from '../../../../hooks/useStateStore'; -import { Pause, Play } from '../../../../icons'; +import { NewPause } from '../../../../icons/NewPause'; +import { NewPlay } from '../../../../icons/NewPlay'; import { NativeHandlers } from '../../../../native'; import { AudioPlayerState } from '../../../../state-store/audio-player'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; import { WaveProgressBar } from '../../../ProgressControl/WaveProgressBar'; const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_HOUR_IN_MILLISECONDS = 3600 * 1000; -export type AudioRecordingPreviewProps = { - recordingDuration: number; - uri: string; - /** - * The waveform data to be presented to show the audio levels. - */ - waveformData: number[]; -}; - const audioPlayerSelector = (state: AudioPlayerState) => ({ duration: state.duration, isPlaying: state.isPlaying, @@ -31,11 +26,27 @@ const audioPlayerSelector = (state: AudioPlayerState) => ({ progress: state.progress, }); +const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ + duration: state.duration, + waveformData: state.waveformData, + recording: state.recording, +}); + /** * Component displayed when the audio is recorded and can be previewed. */ -export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { - const { recordingDuration, uri, waveformData } = props; +export const AudioRecordingPreview = () => { + const { audioRecorderManager } = useMessageInputContext(); + const styles = useStyles(); + + const { + duration: recordingDuration, + waveformData, + recording, + } = useStateStore(audioRecorderManager.state, audioRecorderSelector); + + const uri = + typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string); const audioPlayer = useAudioPlayer({ duration: recordingDuration / ONE_SECOND_IN_MILLISECONDS, @@ -61,7 +72,7 @@ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { const { theme: { - colors: { accent_blue, grey_dark }, + semantics, messageInput: { audioRecordingPreview: { container, @@ -92,47 +103,58 @@ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { return ( - + {!isPlaying ? ( - + ) : ( - + )} {/* `durationMillis` is for Expo apps, `currentPosition` is for Native CLI apps. */} - + {progressDuration} {/* Since the progress is in range 0-1 we convert it in terms of 100% */} - + ); }; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - display: 'flex', - flexDirection: 'row', - paddingBottom: 8, - paddingTop: 4, - }, - currentTime: { - fontSize: 16, - marginLeft: 4, - }, - infoContainer: { - alignItems: 'center', - display: 'flex', - flex: 1, - flexDirection: 'row', - }, - progressBar: { - flex: 3, - }, -}); +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: primitives.spacingSm, + paddingLeft: primitives.spacingSm, + paddingRight: primitives.spacingMd, + }, + durationText: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + infoContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingSm, + }, + progressBar: {}, + }), + [], + ); +}; AudioRecordingPreview.displayName = 'AudioRecordingPreview{messageInput}'; diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx index f0691ac1d..aec5ceab6 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingWaveform.tsx @@ -1,27 +1,27 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; +import { primitives } from '../../../../theme'; -export type AudioRecordingWaveformProps = { +export type AudioRecordingWaveformProps = Pick & { /** * Maximum number of waveform lines that should be rendered in the UI. */ maxDataPointsDrawn: number; - /** - * The waveform data to be presented to show the audio levels. - */ - waveformData: number[]; }; +const WAVEFORM_MAX_HEIGHT = 20; + /** * Waveform Component displayed when the audio is in the recording state. */ export const AudioRecordingWaveform = (props: AudioRecordingWaveformProps) => { const { maxDataPointsDrawn, waveformData } = props; + const styles = useStyles(); const { theme: { - colors: { grey_dark }, messageInput: { audioRecordingWaveform: { container, waveform: waveformTheme }, }, @@ -35,8 +35,7 @@ export const AudioRecordingWaveform = (props: AudioRecordingWaveformProps) => { style={[ styles.waveform, { - backgroundColor: grey_dark, - height: waveform * 30 > 3 ? waveform * 30 : 3, + height: waveform * WAVEFORM_MAX_HEIGHT, }, waveformTheme, ]} @@ -46,17 +45,29 @@ export const AudioRecordingWaveform = (props: AudioRecordingWaveformProps) => { ); }; -const styles = StyleSheet.create({ - container: { - alignSelf: 'center', - flexDirection: 'row', - }, - waveform: { - alignSelf: 'center', - borderRadius: 2, - marginHorizontal: 1, - width: 2, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignSelf: 'center', + flexDirection: 'row', + }, + waveform: { + alignSelf: 'center', + borderRadius: primitives.radiusXxs, + marginHorizontal: 1, + width: 2, + minHeight: 2, + maxHeight: WAVEFORM_MAX_HEIGHT, + backgroundColor: semantics.chatWaveformBar, + }, + }), + [semantics.chatWaveformBar], + ); +}; AudioRecordingWaveform.displayName = 'AudioRecordingWaveform{messageInput}'; diff --git a/package/src/components/MessageInput/components/CommandInput.tsx b/package/src/components/MessageInput/components/CommandInput.tsx deleted file mode 100644 index f8c6c58f8..000000000 --- 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 4330aac3e..9cf9a4a32 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 dda8a74d5..58c9ece24 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 86ca1c486..2c53f82c4 100644 --- a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx +++ b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx @@ -1,47 +1,45 @@ -import { useEffect, useState } from 'react'; - -import { Alert, Platform } from 'react-native'; +import { useCallback, useEffect, useState } from 'react'; import { LocalVoiceRecordingAttachment } from 'stream-chat'; -import { useActiveAudioPlayer } from '../../../contexts/audioPlayerContext/AudioPlayerContext'; import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; -import { AudioRecordingReturnType, NativeHandlers, RecordingStatus } from '../../../native'; +import { MessageInputContextValue } from '../../../contexts/messageInputContext/MessageInputContext'; import type { File } from '../../../types/types'; import { FileTypes } from '../../../types/types'; import { generateRandomId } from '../../../utils/utils'; import { resampleWaveformData } from '../utils/audioSampling'; -import { normalizeAudioLevel } from '../utils/normalizeAudioLevel'; - -export type RecordingStatusStates = 'idle' | 'recording' | 'stopped'; /** * The hook that controls all the async audio core features including start/stop or recording, player, upload/delete of the recorded audio. * * FIXME: Change the name to `useAudioRecorder` in the next major version as the hook will only be used for audio recording. */ -export const useAudioRecorder = () => { - const [micLocked, setMicLocked] = useState(false); - const [permissionsGranted, setPermissionsGranted] = useState(true); - const [waveformData, setWaveformData] = useState([]); +export const useAudioRecorder = ({ + audioRecorderManager, + sendMessage, +}: Pick) => { const [isScheduledForSubmit, setIsScheduleForSubmit] = useState(false); - const [recording, setRecording] = useState(undefined); - const [recordingDuration, setRecordingDuration] = useState(0); - const [recordingStatus, setRecordingStatus] = useState('idle'); const { attachmentManager } = useMessageComposer(); - const activeAudioPlayer = useActiveAudioPlayer(); - const { sendMessage } = useMessageInputContext(); + /** + * A function that takes care of stopping the voice recording from the library's + * side only. Meant to be used as a pure function (during unmounting for instance) + * hence this approach. + */ + const stopVoiceRecording = useCallback(async () => { + const { status } = audioRecorderManager.state.getLatestValue(); + if (status !== 'recording') return; + await audioRecorderManager.stopRecording(); + }, [audioRecorderManager]); // This effect stop the player from playing and stops audio recording on // the audio SDK side on unmount. useEffect( () => () => { - stopSDKVoiceRecording(); + stopVoiceRecording(); }, - [], + [stopVoiceRecording], ); useEffect(() => { @@ -51,155 +49,85 @@ export const useAudioRecorder = () => { } }, [isScheduledForSubmit, sendMessage]); - const onRecordingStatusUpdate = (status: RecordingStatus) => { - if (status.isDoneRecording === true) { - return; - } - setRecordingDuration(status?.currentPosition || status.durationMillis); - // For expo android the lower bound is -120 so we need to normalize according to it. The `status.currentMetering` is undefined for Expo CLI apps, so we can use it. - const lowerBound = Platform.OS === 'ios' || status.currentMetering ? -60 : -120; - const normalizedAudioLevel = normalizeAudioLevel( - status.currentMetering || status.metering, - lowerBound, - ); - setWaveformData((prev) => [...prev, normalizedAudioLevel]); - }; - /** * Function to start voice recording. */ - const startVoiceRecording = async () => { - if (!NativeHandlers.Audio) { - return; - } - const recordingInfo = await NativeHandlers.Audio.startRecording( - { - isMeteringEnabled: true, - }, - onRecordingStatusUpdate, - ); - const accessGranted = recordingInfo.accessGranted; - if (accessGranted) { - setPermissionsGranted(true); - const recording = recordingInfo.recording; - if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) { - recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60); - } - setRecording(recording); - setRecordingStatus('recording'); - if (activeAudioPlayer?.isPlaying) { - await activeAudioPlayer?.pause(); - } - } else { - setPermissionsGranted(false); - resetState(); - Alert.alert('Please allow Audio permissions in settings.'); - } - }; - - /** - * A function that takes care of stopping the voice recording from the library's - * side only. Meant to be used as a pure function (during unmounting for instance) - * hence this approach. - */ - const stopSDKVoiceRecording = async () => { - if (!NativeHandlers.Audio) { - return; - } - await NativeHandlers.Audio.stopRecording(); - }; - - /** - * Function to stop voice recording. - */ - const stopVoiceRecording = async () => { - await stopSDKVoiceRecording(); - setRecordingStatus('stopped'); - }; - - /** - * Function to reset the state of the message input for async voice messages. - */ - const resetState = () => { - setRecording(undefined); - setRecordingStatus('idle'); - setMicLocked(false); - setWaveformData([]); - }; + const startVoiceRecording = useCallback(async () => { + await audioRecorderManager.startRecording(); + }, [audioRecorderManager]); /** * Function to delete voice recording. */ - const deleteVoiceRecording = async () => { - if (recordingStatus === 'recording') { - await stopVoiceRecording(); - } - resetState(); - NativeHandlers.triggerHaptic('impactMedium'); - }; + const deleteVoiceRecording = useCallback(async () => { + await stopVoiceRecording(); + audioRecorderManager.reset(); + }, [audioRecorderManager, stopVoiceRecording]); /** * Function to upload or send voice recording. * @param multiSendEnabled boolean */ - const uploadVoiceRecording = async (multiSendEnabled: boolean) => { - if (recordingStatus === 'recording') { - await stopVoiceRecording(); - } - - const durationInSeconds = parseFloat((recordingDuration / 1000).toFixed(3)); - - const resampledWaveformData = resampleWaveformData(waveformData, 100); - - const clearFilter = new RegExp('[.:]', 'g'); - const date = new Date().toISOString().replace(clearFilter, '_'); - - const file: File = { - duration: durationInSeconds, - name: `audio_recording_${date}.aac`, - size: 0, - type: 'audio/aac', - uri: typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string), - waveform_data: resampledWaveformData, - }; - - const audioFile: LocalVoiceRecordingAttachment = { - asset_url: - typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string), - duration: durationInSeconds, - file_size: 0, - localMetadata: { - file, - id: generateRandomId(), - uploadState: 'pending', - }, - mime_type: 'audio/aac', - title: `audio_recording_${date}.aac`, - type: FileTypes.VoiceRecording, - waveform_data: resampledWaveformData, - }; - - if (multiSendEnabled) { - await attachmentManager.uploadAttachment(audioFile); - } else { - // FIXME: cannot call handleSubmit() directly as the function has stale reference to file uploads - await attachmentManager.uploadAttachment(audioFile); - setIsScheduleForSubmit(true); - } - resetState(); - }; + const uploadVoiceRecording = useCallback( + async (multiSendEnabled: boolean) => { + try { + const { recording, duration, waveformData } = audioRecorderManager.state.getLatestValue(); + await stopVoiceRecording(); + + const durationInSeconds = parseFloat((duration / 1000).toFixed(3)); + + const resampledWaveformData = + waveformData.length > 100 ? resampleWaveformData(waveformData, 100) : waveformData; + + const clearFilter = new RegExp('[.:]', 'g'); + const date = new Date().toISOString().replace(clearFilter, '_'); + + const file: File = { + duration: durationInSeconds, + name: `audio_recording_${date}.aac`, + size: 0, + type: 'audio/aac', + uri: + typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string), + waveform_data: resampledWaveformData, + }; + + const audioFile: LocalVoiceRecordingAttachment = { + asset_url: + typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string), + duration: durationInSeconds, + file_size: 0, + localMetadata: { + file, + id: generateRandomId(), + uploadState: 'pending', + }, + mime_type: 'audio/aac', + title: `audio_recording_${date}.aac`, + type: FileTypes.VoiceRecording, + waveform_data: resampledWaveformData, + }; + + audioRecorderManager.reset(); + + if (multiSendEnabled) { + await attachmentManager.uploadAttachment(audioFile); + } else { + // FIXME: cannot call handleSubmit() directly as the function has stale reference to file uploads + await attachmentManager.uploadAttachment(audioFile); + setIsScheduleForSubmit(true); + } + } catch (error) { + console.log('Error uploading voice recording: ', error); + } + }, + [audioRecorderManager, attachmentManager, stopVoiceRecording], + ); return { deleteVoiceRecording, - micLocked, - permissionsGranted, - recording, - recordingDuration, - recordingStatus, - setMicLocked, startVoiceRecording, stopVoiceRecording, uploadVoiceRecording, - waveformData, }; }; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 968c41689..5a0d646e9 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 565086124..b61413313 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -83,7 +83,6 @@ const styles = StyleSheet.create({ width: '100%', }, scrollToBottomButtonContainer: { - bottom: 8, position: 'absolute', right: 16, }, @@ -1192,7 +1191,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { layout={LinearTransition.duration(200)} style={[ styles.scrollToBottomButtonContainer, - { bottom: messageInputFloating ? messageInputHeight + 8 : 8 }, + { bottom: messageInputFloating ? messageInputHeight : 16 }, scrollToBottomButtonContainer, ]} > diff --git a/package/src/components/ProgressControl/ProgressControl.tsx b/package/src/components/ProgressControl/ProgressControl.tsx index 0c7ee0c49..270ba09fd 100644 --- a/package/src/components/ProgressControl/ProgressControl.tsx +++ b/package/src/components/ProgressControl/ProgressControl.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { ColorValue, StyleSheet, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, @@ -14,7 +14,7 @@ export type ProgressControlProps = { /** * The color of the filled progress bar */ - filledColor: string; + filledColor: ColorValue; /** * The progress of the progress bar in percentage */ diff --git a/package/src/components/ProgressControl/WaveProgressBar.tsx b/package/src/components/ProgressControl/WaveProgressBar.tsx index 3a509a906..a6d21ad8b 100644 --- a/package/src/components/ProgressControl/WaveProgressBar.tsx +++ b/package/src/components/ProgressControl/WaveProgressBar.tsx @@ -9,6 +9,7 @@ import Animated, { } from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; import { resampleWaveformData } from '../MessageInput/utils/audioSampling'; export type WaveProgressBarProps = { @@ -43,27 +44,15 @@ export type WaveProgressBarProps = { }; const WAVEFORM_WIDTH = 2; -const WAVE_MAX_HEIGHT = 25; -const WAVE_MIN_HEIGHT = 3; +const WAVE_MAX_HEIGHT = 20; +const WAVE_MIN_HEIGHT = 2; const ProgressControlThumb = ({ style }: { style?: StyleProp }) => { - const { - theme: { - colors: { black, grey_dark, static_white }, - }, - } = useTheme(); + const styles = useStyles(); return ( ); }; @@ -86,6 +75,8 @@ export const WaveProgressBar = React.memo( const state = useSharedValue(progress); const [currentWaveformProgress, setCurrentWaveformProgress] = useState(0); + const styles = useStyles(); + const waveFormNumberFromProgress = useCallback( (progress: number) => { 'worklet'; @@ -108,7 +99,7 @@ export const WaveProgressBar = React.memo( const { theme: { - colors: { accent_blue, grey_dark }, + semantics, waveProgressBar: { container, thumb, waveform: waveformTheme }, }, } = useTheme(); @@ -162,7 +153,9 @@ export const WaveProgressBar = React.memo( styles.waveform, { backgroundColor: - index < currentWaveformProgress ? filledColor || accent_blue : grey_dark, + index < currentWaveformProgress + ? filledColor || semantics.chatWaveformBarPlaying + : semantics.chatWaveformBar, height: waveform * WAVE_MAX_HEIGHT > WAVE_MIN_HEIGHT ? waveform * WAVE_MAX_HEIGHT @@ -193,30 +186,41 @@ export const WaveProgressBar = React.memo( }, ); -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flexDirection: 'row', - }, - progressControlThumbStyle: { - borderRadius: 5, - borderWidth: 0.2, - elevation: 6, - height: 28, - shadowOffset: { - height: 3, - width: 0, - }, - shadowOpacity: 0.27, - shadowRadius: 4.65, - width: WAVEFORM_WIDTH * 2, - }, - waveform: { - alignSelf: 'center', - borderRadius: 2, - marginRight: WAVEFORM_WIDTH, - width: WAVEFORM_WIDTH, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + }, + progressControlThumbStyle: { + backgroundColor: semantics.accentPrimary, + borderColor: semantics.borderCoreOnAccent, + height: 12, + width: 12, + borderRadius: primitives.radiusMax, + borderWidth: 2, + elevation: 6, + shadowOffset: { + height: 3, + width: 0, + }, + shadowOpacity: 0.27, + shadowRadius: 4.65, + }, + waveform: { + alignSelf: 'center', + borderRadius: primitives.radiusXxs, + marginRight: WAVEFORM_WIDTH, + width: WAVEFORM_WIDTH, + }, + }), + [semantics], + ); +}; WaveProgressBar.displayName = 'WaveProgressBar'; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 4d303ee7d..e005bf8d9 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -17,10 +17,10 @@ import { NewFile } from '../../icons/NewFile'; import { NewLink } from '../../icons/NewLink'; import { NewMapPin } from '../../icons/NewMapPin'; import { NewMic } from '../../icons/NewMic'; -import { NewPencil } from '../../icons/NewPencil'; import { NewPhoto } from '../../icons/NewPhoto'; import { NewPoll } from '../../icons/NewPoll'; import { NewVideo } from '../../icons/NewVideo'; +import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { checkQuotedMessageEquality } from '../../utils/utils'; import { FileIcon } from '../Attachment/FileIcon'; @@ -38,6 +38,7 @@ const selector = (nextValue: PollState) => ({ const RightContent = React.memo((props: { message: LocalMessage }) => { const { message } = props; const attachments = message?.attachments; + const styles = useStyles(); if (!attachments || attachments.length > 1) { return null; @@ -47,14 +48,14 @@ const RightContent = React.memo((props: { message: LocalMessage }) => { if (attachment?.type === FileTypes.Image) { return ( - + ); } if (attachment?.type === FileTypes.Video) { return ( - + @@ -79,6 +80,7 @@ const SubtitleText = React.memo(({ message }: { message?: LocalMessage | null }) reply: { subtitle: subtitleStyle }, }, } = useTheme(); + const styles = useStyles(); const subtitle = useMemo(() => { const attachments = message?.attachments; @@ -175,9 +177,12 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { const { message } = props; const { theme: { + semantics, reply: { pollIcon, locationIcon, linkIcon, audioIcon, fileIcon, videoIcon, photoIcon }, }, } = useTheme(); + const styles = useStyles(); + if (!message) { return null; } @@ -201,7 +206,13 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { if (message.poll_id) { return ( - + ); } @@ -209,7 +220,7 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { return ( { if (hasLink) { return ( - + ); } @@ -227,7 +244,7 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { return ( { if (onlyVideos) { return ( - + ); } if (onlyImages) { return ( - + ); } @@ -255,7 +284,13 @@ const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { audioAttachments?.length ) { return ( - + ); } @@ -275,7 +310,6 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { const { isMyMessage, message: messageFromContext, mode, onDismiss, quotedMessage, style } = props; const { theme: { - colors: { grey_whisper }, reply: { wrapper, container, @@ -285,8 +319,10 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { subtitleContainer, dismissWrapper, }, + semantics, }, } = useTheme(); + const styles = useStyles(); const title = useMemo( () => @@ -294,7 +330,9 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { ? 'Edit Message' : isMyMessage ? 'You' - : `Reply to ${quotedMessage?.user?.name}`, + : quotedMessage?.user?.name + ? `Reply to ${quotedMessage?.user?.name}` + : 'Reply', [mode, isMyMessage, quotedMessage?.user?.name], ); @@ -307,7 +345,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { { - {mode === 'edit' ? : null} {title} @@ -412,76 +453,76 @@ export const Reply = (props: ReplyProps) => { ); }; -const styles = StyleSheet.create({ - attachmentContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - container: { - borderRadius: 12, - flexDirection: 'row', - padding: 8, - }, - contentWrapper: { - backgroundColor: 'white', - borderColor: '#E2E6EA', - borderRadius: 8, - borderWidth: 1, - height: 40, - overflow: 'hidden', - width: 40, - }, - dismissWrapper: { - position: 'absolute', - right: 0, - top: 0, - }, - iconStyle: {}, - imageAttachment: {}, - leftContainer: { - borderLeftColor: '#B8BEC4', - borderLeftWidth: 2, - flex: 1, - justifyContent: 'center', - paddingHorizontal: 8, - paddingVertical: 2, - }, - playIconContainer: { - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.5)', - borderRadius: 10, - height: 20, - justifyContent: 'center', - width: 20, - }, - rightContainer: {}, - subtitle: { - color: '#384047', - flexShrink: 1, - fontSize: 12, - includeFontPadding: false, - lineHeight: 16, - }, - subtitleContainer: { - alignItems: 'center', - flexDirection: 'row', - gap: 4, - paddingTop: 4, - }, - titleContainer: { - alignItems: 'center', - flexDirection: 'row', - gap: 4, - }, - title: { - color: '#384047', - fontSize: 12, - fontWeight: 'bold', - includeFontPadding: false, - lineHeight: 16, - }, - wrapper: { - padding: 4, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + attachmentContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + container: { + borderRadius: primitives.radiusLg, + flexDirection: 'row', + padding: primitives.spacingXs, + }, + contentWrapper: { + borderRadius: primitives.radiusMd, + borderWidth: 1, + height: 40, + overflow: 'hidden', + width: 40, + }, + contentBorder: { + borderColor: semantics.borderCoreOpacity10, + }, + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + iconStyle: {}, + leftContainer: { + borderLeftWidth: 2, + flex: 1, + justifyContent: 'center', + paddingHorizontal: primitives.spacingXs, + }, + rightContainer: {}, + subtitle: { + color: semantics.textPrimary, + flexShrink: 1, + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightRegular, + includeFontPadding: false, + lineHeight: 16, + }, + subtitleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + paddingTop: primitives.spacingXxs, + }, + titleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + }, + title: { + color: semantics.textPrimary, + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightSemiBold, + includeFontPadding: false, + lineHeight: 16, + }, + wrapper: { + padding: primitives.spacingXxs, + }, + }), + [semantics], + ); +}; diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index dd198003b..71f326a96 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1881,13 +1881,13 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { - "borderTopWidth": 1, "paddingHorizontal": 16, "paddingTop": 16, }, { - "backgroundColor": "#FFFFFF", + "backgroundColor": "#ffffff", "borderColor": "#d5dbe1", + "borderTopWidth": 1, "paddingBottom": 16, }, {}, @@ -1895,6 +1895,7 @@ exports[`Thread should match thread snapshot 1`] = ` } > - - - - - + > + + + + diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 898d0188b..e69443a57 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -133,7 +133,6 @@ export * from './MessageInput/components/AudioRecorder/AudioRecordingInProgress' export * from './MessageInput/components/AudioRecorder/AudioRecordingLockIndicator'; export * from './MessageInput/components/AudioRecorder/AudioRecordingPreview'; export * from './MessageInput/components/AudioRecorder/AudioRecordingWaveform'; -export * from './MessageInput/components/CommandInput'; export * from './MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator'; export * from './MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; diff --git a/package/src/components/ui/GiphyBadge.tsx b/package/src/components/ui/GiphyBadge.tsx new file mode 100644 index 000000000..0b8b9e4ca --- /dev/null +++ b/package/src/components/ui/GiphyBadge.tsx @@ -0,0 +1,69 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View, Text, Pressable } from 'react-native'; + +import { TextComposerState } from 'stream-chat'; + +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; +import { NewCross } from '../../icons/NewCross'; +import { NewGiphy } from '../../icons/NewGiphy'; +import { primitives } from '../../theme'; + +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, +}); + +export const GiphyBadge = () => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + + const commandName = (command?.name ?? '').toUpperCase(); + + const onPressHandler = () => { + textComposer.clearCommand(); + messageComposer?.restore(); + }; + + return ( + + + {commandName} + + + + + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: semantics.badgeBgInverse, + borderRadius: primitives.radiusMax, + flexDirection: 'row', + paddingHorizontal: primitives.spacingXs, + paddingVertical: primitives.spacingXxs, + gap: primitives.spacingXxs, + }, + text: { + color: semantics.textInverse, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [semantics], + ); +}; diff --git a/package/src/components/ui/IconButton.tsx b/package/src/components/ui/IconButton.tsx index be00bc919..dd6d9a688 100644 --- a/package/src/components/ui/IconButton.tsx +++ b/package/src/components/ui/IconButton.tsx @@ -1,12 +1,20 @@ import React from 'react'; -import { Pressable, PressableProps, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import { + ColorValue, + Pressable, + PressableProps, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { IconProps } from '../../icons/utils/base'; export type IconButtonProps = PressableProps & { Icon: React.FC | React.ReactNode; - iconColor?: string; + iconColor?: ColorValue; onPress?: () => void; size?: 'sm' | 'md' | 'lg'; status?: 'disabled' | 'pressed' | 'selected' | 'enabled'; @@ -77,8 +85,10 @@ export const IconButton = (props: IconButtonProps) => { ? selectedColor : pressed ? '#F5F6F7' - : getBackgroundColor({ status, type }), - borderColor: '#E2E6EA', + : category === 'outline' + ? 'none' + : getBackgroundColor({ status, type }), + borderColor: type === 'destructive' ? '#D92F26' : '#E2E6EA', borderWidth: category === 'outline' || category === 'filled' ? 1 : 0, }, style as StyleProp, diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 4c47757fc..89a322028 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 6bf086b5b..e9fbdc321 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -317,11 +317,6 @@ export type Theme = { container: ViewStyle; waveform: ViewStyle; }; - commandInput: { - closeButton: ViewStyle; - container: ViewStyle; - text: TextStyle; - }; container: ViewStyle; contentContainer: ViewStyle; cooldownButtonContainer: ViewStyle; @@ -1122,11 +1117,6 @@ export const defaultTheme: Theme = { progressBar: {}, }, audioRecordingWaveform: { container: {}, waveform: {} }, - commandInput: { - closeButton: {}, - container: {}, - text: {}, - }, container: {}, contentContainer: {}, cooldownButtonContainer: {}, diff --git a/package/src/icons/NewChevronLeft.tsx b/package/src/icons/NewChevronLeft.tsx new file mode 100644 index 000000000..badd824b1 --- /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 000000000..f02620efa --- /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 000000000..9992b4811 --- /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 000000000..8eb44b4fa --- /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 000000000..b8c42033f --- /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 000000000..a6e96e744 --- /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 000000000..90fde4ed2 --- /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 000000000..88f24f56c --- /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 000000000..b57bf1a7a --- /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 000000000..cfbd2492d --- /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 000000000..cda158490 --- /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 }); + } +}