@stream-io/video-react-sdk 1.10.6 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { StreamVideoParticipant } from '@stream-io/video-client';
2
1
  import { ParticipantViewProps } from '../ParticipantView';
2
+ import { ParticipantFilter, ParticipantPredicate } from './hooks';
3
3
  export type PaginatedGridLayoutProps = {
4
4
  /**
5
5
  * The number of participants to display per page.
@@ -11,9 +11,24 @@ export type PaginatedGridLayoutProps = {
11
11
  */
12
12
  excludeLocalParticipant?: boolean;
13
13
  /**
14
- * Predicate to filter call participants.
14
+ * Predicate to filter call participants or a filter object.
15
+ * @example
16
+ * // With a predicate:
17
+ * <PaginatedGridLayout
18
+ * filterParticipants={p => p.roles.includes('student')}
19
+ * />
20
+ * @example
21
+ * // With a filter object:
22
+ * <PaginatedGridLayout
23
+ * filterParticipants={{
24
+ * $or: [
25
+ * { roles: { $contains: 'student' } },
26
+ * { isPinned: true },
27
+ * ],
28
+ * }}
29
+ * />
15
30
  */
16
- filterParticipants?: (participant: StreamVideoParticipant) => boolean;
31
+ filterParticipants?: ParticipantPredicate | ParticipantFilter;
17
32
  /**
18
33
  * When set to `false` disables mirroring of the local partipant's video.
19
34
  * @default true
@@ -1,5 +1,5 @@
1
- import { StreamVideoParticipant } from '@stream-io/video-client';
2
1
  import { ParticipantViewProps } from '../ParticipantView';
2
+ import { ParticipantFilter, ParticipantPredicate } from './hooks';
3
3
  export type SpeakerLayoutProps = {
4
4
  /**
5
5
  * The UI to be used for the participant in the spotlight.
@@ -25,9 +25,24 @@ export type SpeakerLayoutProps = {
25
25
  */
26
26
  excludeLocalParticipant?: boolean;
27
27
  /**
28
- * Predicate to filter call participants.
29
- */
30
- filterParticipants?: (participant: StreamVideoParticipant) => boolean;
28
+ * Predicate to filter call participants or a filter object.
29
+ * @example
30
+ * // With a predicate:
31
+ * <SpeakerLayout
32
+ * filterParticipants={p => p.roles.includes('student')}
33
+ * />
34
+ * @example
35
+ * // With a filter object:
36
+ * <SpeakerLayout
37
+ * filterParticipants={{
38
+ * $or: [
39
+ * { roles: { $contains: 'student' } },
40
+ * { isPinned: true },
41
+ * ],
42
+ * }}
43
+ * />
44
+ */
45
+ filterParticipants?: ParticipantPredicate | ParticipantFilter;
31
46
  /**
32
47
  * When set to `false` disables mirroring of the local participant's video.
33
48
  * @default true
@@ -1,7 +1,14 @@
1
1
  import { Call, StreamVideoParticipant } from '@stream-io/video-client';
2
+ import { Filter } from '../../../utilities/filter';
3
+ export type FilterableParticipant = Pick<StreamVideoParticipant, 'userId' | 'isSpeaking' | 'isDominantSpeaker' | 'name' | 'roles'> & {
4
+ isPinned: boolean;
5
+ };
6
+ export type ParticipantFilter = Filter<FilterableParticipant>;
7
+ export type ParticipantPredicate = (paritcipant: StreamVideoParticipant) => boolean;
2
8
  export declare const useFilteredParticipants: ({ excludeLocalParticipant, filterParticipants, }: {
3
9
  excludeLocalParticipant?: boolean;
4
- filterParticipants?: (paritcipant: StreamVideoParticipant) => boolean;
10
+ filterParticipants?: ParticipantFilter | ParticipantPredicate;
5
11
  }) => StreamVideoParticipant[];
12
+ export declare const applyParticipantsFilter: (participants: StreamVideoParticipant[], filter: ParticipantPredicate | ParticipantFilter) => StreamVideoParticipant[];
6
13
  export declare const usePaginatedLayoutSortPreset: (call: Call | undefined) => void;
7
14
  export declare const useSpeakerLayoutSortPreset: (call: Call | undefined, isOneOnOneCall: boolean) => void;
@@ -1,3 +1,4 @@
1
1
  export * from './LivestreamLayout';
2
2
  export * from './PaginatedGridLayout';
3
3
  export * from './SpeakerLayout';
4
+ export type { FilterableParticipant, ParticipantFilter, ParticipantPredicate, } from './hooks';
@@ -0,0 +1,26 @@
1
+ export type Filter<T> = {
2
+ $and: Array<Filter<T>>;
3
+ } | {
4
+ $or: Array<Filter<T>>;
5
+ } | {
6
+ $not: Filter<T>;
7
+ } | Conditions<T>;
8
+ type Conditions<T> = {
9
+ [K in keyof T]?: T[K] extends Array<infer E> ? ArrayOperator<E> : ScalarOperator<T[K]>;
10
+ };
11
+ export type ScalarOperator<T> = EqOperator<T> | NeqOperator<T> | InOperator<T> | T;
12
+ export type ArrayOperator<T> = ContainsOperator<T>;
13
+ export type EqOperator<T> = {
14
+ $eq: T;
15
+ };
16
+ export type NeqOperator<T> = {
17
+ $neq: T;
18
+ };
19
+ export type InOperator<T> = {
20
+ $in: Array<T>;
21
+ };
22
+ export type ContainsOperator<T> = {
23
+ $contains: T;
24
+ };
25
+ export declare function applyFilter<T>(obj: T, filter: Filter<T>): boolean;
26
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-react-sdk",
3
- "version": "1.10.6",
3
+ "version": "1.11.1",
4
4
  "packageManager": "yarn@3.2.4",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "module": "./dist/index.es.js",
@@ -31,9 +31,9 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@floating-ui/react": "^0.26.24",
34
- "@stream-io/video-client": "1.15.5",
34
+ "@stream-io/video-client": "1.15.7",
35
35
  "@stream-io/video-filters-web": "0.1.6",
36
- "@stream-io/video-react-bindings": "1.4.5",
36
+ "@stream-io/video-react-bindings": "1.4.7",
37
37
  "chart.js": "^4.4.4",
38
38
  "clsx": "^2.0.0",
39
39
  "react-chartjs-2": "^5.3.0"
@@ -11,7 +11,12 @@ import {
11
11
  import { ParticipantsAudio } from '../Audio';
12
12
  import { IconButton } from '../../../components';
13
13
  import { chunk } from '../../../utilities';
14
- import { useFilteredParticipants, usePaginatedLayoutSortPreset } from './hooks';
14
+ import {
15
+ ParticipantFilter,
16
+ ParticipantPredicate,
17
+ useFilteredParticipants,
18
+ usePaginatedLayoutSortPreset,
19
+ } from './hooks';
15
20
 
16
21
  const GROUP_SIZE = 16;
17
22
 
@@ -71,9 +76,24 @@ export type PaginatedGridLayoutProps = {
71
76
  excludeLocalParticipant?: boolean;
72
77
 
73
78
  /**
74
- * Predicate to filter call participants.
79
+ * Predicate to filter call participants or a filter object.
80
+ * @example
81
+ * // With a predicate:
82
+ * <PaginatedGridLayout
83
+ * filterParticipants={p => p.roles.includes('student')}
84
+ * />
85
+ * @example
86
+ * // With a filter object:
87
+ * <PaginatedGridLayout
88
+ * filterParticipants={{
89
+ * $or: [
90
+ * { roles: { $contains: 'student' } },
91
+ * { isPinned: true },
92
+ * ],
93
+ * }}
94
+ * />
75
95
  */
76
- filterParticipants?: (participant: StreamVideoParticipant) => boolean;
96
+ filterParticipants?: ParticipantPredicate | ParticipantFilter;
77
97
 
78
98
  /**
79
99
  * When set to `false` disables mirroring of the local partipant's video.
@@ -1,9 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import clsx from 'clsx';
3
- import {
4
- hasScreenShare,
5
- StreamVideoParticipant,
6
- } from '@stream-io/video-client';
3
+ import { hasScreenShare } from '@stream-io/video-client';
7
4
  import { useCall, useCallStateHooks } from '@stream-io/video-react-bindings';
8
5
 
9
6
  import {
@@ -16,7 +13,12 @@ import {
16
13
  useHorizontalScrollPosition,
17
14
  useVerticalScrollPosition,
18
15
  } from '../../../hooks';
19
- import { useFilteredParticipants, useSpeakerLayoutSortPreset } from './hooks';
16
+ import {
17
+ ParticipantFilter,
18
+ ParticipantPredicate,
19
+ useFilteredParticipants,
20
+ useSpeakerLayoutSortPreset,
21
+ } from './hooks';
20
22
  import { useCalculateHardLimit } from '../../hooks/useCalculateHardLimit';
21
23
  import { ParticipantsAudio } from '../Audio';
22
24
 
@@ -45,9 +47,24 @@ export type SpeakerLayoutProps = {
45
47
  */
46
48
  excludeLocalParticipant?: boolean;
47
49
  /**
48
- * Predicate to filter call participants.
50
+ * Predicate to filter call participants or a filter object.
51
+ * @example
52
+ * // With a predicate:
53
+ * <SpeakerLayout
54
+ * filterParticipants={p => p.roles.includes('student')}
55
+ * />
56
+ * @example
57
+ * // With a filter object:
58
+ * <SpeakerLayout
59
+ * filterParticipants={{
60
+ * $or: [
61
+ * { roles: { $contains: 'student' } },
62
+ * { isPinned: true },
63
+ * ],
64
+ * }}
65
+ * />
49
66
  */
50
- filterParticipants?: (participant: StreamVideoParticipant) => boolean;
67
+ filterParticipants?: ParticipantPredicate | ParticipantFilter;
51
68
  /**
52
69
  * When set to `false` disables mirroring of the local participant's video.
53
70
  * @default true
@@ -5,19 +5,31 @@ import {
5
5
  combineComparators,
6
6
  Comparator,
7
7
  defaultSortPreset,
8
+ isPinned,
8
9
  paginatedLayoutSortPreset,
9
10
  screenSharing,
10
11
  speakerLayoutSortPreset,
11
12
  StreamVideoParticipant,
12
13
  } from '@stream-io/video-client';
13
14
  import { useCallStateHooks } from '@stream-io/video-react-bindings';
15
+ import { applyFilter, Filter } from '../../../utilities/filter';
16
+
17
+ export type FilterableParticipant = Pick<
18
+ StreamVideoParticipant,
19
+ 'userId' | 'isSpeaking' | 'isDominantSpeaker' | 'name' | 'roles'
20
+ > & { isPinned: boolean };
21
+
22
+ export type ParticipantFilter = Filter<FilterableParticipant>;
23
+ export type ParticipantPredicate = (
24
+ paritcipant: StreamVideoParticipant,
25
+ ) => boolean;
14
26
 
15
27
  export const useFilteredParticipants = ({
16
28
  excludeLocalParticipant = false,
17
29
  filterParticipants,
18
30
  }: {
19
31
  excludeLocalParticipant?: boolean;
20
- filterParticipants?: (paritcipant: StreamVideoParticipant) => boolean;
32
+ filterParticipants?: ParticipantFilter | ParticipantPredicate;
21
33
  }) => {
22
34
  const { useParticipants, useRemoteParticipants } = useCallStateHooks();
23
35
  const allParticipants = useParticipants();
@@ -26,10 +38,9 @@ export const useFilteredParticipants = ({
26
38
  const unfilteredParticipants = excludeLocalParticipant
27
39
  ? remoteParticipants
28
40
  : allParticipants;
41
+
29
42
  return filterParticipants
30
- ? unfilteredParticipants.filter((participant) =>
31
- filterParticipants(participant),
32
- )
43
+ ? applyParticipantsFilter(unfilteredParticipants, filterParticipants)
33
44
  : unfilteredParticipants;
34
45
  }, [
35
46
  allParticipants,
@@ -39,6 +50,29 @@ export const useFilteredParticipants = ({
39
50
  ]);
40
51
  };
41
52
 
53
+ export const applyParticipantsFilter = (
54
+ participants: StreamVideoParticipant[],
55
+ filter: ParticipantPredicate | ParticipantFilter,
56
+ ) => {
57
+ const filterCallback =
58
+ typeof filter === 'function'
59
+ ? filter
60
+ : (participant: StreamVideoParticipant) =>
61
+ applyFilter(
62
+ {
63
+ userId: participant.userId,
64
+ isSpeaking: participant.isSpeaking,
65
+ isDominantSpeaker: participant.isDominantSpeaker,
66
+ name: participant.name,
67
+ roles: participant.roles,
68
+ isPinned: isPinned(participant),
69
+ },
70
+ filter,
71
+ );
72
+
73
+ return participants.filter(filterCallback);
74
+ };
75
+
42
76
  export const usePaginatedLayoutSortPreset = (call: Call | undefined) => {
43
77
  useEffect(() => {
44
78
  if (!call) return;
@@ -1,3 +1,8 @@
1
1
  export * from './LivestreamLayout';
2
2
  export * from './PaginatedGridLayout';
3
3
  export * from './SpeakerLayout';
4
+ export type {
5
+ FilterableParticipant,
6
+ ParticipantFilter,
7
+ ParticipantPredicate,
8
+ } from './hooks';
@@ -0,0 +1,65 @@
1
+ import { StreamVideoParticipant } from '@stream-io/video-client';
2
+ import { test, type TestContext } from 'node:test';
3
+ import { applyParticipantsFilter } from './hooks';
4
+
5
+ const participants = [
6
+ {
7
+ userId: 'host-id',
8
+ isSpeaking: true,
9
+ isDominantSpeaker: true,
10
+ name: 'Host',
11
+ roles: ['host', 'admin'],
12
+ },
13
+ {
14
+ userId: 'listener-id-1',
15
+ isSpeaking: false,
16
+ isDominantSpeaker: false,
17
+ name: 'Listener 1',
18
+ roles: ['listener', 'user'],
19
+ pin: { pinnedAt: new Date() },
20
+ },
21
+ {
22
+ userId: 'listener-id-2',
23
+ isSpeaking: false,
24
+ isDominantSpeaker: false,
25
+ name: 'Listener 2',
26
+ roles: ['listener', 'user'],
27
+ },
28
+ ] as StreamVideoParticipant[];
29
+
30
+ test('applies predicate filter', (t: TestContext) => {
31
+ const filtered = applyParticipantsFilter(participants, (p) =>
32
+ p.roles.includes('listener'),
33
+ );
34
+
35
+ t.assert.strictEqual(filtered.length, 2);
36
+ t.assert.deepStrictEqual(
37
+ filtered.map((p) => p.userId),
38
+ ['listener-id-1', 'listener-id-2'],
39
+ );
40
+ });
41
+
42
+ test('applies filter object', (t: TestContext) => {
43
+ const filtered = applyParticipantsFilter(participants, {
44
+ $and: [
45
+ { roles: { $contains: 'listener' } },
46
+ { $not: { roles: { $contains: 'host' } } },
47
+ ],
48
+ });
49
+
50
+ t.assert.strictEqual(filtered.length, 2);
51
+ t.assert.deepStrictEqual(
52
+ filtered.map((p) => p.userId),
53
+ ['listener-id-1', 'listener-id-2'],
54
+ );
55
+ });
56
+
57
+ test('filter object supports boolean pin property', (t: TestContext) => {
58
+ const filtered = applyParticipantsFilter(participants, {
59
+ roles: { $contains: 'listener' },
60
+ isPinned: true,
61
+ });
62
+
63
+ t.assert.strictEqual(filtered.length, 1);
64
+ t.assert.strictEqual(filtered[0].userId, 'listener-id-1');
65
+ });
@@ -0,0 +1,119 @@
1
+ import { test, type TestContext } from 'node:test';
2
+ import { applyFilter } from './filter';
3
+
4
+ const obj = {
5
+ num: 42,
6
+ str: 'hello, world',
7
+ array: ['apples', 'bananas'],
8
+ };
9
+
10
+ test('checks single $eq condition', (t: TestContext) => {
11
+ t.assert.ok(applyFilter(obj, { num: { $eq: 42 } }));
12
+ t.assert.ok(!applyFilter(obj, { num: { $eq: 43 } }));
13
+ });
14
+
15
+ test('checks single $neq condition', (t: TestContext) => {
16
+ t.assert.ok(applyFilter(obj, { num: { $neq: 43 } }));
17
+ t.assert.ok(!applyFilter(obj, { num: { $neq: 42 } }));
18
+ });
19
+
20
+ test('checks single $in condition', (t: TestContext) => {
21
+ t.assert.ok(applyFilter(obj, { num: { $in: [41, 42, 43] } }));
22
+ t.assert.ok(!applyFilter(obj, { num: { $in: [1, 2, 3] } }));
23
+ });
24
+
25
+ test('checks single $contains condition', (t: TestContext) => {
26
+ t.assert.ok(applyFilter(obj, { array: { $contains: 'apples' } }));
27
+ t.assert.ok(!applyFilter(obj, { array: { $contains: 'cherries' } }));
28
+ });
29
+
30
+ test('fails $contains condition if value is not array', (t: TestContext) => {
31
+ // This case is not permitted by types, but can still happen in runtime
32
+ t.assert.ok(!applyFilter(obj as any, { str: { $contains: 'apples' } }));
33
+ });
34
+
35
+ test('conditions without operator are treated as $eq', (t: TestContext) => {
36
+ t.assert.ok(applyFilter(obj, { num: 42 }));
37
+ t.assert.ok(!applyFilter(obj, { num: 43 }));
38
+ });
39
+
40
+ test('checks multiple conditions', (t: TestContext) => {
41
+ t.assert.ok(applyFilter(obj, { num: 42, array: { $contains: 'bananas' } }));
42
+ t.assert.ok(!applyFilter(obj, { num: 42, array: { $contains: 'cherries' } }));
43
+ });
44
+
45
+ test('applies $and filter', (t: TestContext) => {
46
+ t.assert.ok(
47
+ applyFilter(obj, {
48
+ $and: [
49
+ { num: 42, array: { $contains: 'bananas' } },
50
+ { str: 'hello, world', array: { $contains: 'apples' } },
51
+ ],
52
+ }),
53
+ );
54
+
55
+ t.assert.ok(
56
+ !applyFilter(obj, {
57
+ $and: [
58
+ { num: 42, array: { $contains: 'bananas' } },
59
+ { str: 'hello, world', array: { $contains: 'cherries' } },
60
+ ],
61
+ }),
62
+ );
63
+ });
64
+
65
+ test('applies $or filter', (t: TestContext) => {
66
+ t.assert.ok(
67
+ applyFilter(obj, {
68
+ $or: [
69
+ { str: 'hello, world', array: { $contains: 'cherries' } },
70
+ { num: 42, array: { $contains: 'bananas' } },
71
+ ],
72
+ }),
73
+ );
74
+
75
+ t.assert.ok(
76
+ !applyFilter(obj, {
77
+ $or: [
78
+ { str: 'hello, world', array: { $contains: 'cherries' } },
79
+ { num: 43, array: { $contains: 'bananas' } },
80
+ ],
81
+ }),
82
+ );
83
+ });
84
+
85
+ test('applies $not filter', (t: TestContext) => {
86
+ t.assert.ok(
87
+ applyFilter(obj, {
88
+ $not: { str: 'hello, world', array: { $contains: 'cherries' } },
89
+ }),
90
+ );
91
+ });
92
+
93
+ test('applies nested filters', (t: TestContext) => {
94
+ t.assert.ok(
95
+ applyFilter(obj, {
96
+ $or: [
97
+ { str: 'hello, world', array: { $contains: 'cherries' } },
98
+ { $and: [{ num: 42 }, { array: { $contains: 'bananas' } }] },
99
+ ],
100
+ }),
101
+ );
102
+
103
+ t.assert.ok(
104
+ applyFilter(obj, {
105
+ $not: {
106
+ $or: [
107
+ { str: 'hello, world', array: { $contains: 'cherries' } },
108
+ {
109
+ $and: [
110
+ { num: 42 },
111
+ { array: { $contains: 'bananas' } },
112
+ { str: 'bye, world' },
113
+ ],
114
+ },
115
+ ],
116
+ },
117
+ }),
118
+ );
119
+ });
@@ -0,0 +1,79 @@
1
+ export type Filter<T> =
2
+ | { $and: Array<Filter<T>> }
3
+ | { $or: Array<Filter<T>> }
4
+ | { $not: Filter<T> }
5
+ | Conditions<T>;
6
+
7
+ type Conditions<T> = {
8
+ [K in keyof T]?: T[K] extends Array<infer E>
9
+ ? ArrayOperator<E>
10
+ : ScalarOperator<T[K]>;
11
+ };
12
+
13
+ export type ScalarOperator<T> =
14
+ | EqOperator<T>
15
+ | NeqOperator<T>
16
+ | InOperator<T>
17
+ | T;
18
+
19
+ export type ArrayOperator<T> = ContainsOperator<T>;
20
+
21
+ export type EqOperator<T> = { $eq: T };
22
+ export type NeqOperator<T> = { $neq: T };
23
+ export type InOperator<T> = { $in: Array<T> };
24
+ export type ContainsOperator<T> = { $contains: T };
25
+
26
+ export function applyFilter<T>(obj: T, filter: Filter<T>): boolean {
27
+ if ('$and' in filter) {
28
+ return filter.$and.every((f) => applyFilter(obj, f));
29
+ }
30
+
31
+ if ('$or' in filter) {
32
+ return filter.$or.some((f) => applyFilter(obj, f));
33
+ }
34
+
35
+ if ('$not' in filter) {
36
+ return !applyFilter(obj, filter.$not);
37
+ }
38
+
39
+ return checkConditions(obj, filter);
40
+ }
41
+
42
+ function checkConditions<T>(obj: T, conditions: Conditions<T>): boolean {
43
+ let match = true;
44
+
45
+ for (const key of Object.keys(conditions) as Array<keyof T>) {
46
+ const operator = conditions[key];
47
+ const maybeOperator = operator && typeof operator === 'object';
48
+ let value = obj[key];
49
+
50
+ if (maybeOperator && '$eq' in operator) {
51
+ const eqOperator = operator as EqOperator<typeof value>;
52
+ match &&= eqOperator.$eq === value;
53
+ } else if (maybeOperator && '$neq' in operator) {
54
+ const neqOperator = operator as NeqOperator<typeof value>;
55
+ match &&= neqOperator.$neq !== value;
56
+ } else if (maybeOperator && '$in' in operator) {
57
+ const inOperator = operator as InOperator<typeof value>;
58
+ match &&= inOperator.$in.includes(value);
59
+ } else if (maybeOperator && '$contains' in operator) {
60
+ if (Array.isArray(value)) {
61
+ const containsOperator = operator as ContainsOperator<
62
+ (typeof value)[number]
63
+ >;
64
+ match &&= value.includes(containsOperator.$contains);
65
+ } else {
66
+ match = false;
67
+ }
68
+ } else {
69
+ const eqValue = operator as typeof value;
70
+ match &&= eqValue === value;
71
+ }
72
+
73
+ if (!match) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ return true;
79
+ }