Skip to content

Commit 7f24be6

Browse files
authored
fix: add pending browser permission state (#1718)
### Overview Previously there was no way to track if the SDK is currently prompting browser for a device permission. For example: 1. Call has camera enabled by default. User visits the call page for the first time. Browser permission prompt is displayed, but in the meanwhile camera toggle is showing that camera is enabled (because `hasBrowserPermission` is true for `prompt` state, and `optimisticStatus` is `enabled`). 2. Permissions have been reset, and user tries to enable camera. Once again, browser permission prompt is displayed, but in the meanwhile camera toggle is showing that camera is enabled. We now introduce another permission state apart from `prompt`, `granted` and `denied`: **`prompting`**. This state is in effect while browser prompt is visible, i.e. when permission state is `prompt` and `getUserMedia` promise is pending. ### Implementation notes New browser permission state `prompting` is added to `BrowserPermission` class. This state is exposed via the `isPermissionPending$` observable in input media device manager state. State hooks for camera and microphone state also return this flag. Default device toggles support this flag and display the new "?" badge while permission is pending: ![Screenshot 2025-03-12 at 13 03 37](https://github.com/user-attachments/assets/4966c66e-f9f2-4ed4-8b9f-3513f3bd67d7) Docs are to follow. --- There are also two other minor changes in this PR. The `settled()` check in concurrency.ts is fixed to prevent possible race conditions. Previously, it was possible to check for `settled()`, then add more promises to the queue, and `settled()` will resolve _before_ these newly added promises are resolved. `isTogglePending` flag is returned from device-related call state hooks. It can be used to display a loader while camera/microphone are enabled/disabled. It's not used by our default toggle buttons, but it's nice to have.
1 parent fcae0bc commit 7f24be6

File tree

7 files changed

+129
-45
lines changed

7 files changed

+129
-45
lines changed

packages/client/src/devices/BrowserPermission.ts

+22-8
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ interface BrowserPermissionConfig {
99
queryName: PermissionName;
1010
}
1111

12+
export type BrowserPermissionState = PermissionState | 'prompting';
13+
1214
export class BrowserPermission {
1315
private ready: Promise<void>;
1416
private disposeController = new AbortController();
15-
private state: PermissionState | undefined;
17+
private state: BrowserPermissionState | undefined;
1618
private wasPrompted: boolean = false;
17-
private listeners = new Set<(state: PermissionState) => void>();
19+
private listeners = new Set<(state: BrowserPermissionState) => void>();
1820
private logger = getLogger(['permissions']);
1921

2022
constructor(private readonly permission: BrowserPermissionConfig) {
@@ -83,6 +85,7 @@ export class BrowserPermission {
8385

8486
try {
8587
this.wasPrompted = true;
88+
this.setState('prompting');
8689
const stream = await navigator.mediaDevices.getUserMedia(
8790
this.permission.constraints,
8891
);
@@ -112,23 +115,21 @@ export class BrowserPermission {
112115
error: e,
113116
permission: this.permission,
114117
});
118+
this.setState('prompt');
115119
throw e;
116120
}
117121
},
118122
);
119123
}
120124

