diff --git a/.changeset/better-waves-burn.md b/.changeset/better-waves-burn.md new file mode 100644 index 000000000..8fe007b9a --- /dev/null +++ b/.changeset/better-waves-burn.md @@ -0,0 +1,6 @@ +--- +"@whereby.com/core": minor +"@whereby.com/media": patch +--- + +Expose connection error to room connection client diff --git a/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts b/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts index 5ffa2fd57..967ac0329 100644 --- a/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts +++ b/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts @@ -13,6 +13,7 @@ export const initialState: RoomConnectionState = { remoteParticipants: [], screenshares: [], connectionStatus: "ready", + connectionError: null, waitingParticipants: [], spotlightedParticipants: [], }; diff --git a/packages/core/src/__mocks__/appMocks.ts b/packages/core/src/__mocks__/appMocks.ts index e4f953ccb..c2d9a269c 100644 --- a/packages/core/src/__mocks__/appMocks.ts +++ b/packages/core/src/__mocks__/appMocks.ts @@ -1,6 +1,6 @@ import { Credentials } from "../api"; import Organization from "../api/models/Organization"; -import { SignalClient } from "@whereby.com/media"; +import { ChatMessage, SignalClient } from "@whereby.com/media"; import { LocalParticipantState } from "../redux/slices/localParticipant"; import { RemoteParticipant } from "../RoomParticipant"; import * as uuidPkg from "uuid"; @@ -104,6 +104,28 @@ export const randomSignalClient = ({ }; }; +export const randomChatMessage = ({ + text = randomString(), + id = randomString(), + messageType = "text", + senderId = randomString(), + sig = randomString(), + timestamp = new Date().toDateString(), + roomName = randomString(), + userId = randomString(), +}: Partial = {}): ChatMessage => { + return { + text, + id, + messageType, + senderId, + sig, + timestamp, + roomName, + userId, + }; +}; + export const randomLocalParticipant = ({ breakoutGroup = null, clientClaim = randomString(), diff --git a/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts b/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts new file mode 100644 index 000000000..63cc0e740 --- /dev/null +++ b/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts @@ -0,0 +1,324 @@ +import { ChatMessage, RoomJoinedEvent, RtcStreamAddedPayload } from "@whereby.com/media"; +import { RoomConnectionClient } from ".."; +import { doHandleStreamingStarted, rtcEvents, setDisplayName, signalEvents } from "../../../redux"; +import { createStore } from "../../../redux/tests/store.setup"; +import { randomChatMessage, randomMediaStream, randomSignalClient, randomString } from "../../../__mocks__/appMocks"; + +describe("RoomConnection", () => { + let roomConnectionClient: RoomConnectionClient; + let store: ReturnType; + + beforeEach(() => { + store = createStore({ connectToRoom: true, withRtcManager: true }); + jest.spyOn(store, "dispatch"); + roomConnectionClient = new RoomConnectionClient(store); + }); + + describe("state subscriptions", () => { + describe("subscribeToChatMessages", () => { + it("triggers when chat messages are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToChatMessages(callback); + + const client = randomSignalClient(); + const chatMessage: ChatMessage = randomChatMessage({ senderId: client.id }); + store.dispatch(signalEvents.newClient({ client })); + store.dispatch(signalEvents.chatMessage(chatMessage)); + + expect(callback).toHaveBeenCalledWith([ + { text: chatMessage.text, senderId: chatMessage.senderId, timestamp: chatMessage.timestamp }, + ]); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToChatMessages(callback); + unsubscribe(); + + const client = randomSignalClient(); + const chatMessage: ChatMessage = randomChatMessage({ senderId: client.id }); + store.dispatch(signalEvents.newClient({ client })); + store.dispatch(signalEvents.chatMessage(chatMessage)); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToCloudRecording", () => { + it("triggers when cloud recording state is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToCloudRecording(callback); + + const client = randomSignalClient({ role: { roleName: "recorder" } }); + store.dispatch(signalEvents.newClient({ client })); + + expect(callback).toHaveBeenCalledWith({ status: "recording" }); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToCloudRecording(callback); + unsubscribe(); + + const client = randomSignalClient({ role: { roleName: "recorder" } }); + store.dispatch(signalEvents.newClient({ client })); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToConnectionStatus", () => { + it("triggers when connection status is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToConnectionStatus(callback); + + store.dispatch(signalEvents.disconnect()); + + expect(callback).toHaveBeenCalledWith("disconnected"); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToConnectionStatus(callback); + unsubscribe(); + + store.dispatch(signalEvents.disconnect()); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToConnectionError", () => { + it("triggers when connection error is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToConnectionError(callback); + + store.dispatch( + signalEvents.roomJoined({ + error: "room_full", + } as RoomJoinedEvent), + ); + + expect(callback).toHaveBeenCalledWith("room_full"); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToConnectionError(callback); + unsubscribe(); + + store.dispatch( + signalEvents.roomJoined({ + error: "room_full", + } as RoomJoinedEvent), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToLiveStream", () => { + it("triggers when live stream state is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToLiveStream(callback); + + store.dispatch(doHandleStreamingStarted()); + + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ status: "streaming" })); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToLiveStream(callback); + unsubscribe(); + + store.dispatch(doHandleStreamingStarted()); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToLocalParticipant", () => { + it("triggers when local participant is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToLocalParticipant(callback); + + store.dispatch(setDisplayName({ displayName: "NewDisplayName" })); + + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ displayName: "NewDisplayName" })); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToLocalParticipant(callback); + unsubscribe(); + + store.dispatch(setDisplayName({ displayName: "NewDisplayName" })); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToRemoteParticipants", () => { + it("triggers when remote participants are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToRemoteParticipants(callback); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: client.id })]), + ); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToRemoteParticipants(callback); + unsubscribe(); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToScreenshares", () => { + it("triggers when screenshares are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToScreenshares(callback); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + const screenshareEvent: RtcStreamAddedPayload = { + clientId: client.id, + stream: randomMediaStream(), + streamId: randomString(), + streamType: "screenshare", + }; + store.dispatch(rtcEvents.streamAdded(screenshareEvent)); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: `pres-${client.id}` })]), + ); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToScreenshares(callback); + unsubscribe(); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + const screenshareEvent: RtcStreamAddedPayload = { + clientId: client.id, + stream: randomMediaStream(), + streamId: randomString(), + streamType: "screenshare", + }; + store.dispatch(rtcEvents.streamAdded(screenshareEvent)); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToWaitingParticipants", () => { + it("triggers when waiting participants are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToWaitingParticipants(callback); + + const clientId = randomString(); + const displayName = randomString(); + store.dispatch( + signalEvents.roomKnocked({ clientId, displayName, imageUrl: randomString(), liveVideo: true }), + ); + + expect(callback).toHaveBeenCalledWith([{ id: clientId, displayName }]); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToWaitingParticipants(callback); + unsubscribe(); + + store.dispatch( + signalEvents.roomKnocked({ + clientId: randomString(), + displayName: randomString(), + imageUrl: randomString(), + liveVideo: true, + }), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToBreakoutConfig", () => { + it("triggers when breakout config is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToBreakoutConfig(callback); + + store.dispatch( + signalEvents.breakoutSessionUpdated({ + groups: { "group-1": "Group 1" }, + startedAt: new Date(), + }), + ); + + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ isActive: true })); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToBreakoutConfig(callback); + unsubscribe(); + + store.dispatch( + signalEvents.breakoutSessionUpdated({ + groups: { "group-1": "Group 1" }, + startedAt: new Date(), + }), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToSpotlightedParticipants", () => { + it("triggers when spotlighted participants are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToSpotlightedParticipants(callback); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + store.dispatch( + signalEvents.spotlightAdded({ clientId: client.id, streamId: "0", requestedByClientId: client.id }), + ); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ clientId: client.id })]), + ); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToSpotlightedParticipants(callback); + unsubscribe(); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + store.dispatch( + signalEvents.spotlightAdded({ + clientId: client.id, + streamId: "0", + requestedByClientId: randomString(), + }), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/core/src/client/RoomConnection/events.ts b/packages/core/src/client/RoomConnection/events.ts index 5ea8e0279..e8c54de60 100644 --- a/packages/core/src/client/RoomConnection/events.ts +++ b/packages/core/src/client/RoomConnection/events.ts @@ -18,6 +18,7 @@ export const CHAT_NEW_MESSAGE = "chat:new-message"; export const CLOUD_RECORDING_STATUS_CHANGED = "cloud-recording:status-changed"; /* Connection Status Events */ export const CONNECTION_STATUS_CHANGED = "connection:status-changed"; +export const CONNECTION_ERROR_CHANGED = "connection:error-changed"; /* Local participant events */ export const LOCAL_PARTICIPANT_CHANGED = "local-participant:changed"; export const LOCAL_SCREENSHARE_STATUS_CHANGED = "local-screenshare:status-changed"; @@ -54,6 +55,7 @@ export type RoomConnectionEvents = { [CHAT_NEW_MESSAGE]: [message: ChatMessage]; [CLOUD_RECORDING_STATUS_CHANGED]: [status: CloudRecordingState | undefined]; [CONNECTION_STATUS_CHANGED]: [status: ConnectionStatus]; + [CONNECTION_ERROR_CHANGED]: [error: string | null]; [LOCAL_PARTICIPANT_CHANGED]: [participant?: LocalParticipantState]; [LOCAL_SCREENSHARE_STATUS_CHANGED]: [status?: LocalScreenshareStatus]; [REMOTE_PARTICIPANTS_CHANGED]: [participants: RemoteParticipantState[]]; diff --git a/packages/core/src/client/RoomConnection/index.ts b/packages/core/src/client/RoomConnection/index.ts index 80ad2df7f..476db2363 100644 --- a/packages/core/src/client/RoomConnection/index.ts +++ b/packages/core/src/client/RoomConnection/index.ts @@ -50,6 +50,7 @@ import { BREAKOUT_CONFIG_CHANGED, CHAT_NEW_MESSAGE, CLOUD_RECORDING_STATUS_CHANGED, + CONNECTION_ERROR_CHANGED, CONNECTION_STATUS_CHANGED, LOCAL_PARTICIPANT_CHANGED, LOCAL_SCREENSHARE_STATUS_CHANGED, @@ -79,6 +80,7 @@ export class RoomConnectionClient extends BaseClient void>(); private breakoutSubscribers = new Set<(config: BreakoutState) => void>(); private connectionStatusSubscribers = new Set<(status: ConnectionStatus) => void>(); + private connectionErrorSubscribers = new Set<(error: string | null) => void>(); private liveStreamSubscribers = new Set<(status: { status: "streaming" } | undefined) => void>(); private localScreenshareStatusSubscribers = new Set<(status?: LocalScreenshareStatus) => void>(); private localParticipantSubscribers = new Set<(participant?: LocalParticipantState) => void>(); @@ -115,6 +117,11 @@ export class RoomConnectionClient extends BaseClient cb(state.connectionError)); + this.emit(CONNECTION_ERROR_CHANGED, state.connectionError); + } + if (state.liveStream !== previousState.liveStream) { this.liveStreamSubscribers.forEach((cb) => cb(state.liveStream)); if (state.liveStream?.status === "streaming") { @@ -237,6 +244,11 @@ export class RoomConnectionClient extends BaseClient this.connectionStatusSubscribers.delete(callback); } + public subscribeToConnectionError(callback: (error: string | null) => void): () => void { + this.connectionErrorSubscribers.add(callback); + return () => this.connectionErrorSubscribers.delete(callback); + } + public subscribeToLiveStream(callback: (status: { status: "streaming" } | undefined) => void): () => void { this.liveStreamSubscribers.add(callback); return () => this.liveStreamSubscribers.delete(callback); @@ -627,6 +639,7 @@ export class RoomConnectionClient extends BaseClient(eventName: K, args: RtcEvents[K]) => void }; }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - emitter.emit("rtc_manager_created", { rtcManager: mockRtcManager as any }); + emitter.emit("rtc_manager_created", { rtcManager: mockRtcManager }); return { stopRtcManager: jest.fn(), @@ -99,8 +102,7 @@ export function createStore({ initialState, withSignalConnection, withRtcManager // eslint-disable-next-line @typescript-eslint/no-explicit-any rtcManagerDispatcher: createRtcDispatcher({ emitter: mockRtcEmitter }) as any, rtcManagerInitialized: true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rtcManager: mockRtcManager as any, + rtcManager: mockRtcManager, isAcceptingStreams: false, ...initialState.rtcConnection, }; diff --git a/packages/core/src/redux/tests/store/rtcConnection.spec.ts b/packages/core/src/redux/tests/store/rtcConnection.spec.ts index 1579b7869..1075bd5d5 100644 --- a/packages/core/src/redux/tests/store/rtcConnection.spec.ts +++ b/packages/core/src/redux/tests/store/rtcConnection.spec.ts @@ -45,7 +45,7 @@ describe("actions", () => { ]), ); - expect(JSON.stringify(mockRtcManager.acceptNewStream.mock.calls)).toStrictEqual( + expect(JSON.stringify((mockRtcManager.acceptNewStream as jest.Mock).mock.calls)).toStrictEqual( JSON.stringify([ [{ streamId: id1, clientId: participant1.id, shouldAddLocalVideo: false, activeBreakout: false }], [{ streamId: id3, clientId: participant2.id, shouldAddLocalVideo: false, activeBreakout: false }],