Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/shiny-shirts-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@whereby.com/browser-sdk": minor
"@whereby.com/media": minor
"@whereby.com/core": minor
---

Add waiting room message support for on-hold and reject knocks. Hosts can now
put a waiting participant on hold or reject them with an optional message via
`holdWaitingParticipant(participantId, message?)` and
`rejectWaitingParticipant(participantId, message?)`. Knockers receive the
message and, when put on hold, a new `knock_on_hold` connection status, both
exposed through the `knockResponse` field on the room connection state.
1 change: 1 addition & 0 deletions packages/browser-sdk/src/lib/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type {
CloudRecordingState as CloudRecording,
LiveStreamState as LiveStreaming,
BreakoutState as Breakout,
KnockResponse,
LocalParticipantState as LocalParticipant,
RemoteParticipantState as RemoteParticipant,
RoomConnectionState as RoomConnection,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,12 @@ export function useRoomConnection(
(participantId: string) => client.acceptWaitingParticipant(participantId),
[client],
);
const holdWaitingParticipant = React.useCallback(
(participantId: string, response?: string) => client.holdWaitingParticipant(participantId, response),
[client],
);
const rejectWaitingParticipant = React.useCallback(
(participantId: string) => client.rejectWaitingParticipant(participantId),
(participantId: string, response?: string) => client.rejectWaitingParticipant(participantId, response),
[client],
);
const startCloudRecording = React.useCallback(() => client.startCloudRecording(), [client]);
Expand Down Expand Up @@ -167,6 +171,7 @@ export function useRoomConnection(
askToSpeak,
askToTurnOnCamera,
acceptWaitingParticipant,
holdWaitingParticipant,
knock,
cancelKnock,
joinRoom,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const initialState: RoomConnectionState = {
screenshares: [],
connectionStatus: "ready",
connectionError: null,
knockResponse: null,
waitingParticipants: [],
spotlightedParticipants: [],
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface RoomConnectionActions {
askToSpeak: (participantId: string) => void;
askToTurnOnCamera: (participantId: string) => void;
acceptWaitingParticipant: (participantId: string) => void;
holdWaitingParticipant: (participantId: string, response?: string) => void;
knock: () => void;
cancelKnock: () => void;
joinRoom: () => Promise<RoomJoinedSuccess>;
Expand All @@ -27,7 +28,7 @@ export interface RoomConnectionActions {
turnOffParticipantCameras: (clientIds: string[]) => void;
kickParticipant: (clientId: string) => void;
endMeeting: (stayBehind?: boolean) => void;
rejectWaitingParticipant: (participantId: string) => void;
rejectWaitingParticipant: (participantId: string, response?: string) => void;
sendChatMessage: (text: string, parentId?: string) => void;
removeChatMessage: (id: string, sig?: string | null) => void;
sendFiles: (files: File[]) => void;
Expand Down
14 changes: 13 additions & 1 deletion packages/browser-sdk/src/stories/components/VideoExperience.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default function VideoExperience({
const [effectPresets, setEffectPresets] = useState<Array<string>>([]);
const [audioDenoiserSupported, setAudioDenoiserSupported] = useState<boolean | null>(null);
const [audioDenoiserOn, setAudioDenoiserOn] = useState(false);
const [knockMessages, setKnockMessages] = useState<Record<string, string>>({});

const { state, actions, events } = useRoomConnection(roomName, {
localMediaOptions: {
Expand Down Expand Up @@ -97,6 +98,7 @@ export default function VideoExperience({
toggleRaiseHand,
askToSpeak,
acceptWaitingParticipant,
holdWaitingParticipant,
rejectWaitingParticipant,
startCloudRecording,
startLiveCaptions,
Expand Down Expand Up @@ -374,11 +376,21 @@ export default function VideoExperience({
<div className="waiting_room">
<h2>Waiting room</h2>
{waitingParticipants.map((p) => {
const message = knockMessages[p.id] || "";
return (
<div key={p.id}>
Waiting: {p.displayName || "unknown"} {p.id}
<input
type="text"
placeholder="Message (optional)"
value={message}
onChange={(e) =>
setKnockMessages((prev) => ({ ...prev, [p.id]: e.target.value }))
}
/>
<button onClick={() => acceptWaitingParticipant(p.id)}>Accept</button>
<button onClick={() => rejectWaitingParticipant(p.id)}>Reject</button>
<button onClick={() => holdWaitingParticipant(p.id, message)}>Hold</button>
<button onClick={() => rejectWaitingParticipant(p.id, message)}>Reject</button>
</div>
);
})}
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/client/RoomConnection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
doCancelKnock,
doBreakoutJoin,
doEndMeeting,
doHoldWaitingParticipant,
doKickParticipant,
doKnockRoom,
doLockRoom,
Expand Down Expand Up @@ -576,11 +577,22 @@ export class RoomConnectionClient extends BaseClient<RoomConnectionState, RoomCo
}

/**
* Reject a waiting participant.
* Put a waiting participant on hold, optionally sending them a message.
* The participant remains in the waiting room.
* @param participantId - The ID of the participant to put on hold.
* @param response - An optional message to show the waiting participant.
*/
public holdWaitingParticipant(participantId: string, response?: string) {
this.store.dispatch(doHoldWaitingParticipant({ participantId, response }));
}

/**
* Reject a waiting participant, optionally sending them a message.
* @param participantId - The ID of the participant to reject.
* @param response - An optional message to show the rejected participant.
*/
public rejectWaitingParticipant(participantId: string) {
this.store.dispatch(doRejectWaitingParticipant({ participantId }));
public rejectWaitingParticipant(participantId: string, response?: string) {
this.store.dispatch(doRejectWaitingParticipant({ participantId, response }));
}

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/client/RoomConnection/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
selectRemoteParticipants,
selectScreenshares,
selectRoomConnectionStatus,
selectRoomConnectionKnockResponse,
selectWaitingParticipants,
selectLiveTranscriptionRaw,
selectLocalMediaStream,
Expand Down Expand Up @@ -41,6 +42,7 @@ export const selectRoomConnectionState = createSelector(
selectScreenshares,
selectRoomConnectionStatus,
selectRoomConnectionError,
selectRoomConnectionKnockResponse,
selectStreamingRaw,
selectWaitingParticipants,
selectNotificationsEmitter,
Expand All @@ -63,6 +65,7 @@ export const selectRoomConnectionState = createSelector(
screenshares,
connectionStatus,
connectionError,
knockResponse,
streaming,
waitingParticipants,
notificationsEmitter,
Expand All @@ -88,6 +91,7 @@ export const selectRoomConnectionState = createSelector(
},
connectionStatus,
connectionError,
knockResponse,
events: notificationsEmitter,
isCameraEnabled,
isMicrophoneEnabled,
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/client/RoomConnection/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { RoleName, ChatFileShare, ChatMessage as SignalChatMessage } from "@whereby.com/media";
import { RoleName, ChatFileShare, ChatMessage as SignalChatMessage, KnockResponse } from "@whereby.com/media";
import { LocalParticipant, RemoteParticipant, Screenshare } from "../../RoomParticipant";
import { ClientView, ConnectionStatus, FileUpload, NotificationsEventEmitter } from "../../redux";
import LiveCaption from "../../api/models/LiveCaption";

export type { RoomJoinedSuccess, ChatFileShare } from "@whereby.com/media";
export type { RoomJoinedSuccess, ChatFileShare, KnockResponse, KnockResponseSender } from "@whereby.com/media";
export type { FileUpload, FileShareError } from "../../redux";

export type LocalMediaOptions = {
Expand Down Expand Up @@ -88,6 +88,7 @@ export type BreakoutState = {
export interface RoomConnectionState {
connectionStatus: ConnectionStatus;
connectionError: string | null;
knockResponse: KnockResponse | null;
chatMessages: ChatMessage[];
fileUploads: FileUpload[];
cloudRecording?: CloudRecordingState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe("roomConnectionSlice", () => {
status: "room_locked",
session: null,
error: null,
knockResponse: null,
});
});

Expand All @@ -37,6 +38,7 @@ describe("roomConnectionSlice", () => {
status: "disconnected",
session: null,
error: "room_full",
knockResponse: null,
});
});
});
Expand Down Expand Up @@ -73,6 +75,7 @@ describe("roomConnectionSlice", () => {
status: "connected",
session: null,
error: null,
knockResponse: null,
});
});
});
Expand All @@ -91,6 +94,7 @@ describe("roomConnectionSlice", () => {
status: "kicked",
session: null,
error: null,
knockResponse: null,
});
});
});
Expand All @@ -103,6 +107,7 @@ describe("roomConnectionSlice", () => {
status: "disconnected",
session: null,
error: null,
knockResponse: null,
});
});
});
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/redux/slices/roomConnection/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PayloadAction, createSelector, createSlice } from "@reduxjs/toolkit";
import { KnockResponse } from "@whereby.com/media";