121-
listen(cb: (state: PermissionState) => void) {
125+
listen(cb: (state: BrowserPermissionState) => void) {
122126
this.listeners.add(cb);
123127
if (this.state) cb(this.state);
124128
return () => this.listeners.delete(cb);
125129
}
126130

127131
asObservable() {
128-
return fromEventPattern<PermissionState>(
129-
(handler) => this.listen(handler),
130-
(handler, unlisten) => unlisten(),
131-
).pipe(
132+
return this.getStateObservable().pipe(
132133
// In some browsers, the 'change' event doesn't reliably emit and hence,
133134
// permissionState stays in 'prompt' state forever.
134135
// Typically, this happens when a user grants one-time permission.
@@ -137,7 +138,20 @@ export class BrowserPermission {
137138
);
138139
}
139140

140-
private setState(state: PermissionState) {
141+
getIsPromptingObservable() {
142+
return this.getStateObservable().pipe(
143+
map((state) => state === 'prompting'),
144+
);
145+
}
146+
147+
private getStateObservable() {
148+
return fromEventPattern<BrowserPermissionState>(
149+
(handler) => this.listen(handler),
150+
(handler, unlisten) => unlisten(),
151+
);
152+
}
153+
154+
private setState(state: BrowserPermissionState) {
141155
if (this.state !== state) {
142156
this.state = state;
143157
this.listeners.forEach((listener) => listener(state));

packages/client/src/devices/InputMediaDeviceManagerState.ts

+10
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ export abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
6767
*/
6868
hasBrowserPermission$: Observable<boolean>;
6969

70+
/**
71+
* An observable that emits `true` when SDK is prompting for browser permission
72+
* (i.e. browser's UI for allowing or disallowing device access is visible)
73+
*/
74+
isPromptingPermission$: Observable<boolean>;
75+
7076
/**
7177
* Constructs new InputMediaDeviceManagerState instance.
7278
*
@@ -81,6 +87,10 @@ export abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
8187
this.hasBrowserPermission$ = permission
8288
? permission.asObservable().pipe(shareReplay(1))
8389
: of(true);
90+
91+
this.isPromptingPermission$ = permission
92+
? permission.getIsPromptingObservable().pipe(shareReplay(1))
93+
: of(false);
8494
}
8595

8696
/**

packages/client/src/devices/__tests__/mocks.ts

+1
Original file line numberDiff line numberDiff line change
@@ -221,4 +221,5 @@ export const emitDeviceIds = (values: MediaDeviceInfo[]) => {
221221

222222
export const mockBrowserPermission = {
223223
asObservable: () => of(true),
224+
getIsPromptingObservable: () => of(false),
224225
} as BrowserPermission;

packages/client/src/helpers/concurrency.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ export function hasPending(tag: string | symbol) {
4848
}
4949

5050
export async function settled(tag: string | symbol) {
51-
await pendingPromises.get(tag)?.promise;
51+
let pending: PendingPromise | undefined;
52+
while ((pending = pendingPromises.get(tag))) {
53+
await pending.promise;
54+
}
5255
}
5356

5457
/**

packages/react-bindings/src/hooks/callStateHooks.ts

+38-30
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
CallStatsReport,
1010
Comparator,
1111
EgressResponse,
12+
InputDeviceStatus,
1213
MemberResponse,
1314
OwnCapability,
1415
StreamVideoParticipant,
@@ -357,28 +358,27 @@ export const useCameraState = () => {
357358
const devices$ = useMemo(() => camera.listDevices(), [camera]);
358359

359360
const { state } = camera;
360-
const status = useObservableValue(state.status$);
361-
const optimisticStatus = useObservableValue(state.optimisticStatus$);
362361
const direction = useObservableValue(state.direction$);
363362
const mediaStream = useObservableValue(state.mediaStream$);
364363
const selectedDevice = useObservableValue(state.selectedDevice$);
365364
const devices = useObservableValue(devices$, EMPTY_DEVICES_ARRAY);
366365
const hasBrowserPermission = useObservableValue(state.hasBrowserPermission$);
367-
const isMute = status !== 'enabled';
368-
const optimisticIsMute = optimisticStatus !== 'enabled';
366+
const isPromptingPermission = useObservableValue(
367+
state.isPromptingPermission$,
368+
);
369369

370370
return {
371371
camera,
372-
status,
373-
optimisticStatus,
374-
isEnabled: status === 'enabled',
375372
direction,
376373
mediaStream,
377374
devices,
378375
hasBrowserPermission,
376+
isPromptingPermission,
379377
selectedDevice,
380-
isMute,
381-
optimisticIsMute,
378+
...getComputedStatus(
379+
useObservableValue(state.status$),
380+
useObservableValue(state.optimisticStatus$),
381+
),
382382
};
383383
};
384384

@@ -394,28 +394,27 @@ export const useMicrophoneState = () => {
394394
const devices$ = useMemo(() => microphone.listDevices(), [microphone]);
395395

396396
const { state } = microphone;
397-
const status = useObservableValue(state.status$);
398-
const optimisticStatus = useObservableValue(state.optimisticStatus$);
399397
const mediaStream = useObservableValue(state.mediaStream$);
400398
const selectedDevice = useObservableValue(state.selectedDevice$);
401399
const devices = useObservableValue(devices$, EMPTY_DEVICES_ARRAY);
402400
const hasBrowserPermission = useObservableValue(state.hasBrowserPermission$);
401+
const isPromptingPermission = useObservableValue(
402+
state.isPromptingPermission$,
403+
);
403404
const isSpeakingWhileMuted = useObservableValue(state.speakingWhileMuted$);
404-
const isMute = status !== 'enabled';
405-
const optimisticIsMute = optimisticStatus !== 'enabled';
406405

407406
return {
408407
microphone,
409-
status,
410-
optimisticStatus,
411-
isEnabled: status === 'enabled',
412408
mediaStream,
413409
devices,
414410
selectedDevice,
415411
hasBrowserPermission,
412+
isPromptingPermission,
416413
isSpeakingWhileMuted,
417-
isMute,
418-
optimisticIsMute,
414+
...getComputedStatus(
415+
useObservableValue(state.status$),
416+
useObservableValue(state.optimisticStatus$),
417+
),
419418
};
420419
};
421420

@@ -452,20 +451,13 @@ export const useScreenShareState = () => {
452451
const call = useCall();
453452
const { screenShare } = call as Call;
454453

455-
const status = useObservableValue(screenShare.state.status$);
456-
const pendingStatus = useObservableValue(screenShare.state.optimisticStatus$);
457-
const mediaStream = useObservableValue(screenShare.state.mediaStream$);
458-
const isMute = status !== 'enabled';
459-
const optimisticStatus = pendingStatus ?? status;
460-
const optimisticIsMute = optimisticStatus !== 'enabled';
461-
462454
return {
463455
screenShare,
464-
mediaStream,
465-
status,
466-
optimisticStatus,
467-
isMute,
468-
optimisticIsMute,
456+
mediaStream: useObservableValue(screenShare.state.mediaStream$),
457+
...getComputedStatus(
458+
useObservableValue(screenShare.state.status$),
459+
useObservableValue(screenShare.state.optimisticStatus$),
460+
),
469461
};
470462
};
471463

@@ -496,3 +488,19 @@ export const useIsCallCaptioningInProgress = (): boolean => {
496488
const { captioning$ } = useCallState();
497489
return useObservableValue(captioning$);
498490
};
491+
492+
function getComputedStatus(
493+
status: InputDeviceStatus,
494+
pendingStatus: InputDeviceStatus,
495+
) {
496+
const optimisticStatus = pendingStatus ?? status;
497+
498+
return {
499+
status,
500+
optimisticStatus,
501+
isEnabled: status === 'enabled',
502+
isMute: status !== 'enabled',
503+
optimisticIsMute: optimisticStatus !== 'enabled',
504+
isTogglePending: optimisticStatus !== status,
505+
};
506+
}

packages/react-sdk/src/components/CallControls/ToggleAudioButton.tsx

+27-4
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ export const ToggleAudioPreviewButton = (
3030
const { caption, onMenuToggle, ...restCompositeButtonProps } = props;
3131
const { t } = useI18n();
3232
const { useMicrophoneState } = useCallStateHooks();
33-
const { microphone, optimisticIsMute, hasBrowserPermission } =
34-
useMicrophoneState();
33+
const {
34+
microphone,
35+
optimisticIsMute,
36+
hasBrowserPermission,
37+
isPromptingPermission,
38+
} = useMicrophoneState();
3539
const [tooltipDisabled, setTooltipDisabled] = useState(false);
3640
const handleClick = createCallControlHandler(props, () =>
3741
microphone.toggle(),
@@ -74,6 +78,13 @@ export const ToggleAudioPreviewButton = (
7478
children="!"
7579
/>
7680
)}
81+
{isPromptingPermission && (
82+
<span
83+
className="str-video__pending-permission"
84+
title={t('Waiting for permission')}
85+
children="?"
86+
/>
87+
)}
7788
</CompositeButton>
7889
</WithTooltip>
7990
);
@@ -102,8 +113,12 @@ export const ToggleAudioPublishingButton = (
102113
useRequestPermission(OwnCapability.SEND_AUDIO);
103114

104115
const { useMicrophoneState } = useCallStateHooks();
105-
const { microphone, optimisticIsMute, hasBrowserPermission } =
106-
useMicrophoneState();
116+
const {
117+
microphone,
118+
optimisticIsMute,
119+
hasBrowserPermission,
120+
isPromptingPermission,
121+
} = useMicrophoneState();
107122
const [tooltipDisabled, setTooltipDisabled] = useState(false);
108123
const handleClick = createCallControlHandler(props, async () => {
109124
if (!hasPermission) {
@@ -154,6 +169,14 @@ export const ToggleAudioPublishingButton = (
154169
{(!hasBrowserPermission || !hasPermission) && (
155170
<span className="str-video__no-media-permission">!</span>
156171
)}
172+
{isPromptingPermission && (
173+
<span
174+
className="str-video__pending-permission"
175+
title={t('Waiting for permission')}
176+
>
177+
?
178+
</span>
179+
)}
157180
</CompositeButton>
158181
</WithTooltip>
159182
</PermissionNotification>

packages/react-sdk/src/components/CallControls/ToggleVideoButton.tsx

+27-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ export const ToggleVideoPreviewButton = (
3636
} = props;
3737
const { t } = useI18n();
3838
const { useCameraState } = useCallStateHooks();
39-
const { camera, optimisticIsMute, hasBrowserPermission } = useCameraState();
39+
const {
40+
camera,
41+
optimisticIsMute,
42+
hasBrowserPermission,
43+
isPromptingPermission,
44+
} = useCameraState();
4045
const [tooltipDisabled, setTooltipDisabled] = useState(false);
4146
const handleClick = createCallControlHandler(props, () => camera.toggle());
4247

@@ -79,6 +84,13 @@ export const ToggleVideoPreviewButton = (
7984
children="!"
8085
/>
8186
)}
87+
{isPromptingPermission && (
88+
<span
89+
className="str-video__pending-permission"
90+
title={t('Waiting for permission')}
91+
children="?"
92+
/>
93+
)}
8294
</CompositeButton>
8395
</WithTooltip>
8496
);
@@ -107,7 +119,12 @@ export const ToggleVideoPublishingButton = (
107119
useRequestPermission(OwnCapability.SEND_VIDEO);
108120

109121
const { useCameraState, useCallSettings } = useCallStateHooks();
110-
const { camera, optimisticIsMute, hasBrowserPermission } = useCameraState();
122+
const {
123+
camera,
124+
optimisticIsMute,
125+
hasBrowserPermission,
126+
isPromptingPermission,
127+
} = useCameraState();
111128
const callSettings = useCallSettings();
112129
const isPublishingVideoAllowed = callSettings?.video.enabled;
113130
const [tooltipDisabled, setTooltipDisabled] = useState(false);
@@ -170,6 +187,14 @@ export const ToggleVideoPublishingButton = (
170187
!isPublishingVideoAllowed) && (
171188
<span className="str-video__no-media-permission">!</span>
172189
)}
190+
{isPromptingPermission && (
191+
<span
192+
className="str-video__pending-permission"
193+
title={t('Waiting for permission')}
194+
>
195+
?
196+
</span>
197+
)}
173198
</CompositeButton>
174199
</WithTooltip>
175200
</PermissionNotification>

0 commit comments

Comments
 (0)