From 392e2103b06cfaeaf33b5092a0443dfef15607b0 Mon Sep 17 00:00:00 2001 From: Richard Tibbett Date: Wed, 13 Nov 2024 14:28:31 +0100 Subject: [PATCH] core: Attach breakoutGroup to all remote and local participants on room_joined and breakout_group_joined signal events --- .changeset/bright-grapes-work.md | 6 ++ packages/core/src/RoomParticipant.ts | 8 +- packages/core/src/__mocks__/appMocks.ts | 8 ++ .../slices/__tests__/authorization.unit.ts | 2 + .../slices/__tests__/cloudRecording.unit.ts | 1 + .../slices/__tests__/localParticipant.unit.ts | 65 +++++++++++++ .../__tests__/remoteParticipants.unit.ts | 94 ++++++++----------- .../src/redux/slices/__tests__/room.unit.ts | 1 + .../slices/__tests__/roomConnection.unit.ts | 3 + .../__tests__/waitingParticipants.unit.ts | 1 + .../core/src/redux/slices/localParticipant.ts | 12 +++ .../src/redux/slices/remoteParticipants.ts | 12 ++- .../redux/slices/signalConnection/actions.ts | 2 + .../redux/slices/signalConnection/index.ts | 4 + packages/media/src/utils/types.ts | 8 ++ 15 files changed, 171 insertions(+), 56 deletions(-) create mode 100644 .changeset/bright-grapes-work.md create mode 100644 packages/core/src/redux/slices/__tests__/localParticipant.unit.ts diff --git a/.changeset/bright-grapes-work.md b/.changeset/bright-grapes-work.md new file mode 100644 index 000000000..3c30f2cee --- /dev/null +++ b/.changeset/bright-grapes-work.md @@ -0,0 +1,6 @@ +--- +"@whereby.com/core": minor +"@whereby.com/media": patch +--- + +Add missing `breakoutGroup` property to all in-room participants diff --git a/packages/core/src/RoomParticipant.ts b/packages/core/src/RoomParticipant.ts index 42d08eac6..eeba4ded9 100644 --- a/packages/core/src/RoomParticipant.ts +++ b/packages/core/src/RoomParticipant.ts @@ -11,6 +11,7 @@ interface RoomParticipantData { stream?: MediaStream; isAudioEnabled: boolean; isVideoEnabled: boolean; + breakoutGroup: string | null; stickyReaction?: StickyReaction | null; isDialIn: boolean; } @@ -22,6 +23,7 @@ export default class RoomParticipant { public readonly isAudioEnabled: boolean; public readonly isLocalParticipant: boolean = false; public readonly isVideoEnabled: boolean; + public readonly breakoutGroup; public readonly stickyReaction?: StickyReaction | null; public readonly isDialIn: boolean; @@ -31,6 +33,7 @@ export default class RoomParticipant { stream, isAudioEnabled, isVideoEnabled, + breakoutGroup, stickyReaction, isDialIn, }: RoomParticipantData) { @@ -39,6 +42,7 @@ export default class RoomParticipant { this.stream = stream; this.isAudioEnabled = isAudioEnabled; this.isVideoEnabled = isVideoEnabled; + this.breakoutGroup = breakoutGroup; this.stickyReaction = stickyReaction; this.isDialIn = isDialIn; } @@ -70,6 +74,7 @@ export interface RemoteParticipant { isAudioEnabled: boolean; isVideoEnabled: boolean; isLocalParticipant: boolean; + breakoutGroup: string | null; stream: (MediaStream & { inboundId?: string }) | null; streams: Stream[]; newJoiner: boolean; @@ -88,10 +93,11 @@ export class LocalParticipant extends RoomParticipant { stream, isAudioEnabled, isVideoEnabled, + breakoutGroup, stickyReaction, isDialIn, }: RoomParticipantData) { - super({ displayName, id, stream, isAudioEnabled, isVideoEnabled, stickyReaction, isDialIn }); + super({ displayName, id, stream, isAudioEnabled, isVideoEnabled, breakoutGroup, stickyReaction, isDialIn }); } } diff --git a/packages/core/src/__mocks__/appMocks.ts b/packages/core/src/__mocks__/appMocks.ts index 1abcdf9a8..f04d3f3f6 100644 --- a/packages/core/src/__mocks__/appMocks.ts +++ b/packages/core/src/__mocks__/appMocks.ts @@ -81,6 +81,7 @@ export const randomSignalClient = ({ streams = [], isAudioEnabled = true, isVideoEnabled = true, + breakoutGroup = null, role = { roleName: "visitor" }, startedCloudRecordingAt = null, externalId = null, @@ -92,6 +93,7 @@ export const randomSignalClient = ({ streams, isAudioEnabled, isVideoEnabled, + breakoutGroup, role, startedCloudRecordingAt, externalId, @@ -105,9 +107,11 @@ export const randomLocalParticipant = ({ stream = undefined, isAudioEnabled = true, isVideoEnabled = true, + breakoutGroup = null, clientClaim = randomString(), isScreenSharing = false, roleName = "visitor", + stickyReaction = undefined, isDialIn = false, }: Partial = {}): LocalParticipantState => { return { @@ -116,10 +120,12 @@ export const randomLocalParticipant = ({ stream, isAudioEnabled, isVideoEnabled, + breakoutGroup, isLocalParticipant: true, clientClaim, isScreenSharing, roleName, + stickyReaction, isDialIn, }; }; @@ -130,6 +136,7 @@ export const randomRemoteParticipant = ({ roleName = "visitor", isAudioEnabled = true, isVideoEnabled = true, + breakoutGroup = null, isLocalParticipant = false, stream = null, streams = [], @@ -144,6 +151,7 @@ export const randomRemoteParticipant = ({ roleName, isAudioEnabled, isVideoEnabled, + breakoutGroup, isLocalParticipant, stream, streams, diff --git a/packages/core/src/redux/slices/__tests__/authorization.unit.ts b/packages/core/src/redux/slices/__tests__/authorization.unit.ts index b97280bce..104588a78 100644 --- a/packages/core/src/redux/slices/__tests__/authorization.unit.ts +++ b/packages/core/src/redux/slices/__tests__/authorization.unit.ts @@ -34,6 +34,7 @@ describe("authorizationSlice", () => { undefined, signalEvents.roomJoined({ selfId: "selfId", + breakoutGroup: null, clientClaim: "clientClaim", isLocked: false, room: { @@ -44,6 +45,7 @@ describe("authorizationSlice", () => { streams: [], isAudioEnabled: true, isVideoEnabled: true, + breakoutGroup: null, role: { roleName: "host", }, diff --git a/packages/core/src/redux/slices/__tests__/cloudRecording.unit.ts b/packages/core/src/redux/slices/__tests__/cloudRecording.unit.ts index 101020fb4..9379b757e 100644 --- a/packages/core/src/redux/slices/__tests__/cloudRecording.unit.ts +++ b/packages/core/src/redux/slices/__tests__/cloudRecording.unit.ts @@ -35,6 +35,7 @@ describe("cloudRecordingSlice", () => { streams: [], isAudioEnabled: true, isVideoEnabled: false, + breakoutGroup: null, id: "id", role: { roleName: "recorder", diff --git a/packages/core/src/redux/slices/__tests__/localParticipant.unit.ts b/packages/core/src/redux/slices/__tests__/localParticipant.unit.ts new file mode 100644 index 000000000..1ec333b0d --- /dev/null +++ b/packages/core/src/redux/slices/__tests__/localParticipant.unit.ts @@ -0,0 +1,65 @@ +import { localParticipantSlice } from "../localParticipant"; +import { signalEvents } from "../signalConnection/actions"; +import { randomSignalClient, randomLocalParticipant } from "../../../__mocks__/appMocks"; + +describe("localParticipantSlice", () => { + describe("reducers", () => { + describe("signalEvents.roomJoined", () => { + it("should update state", () => { + const localClient = randomSignalClient({ role: { roleName: "visitor" }, breakoutGroup: "a" }); + const remoteClient = randomSignalClient(); + + const localParticipant = randomLocalParticipant({ + id: localClient.id, + roleName: "viewer", + breakoutGroup: "b", + }); + + const result = localParticipantSlice.reducer( + undefined, + signalEvents.roomJoined({ + ...localParticipant, + isLocked: false, + selfId: localClient.id, + room: { + clients: [remoteClient, localClient], + knockers: [], + spotlights: [], + session: null, + }, + }), + ); + + expect(result).toEqual({ + ...localParticipant, + id: localParticipant.id, + displayName: "", // not set from roomJoined event + roleName: localClient.role.roleName, + breakoutGroup: localClient.breakoutGroup, + clientClaim: localParticipant.clientClaim, + }); + }); + }); + + describe("signalEvents.breakoutGroupJoined", () => { + it("should update the participant", () => { + const breakoutGroupId = "test_breakout_group"; + const participant = randomLocalParticipant(); + const state = { ...participant }; + + const result = localParticipantSlice.reducer( + state, + signalEvents.breakoutGroupJoined({ + clientId: participant.id, + group: breakoutGroupId, + }), + ); + + expect(result).toEqual({ + ...participant, + breakoutGroup: breakoutGroupId, + }); + }); + }); + }); +}); diff --git a/packages/core/src/redux/slices/__tests__/remoteParticipants.unit.ts b/packages/core/src/redux/slices/__tests__/remoteParticipants.unit.ts index fb04d4e81..337415d9f 100644 --- a/packages/core/src/redux/slices/__tests__/remoteParticipants.unit.ts +++ b/packages/core/src/redux/slices/__tests__/remoteParticipants.unit.ts @@ -1,8 +1,9 @@ -import { remoteParticipantsSlice } from "../remoteParticipants"; +import { remoteParticipantsSlice, createRemoteParticipant } from "../remoteParticipants"; import { signalEvents } from "../signalConnection/actions"; import { rtcEvents } from "../rtcConnection/actions"; import { randomSignalClient, + randomLocalParticipant, randomRemoteParticipant, randomString, randomMediaStream, @@ -12,28 +13,22 @@ describe("remoteParticipantsSlice", () => { describe("reducers", () => { describe("signalEvents.roomJoined", () => { it("should update state", () => { + const localClient = randomSignalClient(); + const localParticipant = randomLocalParticipant({ + id: localClient.id, + }); + + const remoteClient = randomSignalClient({ breakoutGroup: "b" }); + const remoteParticipant = createRemoteParticipant(remoteClient); + const result = remoteParticipantsSlice.reducer( undefined, signalEvents.roomJoined({ + ...localParticipant, isLocked: false, - selfId: "selfId", - clientClaim: "clientClaim", + selfId: localClient.id, room: { - clients: [ - { - displayName: "displayName", - id: "id", - streams: [], - isAudioEnabled: true, - isVideoEnabled: true, - role: { - roleName: "visitor", - }, - startedCloudRecordingAt: null, - externalId: null, - isDialIn: false, - }, - ], + clients: [localClient, remoteClient], knockers: [], spotlights: [], session: null, @@ -41,28 +36,12 @@ describe("remoteParticipantsSlice", () => { }), ); - expect(result.remoteParticipants).toEqual([ - { - displayName: "displayName", - id: "id", - streams: [], - isAudioEnabled: true, - isVideoEnabled: true, - isLocalParticipant: false, - stream: null, - newJoiner: false, - roleName: "visitor", - startedCloudRecordingAt: null, - presentationStream: null, - externalId: null, - isDialIn: false, - }, - ]); + expect(result.remoteParticipants).toEqual([remoteParticipant]); }); }); it("signalEvents.newClient", () => { - const client = randomSignalClient(); + const client = randomSignalClient({ breakoutGroup: "a" }); const result = remoteParticipantsSlice.reducer( undefined, @@ -71,23 +50,7 @@ describe("remoteParticipantsSlice", () => { }), ); - expect(result.remoteParticipants).toEqual([ - { - id: client.id, - displayName: client.displayName, - isAudioEnabled: client.isAudioEnabled, - isVideoEnabled: client.isVideoEnabled, - isLocalParticipant: false, - stream: null, - streams: [], - newJoiner: true, - roleName: client.role.roleName, - startedCloudRecordingAt: client.startedCloudRecordingAt, - presentationStream: null, - externalId: null, - isDialIn: false, - }, - ]); + expect(result.remoteParticipants).toEqual([createRemoteParticipant(client, true)]); }); it("signalEvents.clientLeft", () => { @@ -206,6 +169,31 @@ describe("remoteParticipantsSlice", () => { }); }); + describe("signalEvents.breakoutGroupJoined", () => { + it("should update the participant", () => { + const breakoutGroupId = "test_breakout_group"; + const participant = randomRemoteParticipant(); + const state = { + remoteParticipants: [participant], + }; + + const result = remoteParticipantsSlice.reducer( + state, + signalEvents.breakoutGroupJoined({ + clientId: participant.id, + group: breakoutGroupId, + }), + ); + + expect(result.remoteParticipants).toEqual([ + { + ...participant, + breakoutGroup: breakoutGroupId, + }, + ]); + }); + }); + describe("signalEvents.screenshareStarted", () => { it("should update the participant", () => { const participant = randomRemoteParticipant(); diff --git a/packages/core/src/redux/slices/__tests__/room.unit.ts b/packages/core/src/redux/slices/__tests__/room.unit.ts index 787821df7..ba78e7b34 100644 --- a/packages/core/src/redux/slices/__tests__/room.unit.ts +++ b/packages/core/src/redux/slices/__tests__/room.unit.ts @@ -9,6 +9,7 @@ describe("roomSlice", () => { undefined, signalEvents.roomJoined({ selfId: "selfId", + breakoutGroup: "", clientClaim: "clientClaim", isLocked: true, }), diff --git a/packages/core/src/redux/slices/__tests__/roomConnection.unit.ts b/packages/core/src/redux/slices/__tests__/roomConnection.unit.ts index ce1449971..1298dfe96 100644 --- a/packages/core/src/redux/slices/__tests__/roomConnection.unit.ts +++ b/packages/core/src/redux/slices/__tests__/roomConnection.unit.ts @@ -11,6 +11,7 @@ describe("roomConnectionSlice", () => { signalEvents.roomJoined({ error: "room_locked", selfId: "selfId", + breakoutGroup: "", clientClaim: "clientClaim", isLocked: true, }), @@ -28,6 +29,7 @@ describe("roomConnectionSlice", () => { undefined, signalEvents.roomJoined({ selfId: "selfId", + breakoutGroup: "", clientClaim: "clientClaim", isLocked: false, }), @@ -46,6 +48,7 @@ describe("roomConnectionSlice", () => { signalEvents.roomJoined({ error: "room_full", selfId: "selfId", + breakoutGroup: "", isLocked: false, }), ); diff --git a/packages/core/src/redux/slices/__tests__/waitingParticipants.unit.ts b/packages/core/src/redux/slices/__tests__/waitingParticipants.unit.ts index 91a44d2d9..bbd0f8c4b 100644 --- a/packages/core/src/redux/slices/__tests__/waitingParticipants.unit.ts +++ b/packages/core/src/redux/slices/__tests__/waitingParticipants.unit.ts @@ -12,6 +12,7 @@ describe("reducer", () => { signalEvents.roomJoined({ isLocked: true, selfId: "self-id", + breakoutGroup: null, clientClaim: "client-claim", room: { clients: [], diff --git a/packages/core/src/redux/slices/localParticipant.ts b/packages/core/src/redux/slices/localParticipant.ts index b60650e8c..3f98ec317 100644 --- a/packages/core/src/redux/slices/localParticipant.ts +++ b/packages/core/src/redux/slices/localParticipant.ts @@ -20,6 +20,7 @@ export interface LocalParticipantState extends LocalParticipant { const initialState: LocalParticipantState = { displayName: "", id: "", + breakoutGroup: null, isAudioEnabled: true, isVideoEnabled: true, isLocalParticipant: true, @@ -79,6 +80,17 @@ export const localParticipantSlice = createSlice({ id: action.payload.selfId, roleName: client?.role.roleName || "none", clientClaim: action.payload.clientClaim, + breakoutGroup: client?.breakoutGroup || null, + }; + }); + builder.addCase(signalEvents.breakoutGroupJoined, (state, action) => { + if (action.payload?.clientId !== state.id) { + return state; + } + + return { + ...state, + breakoutGroup: action.payload?.group, }; }); }, diff --git a/packages/core/src/redux/slices/remoteParticipants.ts b/packages/core/src/redux/slices/remoteParticipants.ts index 3d1d9de94..192d92eff 100644 --- a/packages/core/src/redux/slices/remoteParticipants.ts +++ b/packages/core/src/redux/slices/remoteParticipants.ts @@ -19,8 +19,8 @@ import { selectLocalParticipantRaw } from "./localParticipant"; * State mapping utils */ -function createRemoteParticipant(client: SignalClient, newJoiner = false): RemoteParticipant { - const { streams, role, ...rest } = client; +export function createRemoteParticipant(client: SignalClient, newJoiner = false): RemoteParticipant { + const { streams, role, breakoutGroup, ...rest } = client; return { ...rest, @@ -29,6 +29,7 @@ function createRemoteParticipant(client: SignalClient, newJoiner = false): Remot isLocalParticipant: false, roleName: role?.roleName || "none", presentationStream: null, + breakoutGroup: breakoutGroup || null, newJoiner, }; } @@ -260,6 +261,13 @@ export const remoteParticipantsSlice = createSlice({ stickyReaction, }); }); + builder.addCase(signalEvents.breakoutGroupJoined, (state, action) => { + const { clientId, group } = action.payload; + + return updateParticipant(state, clientId, { + breakoutGroup: group || null, + }); + }); builder.addCase(signalEvents.screenshareStarted, (state, action) => { const { clientId, streamId } = action.payload; diff --git a/packages/core/src/redux/slices/signalConnection/actions.ts b/packages/core/src/redux/slices/signalConnection/actions.ts index 79c29b3ac..5bf7dad41 100644 --- a/packages/core/src/redux/slices/signalConnection/actions.ts +++ b/packages/core/src/redux/slices/signalConnection/actions.ts @@ -2,6 +2,7 @@ import { createAction } from "@reduxjs/toolkit"; import { AudioEnabledEvent, + BreakoutGroupJoinedEvent, ChatMessage, ClientLeftEvent, ClientKickedEvent, @@ -34,6 +35,7 @@ function createSignalEventAction(name: string) { export const signalEvents = { audioEnabled: createSignalEventAction("audioEnabled"), audioEnableRequested: createSignalEventAction("audioEnableRequested"), + breakoutGroupJoined: createSignalEventAction("breakoutGroupJoined"), chatMessage: createSignalEventAction("chatMessage"), clientLeft: createSignalEventAction("clientLeft"), clientKicked: createSignalEventAction("clientKicked"), diff --git a/packages/core/src/redux/slices/signalConnection/index.ts b/packages/core/src/redux/slices/signalConnection/index.ts index 859ba4f63..1ff4aa257 100644 --- a/packages/core/src/redux/slices/signalConnection/index.ts +++ b/packages/core/src/redux/slices/signalConnection/index.ts @@ -7,6 +7,7 @@ import { selectDeviceCredentialsRaw } from "../deviceCredentials"; import { AudioEnableRequestedEvent, AudioEnabledEvent, + BreakoutGroupJoinedEvent, ChatMessage, ClientKickedEvent, ClientLeftEvent, @@ -88,6 +89,9 @@ function forwardSocketEvents(socket: ServerSocket, dispatch: ThunkDispatch dispatch(signalEvents.videoEnableRequested(payload)), ); + socket.on("breakout_group_joined", (payload: BreakoutGroupJoinedEvent) => + dispatch(signalEvents.breakoutGroupJoined(payload)), + ); } const SIGNAL_BASE_URL = process.env.REACT_APP_SIGNAL_BASE_URL || "wss://signal.appearin.net"; diff --git a/packages/media/src/utils/types.ts b/packages/media/src/utils/types.ts index 618a4af1c..00bb73d54 100644 --- a/packages/media/src/utils/types.ts +++ b/packages/media/src/utils/types.ts @@ -54,6 +54,7 @@ export interface SignalClient { isVideoEnabled: boolean; role: ClientRole; startedCloudRecordingAt: string | null; + breakoutGroup: string | null; externalId: string | null; isDialIn: boolean; } @@ -68,6 +69,11 @@ export interface AudioEnabledEvent { isAudioEnabled: boolean; } +export interface BreakoutGroupJoinedEvent { + clientId: string; + group: string; +} + export interface ChatMessage { id: string; messageType: "text"; @@ -132,6 +138,7 @@ export interface RoomJoinedEvent { } | null; }; selfId: string; + breakoutGroup: string | null; clientClaim?: string; } @@ -223,6 +230,7 @@ export interface LiveTranscriptionStoppedEvent { export interface SignalEvents { audio_enabled: AudioEnabledEvent; audio_enable_requested: AudioEnableRequestedEvent; + breakout_group_joined: BreakoutGroupJoinedEvent; client_left: ClientLeftEvent; client_kicked: ClientKickedEvent; client_metadata_received: ClientMetadataReceivedEvent;