diff --git a/.changeset/forty-bikes-knock.md b/.changeset/forty-bikes-knock.md new file mode 100644 index 000000000..f9963976b --- /dev/null +++ b/.changeset/forty-bikes-knock.md @@ -0,0 +1,6 @@ +--- +"@whereby.com/browser-sdk": minor +"@whereby.com/core": minor +--- + +Add advanced breakout controls diff --git a/packages/browser-sdk/src/lib/react/useRoomConnection/index.ts b/packages/browser-sdk/src/lib/react/useRoomConnection/index.ts index e4fc09257..17912bc15 100644 --- a/packages/browser-sdk/src/lib/react/useRoomConnection/index.ts +++ b/packages/browser-sdk/src/lib/react/useRoomConnection/index.ts @@ -76,7 +76,8 @@ export function useRoomConnection( return client.joinRoom(); }, [client]); const sendChatMessage = React.useCallback( - (text: string, parentId?: string) => client.sendChatMessage(text, parentId), + (text: string, parentId?: string, isBroadcast?: boolean) => + client.sendChatMessage(text, parentId, isBroadcast), [client], ); const removeChatMessage = React.useCallback( @@ -154,6 +155,25 @@ export function useRoomConnection( (assignments: { [clientId: string]: string }) => client.assignBreakoutParticipants(assignments), [client], ); + const assignAllBreakoutParticipants = React.useCallback( + () => client.assignAllBreakoutParticipants(), + [client], + ); + const unassignAllBreakoutParticipants = React.useCallback( + () => client.unassignAllBreakoutParticipants(), + [client], + ); + const shuffleBreakoutParticipants = React.useCallback(() => client.shuffleBreakoutParticipants(), [client]); + const extendBreakoutTimer = React.useCallback((seconds?: number) => client.extendBreakoutTimer(seconds), [client]); + const stopBreakoutTimer = React.useCallback(() => client.stopBreakoutTimer(), [client]); + const broadcastToGroups = React.useCallback( + (participantId: string) => client.broadcastToGroups(participantId), + [client], + ); + const stopBroadcastToGroups = React.useCallback( + (participantId: string) => client.stopBroadcastToGroups(participantId), + [client], + ); const switchCameraEffect = React.useCallback( async (effectId: string) => { await client.switchCameraEffect(effectId); @@ -220,6 +240,13 @@ export function useRoomConnection( updateBreakoutSession, stopBreakoutSession, assignBreakoutParticipants, + assignAllBreakoutParticipants, + unassignAllBreakoutParticipants, + shuffleBreakoutParticipants, + extendBreakoutTimer, + stopBreakoutTimer, + broadcastToGroups, + stopBroadcastToGroups, switchCameraEffect, switchCameraEffectCustom, clearCameraEffect, diff --git a/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts b/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts index 1da6ad8a8..851971ab7 100644 --- a/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts +++ b/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts @@ -9,8 +9,19 @@ export const initialState: RoomConnectionState = { currentGroup: null, groups: null, enforceAssignment: false, + autoMoveToGroup: false, + moveToGroupGracePeriod: null, + autoMoveToMain: false, + moveToMainGracePeriod: null, + breakoutTimerSetting: false, + breakoutTimerDuration: 0, + startedAt: null, + endTime: null, + moveToGroupAt: null, + moveToMainAt: null, groupedParticipants: [], participantsInCurrentGroup: [], + broadcastingParticipants: [], }, isCameraEnabled: false, isMicrophoneEnabled: false, diff --git a/packages/browser-sdk/src/lib/react/useRoomConnection/types.ts b/packages/browser-sdk/src/lib/react/useRoomConnection/types.ts index 9b88fde5c..30fd19a99 100644 --- a/packages/browser-sdk/src/lib/react/useRoomConnection/types.ts +++ b/packages/browser-sdk/src/lib/react/useRoomConnection/types.ts @@ -34,7 +34,7 @@ export interface RoomConnectionActions { kickParticipant: (clientId: string) => void; endMeeting: (stayBehind?: boolean) => void; rejectWaitingParticipant: (participantId: string) => void; - sendChatMessage: (text: string, parentId?: string) => void; + sendChatMessage: (text: string, parentId?: string, isBroadcast?: boolean) => void; removeChatMessage: (id: string, sig?: string | null) => void; sendFiles: (files: File[]) => void; downloadFile: (file: ChatFileShare) => Promise; @@ -59,6 +59,13 @@ export interface RoomConnectionActions { updateBreakoutSession: (options: UpdateBreakoutSessionOptions) => void; stopBreakoutSession: () => void; assignBreakoutParticipants: (assignments: { [clientId: string]: string }) => void; + assignAllBreakoutParticipants: () => void; + unassignAllBreakoutParticipants: () => void; + shuffleBreakoutParticipants: () => void; + extendBreakoutTimer: (seconds?: number) => void; + stopBreakoutTimer: () => void; + broadcastToGroups: (participantId: string) => void; + stopBroadcastToGroups: (participantId: string) => void; switchCameraEffect: (effectId: string) => Promise; switchCameraEffectCustom: (imageUrl: string) => Promise; clearCameraEffect: () => Promise; diff --git a/packages/browser-sdk/src/stories/components/VideoExperience.tsx b/packages/browser-sdk/src/stories/components/VideoExperience.tsx index 881d6d549..79abdd281 100644 --- a/packages/browser-sdk/src/stories/components/VideoExperience.tsx +++ b/packages/browser-sdk/src/stories/components/VideoExperience.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import toast, { Toaster } from "react-hot-toast"; import DisplayNameForm from "./DisplayNameForm"; +import BreakoutPanel from "./breakout/BreakoutPanel"; import { UseLocalMediaResult } from "../../lib/react/useLocalMedia/types"; import { useRoomConnection } from "../../lib/react/useRoomConnection"; import { VideoView } from "../../lib/react/VideoView"; @@ -42,13 +43,9 @@ export default function VideoExperience({ showAudioDenoiser?: boolean; showFileSharing?: boolean; }) { - const [breakoutGroups, setBreakoutGroups] = useState<{ [groupId: string]: string }>({ - a: "Group A", - b: "Group B", - }); - const [chatMessage, setChatMessage] = useState(""); const [chatMessageParent, setChatMessageParent] = useState(""); + const [chatBroadcast, setChatBroadcast] = useState(false); const [isLocalScreenshareActive, setIsLocalScreenshareActive] = useState(false); const [effectPresets, setEffectPresets] = useState>([]); const [audioDenoiserSupported, setAudioDenoiserSupported] = useState(null); @@ -111,12 +108,6 @@ export default function VideoExperience({ removeSpotlight, turnOffParticipantCameras, askToTurnOnCamera, - joinBreakoutGroup, - joinBreakoutMainRoom, - startBreakoutSession, - updateBreakoutSession, - stopBreakoutSession, - assignBreakoutParticipants, switchCameraEffect, switchCameraEffectCustom, clearCameraEffect, @@ -124,37 +115,6 @@ export default function VideoExperience({ disableAudioDenoiser, } = actions; - useEffect(() => { - if (breakout.groups && JSON.stringify(breakout.groups) !== JSON.stringify(breakoutGroups)) { - setBreakoutGroups(breakout.groups); - } - }, [breakout.groups]); - - function applyGroups(newGroups: { [groupId: string]: string }) { - setBreakoutGroups(newGroups); - if (breakout.isActive) { - updateBreakoutSession({ groups: newGroups }); - } - } - - function addBreakoutGroup() { - const alphabet = "abcdefghijklmnopqrstuvwxyz"; - const usedIds = Object.keys(breakoutGroups); - const nextId = alphabet.split("").find((c) => !usedIds.includes(c)); - if (!nextId) return; - applyGroups({ ...breakoutGroups, [nextId]: `Group ${nextId.toUpperCase()}` }); - } - - function removeBreakoutGroup(id: string) { - const rest = { ...breakoutGroups }; - delete rest[id]; - applyGroups(rest); - } - - function renameBreakoutGroup(id: string, name: string) { - applyGroups({ ...breakoutGroups, [id]: name }); - } - async function handleDownloadFile(file: ChatFileShare) { try { const blob = await downloadFile(file); @@ -349,6 +309,18 @@ export default function VideoExperience({ case "requestVideoDisable": showRequestVideoDisableNotification(event); break; + case "breakoutTimerEnding": + toast(event.message, { id: "breakoutTimerEnding", icon: "⏳" }); + break; + case "breakoutTimerExtended": + toast(event.message, { id: "breakoutTimerExtended", icon: "⏱️" }); + break; + case "breakoutTimerEnded": + toast(event.message, { id: "breakoutTimerEnded", icon: "⏰" }); + break; + case "breakoutGroupAssigned": + toast(event.message, { id: "breakoutGroupAssigned", icon: "👥" }); + break; } }; @@ -464,140 +436,15 @@ export default function VideoExperience({ )} {showBreakoutGroups ? ( -
-

Breakout is {breakout.isActive ? "active" : "inactive"}

- {showHostControls ? ( -
-
-

Groups

- {Object.entries(breakoutGroups).map(([id, name]) => ( -
- renameBreakoutGroup(id, e.target.value)} - /> - -
- ))} - -
- - - -
-

Assign participants

- - {localParticipant ? ( -
- {localParticipant.displayName || "Guest"} (you){" "} - -
- ) : null} - {remoteParticipants.map((p) => ( -
- {p.displayName || "Guest"}{" "} - -
- ))} -
- - {!breakout.isActive ? ( - - ) : ( - - )} -
- ) : null} - {breakout.isActive ?

Breakout groups

: null} - {breakout.isActive ?

Current group: {breakout.currentGroup?.name}

: null} - {breakout.groupedParticipants.map((group) => { - // main room - if (group.group?.id === "") { - return null; - } - return ( -
-

{group.group?.name}

- {group.clients.map((p) => ( -
{p.displayName || "Guest"}
- ))} - -
- ); - })} - {breakout.isActive ?

Main room

: null} - {breakout.groupedParticipants.map((p) => { - if (p.group?.id === "") { - return p.clients.map((p) =>
{p.displayName || "Guest"}
); - } - return null; - })} - {breakout.isActive ? ( - - ) : null} -
+ ) : null} {showCameraEffects ? ( @@ -858,12 +705,22 @@ export default function VideoExperience({
{ e.preventDefault(); - sendChatMessage(chatMessage, chatMessageParent); + sendChatMessage(chatMessage, chatMessageParent, chatBroadcast); setChatMessage(""); setChatMessageParent(""); }} > setChatMessage(e.target.value)} /> + {breakout.isActive ? ( + + ) : null} renameBreakoutGroup(id, e.target.value)} /> + + + ))} + + + + + +
+

Settings

+ {" "} + {breakout.autoMoveToGroup ? ( + + ) : null} + {" "} + {breakout.autoMoveToMain ? ( + + ) : null} + +
+ +
+

Assign participants

+ + + + {localParticipant ? ( +
+ {localParticipant.displayName || "Guest"} (you){" "} + +
+ ) : null} + {remoteParticipants.map((p) => ( +
+ {p.displayName || "Guest"}{" "} + +
+ ))} +
+ + + + ) : null} + + {breakout.isActive ? ( +
+ {breakout.breakoutTimerSetting ? ( +
+ {" "} + {breakout.endTime ? ( + Ends at {new Date(breakout.endTime).toLocaleTimeString()} + ) : null} + + +
+ ) : null} + +
+ ) : null} + + {breakout.isActive ? ( +
+

Broadcast to all groups

+ {breakout.broadcastingParticipants.length ? ( +
+ Currently broadcasting:{" "} + {breakout.broadcastingParticipants.map((c) => c.displayName || "Guest").join(", ")} +
+ ) : null} + {[localParticipant, ...remoteParticipants] + .filter((p) => p && !p.breakoutGroup) + .map((p) => { + if (!p) return null; + const isBroadcasting = spotlightedParticipants.some((s) => s.id === p.id); + return ( +
+ {p.displayName || "Guest"}{" "} + +
+ ); + })} +
+ ) : null} + + ); +} diff --git a/packages/browser-sdk/src/stories/components/breakout/BreakoutPanel.tsx b/packages/browser-sdk/src/stories/components/breakout/BreakoutPanel.tsx new file mode 100644 index 000000000..f7ab5b36d --- /dev/null +++ b/packages/browser-sdk/src/stories/components/breakout/BreakoutPanel.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import BreakoutHostControls from "./BreakoutHostControls"; +import BreakoutRoomsList from "./BreakoutRoomsList"; +import { useBreakoutGroups } from "./useBreakoutGroups"; +import { useBreakoutNotifications } from "./useBreakoutNotifications"; +import { BreakoutActions, BreakoutState, LocalParticipant, RemoteParticipant, SpotlightedParticipant } from "./types"; + +export default function BreakoutPanel({ + breakout, + connectionStatus, + localParticipant, + remoteParticipants, + spotlightedParticipants, + actions, + showHostControls, +}: { + breakout: BreakoutState; + connectionStatus: string; + localParticipant?: LocalParticipant; + remoteParticipants: RemoteParticipant[]; + spotlightedParticipants: SpotlightedParticipant[]; + actions: BreakoutActions; + showHostControls?: boolean; +}) { + const { breakoutGroups, addBreakoutGroup, removeBreakoutGroup, renameBreakoutGroup } = useBreakoutGroups({ + breakout, + isHost: localParticipant?.roleName === "host", + connectionStatus, + updateBreakoutSession: actions.updateBreakoutSession, + }); + + useBreakoutNotifications({ + breakout, + localParticipant, + joinBreakoutGroup: actions.joinBreakoutGroup, + joinBreakoutMainRoom: actions.joinBreakoutMainRoom, + }); + + return ( +
+

Breakout is {breakout.isActive ? "active" : "inactive"}

+ + {showHostControls ? ( + + ) : null} + + +
+ ); +} diff --git a/packages/browser-sdk/src/stories/components/breakout/BreakoutRoomsList.tsx b/packages/browser-sdk/src/stories/components/breakout/BreakoutRoomsList.tsx new file mode 100644 index 000000000..0ffe45643 --- /dev/null +++ b/packages/browser-sdk/src/stories/components/breakout/BreakoutRoomsList.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import BreakoutGroup from "./BreakoutGroup"; +import { BreakoutActions, BreakoutState } from "./types"; + +export default function BreakoutRoomsList({ + breakout, + joinBreakoutGroup, + joinBreakoutMainRoom, +}: { + breakout: BreakoutState; + joinBreakoutGroup: BreakoutActions["joinBreakoutGroup"]; + joinBreakoutMainRoom: BreakoutActions["joinBreakoutMainRoom"]; +}) { + if (!breakout.isActive) { + return null; + } + + const currentGroupId = breakout.currentGroup?.id ?? ""; + const mainRoom = breakout.groupedParticipants.find((g) => g.group?.id === ""); + const groups = breakout.groupedParticipants.filter((g) => g.group && g.group.id !== ""); + + return ( +
+
+ You are in: {breakout.currentGroup?.name || "Main room"} +
+ +

Breakout groups

+
+ {groups.map((group) => ( + + ))} +
+ +
+
+

Main room

+ {mainRoom?.clients.length ?? 0} +
+
    + {mainRoom?.clients.map((p) => ( +
  • + {p.displayName || "Guest"} +
  • + ))} +
+ +
+
+ ); +} diff --git a/packages/browser-sdk/src/stories/components/breakout/types.ts b/packages/browser-sdk/src/stories/components/breakout/types.ts new file mode 100644 index 000000000..20dc7b420 --- /dev/null +++ b/packages/browser-sdk/src/stories/components/breakout/types.ts @@ -0,0 +1,26 @@ +import { RoomConnectionState } from "@whereby.com/core"; +import { RoomConnectionActions } from "../../../lib/react/useRoomConnection/types"; + +export type BreakoutState = RoomConnectionState["breakout"]; +export type LocalParticipant = NonNullable; +export type RemoteParticipant = RoomConnectionState["remoteParticipants"][number]; +export type SpotlightedParticipant = RoomConnectionState["spotlightedParticipants"][number]; + +export type BreakoutActions = Pick< + RoomConnectionActions, + | "joinBreakoutGroup" + | "joinBreakoutMainRoom" + | "startBreakoutSession" + | "updateBreakoutSession" + | "stopBreakoutSession" + | "assignBreakoutParticipants" + | "assignAllBreakoutParticipants" + | "unassignAllBreakoutParticipants" + | "shuffleBreakoutParticipants" + | "extendBreakoutTimer" + | "stopBreakoutTimer" + | "broadcastToGroups" + | "stopBroadcastToGroups" +>; + +export type BreakoutGroups = { [groupId: string]: string }; diff --git a/packages/browser-sdk/src/stories/components/breakout/useBreakoutGroups.ts b/packages/browser-sdk/src/stories/components/breakout/useBreakoutGroups.ts new file mode 100644 index 000000000..4faa46c9b --- /dev/null +++ b/packages/browser-sdk/src/stories/components/breakout/useBreakoutGroups.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { BreakoutActions, BreakoutGroups, BreakoutState } from "./types"; + +const ALPHABET = "abcdefghijklmnopqrstuvwxyz"; + +export function useBreakoutGroups({ + breakout, + isHost, + connectionStatus, + updateBreakoutSession, +}: { + breakout: BreakoutState; + isHost: boolean; + connectionStatus: string; + updateBreakoutSession: BreakoutActions["updateBreakoutSession"]; +}) { + const [breakoutGroups, setBreakoutGroups] = useState({ + a: "Group A", + b: "Group B", + }); + + useEffect(() => { + if (connectionStatus !== "connected" || !isHost) return; + if (!breakout.groups) { + updateBreakoutSession({ groups: breakoutGroups }); + } else if (JSON.stringify(breakout.groups) !== JSON.stringify(breakoutGroups)) { + setBreakoutGroups(breakout.groups); + } + }, [connectionStatus, isHost, breakout.groups]); + + function applyGroups(newGroups: BreakoutGroups) { + setBreakoutGroups(newGroups); + updateBreakoutSession({ groups: newGroups }); + } + + function addBreakoutGroup() { + const usedIds = Object.keys(breakoutGroups); + const nextId = ALPHABET.split("").find((c) => !usedIds.includes(c)); + if (!nextId) return; + applyGroups({ ...breakoutGroups, [nextId]: `Group ${nextId.toUpperCase()}` }); + } + + function removeBreakoutGroup(id: string) { + const rest = { ...breakoutGroups }; + delete rest[id]; + applyGroups(rest); + } + + function renameBreakoutGroup(id: string, name: string) { + applyGroups({ ...breakoutGroups, [id]: name }); + } + + return { breakoutGroups, addBreakoutGroup, removeBreakoutGroup, renameBreakoutGroup }; +} diff --git a/packages/browser-sdk/src/stories/components/breakout/useBreakoutNotifications.tsx b/packages/browser-sdk/src/stories/components/breakout/useBreakoutNotifications.tsx new file mode 100644 index 000000000..9ccb6d868 --- /dev/null +++ b/packages/browser-sdk/src/stories/components/breakout/useBreakoutNotifications.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { BreakoutActions, BreakoutState, LocalParticipant } from "./types"; + +const MOVE_TO_GROUP_TOAST = "breakoutMoveToGroup"; +const MOVE_TO_MAIN_TOAST = "breakoutMoveToMain"; + +function secondsUntil(deadline: number | null): number | null { + if (deadline == null) return null; + return Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); +} + +export function useBreakoutNotifications({ + breakout, + localParticipant, + joinBreakoutGroup, + joinBreakoutMainRoom, +}: { + breakout: BreakoutState; + localParticipant?: LocalParticipant; + joinBreakoutGroup: BreakoutActions["joinBreakoutGroup"]; + joinBreakoutMainRoom: BreakoutActions["joinBreakoutMainRoom"]; +}) { + const assigned = localParticipant?.breakoutGroupAssigned || ""; + const currentGroup = localParticipant?.breakoutGroup || ""; + const groupName = (id: string) => breakout.groups?.[id] || "your group"; + + const moveToGroupDeadline = assigned && currentGroup !== assigned ? breakout.moveToGroupAt : null; + const moveToMainDeadline = currentGroup ? breakout.moveToMainAt : null; + + const [, setTick] = useState(0); + const hasCountdown = moveToGroupDeadline != null || moveToMainDeadline != null; + useEffect(() => { + if (!hasCountdown) return; + const interval = setInterval(() => setTick((t) => t + 1), 500); + return () => clearInterval(interval); + }, [hasCountdown]); + + const groupRemaining = secondsUntil(moveToGroupDeadline); + useEffect(() => { + if (groupRemaining == null || groupRemaining <= 0) { + toast.dismiss(MOVE_TO_GROUP_TOAST); + return; + } + toast( + () => ( + + You've been assigned to {groupName(assigned)}. Joining in {groupRemaining}s{" "} + + + ), + { id: MOVE_TO_GROUP_TOAST, icon: "⏳", duration: Infinity }, + ); + }, [groupRemaining, assigned]); + + const mainRemaining = secondsUntil(moveToMainDeadline); + useEffect(() => { + if (mainRemaining == null || mainRemaining <= 0) { + toast.dismiss(MOVE_TO_MAIN_TOAST); + return; + } + toast( + () => ( + + Session ended — leaving {groupName(currentGroup)} in {mainRemaining}s{" "} + + + ), + { id: MOVE_TO_MAIN_TOAST, icon: "⏳", duration: Infinity }, + ); + }, [mainRemaining]); + + useEffect(() => { + return () => { + toast.dismiss(MOVE_TO_GROUP_TOAST); + toast.dismiss(MOVE_TO_MAIN_TOAST); + }; + }, []); +} diff --git a/packages/browser-sdk/src/stories/custom-ui.stories.tsx b/packages/browser-sdk/src/stories/custom-ui.stories.tsx index 1ed51f6f2..45ba24345 100644 --- a/packages/browser-sdk/src/stories/custom-ui.stories.tsx +++ b/packages/browser-sdk/src/stories/custom-ui.stories.tsx @@ -320,18 +320,97 @@ RoomConnectionStrictMode.parameters = { }, }; -export const RoomConnectionWithBreakoutGroups = ({ - roomUrl, - displayName, -}: { - roomUrl: string; - displayName?: string; -}) => { - if (!roomUrl || !roomUrl.match(roomRegEx)) { - return

Set room url on the Controls panel

; - } +export const RoomConnectionWithBreakoutGroups = { + render: ({ + roomUrl, + roomKey, + displayName, + roomOptions, + }: { + roomUrl: string; + roomKey: string; + displayName?: string; + roomOptions: Array; + }) => { + if (!roomUrl || !roomUrl.match(roomRegEx)) { + return

Set room url on the Controls panel

; + } + + return ( + + ); + }, + argTypes: { + ...defaultArgs.argTypes, + roomKey: { control: "text", type: { required: true } }, + roomOptions: { + name: "Room options", + control: { + type: "check", + labels: { + joinRoomOnLoad: "Join room when useRoomConnection is created", + }, + }, + options: ["joinRoomOnLoad"], + }, + }, + args: { + ...defaultArgs.args, + roomKey: process.env.STORYBOOK_ROOM_HOST_ROOMKEY || "[Host roomKey required]", + roomOptions: [], + }, +}; - return ; +// Participant's view of a breakout session (no host controls). Open this in one or more extra +// tabs alongside the host story above (same room) to see the rooms overview / join flow and to +// test participant-facing notifications (breakout timer, chat/media broadcast, etc). +export const RoomConnectionWithBreakoutGroupsParticipant = { + render: ({ + roomUrl, + displayName, + roomOptions, + }: { + roomUrl: string; + displayName?: string; + roomOptions: Array; + }) => { + if (!roomUrl || !roomUrl.match(roomRegEx)) { + return

Set room url on the Controls panel

; + } + + return ( + + ); + }, + argTypes: { + ...defaultArgs.argTypes, + roomOptions: { + name: "Room options", + control: { + type: "check", + labels: { + joinRoomOnLoad: "Join room when useRoomConnection is created", + }, + }, + options: ["joinRoomOnLoad"], + }, + }, + args: { + ...defaultArgs.args, + roomOptions: [], + }, }; export const RoomConnectionWithCameraEffects = ({ diff --git a/packages/browser-sdk/src/stories/styles.css b/packages/browser-sdk/src/stories/styles.css index 71b09aa34..3b602a09d 100644 --- a/packages/browser-sdk/src/stories/styles.css +++ b/packages/browser-sdk/src/stories/styles.css @@ -157,3 +157,110 @@ margin: 0; padding-left: 18px; } + +/* Breakout */ +.breakout { + border: 1px solid #ddd; + border-radius: 8px; + padding: 12px; + margin: 12px 0; +} + +.breakoutHostControls { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + background-color: #f6f6f8; + border-radius: 6px; + margin-bottom: 16px; +} + +.breakoutGroupsEditor, +.breakoutSettings, +.breakoutTimerControls, +.breakoutBroadcast { + display: flex; + flex-direction: column; + gap: 6px; +} + +.breakoutSettings label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.breakoutCurrentGroup { + padding: 6px 10px; + margin-bottom: 12px; + background-color: #eef4ff; + border-radius: 4px; + display: inline-block; +} + +.breakoutGroups { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.breakoutGroup { + border: 1px solid #ddd; + border-radius: 8px; + padding: 10px; + background-color: #fff; + display: flex; + flex-direction: column; + gap: 8px; +} + +.breakoutGroup--current { + border-color: #3b82f6; + box-shadow: 0 0 0 1px #3b82f6; + background-color: #f5f9ff; +} + +.breakoutGroupHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.breakoutGroupHeader h2, +.breakoutGroupHeader h3 { + margin: 0; +} + +.breakoutGroupCount { + background-color: #eee; + border-radius: 10px; + padding: 1px 8px; + font-size: 12px; + color: #555; +} + +.breakoutGroupParticipants { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.breakoutGroupParticipant { + background-color: #eef1f5; + border-radius: 12px; + padding: 2px 10px; + font-size: 13px; +} + +.breakoutGroupParticipant--empty { + background: none; + color: #999; + font-style: italic; + padding-left: 0; +} diff --git a/packages/core/src/client/RoomConnection/index.ts b/packages/core/src/client/RoomConnection/index.ts index ef3ceb1b1..c82f1f2cf 100644 --- a/packages/core/src/client/RoomConnection/index.ts +++ b/packages/core/src/client/RoomConnection/index.ts @@ -12,6 +12,11 @@ import { doUpdateBreakoutSession, doStopBreakoutSession, doAssignBreakoutParticipants, + doAssignAllBreakoutParticipants, + doUnassignAllBreakoutParticipants, + doShuffleBreakoutParticipants, + doExtendBreakoutTimer, + doStopBreakoutTimer, doEndMeeting, doKickParticipant, doKnockRoom, @@ -427,9 +432,12 @@ export class RoomConnectionClient extends BaseClient { + // Main-room participants that are spotlighted broadcast their audio/video into every group. + // Only relevant for participants who are currently inside a group. + const broadcastingParticipants = + breakoutActive && breakoutCurrentId + ? spotlightedClientViews.filter((c) => !c.breakoutGroup && c.isAudioEnabled && !c.isPresentation) + : []; + const state: RoomConnectionState = { chatMessages, fileUploads, @@ -91,8 +131,19 @@ export const selectRoomConnectionState = createSelector( currentGroup: breakoutCurrentGroup, groups: breakoutGroups ?? null, enforceAssignment: breakoutEnforceAssignment ?? false, + autoMoveToGroup: breakoutAutoMoveToGroup ?? false, + moveToGroupGracePeriod: breakoutMoveToGroupGracePeriod ?? null, + autoMoveToMain: breakoutAutoMoveToMain ?? false, + moveToMainGracePeriod: breakoutMoveToMainGracePeriod ?? null, + breakoutTimerSetting: breakoutTimerSetting ?? false, + breakoutTimerDuration: breakoutTimerDuration ?? 0, + startedAt: breakoutStartedAt ?? null, + endTime: breakoutEndTime, + moveToGroupAt: breakoutMoveToGroupAt, + moveToMainAt: breakoutMoveToMainAt, groupedParticipants: breakoutGroupedParticipants, participantsInCurrentGroup: clientViewsInCurrentGroup, + broadcastingParticipants, }, connectionStatus, connectionError, diff --git a/packages/core/src/client/RoomConnection/types.ts b/packages/core/src/client/RoomConnection/types.ts index 0e7019765..660311ba8 100644 --- a/packages/core/src/client/RoomConnection/types.ts +++ b/packages/core/src/client/RoomConnection/types.ts @@ -72,6 +72,16 @@ export type BreakoutState = { } | null; groups: { [groupId: string]: string } | null; enforceAssignment: boolean; + autoMoveToGroup: boolean; + moveToGroupGracePeriod: number | null; + autoMoveToMain: boolean; + moveToMainGracePeriod: number | null; + breakoutTimerSetting: boolean; + breakoutTimerDuration: number; + startedAt: Date | null; + endTime: number | null; + moveToGroupAt: number | null; + moveToMainAt: number | null; groupedParticipants: { clients: ClientView[]; group: { @@ -80,6 +90,7 @@ export type BreakoutState = { } | null; }[]; participantsInCurrentGroup: ClientView[]; + broadcastingParticipants: ClientView[]; }; export interface RoomConnectionState { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3a9820d9a..b5ac78415 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,11 @@ export type { StartBreakoutSessionOptions, UpdateBreakoutSessionOptions, } from "./redux/slices/breakout"; +export { + BREAKOUT_GROUPS_MIN_MAX, + createBreakoutGroups, + defaultBreakoutGroupName, +} from "./redux/slices/breakout"; export { MAX_FILES_PER_UPLOAD, MAX_FILE_SIZE, ACCEPTED_FILE_TYPES } from "./redux/slices/fileShare"; export type { ClientView } from "./redux/types"; export type { ConnectionStatus } from "./redux/slices/roomConnection"; @@ -30,5 +35,6 @@ export type { SignalStatusEvent, SignalClientEvent, StickyReactionEvent, + BreakoutTimerEvent, NotificationsEventEmitter, } from "./redux/slices/notifications"; diff --git a/packages/core/src/redux/slices/__tests__/breakout.unit.ts b/packages/core/src/redux/slices/__tests__/breakout.unit.ts index 80db995f8..413c5899e 100644 --- a/packages/core/src/redux/slices/__tests__/breakout.unit.ts +++ b/packages/core/src/redux/slices/__tests__/breakout.unit.ts @@ -1,7 +1,16 @@ -import { breakoutSlice, breakoutSliceInitialState } from "../breakout"; +import { + breakoutSlice, + breakoutSliceInitialState, + selectBreakoutMoveToGroupAt, + selectBreakoutMoveToMainAt, +} from "../breakout"; import { signalEvents } from "../signalConnection"; +import { RootState } from "../../store"; import { randomLocalParticipant, randomSignalClient } from "../../../__mocks__/appMocks"; +const stateWithBreakout = (overrides: Partial) => + ({ breakout: { ...breakoutSliceInitialState, ...overrides } }) as unknown as RootState; + const breakoutConfig = { assignments: null, groups: { @@ -91,4 +100,51 @@ describe("breakoutSlice", () => { }); }); }); + + describe("selectors", () => { + const startedAt = "2020-01-01T00:00:00.000Z"; + const endedAt = "2020-01-01T01:00:00.000Z"; + + describe("selectBreakoutMoveToGroupAt", () => { + it("returns null when auto-move-to-group is off", () => { + const state = stateWithBreakout({ autoMoveToGroup: false, breakoutStartedAt: startedAt }); + expect(selectBreakoutMoveToGroupAt(state)).toBeNull(); + }); + + it("returns null when the session has not started", () => { + const state = stateWithBreakout({ autoMoveToGroup: true, breakoutStartedAt: null }); + expect(selectBreakoutMoveToGroupAt(state)).toBeNull(); + }); + + it("returns startedAt + grace period when enabled", () => { + const state = stateWithBreakout({ + autoMoveToGroup: true, + moveToGroupGracePeriod: 10, + breakoutStartedAt: startedAt, + }); + expect(selectBreakoutMoveToGroupAt(state)).toBe(new Date(startedAt).getTime() + 10_000); + }); + }); + + describe("selectBreakoutMoveToMainAt", () => { + it("returns null when auto-move-to-main is off", () => { + const state = stateWithBreakout({ autoMoveToMain: false, breakoutEndedAt: endedAt }); + expect(selectBreakoutMoveToMainAt(state)).toBeNull(); + }); + + it("returns null when the session has not ended", () => { + const state = stateWithBreakout({ autoMoveToMain: true, breakoutEndedAt: null }); + expect(selectBreakoutMoveToMainAt(state)).toBeNull(); + }); + + it("returns endedAt + grace period when enabled", () => { + const state = stateWithBreakout({ + autoMoveToMain: true, + moveToMainGracePeriod: 30, + breakoutEndedAt: endedAt, + }); + expect(selectBreakoutMoveToMainAt(state)).toBe(new Date(endedAt).getTime() + 30_000); + }); + }); + }); }); diff --git a/packages/core/src/redux/slices/breakout.ts b/packages/core/src/redux/slices/breakout.ts index 85c01e922..fc5055082 100644 --- a/packages/core/src/redux/slices/breakout.ts +++ b/packages/core/src/redux/slices/breakout.ts @@ -6,6 +6,8 @@ import { selectLocalParticipantBreakoutGroup, selectLocalParticipantRaw } from " import { selectIsAuthorizedToManageBreakout } from "./authorization"; import { selectRemoteParticipants } from "./remoteParticipants"; import { selectDeviceId } from "./deviceCredentials"; +import { doSetNotification, createNotificationEvent } from "./notifications"; +import { BreakoutTimerEventProps } from "./notifications/events"; import { startAppListening } from "../listenerMiddleware"; import { createAppThunk, createAppAuthorizedThunk } from "../thunk"; @@ -122,9 +124,23 @@ export interface UpdateBreakoutSessionOptions extends BreakoutSessionSettings { assignments?: { [clientId: string]: string }; } +function ensureGracePeriods(state: RootState, payload: BreakoutSessionUpdateRequest): BreakoutSessionUpdateRequest { + const { moveToGroupGracePeriod, moveToMainGracePeriod } = state.breakout; + const next = { ...payload }; + + if (next.autoMoveToGroup === true && next.moveToGroupGracePeriod == null) { + next.moveToGroupGracePeriod = moveToGroupGracePeriod ?? 10; + } + if (next.autoMoveToMain === true && next.moveToMainGracePeriod == null) { + next.moveToMainGracePeriod = moveToMainGracePeriod ?? 30; + } + + return next; +} + function emitBreakoutSessionUpdate(state: RootState, payload: BreakoutSessionUpdateRequest) { const { socket } = selectSignalConnectionRaw(state); - socket?.emit("update_breakout_session", payload); + socket?.emit("update_breakout_session", ensureGracePeriods(state, payload)); } function resolveClientAssignmentsToDeviceAssignments( @@ -193,6 +209,111 @@ export const doAssignBreakoutParticipants = createAppAuthorizedThunk( }, ); +/** + * Group helpers + */ +export const BREAKOUT_GROUPS_MIN_MAX: [number, number] = [2, 20]; +const GROUP_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz"; + +export function defaultBreakoutGroupName(groupId: string) { + return `Group ${groupId.toUpperCase()}`; +} + +export function createBreakoutGroups(count: number = BREAKOUT_GROUPS_MIN_MAX[0]) { + const [min, max] = BREAKOUT_GROUPS_MIN_MAX; + const amount = Math.min(Math.max(count, min), max); + const groups: { [groupId: string]: string } = {}; + for (let i = 0; i < amount; i++) { + const id = GROUP_ID_ALPHABET[i]; + groups[id] = defaultBreakoutGroupName(id); + } + return groups; +} + +function shuffle(items: T[]): T[] { + return [...items].sort(() => Math.random() - 0.5); +} + +function chunk(items: T[], chunkCount: number): T[][] { + let remaining = [...items]; + let count = chunkCount; + const chunks: T[][] = []; + while (remaining.length && count > 0) { + const size = Math.ceil(remaining.length / count); + chunks.push(remaining.slice(0, size)); + remaining = remaining.slice(size); + count -= 1; + } + return chunks; +} + +function randomizeAssignments(deviceIds: string[], groupIds: string[]) { + const assignments: { [deviceId: string]: string } = {}; + chunk(deviceIds, groupIds.length).forEach((chunkOfDevices, index) => { + chunkOfDevices.forEach((deviceId) => { + assignments[deviceId] = groupIds[index]; + }); + }); + return assignments; +} + +export const doAssignAllBreakoutParticipants = createAppAuthorizedThunk( + (state) => selectIsAuthorizedToManageBreakout(state), + () => (_, getState) => { + const state = getState(); + const groupIds = Object.keys(selectBreakoutGroups(state) || {}); + if (!groupIds.length) return; + const deviceIds = shuffle(selectRemoteParticipants(state).map((participant) => participant.deviceId)); + emitBreakoutSessionUpdate(state, { assignments: randomizeAssignments(deviceIds, groupIds) }); + }, +); + +export const doUnassignAllBreakoutParticipants = createAppAuthorizedThunk( + (state) => selectIsAuthorizedToManageBreakout(state), + () => (_, getState) => { + const state = getState(); + const assignments = selectBreakoutAssignments(state) || {}; + const cleared = Object.keys(assignments).reduce<{ [deviceId: string]: string }>((acc, deviceId) => { + acc[deviceId] = ""; + return acc; + }, {}); + emitBreakoutSessionUpdate(state, { assignments: cleared }); + }, +); + +export const doShuffleBreakoutParticipants = createAppAuthorizedThunk( + (state) => selectIsAuthorizedToManageBreakout(state), + () => (_, getState) => { + const state = getState(); + const groupIds = Object.keys(selectBreakoutGroups(state) || {}); + if (!groupIds.length) return; + const assignments = selectBreakoutAssignments(state) || {}; + const deviceIds = shuffle( + Object.entries(assignments) + .filter(([, groupId]) => !!groupId) + .map(([deviceId]) => deviceId), + ); + emitBreakoutSessionUpdate(state, { assignments: randomizeAssignments(deviceIds, groupIds) }); + }, +); + +export const doExtendBreakoutTimer = createAppAuthorizedThunk( + (state) => selectIsAuthorizedToManageBreakout(state), + (payload: { seconds?: number } | void) => (_, getState) => { + const state = getState(); + const additionalSeconds = payload?.seconds ?? 60; + const breakoutTimerDuration = (selectBreakoutTimerDuration(state) || 0) + additionalSeconds; + emitBreakoutSessionUpdate(state, { breakoutTimerDuration }); + }, +); + +export const doStopBreakoutTimer = createAppAuthorizedThunk( + (state) => selectIsAuthorizedToManageBreakout(state), + () => (_, getState) => { + emitBreakoutSessionUpdate(getState(), { breakoutTimerSetting: false }); + }, +); + /** * Selectors */ @@ -202,6 +323,30 @@ export const selectBreakoutActive = (state: RootState) => !!state.breakout.start export const selectBreakoutAssignments = (state: RootState) => state.breakout.assignments; export const selectBreakoutGroups = (state: RootState) => state.breakout.groups; export const selectBreakoutEnforceAssignment = (state: RootState) => state.breakout.enforceAssignment; +export const selectBreakoutTimerSetting = (state: RootState) => state.breakout.breakoutTimerSetting; +export const selectBreakoutTimerDuration = (state: RootState) => state.breakout.breakoutTimerDuration; +export const selectBreakoutAutoMoveToGroup = (state: RootState) => state.breakout.autoMoveToGroup; +export const selectBreakoutMoveToGroupGracePeriod = (state: RootState) => state.breakout.moveToGroupGracePeriod; +export const selectBreakoutAutoMoveToMain = (state: RootState) => state.breakout.autoMoveToMain; +export const selectBreakoutMoveToMainGracePeriod = (state: RootState) => state.breakout.moveToMainGracePeriod; +export const selectBreakoutStartedAt = (state: RootState) => state.breakout.startedAt; + +export const selectBreakoutEndTime = createSelector(selectBreakoutRaw, (raw) => { + if (!raw.breakoutStartedAt) return null; + const startTime = new Date(raw.breakoutStartedAt).getTime(); + const gracePeriod = raw.autoMoveToGroup ? raw.moveToGroupGracePeriod || 0 : 0; + return startTime + ((raw.breakoutTimerDuration || 0) + gracePeriod) * 1000; +}); + +export const selectBreakoutMoveToGroupAt = createSelector(selectBreakoutRaw, (raw) => { + if (!raw.autoMoveToGroup || !raw.breakoutStartedAt) return null; + return new Date(raw.breakoutStartedAt).getTime() + (raw.moveToGroupGracePeriod || 0) * 1000; +}); + +export const selectBreakoutMoveToMainAt = createSelector(selectBreakoutRaw, (raw) => { + if (!raw.autoMoveToMain || !raw.breakoutEndedAt) return null; + return new Date(raw.breakoutEndedAt).getTime() + (raw.moveToMainGracePeriod || 0) * 1000; +}); export const selectBreakoutCurrentId = createSelector( selectBreakoutRaw, @@ -256,3 +401,48 @@ startAppListening({ } }, }); + +startAppListening({ + actionCreator: signalEvents.breakoutEnding, + effect: (_, { dispatch }) => { + dispatch( + doSetNotification( + createNotificationEvent<"breakoutTimerEnding", BreakoutTimerEventProps>({ + type: "breakoutTimerEnding", + message: "The breakout session is ending soon", + props: {}, + }), + ), + ); + }, +}); + +startAppListening({ + actionCreator: signalEvents.breakoutTimerExtended, + effect: (_, { dispatch }) => { + dispatch( + doSetNotification( + createNotificationEvent<"breakoutTimerExtended", BreakoutTimerEventProps>({ + type: "breakoutTimerExtended", + message: "The breakout timer was extended", + props: {}, + }), + ), + ); + }, +}); + +startAppListening({ + actionCreator: signalEvents.breakoutTimerEnded, + effect: (_, { dispatch }) => { + dispatch( + doSetNotification( + createNotificationEvent<"breakoutTimerEnded", BreakoutTimerEventProps>({ + type: "breakoutTimerEnded", + message: "The breakout timer ended", + props: {}, + }), + ), + ); + }, +}); diff --git a/packages/core/src/redux/slices/localParticipant/index.ts b/packages/core/src/redux/slices/localParticipant/index.ts index 7c5605db5..beb5678e6 100644 --- a/packages/core/src/redux/slices/localParticipant/index.ts +++ b/packages/core/src/redux/slices/localParticipant/index.ts @@ -8,8 +8,10 @@ import { toggleCameraEnabled, toggleMicrophoneEnabled } from "../localMedia"; import { createReactor, startAppListening } from "../../listenerMiddleware"; import { signalEvents } from "../signalConnection/actions"; import { selectRoomConnectionStatus } from "../roomConnection/selectors"; -import { selectBreakoutAssignments } from "../breakout"; +import { selectBreakoutAssignments, selectBreakoutGroups } from "../breakout"; import { selectDeviceId } from "../deviceCredentials"; +import { doSetNotification, createNotificationEvent } from "../notifications"; +import { BreakoutGroupAssignedEventProps } from "../notifications/events"; import { selectLocalParticipantDisplayName, selectLocalParticipantRaw, @@ -250,8 +252,8 @@ createReactor( ); createReactor( - [selectBreakoutAssignments, selectDeviceId, selectLocalParticipantRaw], - ({ dispatch }, breakoutAssignments, deviceId, localParticipant) => { + [selectBreakoutAssignments, selectDeviceId, selectLocalParticipantRaw, selectBreakoutGroups], + ({ dispatch }, breakoutAssignments, deviceId, localParticipant, breakoutGroups) => { const breakoutGroupAssigned = breakoutAssignments?.[deviceId || ""] || ""; if (localParticipant.breakoutGroupAssigned === breakoutGroupAssigned) { @@ -259,5 +261,20 @@ createReactor( } dispatch(setBreakoutGroupAssigned({ breakoutGroupAssigned })); + + // Notify the local participant when they've been assigned to a group. Unassignment + // (empty group) is intentionally not surfaced as a notification. + if (breakoutGroupAssigned) { + const groupName = breakoutGroups?.[breakoutGroupAssigned] || ""; + dispatch( + doSetNotification( + createNotificationEvent<"breakoutGroupAssigned", BreakoutGroupAssignedEventProps>({ + type: "breakoutGroupAssigned", + message: groupName ? `You've been assigned to ${groupName}` : "You've been assigned to a group", + props: { group: breakoutGroupAssigned, groupName }, + }), + ), + ); + } }, ); diff --git a/packages/core/src/redux/slices/notifications/events.ts b/packages/core/src/redux/slices/notifications/events.ts index 07a8f1db3..e3f5eee4e 100644 --- a/packages/core/src/redux/slices/notifications/events.ts +++ b/packages/core/src/redux/slices/notifications/events.ts @@ -43,6 +43,19 @@ export interface StickyReactionEventProps { } export type StickyReactionEvent = NotificationEvent<"remoteHandRaised" | "remoteHandLowered", StickyReactionEventProps>; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface BreakoutTimerEventProps {} +export type BreakoutTimerEvent = NotificationEvent< + "breakoutTimerEnding" | "breakoutTimerEnded" | "breakoutTimerExtended", + BreakoutTimerEventProps +>; + +export interface BreakoutGroupAssignedEventProps { + group: string; + groupName: string; +} +export type BreakoutGroupAssignedEvent = NotificationEvent<"breakoutGroupAssigned", BreakoutGroupAssignedEventProps>; + type NotificationEventTypes = { ["requestAudioEnable"]: RequestAudioEvent; ["requestAudioDisable"]: RequestAudioEvent; @@ -54,6 +67,10 @@ type NotificationEventTypes = { ["clientUnableToJoinFullRoom"]: SignalClientEvent; ["requestVideoEnable"]: RequestVideoEvent; ["requestVideoDisable"]: RequestVideoEvent; + ["breakoutTimerEnding"]: BreakoutTimerEvent; + ["breakoutTimerEnded"]: BreakoutTimerEvent; + ["breakoutTimerExtended"]: BreakoutTimerEvent; + ["breakoutGroupAssigned"]: BreakoutGroupAssignedEvent; }; export type NotificationEvents = NotificationEventTypes[keyof NotificationEventTypes]; diff --git a/packages/core/src/redux/slices/notifications/index.ts b/packages/core/src/redux/slices/notifications/index.ts index ca1aebdb6..0da4f646c 100644 --- a/packages/core/src/redux/slices/notifications/index.ts +++ b/packages/core/src/redux/slices/notifications/index.ts @@ -27,7 +27,7 @@ export type NotificationsEventEmitter = EventEmitter; const emitter: NotificationsEventEmitter = new EventEmitter(); -function createNotificationEvent( +export function createNotificationEvent( payload: Notification, ): NotificationEvent { const notificationEvent = { diff --git a/packages/core/src/redux/slices/signalConnection/actions.ts b/packages/core/src/redux/slices/signalConnection/actions.ts index 232a01bd4..ad6446c97 100644 --- a/packages/core/src/redux/slices/signalConnection/actions.ts +++ b/packages/core/src/redux/slices/signalConnection/actions.ts @@ -42,6 +42,9 @@ export const signalEvents = { breakoutMoveToGroup: createSignalEventAction("breakoutMoveToGroup"), breakoutMoveToMain: createSignalEventAction("breakoutMoveToMain"), breakoutSessionUpdated: createSignalEventAction("breakoutSessionUpdated"), + breakoutEnding: createSignalEventAction("breakoutEnding"), + breakoutTimerEnded: createSignalEventAction("breakoutTimerEnded"), + breakoutTimerExtended: createSignalEventAction("breakoutTimerExtended"), chatMessage: createSignalEventAction("chatMessage"), chatMessageRemoved: createSignalEventAction("chatMessageRemoved"), fileSharingError: createSignalEventAction("fileSharingError"), diff --git a/packages/core/src/redux/slices/signalConnection/index.ts b/packages/core/src/redux/slices/signalConnection/index.ts index 3ebeb801f..4fc70e3b8 100644 --- a/packages/core/src/redux/slices/signalConnection/index.ts +++ b/packages/core/src/redux/slices/signalConnection/index.ts @@ -112,6 +112,9 @@ function forwardSocketEvents(socket: ServerSocket, dispatch: ThunkDispatch dispatch(signalEvents.breakoutMoveToGroup())); socket.on("breakout_move_to_main", () => dispatch(signalEvents.breakoutMoveToMain())); + socket.on("breakout_ending", () => dispatch(signalEvents.breakoutEnding())); + socket.on("breakout_timer_ended", () => dispatch(signalEvents.breakoutTimerEnded())); + socket.on("breakout_timer_extended", () => dispatch(signalEvents.breakoutTimerExtended())); } const SIGNAL_BASE_URL = process.env.REACT_APP_SIGNAL_BASE_URL; diff --git a/packages/core/src/redux/tests/store/breakout.spec.ts b/packages/core/src/redux/tests/store/breakout.spec.ts index 222c5f714..ed7607d7a 100644 --- a/packages/core/src/redux/tests/store/breakout.spec.ts +++ b/packages/core/src/redux/tests/store/breakout.spec.ts @@ -6,6 +6,13 @@ import { doUpdateBreakoutSession, doStopBreakoutSession, doAssignBreakoutParticipants, + doAssignAllBreakoutParticipants, + doUnassignAllBreakoutParticipants, + doShuffleBreakoutParticipants, + doExtendBreakoutTimer, + doStopBreakoutTimer, + createBreakoutGroups, + defaultBreakoutGroupName, breakoutSliceInitialState, } from "../../slices/breakout"; @@ -82,6 +89,59 @@ describe("actions", () => { expect(mockSignalEmit).not.toHaveBeenCalled(); }); + + it("should send a grace period when enabling autoMoveToGroup without one", () => { + const store = createStore({ + initialState: { authorization: { roomKey: null, roleName: "host" } }, + withSignalConnection: true, + }); + + store.dispatch(doUpdateBreakoutSession({ autoMoveToGroup: true })); + + expect(mockSignalEmit).toHaveBeenCalledWith("update_breakout_session", { + autoMoveToGroup: true, + moveToGroupGracePeriod: 10, + }); + }); + + it("should send a grace period when enabling autoMoveToMain without one", () => { + const store = createStore({ + initialState: { authorization: { roomKey: null, roleName: "host" } }, + withSignalConnection: true, + }); + + store.dispatch(doUpdateBreakoutSession({ autoMoveToMain: true })); + + expect(mockSignalEmit).toHaveBeenCalledWith("update_breakout_session", { + autoMoveToMain: true, + moveToMainGracePeriod: 30, + }); + }); + + it("should not override an explicitly provided grace period", () => { + const store = createStore({ + initialState: { authorization: { roomKey: null, roleName: "host" } }, + withSignalConnection: true, + }); + + store.dispatch(doUpdateBreakoutSession({ autoMoveToGroup: true, moveToGroupGracePeriod: 25 })); + + expect(mockSignalEmit).toHaveBeenCalledWith("update_breakout_session", { + autoMoveToGroup: true, + moveToGroupGracePeriod: 25, + }); + }); + + it("should not inject a grace period when disabling autoMoveToGroup", () => { + const store = createStore({ + initialState: { authorization: { roomKey: null, roleName: "host" } }, + withSignalConnection: true, + }); + + store.dispatch(doUpdateBreakoutSession({ autoMoveToGroup: false })); + + expect(mockSignalEmit).toHaveBeenCalledWith("update_breakout_session", { autoMoveToGroup: false }); + }); }); describe("doStopBreakoutSession", () => { @@ -146,4 +206,113 @@ describe("actions", () => { expect(mockSignalEmit).not.toHaveBeenCalled(); }); }); + + describe("doAssignAllBreakoutParticipants", () => { + it("should distribute all remote participants across the groups", () => { + const store = createStore({ + initialState: { + authorization: { roomKey: null, roleName: "host" }, + remoteParticipants: { + remoteParticipants: [ + randomRemoteParticipant({ id: "client-1", deviceId: "device-1" }), + randomRemoteParticipant({ id: "client-2", deviceId: "device-2" }), + ], + }, + breakout: { ...breakoutSliceInitialState, groups: { a: "Group A", b: "Group B" } }, + }, + withSignalConnection: true, + }); + + store.dispatch(doAssignAllBreakoutParticipants()); + + expect(mockSignalEmit).toHaveBeenCalledTimes(1); + const [, payload] = mockSignalEmit.mock.calls[0]; + const assignments = payload.assignments as { [deviceId: string]: string }; + expect(Object.keys(assignments).sort()).toEqual(["device-1", "device-2"]); + Object.values(assignments).forEach((groupId) => expect(["a", "b"]).toContain(groupId)); + }); + }); + + describe("doUnassignAllBreakoutParticipants", () => { + it("should clear all assignments", () => { + const store = createStore({ + initialState: { + authorization: { roomKey: null, roleName: "host" }, + breakout: { ...breakoutSliceInitialState, assignments: { "device-1": "a", "device-2": "b" } }, + }, + withSignalConnection: true, + }); + + store.dispatch(doUnassignAllBreakoutParticipants()); + + expect(mockSignalEmit).toHaveBeenCalledWith("update_breakout_session", { + assignments: { "device-1": "", "device-2": "" }, + }); + }); + }); + + describe("doShuffleBreakoutParticipants", () => { + it("should redistribute the currently-assigned participants", () => { + const store = createStore({ + initialState: { + authorization: { roomKey: null, roleName: "host" }, + breakout: { + ...breakoutSliceInitialState, + groups: { a: "Group A", b: "Group B" }, + assignments: { "device-1": "a", "device-2": "a", "device-3": "" }, + }, + }, + withSignalConnection: true, + }); + + store.dispatch(doShuffleBreakoutParticipants()); + + const [, payload] = mockSignalEmit.mock.calls[0]; + const assignments = payload.assignments as { [deviceId: string]: string }; + // Only previously-assigned (non-empty) devices are shuffled. + expect(Object.keys(assignments).sort()).toEqual(["device-1", "device-2"]); + Object.values(assignments).forEach((groupId) => expect(["a", "b"]).toContain(groupId)); + }); + }); + + describe("doExtendBreakoutTimer", () => { + it("should emit the increased duration", () => { + const store = createStore({ + initialState: { + authorization: { roomKey: null, roleName: "host" }, + breakout: { ...breakoutSliceInitialState, breakoutTimerDuration: 300 }, + }, + withSignalConnection: true, + }); + + store.dispatch(doExtendBreakoutTimer({ seconds: 120 })); + + expect(mockSignalEmit).toHaveBeenCalledWith("update_breakout_session", { breakoutTimerDuration: 420 }); + }); + }); + + describe("doStopBreakoutTimer", () => { + it("should disable the timer setting", () => { + const store = createStore({ + initialState: { authorization: { roomKey: null, roleName: "host" } }, + withSignalConnection: true, + }); + + store.dispatch(doStopBreakoutTimer()); + + expect(mockSignalEmit).toHaveBeenCalledWith("update_breakout_session", { breakoutTimerSetting: false }); + }); + }); +}); + +describe("breakout group helpers", () => { + it("defaultBreakoutGroupName", () => { + expect(defaultBreakoutGroupName("a")).toBe("Group A"); + }); + + it("createBreakoutGroups clamps to the 2-20 range and uses default names", () => { + expect(createBreakoutGroups(3)).toEqual({ a: "Group A", b: "Group B", c: "Group C" }); + expect(Object.keys(createBreakoutGroups(1))).toHaveLength(2); + expect(Object.keys(createBreakoutGroups(50))).toHaveLength(20); + }); });