Skip to content

Commit 0acaa8c

Browse files
authored
fix: voice recording race conditions (#3382)
## 🎯 Goal This PR fixes a couple of race conditions and locked stale state issues with regards to voice recording in the SDK. Most notably, the following: - Disabling permissions prevents voice recording from being played ever again - Quickly jumping from recording to prevented recording breaks the recording state - Mismatch between when we're actually recording and the state updating (possible missed recording by up to a second) - The underlying `Pressable`'s gesture not being recognized sometimes as the `Tap` gesture from RNGH took precedence It also includes various optimizations for the animations to make sure we are not rerendering heavily when the animations are running (or recreating gesture detector primitives). ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent ff44253 commit 0acaa8c

File tree

5 files changed

+123
-113
lines changed

5 files changed

+123
-113
lines changed

package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx

Lines changed: 111 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import React from 'react';
1+
import React, { useMemo } from 'react';
22
import { Alert, Linking, StyleSheet } from 'react-native';
33

4-
import {
5-
Gesture,
6-
GestureDetector,
7-
PanGestureHandlerEventPayload,
8-
} from 'react-native-gesture-handler';
4+
import { Gesture, GestureDetector, State } from 'react-native-gesture-handler';
95
import Animated, {
6+
clamp,
107
runOnJS,
118
SharedValue,
129
useAnimatedStyle,
@@ -22,6 +19,7 @@ import {
2219
} from '../../../../contexts/messageInputContext/MessageInputContext';
2320
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
2421
import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
22+
import { useStableCallback } from '../../../../hooks';
2523
import { useStateStore } from '../../../../hooks/useStateStore';
2624
import { NewMic } from '../../../../icons/NewMic';
2725
import { NativeHandlers } from '../../../../native';
@@ -38,7 +36,7 @@ export type AudioRecordingButtonPropsWithContext = Pick<
3836
| 'deleteVoiceRecording'
3937
| 'uploadVoiceRecording'
4038
> &
41-
Pick<AudioRecorderManagerState, 'duration' | 'recording' | 'status' | 'permissionsGranted'> & {
39+
Pick<AudioRecorderManagerState, 'recording' | 'status'> & {
4240
/**
4341
* Size of the mic button.
4442
*/
@@ -53,6 +51,7 @@ export type AudioRecordingButtonPropsWithContext = Pick<
5351
handlePress?: () => void;
5452
micPositionX: SharedValue<number>;
5553
micPositionY: SharedValue<number>;
54+
cancellableDuration: boolean;
5655
};
5756

5857
/**
@@ -72,8 +71,7 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps
7271
handlePress,
7372
micPositionX,
7473
micPositionY,
75-
permissionsGranted,
76-
duration: recordingDuration,
74+
cancellableDuration,
7775
status,
7876
recording,
7977
} = props;
@@ -87,118 +85,139 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps
8785
},
8886
} = useTheme();
8987

90-
const onPressHandler = () => {
88+
const onPressHandler = useStableCallback(() => {
9189
if (handlePress) {
9290
handlePress();
9391
}
9492
if (!recording) {
9593
NativeHandlers.triggerHaptic('notificationError');
9694
Alert.alert(t('Hold to start recording.'));
9795
}
98-
};
96+
});
9997

100-
const onLongPressHandler = async () => {
98+
const onLongPressHandler = useStableCallback(async () => {
10199
if (handleLongPress) {
102100
handleLongPress();
103101
return;
104102
}
105103
if (recording) return;
106-
NativeHandlers.triggerHaptic('impactHeavy');
107-
if (!permissionsGranted) {
108-
Alert.alert(t('Please allow Audio permissions in settings.'), '', [
109-
{
110-
onPress: () => {
111-
Linking.openSettings();
112-
},
113-
text: t('Open Settings'),
114-
},
115-
]);
116-
return;
117-
}
118104
if (startVoiceRecording) {
119105
if (activeAudioPlayer?.isPlaying) {
120-
await activeAudioPlayer?.pause();
106+
activeAudioPlayer?.pause();
107+
}
108+
const permissionsGranted = await startVoiceRecording();
109+
if (!permissionsGranted) {
110+
Alert.alert(t('Please allow Audio permissions in settings.'), '', [
111+
{
112+
onPress: () => {
113+
Linking.openSettings();
114+
},
115+
text: t('Open Settings'),
116+
},
117+
{
118+
text: t('Cancel'),
119+
style: 'cancel',
120+
},
121+
]);
122+
return;
121123
}
122-
await startVoiceRecording();
124+
NativeHandlers.triggerHaptic('impactHeavy');
123125
}
124-
};
126+
});
127+
125128
const X_AXIS_POSITION = -asyncMessagesSlideToCancelDistance;
126129
const Y_AXIS_POSITION = -asyncMessagesLockDistance;
127130

128-
const micUnlockHandler = () => {
129-
audioRecorderManager.micLocked = false;
130-
};
131-
132-
const micLockHandler = (value: boolean) => {
133-
audioRecorderManager.micLocked = value;
134-
};
131+
const micLockHandler = useStableCallback((value: boolean) => {
132+
if (status === 'recording') {
133+
audioRecorderManager.micLocked = value;
134+
}
135+
});
135136

136-
const resetAudioRecording = async () => {
137+
const resetAudioRecording = useStableCallback(async () => {
137138
NativeHandlers.triggerHaptic('notificationSuccess');
138139
await deleteVoiceRecording();
139-
};
140+
});
140141

141-
const onEarlyReleaseHandler = () => {
142+
const onEarlyReleaseHandler = useStableCallback(() => {
142143
NativeHandlers.triggerHaptic('notificationError');
143144
resetAudioRecording();
144-
};
145-
146-
const tapGesture = Gesture.Tap()
147-
.onBegin(() => {
148-
scale.value = withSpring(0.8, { mass: 0.5 });
149-
})
150-
.onEnd(() => {
151-
scale.value = withSpring(1, { mass: 0.5 });
152-
});
153-
154-
const panGesture = Gesture.Pan()
155-
.activateAfterLongPress(asyncMessagesMinimumPressDuration + 100)
156-
.onChange((event: PanGestureHandlerEventPayload) => {
157-
const newPositionX = event.translationX;
158-
const newPositionY = event.translationY;
145+
});
159146

160-
if (newPositionX <= 0 && newPositionX >= X_AXIS_POSITION) {
161-
micPositionX.value = newPositionX;
162-
}
163-
if (newPositionY <= 0 && newPositionY >= Y_AXIS_POSITION) {
164-
micPositionY.value = newPositionY;
147+
const onTouchGestureEnd = useStableCallback(() => {
148+
if (status === 'recording') {
149+
if (cancellableDuration) {
150+
runOnJS(onEarlyReleaseHandler)();
151+
} else {
152+
runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled);
165153
}
166-
})
167-
.onStart(() => {
168-
micPositionX.value = 0;
169-
micPositionY.value = 0;
170-
runOnJS(micUnlockHandler)();
171-
})
172-
.onEnd(() => {
173-
const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2;
174-
const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2;
154+
}
155+
});
175156

176-
if (belowThresholdY && belowThresholdX) {
177-
micPositionY.value = withSpring(0);
178-
micPositionX.value = withSpring(0);
179-
if (status === 'recording') {
180-
if (recordingDuration < 300) {
181-
runOnJS(onEarlyReleaseHandler)();
182-
} else {
183-
runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled);
157+
const tapGesture = useMemo(
158+
() =>
159+
Gesture.LongPress()
160+
.minDuration(asyncMessagesMinimumPressDuration)
161+
.onBegin(() => {
162+
scale.value = withSpring(0.8, { mass: 0.5 });
163+
})
164+
.onStart(() => {
165+
runOnJS(onLongPressHandler)();
166+
})
167+
.onFinalize((e) => {
168+
scale.value = withSpring(1, { mass: 0.5 });
169+
if (e.state === State.FAILED) {
170+
runOnJS(onPressHandler)();
184171
}
185-
}
186-
return;
187-
}
172+
}),
173+
[asyncMessagesMinimumPressDuration, onLongPressHandler, onPressHandler, scale],
174+
);
188175

189-
if (!belowThresholdY) {
190-
micPositionY.value = withSpring(Y_AXIS_POSITION);
191-
runOnJS(micLockHandler)(true);
192-
}
176+
const panGesture = useMemo(
177+
() =>
178+
Gesture.Pan()
179+
.activateAfterLongPress(asyncMessagesMinimumPressDuration)
180+
.onUpdate((e) => {
181+
micPositionX.value = clamp(e.translationX, X_AXIS_POSITION, 0);
182+
micPositionY.value = clamp(e.translationY, Y_AXIS_POSITION, 0);
183+
})
184+
.onStart(() => {
185+
micPositionX.value = 0;
186+
micPositionY.value = 0;
187+
})
188+
.onEnd(() => {
189+
const belowThresholdY = micPositionY.value > Y_AXIS_POSITION / 2;
190+
const belowThresholdX = micPositionX.value > X_AXIS_POSITION / 2;
193191

194-
if (!belowThresholdX) {
195-
micPositionX.value = withSpring(X_AXIS_POSITION);
196-
runOnJS(resetAudioRecording)();
197-
}
192+
if (belowThresholdY && belowThresholdX) {
193+
micPositionY.value = withSpring(0);
194+
micPositionX.value = withSpring(0);
195+
runOnJS(onTouchGestureEnd)();
196+
return;
197+
}
198+
199+
if (!belowThresholdX) {
200+
micPositionX.value = withSpring(X_AXIS_POSITION);
201+
runOnJS(resetAudioRecording)();
202+
} else if (!belowThresholdY) {
203+
micPositionY.value = withSpring(Y_AXIS_POSITION);
204+
runOnJS(micLockHandler)(true);
205+
}
198206

199-
micPositionX.value = 0;
200-
micPositionY.value = 0;
201-
});
207+
micPositionX.value = 0;
208+
micPositionY.value = 0;
209+
}),
210+
[
211+
X_AXIS_POSITION,
212+
Y_AXIS_POSITION,
213+
asyncMessagesMinimumPressDuration,
214+
micLockHandler,
215+
micPositionX,
216+
micPositionY,
217+
onTouchGestureEnd,
218+
resetAudioRecording,
219+
],
220+
);
202221

203222
const animatedStyle = useAnimatedStyle(() => {
204223
return {
@@ -210,12 +229,10 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps
210229
<GestureDetector gesture={Gesture.Simultaneous(panGesture, tapGesture)}>
211230
<Animated.View style={[styles.container, animatedStyle, micButtonContainer]}>
212231
<IconButton
232+
disabled={true}
213233
accessibilityLabel='Start recording'
214234
category='ghost'
215-
delayLongPress={asyncMessagesMinimumPressDuration}
216235
Icon={NewMic}
217-
onLongPress={onLongPressHandler}
218-
onPress={onPressHandler}
219236
size='sm'
220237
type='secondary'
221238
/>
@@ -234,8 +251,7 @@ const MemoizedAudioRecordingButton = React.memo(
234251
) as typeof AudioRecordingButtonWithContext;
235252

236253
const audioRecorderSelector = (state: AudioRecorderManagerState) => ({
237-
duration: state.duration,
238-
permissionsGranted: state.permissionsGranted,
254+
cancellableDuration: state.duration < 300,
239255
recording: state.recording,
240256
status: state.status,
241257
});
@@ -252,7 +268,7 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => {
252268
uploadVoiceRecording,
253269
} = useMessageInputContext();
254270

255-
const { duration, status, permissionsGranted, recording } = useStateStore(
271+
const { cancellableDuration, status, recording } = useStateStore(
256272
audioRecorderManager.state,
257273
audioRecorderSelector,
258274
);
@@ -268,9 +284,8 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => {
268284
startVoiceRecording,
269285
deleteVoiceRecording,
270286
uploadVoiceRecording,
271-
duration,
287+
cancellableDuration,
272288
status,
273-
permissionsGranted,
274289
recording,
275290
}}
276291
{...props}

package/src/components/MessageInput/hooks/useAudioRecorder.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,12 @@ export const useAudioRecorder = ({
5050
}, [isScheduledForSubmit, sendMessage]);
5151

5252
/**
53-
* Function to start voice recording.
53+
* Function to start voice recording. Will return whether access is granted
54+
* with regards to the microphone permission as that's how the underlying
55+
* library works on iOS.
5456
*/
5557
const startVoiceRecording = useCallback(async () => {
56-
await audioRecorderManager.startRecording();
58+
return await audioRecorderManager.startRecording();
5759
}, [audioRecorderManager]);
5860

5961
/**

package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2195,7 +2195,7 @@ exports[`Thread should match thread snapshot 1`] = `
21952195
{
21962196
"busy": undefined,
21972197
"checked": undefined,
2198-
"disabled": false,
2198+
"disabled": true,
21992199
"expanded": undefined,
22002200
"selected": undefined,
22012201
}

package/src/contexts/messageInputContext/MessageInputContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export type LocalMessageInputContext = {
102102
toggleAttachmentPicker: () => void;
103103
uploadNewFile: (file: File) => Promise<void>;
104104
audioRecorderManager: AudioRecorderManager;
105-
startVoiceRecording: () => Promise<void>;
105+
startVoiceRecording: () => Promise<boolean | undefined>;
106106
deleteVoiceRecording: () => Promise<void>;
107107
uploadVoiceRecording: (multiSendEnabled: boolean) => Promise<void>;
108108
stopVoiceRecording: () => Promise<void>;

0 commit comments

Comments
 (0)