Skip to content

Commit 4105ee7

Browse files
authored
feat(react-native): expose useModeration hook (#2073)
Introduces new `useModeration` hook that handles the `call.moderation_blur` event. Follow-up of: #1822 🎫 Ticket: https://linear.app/stream/issue/RN-329/moderation-video-blurring 📑 Docs: GetStream/docs-content#373
1 parent 328e6f8 commit 4105ee7

File tree

6 files changed

+171
-105
lines changed

6 files changed

+171
-105
lines changed

packages/react-native-sdk/src/contexts/BackgroundFilters.tsx

Lines changed: 24 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import React, {
2-
createContext,
32
type PropsWithChildren,
43
useCallback,
54
useContext,
@@ -10,15 +9,19 @@ import React, {
109
import { MediaStream } from '@stream-io/react-native-webrtc';
1110
import { useCall } from '@stream-io/video-react-bindings';
1211
import { Image, Platform } from 'react-native';
13-
14-
const isSupported = (function () {
15-
if (Platform.OS === 'ios') {
16-
// only supported on ios 15 and above
17-
const currentVersion = parseInt(Platform.Version, 10);
18-
return currentVersion >= 15;
19-
}
20-
return Platform.OS === 'android';
21-
})();
12+
import {
13+
BackgroundFiltersContext,
14+
type BlurIntensity,
15+
type CurrentBackgroundFilter,
16+
type ImageSourceType,
17+
} from './internal/BackgroundFiltersContext';
18+
19+
// for maintaining backwards compatibility
20+
export type {
21+
BlurIntensity,
22+
CurrentBackgroundFilter,
23+
BackgroundFiltersAPI,
24+
} from './internal/BackgroundFiltersContext';
2225

2326
type VideoFiltersModuleType =
2427
typeof import('@stream-io/video-filters-react-native');
@@ -29,62 +32,15 @@ try {
2932
videoFiltersModule = require('@stream-io/video-filters-react-native');
3033
} catch {}
3134

32-
const resolveAssetSourceFunc = Image.resolveAssetSource;
33-
34-
// excluding array of images and only allow one image
35-
type ImageSourceType = Exclude<
36-
Parameters<typeof resolveAssetSourceFunc>[0],
37-
Array<any>
38-
>;
39-
40-
export type BlurIntensity = 'light' | 'medium' | 'heavy';
41-
42-
export type BackgroundFilterType = 'blur' | 'image';
43-
44-
export type CurrentBackgroundFilter = {
45-
blur?: BlurIntensity;
46-
image?: ImageSourceType;
47-
};
48-
49-
export type BackgroundFiltersAPI = {
50-
/**
51-
* The currently applied background filter. Undefined value indicates that no filter is applied.
52-
*/
53-
currentBackgroundFilter: CurrentBackgroundFilter | undefined;
54-
/**
55-
* Whether the current device supports the background filters.
56-
*/
57-
isSupported: boolean;
58-
/**
59-
* Applies a background image filter to the video.
60-
*
61-
* @param imageSource the URL of the image to use as the background.
62-
*/
63-
applyBackgroundImageFilter: (imageSource: ImageSourceType) => void;
64-
/**
65-
* Applies a background blur filter to the video.
66-
*
67-
* @param blurLevel the level of blur to apply to the background.
68-
*/
69-
applyBackgroundBlurFilter: (blurIntensity: BlurIntensity) => void;
70-
/**
71-
* Applies a video blur filter to the video.
72-
*
73-
* @param blurIntensity the level of blur to apply to the video.
74-
*/
75-
applyVideoBlurFilter: (blurIntensity: BlurIntensity) => void;
76-
/**
77-
* Disables all filters applied to the video.
78-
*/
79-
disableAllFilters: () => void;
80-
};
81-
82-
/**
83-
* The context for the background filters.
84-
*/
85-
const BackgroundFiltersContext = createContext<
86-
BackgroundFiltersAPI | undefined
87-
>(undefined);
35+
const isSupported = (function () {
36+
if (!videoFiltersModule) return false;
37+
if (Platform.OS === 'ios') {
38+
// only supported on ios 15 and above
39+
const currentVersion = parseInt(Platform.Version, 10);
40+
return currentVersion >= 15;
41+
}
42+
return Platform.OS === 'android';
43+
})();
8844

8945
/**
9046
* A hook to access the background filters context API.
@@ -165,7 +121,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => {
165121
} else if (blurIntensity === 'light') {
166122
filterName = 'BlurLight';
167123
}
168-
call?.tracer.trace('backgroundFilters.apply', filterName);
124+
call?.tracer.trace('videoFilters.apply', filterName);
169125
(call?.camera.state.mediaStream as MediaStream | undefined)
170126
?.getVideoTracks()
171127
.forEach((track) => {
@@ -181,7 +137,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => {
181137
if (!isSupported) {
182138
return;
183139
}
184-
const source = resolveAssetSourceFunc(imageSource);
140+
const source = Image.resolveAssetSource(imageSource);
185141
const imageUri = source.uri;
186142
const registeredImageFiltersSet = registeredImageFiltersSetRef.current;
187143
if (!registeredImageFiltersSet.has(imageUri)) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createContext } from 'react';
2+
import { type ImageSourcePropType } from 'react-native';
3+
4+
// excluding array of images and only allow one image
5+
export type ImageSourceType = Exclude<ImageSourcePropType, Array<any>>;
6+
7+
export type BlurIntensity = 'light' | 'medium' | 'heavy';
8+
9+
export type CurrentBackgroundFilter = {
10+
blur?: BlurIntensity;
11+
image?: ImageSourceType;
12+
};
13+
14+
export type BackgroundFiltersAPI = {
15+
/**
16+
* The currently applied background filter. Undefined value indicates that no filter is applied.
17+
*/
18+
currentBackgroundFilter: CurrentBackgroundFilter | undefined;
19+
/**
20+
* Whether the current device supports the background filters.
21+
*/
22+
isSupported: boolean;
23+
/**
24+
* Applies a background image filter to the video.
25+
*
26+
* @param imageSource the URL of the image to use as the background.
27+
*/
28+
applyBackgroundImageFilter: (imageSource: ImageSourceType) => Promise<void>;
29+
/**
30+
* Applies a background blur filter to the video.
31+
*
32+
* @param blurLevel the level of blur to apply to the background.
33+
*/
34+
applyBackgroundBlurFilter: (blurIntensity: BlurIntensity) => Promise<void>;
35+
/**
36+
* Applies a video blur filter to the video.
37+
*
38+
* @param blurIntensity the level of blur to apply to the video.
39+
*/
40+
applyVideoBlurFilter: (blurIntensity: BlurIntensity) => Promise<void>;
41+
/**
42+
* Disables all filters applied to the video.
43+
*/
44+
disableAllFilters: () => void;
45+
};
46+
47+
/**
48+
* The context for the background filters.
49+
*/
50+
export const BackgroundFiltersContext = createContext<
51+
BackgroundFiltersAPI | undefined
52+
>(undefined);

packages/react-native-sdk/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './useScreenShareButton';
99
export * from './useTrackDimensions';
1010
export * from './useScreenshot';
1111
export * from './useSpeechDetection';
12+
export * from './useModeration';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useContext, useEffect, useRef } from 'react';
2+
import { useCall } from '@stream-io/video-react-bindings';
3+
import { BackgroundFiltersContext } from '../contexts/internal/BackgroundFiltersContext';
4+
5+
export interface ModerationOptions {
6+
/**
7+
* How long the moderation effect should stay active before being disabled.
8+
* Set to `0` to keep it active indefinitely. Defaults to 5000 ms.
9+
*/
10+
duration?: number;
11+
}
12+
13+
export const useModeration = (options?: ModerationOptions) => {
14+
const { duration = 5000 } = options || {};
15+
const call = useCall();
16+
17+
// accessing the filters context directly, as it is optional, but our
18+
// useBackgroundFilters() throws an error if used outside the provider
19+
const filtersApi = useContext(BackgroundFiltersContext);
20+
const {
21+
isSupported = false,
22+
currentBackgroundFilter,
23+
applyBackgroundBlurFilter,
24+
applyBackgroundImageFilter,
25+
applyVideoBlurFilter,
26+
disableAllFilters,
27+
} = filtersApi || {};
28+
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
29+
const restoreRef = useRef<Promise<void>>(undefined);
30+
useEffect(() => {
31+
if (!call) return;
32+
const unsubscribe = call.on('call.moderation_blur', () => {
33+
const turnCameraOff = () =>
34+
call.camera.disable().catch((err) => {
35+
console.error(`Failed to disable camera`, err);
36+
});
37+
38+
// not scheduling a timeout to enable the camera
39+
clearTimeout(blurTimeoutRef.current);
40+
if (!isSupported) return turnCameraOff();
41+
42+
restoreRef.current = (restoreRef.current || Promise.resolve()).then(() =>
43+
applyVideoBlurFilter?.('heavy').then(() => {
44+
if (duration <= 0) return;
45+
46+
const restore = () => {
47+
const { blur, image } = currentBackgroundFilter || {};
48+
const action = blur
49+
? applyVideoBlurFilter?.(blur)
50+
: image
51+
? applyBackgroundImageFilter?.(image)
52+
: Promise.resolve(disableAllFilters?.());
53+
54+
action?.catch((err) => {
55+
console.error(`Failed to restore pre-moderation effect`, err);
56+
});
57+
};
58+
59+
blurTimeoutRef.current = setTimeout(restore, duration);
60+
}, turnCameraOff),
61+
);
62+
});
63+
return () => {
64+
unsubscribe();
65+
};
66+
}, [
67+
applyBackgroundBlurFilter,
68+
applyBackgroundImageFilter,
69+
applyVideoBlurFilter,
70+
call,
71+
currentBackgroundFilter,
72+
disableAllFilters,
73+
duration,
74+
isSupported,
75+
]);
76+
77+
useEffect(
78+
() => () => {
79+
restoreRef.current?.then(() => clearTimeout(blurTimeoutRef.current));
80+
},
81+
[],
82+
);
83+
};

sample-apps/react-native/dogfood/ios/Podfile.lock

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2937,7 +2937,7 @@ PODS:
29372937
- SocketRocket
29382938
- Yoga
29392939
- SocketRocket (0.7.1)
2940-
- stream-chat-react-native (8.9.1):
2940+
- stream-chat-react-native (8.12.0):
29412941
- boost
29422942
- DoubleConversion
29432943
- fast_float
@@ -2966,7 +2966,7 @@ PODS:
29662966
- ReactCommon/turbomodule/core
29672967
- SocketRocket
29682968
- Yoga
2969-
- stream-io-noise-cancellation-react-native (0.4.2):
2969+
- stream-io-noise-cancellation-react-native (0.4.4):
29702970
- boost
29712971
- DoubleConversion
29722972
- fast_float
@@ -2996,7 +2996,7 @@ PODS:
29962996
- stream-react-native-webrtc
29972997
- StreamVideoNoiseCancellation
29982998
- Yoga
2999-
- stream-io-video-filters-react-native (0.9.2):
2999+
- stream-io-video-filters-react-native (0.9.3):
30003000
- boost
30013001
- DoubleConversion
30023002
- fast_float
@@ -3028,7 +3028,7 @@ PODS:
30283028
- stream-react-native-webrtc (137.0.2):
30293029
- React-Core
30303030
- StreamWebRTC (~> 137.0.52)
3031-
- stream-video-react-native (1.26.1):
3031+
- stream-video-react-native (1.26.5):
30323032
- boost
30333033
- DoubleConversion
30343034
- fast_float
@@ -3461,11 +3461,11 @@ SPEC CHECKSUMS:
34613461
RNVoipPushNotification: 4998fe6724d421da616dca765da7dc421ff54c4e
34623462
RNWorklets: ad0606bee2a8103c14adb412149789c60b72bfb2
34633463
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
3464-
stream-chat-react-native: 2138656043846cd04d5d284605ef07af9a5309bc
3465-
stream-io-noise-cancellation-react-native: 17dfc185dc9b2552f70a1510cf818228dcd2e436
3466-
stream-io-video-filters-react-native: 5f24440c96fda08d50d7e33910a60d354c9771fe
3464+
stream-chat-react-native: 2ad28c26fbc0b8cb20d675c6b2674871d78f914f
3465+
stream-io-noise-cancellation-react-native: 208e38e34b4bc2922b7f2837cdb2e477e2202784
3466+
stream-io-video-filters-react-native: 3f44cffc89a66b2c6216ab11f3ac4bd3ac725d0f
34673467
stream-react-native-webrtc: 1bef09475caf435abbbd8aec8f8c3a9b1ee1fdf0
3468-
stream-video-react-native: e4023430bda1d702a1ce83355db70515ace3f727
3468+
stream-video-react-native: f52d912ce960a221ea43fce73771973c7b93ea12
34693469
StreamVideoNoiseCancellation: 41f5a712aba288f9636b64b17ebfbdff52c61490
34703470
StreamWebRTC: 57bd35729bcc46b008de4e741a5b23ac28b8854d
34713471
VisionCamera: 891edb31806dd3a239c8a9d6090d6ec78e11ee80

sample-apps/react-native/dogfood/src/components/ActiveCall.tsx

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
1-
import React, {
2-
useCallback,
3-
useEffect,
4-
useMemo,
5-
useRef,
6-
useState,
7-
} from 'react';
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
82
import {
93
CallContent,
104
callManager,
115
NoiseCancellationProvider,
12-
useBackgroundFilters,
136
useCall,
147
useIsInPiPMode,
8+
useModeration,
159
useTheme,
1610
useToggleCallRecording,
1711
} from '@stream-io/video-react-native-sdk';
@@ -55,8 +49,6 @@ export const ActiveCall = ({
5549
const currentOrientation = useOrientation();
5650
const isTablet = DeviceInfo.isTablet();
5751
const isLandscape = !isTablet && currentOrientation === 'landscape';
58-
const { applyVideoBlurFilter, disableAllFilters } = useBackgroundFilters();
59-
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
6052

6153
const onOpenCallParticipantsInfo = useCallback(() => {
6254
setIsCallParticipantsVisible(true);
@@ -82,25 +74,7 @@ export const ActiveCall = ({
8274
};
8375
}, []);
8476

85-
useEffect(() => {
86-
const unsub = call?.on('call.moderation_blur', () => {
87-
applyVideoBlurFilter('heavy');
88-
clearTimeout(blurTimeoutRef.current);
89-
90-
blurTimeoutRef.current = setTimeout(() => {
91-
disableAllFilters();
92-
blurTimeoutRef.current = undefined;
93-
}, 10000);
94-
});
95-
return () => {
96-
unsub?.();
97-
if (blurTimeoutRef.current) {
98-
clearTimeout(blurTimeoutRef.current);
99-
blurTimeoutRef.current = undefined;
100-
}
101-
disableAllFilters();
102-
};
103-
}, [call, applyVideoBlurFilter, disableAllFilters]);
77+
useModeration({ duration: 10000 });
10478

10579
useEffect(() => {
10680
return call?.on('call.ended', (event) => {

0 commit comments

Comments
 (0)