diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 430dd868a..6950f4370 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -1,12 +1,9 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Alert, Linking, StyleSheet } from 'react-native'; -import { - Gesture, - GestureDetector, - PanGestureHandlerEventPayload, -} from 'react-native-gesture-handler'; +import { Gesture, GestureDetector, State } from 'react-native-gesture-handler'; import Animated, { + clamp, runOnJS, SharedValue, useAnimatedStyle, @@ -22,6 +19,7 @@ import { } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { useStableCallback } from '../../../../hooks'; import { useStateStore } from '../../../../hooks/useStateStore'; import { NewMic } from '../../../../icons/NewMic'; import { NativeHandlers } from '../../../../native'; @@ -38,7 +36,7 @@ export type AudioRecordingButtonPropsWithContext = Pick< | 'deleteVoiceRecording' | 'uploadVoiceRecording' > & - Pick & { + Pick & { /** * Size of the mic button. */ @@ -53,6 +51,7 @@ export type AudioRecordingButtonPropsWithContext = Pick< handlePress?: () => void; micPositionX: SharedValue; micPositionY: SharedValue; + cancellableDuration: boolean; }; /** @@ -72,8 +71,7 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps handlePress, micPositionX, micPositionY, - permissionsGranted, - duration: recordingDuration, + cancellableDuration, status, recording, } = props; @@ -87,7 +85,7 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps }, } = useTheme(); - const onPressHandler = () => { + const onPressHandler = useStableCallback(() => { if (handlePress) { handlePress(); } @@ -95,110 +93,131 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps NativeHandlers.triggerHaptic('notificationError'); Alert.alert(t('Hold to start recording.')); } - }; + }); - const onLongPressHandler = async () => { + const onLongPressHandler = useStableCallback(async () => { if (handleLongPress) { handleLongPress(); return; } if (recording) return; - NativeHandlers.triggerHaptic('impactHeavy'); - if (!permissionsGranted) { - Alert.alert(t('Please allow Audio permissions in settings.'), '', [ - { - onPress: () => { - Linking.openSettings(); - }, - text: t('Open Settings'), - }, - ]); - return; - } if (startVoiceRecording) { if (activeAudioPlayer?.isPlaying) { - await activeAudioPlayer?.pause(); + activeAudioPlayer?.pause(); + } + const permissionsGranted = await startVoiceRecording(); + if (!permissionsGranted) { + Alert.alert(t('Please allow Audio permissions in settings.'), '', [ + { + onPress: () => { + Linking.openSettings(); + }, + text: t('Open Settings'), + }, + { + text: t('Cancel'), + style: 'cancel', + }, + ]); + return; } - await startVoiceRecording(); + NativeHandlers.triggerHaptic('impactHeavy'); } - }; + }); + const X_AXIS_POSITION = -asyncMessagesSlideToCancelDistance; const Y_AXIS_POSITION = -asyncMessagesLockDistance; - const micUnlockHandler = () => { - audioRecorderManager.micLocked = false; - }; - - const micLockHandler = (value: boolean) => { - audioRecorderManager.micLocked = value; - }; + const micLockHandler = useStableCallback((value: boolean) => { + if (status === 'recording') { + audioRecorderManager.micLocked = value; + } + }); - const resetAudioRecording = async () => { + const resetAudioRecording = useStableCallback(async () => { NativeHandlers.triggerHaptic('notificationSuccess'); await deleteVoiceRecording(); - }; + }); - const onEarlyReleaseHandler = () => { + const onEarlyReleaseHandler = useStableCallback(() => { NativeHandlers.triggerHaptic('notificationError'); resetAudioRecording(); - }; - - const tapGesture = Gesture.Tap() - .onBegin(() => { - scale.value = withSpring(0.8, { mass: 0.5 }); - }) - .onEnd(() => { - scale.value = withSpring(1, { mass: 0.5 }); - }); - - const panGesture = Gesture.Pan() - .activateAfterLongPress(asyncMessagesMinimumPressDuration + 100) - .onChange((event: PanGestureHandlerEventPayload) => { - const newPositionX = event.translationX; - const newPositionY = event.translationY; + }); - if (newPositionX <= 0 && newPositionX >= X_AXIS_POSITION) { - micPositionX.value = newPositionX; - } - if (newPositionY <= 0 && newPositionY >= Y_AXIS_POSITION) { - micPositionY.value = newPositionY; + const onTouchGestureEnd = useStableCallback(() => { + if (status === 'recording') { + if (cancellableDuration) { + runOnJS(onEarlyReleaseHandler)(); + } else { + runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); } - }) - .onStart(() => { - micPositionX.value = 0; - micPositionY.value = 0; - runOnJS(micUnlockHandler)(); - }) - .onEnd(() => { - const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2; - const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2; + } + }); - if (belowThresholdY && belowThresholdX) { - micPositionY.value = withSpring(0); - micPositionX.value = withSpring(0); - if (status === 'recording') { - if (recordingDuration < 300) { - runOnJS(onEarlyReleaseHandler)(); - } else { - runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); + const tapGesture = useMemo( + () => + Gesture.LongPress() + .minDuration(asyncMessagesMinimumPressDuration) + .onBegin(() => { + scale.value = withSpring(0.8, { mass: 0.5 }); + }) + .onStart(() => { + runOnJS(onLongPressHandler)(); + }) + .onFinalize((e) => { + scale.value = withSpring(1, { mass: 0.5 }); + if (e.state === State.FAILED) { + runOnJS(onPressHandler)(); } - } - return; - } + }), + [asyncMessagesMinimumPressDuration, onLongPressHandler, onPressHandler, scale], + ); - if (!belowThresholdY) { - micPositionY.value = withSpring(Y_AXIS_POSITION); - runOnJS(micLockHandler)(true); - } + const panGesture = useMemo( + () => + Gesture.Pan() + .activateAfterLongPress(asyncMessagesMinimumPressDuration) + .onUpdate((e) => { + micPositionX.value = clamp(e.translationX, X_AXIS_POSITION, 0); + micPositionY.value = clamp(e.translationY, Y_AXIS_POSITION, 0); + }) + .onStart(() => { + micPositionX.value = 0; + micPositionY.value = 0; + }) + .onEnd(() => { + const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2; + const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2; - if (!belowThresholdX) { - micPositionX.value = withSpring(X_AXIS_POSITION); - runOnJS(resetAudioRecording)(); - } + if (belowThresholdY && belowThresholdX) { + micPositionY.value = withSpring(0); + micPositionX.value = withSpring(0); + runOnJS(onTouchGestureEnd)(); + return; + } + + if (!belowThresholdX) { + micPositionX.value = withSpring(X_AXIS_POSITION); + runOnJS(resetAudioRecording)(); + } else if (!belowThresholdY) { + micPositionY.value = withSpring(Y_AXIS_POSITION); + runOnJS(micLockHandler)(true); + } - micPositionX.value = 0; - micPositionY.value = 0; - }); + micPositionX.value = 0; + micPositionY.value = 0; + }), + [ + X_AXIS_POSITION, + Y_AXIS_POSITION, + asyncMessagesMinimumPressDuration, + micLockHandler, + micPositionX, + micPositionY, + onTouchGestureEnd, + resetAudioRecording, + ], + ); const animatedStyle = useAnimatedStyle(() => { return { @@ -210,12 +229,10 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps @@ -234,8 +251,7 @@ const MemoizedAudioRecordingButton = React.memo( ) as typeof AudioRecordingButtonWithContext; const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ - duration: state.duration, - permissionsGranted: state.permissionsGranted, + cancellableDuration: state.duration < 300, recording: state.recording, status: state.status, }); @@ -252,7 +268,7 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { uploadVoiceRecording, } = useMessageInputContext(); - const { duration, status, permissionsGranted, recording } = useStateStore( + const { cancellableDuration, status, recording } = useStateStore( audioRecorderManager.state, audioRecorderSelector, ); @@ -268,9 +284,8 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { startVoiceRecording, deleteVoiceRecording, uploadVoiceRecording, - duration, + cancellableDuration, status, - permissionsGranted, recording, }} {...props} diff --git a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx index 2c53f82c4..b847f6237 100644 --- a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx +++ b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx @@ -50,10 +50,12 @@ export const useAudioRecorder = ({ }, [isScheduledForSubmit, sendMessage]); /** - * Function to start voice recording. + * Function to start voice recording. Will return whether access is granted + * with regards to the microphone permission as that's how the underlying + * library works on iOS. */ const startVoiceRecording = useCallback(async () => { - await audioRecorderManager.startRecording(); + return await audioRecorderManager.startRecording(); }, [audioRecorderManager]); /** diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 44aebeaea..76dc28859 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -2195,7 +2195,7 @@ exports[`Thread should match thread snapshot 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": false, + "disabled": true, "expanded": undefined, "selected": undefined, } diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 5bcb403e3..ab3e8f3e4 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -102,7 +102,7 @@ export type LocalMessageInputContext = { toggleAttachmentPicker: () => void; uploadNewFile: (file: File) => Promise; audioRecorderManager: AudioRecorderManager; - startVoiceRecording: () => Promise; + startVoiceRecording: () => Promise; deleteVoiceRecording: () => Promise; uploadVoiceRecording: (multiSendEnabled: boolean) => Promise; stopVoiceRecording: () => Promise; diff --git a/package/src/state-store/audio-recorder-manager.ts b/package/src/state-store/audio-recorder-manager.ts index cda158490..e355191be 100644 --- a/package/src/state-store/audio-recorder-manager.ts +++ b/package/src/state-store/audio-recorder-manager.ts @@ -1,4 +1,4 @@ -import { Alert, Platform } from 'react-native'; +import { Platform } from 'react-native'; import { StateStore } from 'stream-chat'; @@ -9,7 +9,6 @@ export type RecordingStatusStates = 'idle' | 'recording' | 'stopped'; export type AudioRecorderManagerState = { micLocked: boolean; - permissionsGranted: boolean; recording: AudioRecordingReturnType; waveformData: number[]; duration: number; @@ -18,7 +17,6 @@ export type AudioRecorderManagerState = { const INITIAL_STATE: AudioRecorderManagerState = { micLocked: false, - permissionsGranted: true, waveformData: [], recording: undefined, duration: 0, @@ -56,28 +54,23 @@ export class AudioRecorderManager { if (!NativeHandlers.Audio) { return; } - this.state.partialNext({ - status: 'recording', - }); const recordingInfo = await NativeHandlers.Audio.startRecording( { isMeteringEnabled: true, }, this.onRecordingStatusUpdate, ); - const accessGranted = recordingInfo.accessGranted; - if (accessGranted) { - this.state.partialNext({ permissionsGranted: true }); - const recording = recordingInfo.recording; + const { accessGranted, recording } = recordingInfo; + if (accessGranted && recording) { if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) { recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60); } - this.state.partialNext({ recording }); + this.state.partialNext({ recording, status: 'recording' }); } else { this.reset(); - this.state.partialNext({ permissionsGranted: false }); - Alert.alert('Please allow Audio permissions in settings.'); } + + return accessGranted; } async stopRecording() {