import { createReactor, startAppListening } from "../../listenerMiddleware";
import { selectRoomConnectionError, selectRoomConnectionStatus } from "./selectors";
Expand Down Expand Up @@ -32,6 +33,7 @@ export type ConnectionStatus =
| "connected"
| "room_locked"
| "knocking"
| "knock_on_hold"
| "knock_rejected"
| "kicked"
| "leaving"
Expand All @@ -47,12 +49,14 @@ export interface RoomConnectionState {
session: { createdAt: string; id: string } | null;
status: ConnectionStatus;
error: string | null;
knockResponse: KnockResponse | null;
}

export const roomConnectionSliceInitialState: RoomConnectionState = {
session: null,
status: "ready",
error: null,
knockResponse: null,
};

export const roomConnectionSlice = createSlice({
Expand All @@ -65,6 +69,12 @@ export const roomConnectionSlice = createSlice({
status: action.payload,
};
},
knockResponseReceived: (state, action: PayloadAction<KnockResponse | null>) => {
return {
...state,
knockResponse: action.payload,
};
},
},
extraReducers: (builder) => {
builder.addCase(signalEvents.roomJoined, (state, action) => {
Expand Down Expand Up @@ -142,7 +152,7 @@ export const roomConnectionSlice = createSlice({
* Action creators
*/

const { connectionStatusChanged } = roomConnectionSlice.actions;
const { connectionStatusChanged, knockResponseReceived } = roomConnectionSlice.actions;

export const doKnockRoom = createAppThunk(() => (dispatch, getState) => {
const state = getState();
Expand Down Expand Up @@ -298,7 +308,11 @@ startAppListening({
if (resolution === "accepted") {
dispatch(setRoomKey(payload.metadata.roomKey));
dispatch(doConnectRoom());
} else if (resolution === "on_hold") {
dispatch(knockResponseReceived(payload.knockResponse ?? null));
dispatch(connectionStatusChanged("knock_on_hold"));
} else if (resolution === "rejected") {
dispatch(knockResponseReceived(payload.knockResponse ?? null));
dispatch(connectionStatusChanged("knock_rejected"));
}
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/redux/slices/roomConnection/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export const selectRoomConnectionSession = (state: RootState) => state.roomConne
export const selectRoomConnectionSessionId = (state: RootState) => state.roomConnection.session?.id;
export const selectRoomConnectionStatus = (state: RootState) => state.roomConnection.status;
export const selectRoomConnectionError = (state: RootState) => state.roomConnection.error;
export const selectRoomConnectionKnockResponse = (state: RootState) => state.roomConnection.knockResponse;
3 changes: 2 additions & 1 deletion packages/core/src/redux/slices/signalConnection/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CloudRecordingStartedEvent,
KnockerLeftEvent,
KnockAcceptedEvent,
KnockOnHoldEvent,
KnockRejectedEvent,
LiveCaptionsStartedEvent,
LiveCaptionsStoppedEvent,
Expand Down Expand Up @@ -56,7 +57,7 @@ export const signalEvents = {
cloudRecordingStopped: createSignalEventAction<void>("cloudRecordingStopped"),
disconnect: createSignalEventAction<void>("disconnect"),
knockerLeft: createSignalEventAction<KnockerLeftEvent>("knockerLeft"),
knockHandled: createSignalEventAction<KnockAcceptedEvent | KnockRejectedEvent>("knockHandled"),
knockHandled: createSignalEventAction<KnockAcceptedEvent | KnockOnHoldEvent | KnockRejectedEvent>("knockHandled"),
newClient: createSignalEventAction<NewClientEvent>("newClient"),
roomJoined: createSignalEventAction<RoomJoinedEvent>("roomJoined"),
roomKnocked: createSignalEventAction<RoomKnockedEvent>("roomKnocked"),
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/redux/slices/signalConnection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ClientUnableToJoinEvent,
CloudRecordingStartedEvent,
KnockAcceptedEvent,
KnockOnHoldEvent,
KnockRejectedEvent,
KnockerLeftEvent,
LiveCaptionsStartedEvent,
Expand Down Expand Up @@ -78,7 +79,7 @@ function forwardSocketEvents(socket: ServerSocket, dispatch: ThunkDispatch<RootS
dispatch(signalEvents.roomSessionEnded(payload)),
);
socket.on("knocker_left", (payload: KnockerLeftEvent) => dispatch(signalEvents.knockerLeft(payload)));
socket.on("knock_handled", (payload: KnockAcceptedEvent | KnockRejectedEvent) =>
socket.on("knock_handled", (payload: KnockAcceptedEvent | KnockOnHoldEvent | KnockRejectedEvent) =>
dispatch(signalEvents.knockHandled(payload)),
);
socket.on("screenshare_started", (payload: ScreenshareStartedEvent) =>
Expand Down
32 changes: 28 additions & 4 deletions packages/core/src/redux/slices/waitingParticipants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createSlice } from "@reduxjs/toolkit";
import { KnockResponse } from "@whereby.com/media";
import { RootState } from "../store";
import { WaitingParticipant } from "../../RoomParticipant";
import { createRoomConnectedThunk } from "../thunk";
import { signalEvents } from "./signalConnection/actions";
import { selectSignalConnectionSocket } from "./signalConnection";
import { selectLocalParticipantDisplayName } from "./localParticipant/selectors";

/**
* Reducer
Expand Down Expand Up @@ -71,21 +73,43 @@ export const doAcceptWaitingParticipant = createRoomConnectedThunk(
socket?.emit("handle_knock", {
action: "accept",
clientId: participantId,
response: {},
knockResponse: {},
});
},
);

export const doHoldWaitingParticipant = createRoomConnectedThunk(
(payload: { participantId: string; response?: string }) => (dispatch, getState) => {
const { participantId, response } = payload;
const state = getState();
const socket = selectSignalConnectionSocket(state);

const message = response?.trim();
const knockResponse: KnockResponse = message
? { message, sender: { displayName: selectLocalParticipantDisplayName(state) } }
: {};

socket?.emit("handle_knock", {
action: "hold",
clientId: participantId,
knockResponse,
});
},
);

export const doRejectWaitingParticipant = createRoomConnectedThunk(
(payload: { participantId: string }) => (dispatch, getState) => {
const { participantId } = payload;
(payload: { participantId: string; response?: string }) => (dispatch, getState) => {
const { participantId, response } = payload;
const state = getState();
const socket = selectSignalConnectionSocket(state);

const message = response?.trim();
const knockResponse: KnockResponse = message ? { message, sender: {} } : {};

socket?.emit("handle_knock", {
action: "reject",
clientId: participantId,
response: {},
knockResponse,
});
},
);
Expand Down
Loading
Loading