mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
fix: show meeting guests to members and hide the roster from guests (#1353)
This commit is contained in:
1 parent
ab43563247
commit
f896e121e2
14 files changed
+254
-19
No files matched your search
@@ -71,6 +71,9 @@
|
|||||||
"members": "Members",
|
"members": "Members",
|
||||||
"membersPanel": "Room members and control requests",
|
"membersPanel": "Room members and control requests",
|
||||||
"membersWithCount": "Members ({{count}})",
|
"membersWithCount": "Members ({{count}})",
|
||||||
|
"guestsWithCount": "Guests ({{count}})",
|
||||||
|
"removeGuests": "Remove guests",
|
||||||
|
"anonymousGuest": "Guest",
|
||||||
"online": "online",
|
"online": "online",
|
||||||
"offline": "offline",
|
"offline": "offline",
|
||||||
"you": "(you)",
|
"you": "(you)",
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface LiveSessionProvider {
|
|||||||
shareId: string,
|
shareId: string,
|
||||||
controllerUserId: string | null,
|
controllerUserId: string | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
listGuests?: (
|
||||||
|
sessionId: string,
|
||||||
|
shareId: string,
|
||||||
|
) => { label: string | null }[];
|
||||||
createViewerToken?: (sessionId: string, readOnly: boolean) => string;
|
createViewerToken?: (sessionId: string, readOnly: boolean) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +108,24 @@ export function createLiveSessions(ctx: PluginContext) {
|
|||||||
return !!session && session.isConnected && session.userId === userId;
|
return !!session && session.isConnected && session.userId === userId;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Anonymous link guests on one share; empty when the type cannot tell. */
|
||||||
|
async listGuests(
|
||||||
|
protocol: string,
|
||||||
|
sessionId: string,
|
||||||
|
shareId: string,
|
||||||
|
asUser?: string,
|
||||||
|
): Promise<{ label: string | null }[]> {
|
||||||
|
const live = provider(protocol);
|
||||||
|
if (!live || !("listGuests" in live)) return [];
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
(await run(asUser, () => live.listGuests(sessionId, shareId))) ?? []
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/** Ends the session for every guest. Best effort, like before. */
|
/** Ends the session for every guest. Best effort, like before. */
|
||||||
async ownerEndSession(
|
async ownerEndSession(
|
||||||
protocol: string,
|
protocol: string,
|
||||||
|
|||||||
@@ -364,6 +364,16 @@ export function registerRoomRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const members = await rooms.listMembers(roomId);
|
const members = await rooms.listMembers(roomId);
|
||||||
|
const stageShare = await liveStageShare(access.room);
|
||||||
|
const guests =
|
||||||
|
stageShare && access.room.stageProtocol
|
||||||
|
? await live.listGuests(
|
||||||
|
access.room.stageProtocol,
|
||||||
|
stageShare.sessionId,
|
||||||
|
stageShare.id,
|
||||||
|
stageShare.ownerUserId,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
const mayReview = access.isHost || access.room.presenterUserId === userId;
|
const mayReview = access.isHost || access.room.presenterUserId === userId;
|
||||||
const controlRequests = await store.listRequests(roomId);
|
const controlRequests = await store.listRequests(roomId);
|
||||||
res.json({
|
res.json({
|
||||||
@@ -373,6 +383,7 @@ export function registerRoomRoutes(
|
|||||||
members,
|
members,
|
||||||
online: await hub.onlineUsers(roomId),
|
online: await hub.onlineUsers(roomId),
|
||||||
stage: stagePayload(access.room),
|
stage: stagePayload(access.room),
|
||||||
|
guests,
|
||||||
controllerUserId: await store.getController(roomId),
|
controllerUserId: await store.getController(roomId),
|
||||||
controlRequests: mayReview
|
controlRequests: mayReview
|
||||||
? controlRequests
|
? controlRequests
|
||||||
@@ -1124,6 +1135,65 @@ export function registerRoomRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugin-api/session-sharing/rooms/{id}/guests/remove:
|
||||||
|
* post:
|
||||||
|
* summary: Disconnect every anonymous guest watching the stage (host only)
|
||||||
|
* description: The guest link stays valid, so a guest may open it again.
|
||||||
|
* tags:
|
||||||
|
* - Collab
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Guests disconnected
|
||||||
|
* 403:
|
||||||
|
* description: Not the host
|
||||||
|
* 404:
|
||||||
|
* description: Not a member, or no such room
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/rooms/:id/guests/remove",
|
||||||
|
requireUse,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = actorOf(ctx);
|
||||||
|
const roomId = String(req.params.id);
|
||||||
|
try {
|
||||||
|
const access = await requireRoomMember(roomId, userId);
|
||||||
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
||||||
|
if (!access.isHost) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Only the host can remove guests" });
|
||||||
|
}
|
||||||
|
const share = await liveStageShare(access.room);
|
||||||
|
if (share && access.room.stageProtocol) {
|
||||||
|
await live.disconnectParticipants(
|
||||||
|
access.room.stageProtocol,
|
||||||
|
share.sessionId,
|
||||||
|
share.id,
|
||||||
|
{ userId: null, reason: "The host removed the guests" },
|
||||||
|
share.ownerUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
fail(
|
||||||
|
res,
|
||||||
|
"collab_guests_remove_error",
|
||||||
|
"Failed to remove guests",
|
||||||
|
)(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /plugin-api/session-sharing/rooms/{id}/guest-link:
|
* /plugin-api/session-sharing/rooms/{id}/guest-link:
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
|
import { AlertCircle, Eye, Presentation } from "lucide-react";
|
||||||
import { RemoteDisplay } from "./shared";
|
import { RemoteDisplay } from "./shared";
|
||||||
import {
|
import { GuestTerminalView } from "./SharedSessionView";
|
||||||
GuestTerminalView,
|
|
||||||
type SessionParticipantInfo,
|
|
||||||
} from "./SharedSessionView";
|
|
||||||
import { resolveCollabGuestStage, type CollabGuestStage } from "./api";
|
import { resolveCollabGuestStage, type CollabGuestStage } from "./api";
|
||||||
|
|
||||||
const POLL_MS = 5000;
|
const POLL_MS = 5000;
|
||||||
@@ -21,7 +18,6 @@ export default function CollabGuestView() {
|
|||||||
const [roomName, setRoomName] = useState<string | null>(null);
|
const [roomName, setRoomName] = useState<string | null>(null);
|
||||||
const [stage, setStage] = useState<CollabGuestStage | null>(null);
|
const [stage, setStage] = useState<CollabGuestStage | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [participantCount, setParticipantCount] = useState(0);
|
|
||||||
const stageShareIdRef = useRef<string | null>(null);
|
const stageShareIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,7 +37,6 @@ export default function CollabGuestView() {
|
|||||||
if (nextShareId !== stageShareIdRef.current) {
|
if (nextShareId !== stageShareIdRef.current) {
|
||||||
stageShareIdRef.current = nextShareId;
|
stageShareIdRef.current = nextShareId;
|
||||||
setStage(result.stage);
|
setStage(result.stage);
|
||||||
setParticipantCount(0);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError(t("collab.guest.linkInvalid"));
|
if (!cancelled) setError(t("collab.guest.linkInvalid"));
|
||||||
@@ -66,12 +61,6 @@ export default function CollabGuestView() {
|
|||||||
{roomName ?? t("collab.guest.title")}
|
{roomName ?? t("collab.guest.title")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-3 shrink-0 ml-auto text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 shrink-0 ml-auto text-xs text-muted-foreground">
|
||||||
{participantCount >= 2 && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Users className="size-3.5" />
|
|
||||||
{participantCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Eye className="size-3.5" />
|
<Eye className="size-3.5" />
|
||||||
{t("sessionSharing.guestView.readOnlyBadge")}
|
{t("sessionSharing.guestView.readOnlyBadge")}
|
||||||
@@ -92,9 +81,6 @@ export default function CollabGuestView() {
|
|||||||
share={{ permissionLevel: "read-only" }}
|
share={{ permissionLevel: "read-only" }}
|
||||||
wsPath={stage.wsPath ?? ""}
|
wsPath={stage.wsPath ?? ""}
|
||||||
hideBadges
|
hideBadges
|
||||||
onParticipantsChange={(participants: SessionParticipantInfo[]) =>
|
|
||||||
setParticipantCount(participants.length)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : stage.connectParams?.token ? (
|
) : stage.connectParams?.token ? (
|
||||||
<RemoteDisplay
|
<RemoteDisplay
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Crown,
|
Crown,
|
||||||
|
Eye,
|
||||||
Hand,
|
Hand,
|
||||||
MonitorUp,
|
MonitorUp,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
@@ -31,6 +32,7 @@ interface CollabMembersSidebarProps {
|
|||||||
onControl: (userId: string | null) => Promise<void>;
|
onControl: (userId: string | null) => Promise<void>;
|
||||||
onDismissRequest: (userId: string) => Promise<void>;
|
onDismissRequest: (userId: string) => Promise<void>;
|
||||||
onRemoveMember: (userId: string) => Promise<void>;
|
onRemoveMember: (userId: string) => Promise<void>;
|
||||||
|
onRemoveGuests: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollabMembersSidebar({
|
export function CollabMembersSidebar({
|
||||||
@@ -40,6 +42,7 @@ export function CollabMembersSidebar({
|
|||||||
onControl,
|
onControl,
|
||||||
onDismissRequest,
|
onDismissRequest,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
|
onRemoveGuests,
|
||||||
}: CollabMembersSidebarProps) {
|
}: CollabMembersSidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [removing, setRemoving] = useState<{
|
const [removing, setRemoving] = useState<{
|
||||||
@@ -47,6 +50,7 @@ export function CollabMembersSidebar({
|
|||||||
username: string;
|
username: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
||||||
|
const guests = detail.guests ?? [];
|
||||||
const onlineIds = new Set(detail.online.map((user) => user.userId));
|
const onlineIds = new Set(detail.online.map((user) => user.userId));
|
||||||
const presenterUserId = detail.stage.presenterUserId;
|
const presenterUserId = detail.stage.presenterUserId;
|
||||||
const canManageControl = detail.isHost || presenterUserId === detail.me;
|
const canManageControl = detail.isHost || presenterUserId === detail.me;
|
||||||
@@ -252,6 +256,40 @@ export function CollabMembersSidebar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{guests.length > 0 && (
|
||||||
|
<div className="mt-3 border-t border-border pt-2">
|
||||||
|
<div className="flex items-center gap-2 px-2 pb-1">
|
||||||
|
<Eye className="size-3.5 text-muted-foreground" />
|
||||||
|
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide">
|
||||||
|
{t("collab.guestsWithCount", { count: guests.length })}
|
||||||
|
</h3>
|
||||||
|
{detail.isHost && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={busyUserId === "guests"}
|
||||||
|
onClick={() => void run("guests", onRemoveGuests)}
|
||||||
|
>
|
||||||
|
{t("collab.removeGuests")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{guests.map((guest, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex min-h-9 items-center gap-2 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<span className="size-2 shrink-0 rounded-full bg-green-500" />
|
||||||
|
<span className="truncate">
|
||||||
|
{guest.label || t("collab.anonymousGuest")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export function CollabRoomTab({
|
|||||||
presentCollabStage,
|
presentCollabStage,
|
||||||
requestCollabStageControl,
|
requestCollabStageControl,
|
||||||
removeCollabMember,
|
removeCollabMember,
|
||||||
|
removeCollabGuests,
|
||||||
setCollabGuestLink,
|
setCollabGuestLink,
|
||||||
setCollabStageControl,
|
setCollabStageControl,
|
||||||
stopCollabStage,
|
stopCollabStage,
|
||||||
@@ -310,6 +311,16 @@ export function CollabRoomTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeGuests() {
|
||||||
|
if (!roomId) return;
|
||||||
|
try {
|
||||||
|
await removeCollabGuests(roomId);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeMember(targetId: string) {
|
async function removeMember(targetId: string) {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
try {
|
try {
|
||||||
@@ -607,7 +618,7 @@ export function CollabRoomTab({
|
|||||||
onClick={() => setMembersOpen((open) => !open)}
|
onClick={() => setMembersOpen((open) => !open)}
|
||||||
>
|
>
|
||||||
<Users className="mr-1 size-3.5" />
|
<Users className="mr-1 size-3.5" />
|
||||||
{detail?.members.length ?? 0}
|
{(detail?.members.length ?? 0) + (detail?.guests?.length ?? 0)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -779,6 +790,7 @@ export function CollabRoomTab({
|
|||||||
onControl={changeControl}
|
onControl={changeControl}
|
||||||
onDismissRequest={dismissControlRequest}
|
onDismissRequest={dismissControlRequest}
|
||||||
onRemoveMember={removeMember}
|
onRemoveMember={removeMember}
|
||||||
|
onRemoveGuests={removeGuests}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ export interface CollabRoomDetail {
|
|||||||
members: CollabRoomMember[];
|
members: CollabRoomMember[];
|
||||||
online: CollabOnlineUser[];
|
online: CollabOnlineUser[];
|
||||||
stage: CollabStage;
|
stage: CollabStage;
|
||||||
|
/** Anonymous guests watching the stage through the guest link. */
|
||||||
|
guests?: { label: string | null }[];
|
||||||
controllerUserId: string | null;
|
controllerUserId: string | null;
|
||||||
controlRequests: CollabControlRequest[];
|
controlRequests: CollabControlRequest[];
|
||||||
/** Where live room events are served, as /plugin-ws/<id>/<path>. */
|
/** Where live room events are served, as /plugin-ws/<id>/<path>. */
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ export function createMeetingApi(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeCollabGuests(roomId: string): Promise<void> {
|
||||||
|
await call(
|
||||||
|
() => api().post(room(roomId, "/guests/remove")),
|
||||||
|
"Failed to remove guests",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getDirectory() {
|
function getDirectory() {
|
||||||
return call(
|
return call(
|
||||||
() =>
|
() =>
|
||||||
@@ -202,6 +209,7 @@ export function createMeetingApi(
|
|||||||
endCollabRoom,
|
endCollabRoom,
|
||||||
deleteCollabRoom,
|
deleteCollabRoom,
|
||||||
setCollabGuestLink,
|
setCollabGuestLink,
|
||||||
|
removeCollabGuests,
|
||||||
getDirectory,
|
getDirectory,
|
||||||
resolveHost,
|
resolveHost,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function createFakeLive() {
|
|||||||
setRoomShareControl: (...args: unknown[]) => {
|
setRoomShareControl: (...args: unknown[]) => {
|
||||||
calls.push(["setRoomShareControl", ...args]);
|
calls.push(["setRoomShareControl", ...args]);
|
||||||
},
|
},
|
||||||
|
listGuests: () => [{ label: "Guest 1" }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,33 @@ describe("collab rooms", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists stage guests for members and lets only the host remove them", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
const roomId = await roomWithBob(server);
|
||||||
|
await present(server, roomId);
|
||||||
|
|
||||||
|
const detail = await server.request("GET", `/rooms/${roomId}`, {
|
||||||
|
user: "bob",
|
||||||
|
});
|
||||||
|
expect(detail.body.guests).toEqual([{ label: "Guest 1" }]);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await server.request("POST", `/rooms/${roomId}/guests/remove`, {
|
||||||
|
user: "bob",
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await server.request("POST", `/rooms/${roomId}/guests/remove`)).status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(server.live.calls).toContainEqual([
|
||||||
|
"disconnectParticipants",
|
||||||
|
"sess-alice",
|
||||||
|
expect.any(String),
|
||||||
|
{ userId: null, reason: "The host removed the guests" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("ends a one-off room for good and keeps a persistent one", async () => {
|
it("ends a one-off room for good and keeps a persistent one", async () => {
|
||||||
server = await startServer();
|
server = await startServer();
|
||||||
const oneOff = await roomWithBob(server);
|
const oneOff = await roomWithBob(server);
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export async function activate(ctx: PluginContext) {
|
|||||||
sessionManager.ownerEndSession(sessionId, reason),
|
sessionManager.ownerEndSession(sessionId, reason),
|
||||||
disconnectParticipants: (sessionId, shareId, options) =>
|
disconnectParticipants: (sessionId, shareId, options) =>
|
||||||
sessionManager.disconnectShareParticipants(sessionId, shareId, options),
|
sessionManager.disconnectShareParticipants(sessionId, shareId, options),
|
||||||
|
listGuests: (sessionId, shareId) =>
|
||||||
|
sessionManager.listShareGuests(sessionId, shareId),
|
||||||
setRoomShareControl: (sessionId, shareId, controllerUserId) =>
|
setRoomShareControl: (sessionId, shareId, controllerUserId) =>
|
||||||
sessionManager.setRoomShareControl(sessionId, shareId, controllerUserId),
|
sessionManager.setRoomShareControl(sessionId, shareId, controllerUserId),
|
||||||
subscribe: (sessionId, onData) => {
|
subscribe: (sessionId, onData) => {
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export interface LiveSessionsV1 {
|
|||||||
shareId: string,
|
shareId: string,
|
||||||
options: { reason: string; userId?: string | null },
|
options: { reason: string; userId?: string | null },
|
||||||
) => number;
|
) => number;
|
||||||
|
/** The anonymous link guests watching through one share. */
|
||||||
|
listGuests: (
|
||||||
|
sessionId: string,
|
||||||
|
shareId: string,
|
||||||
|
) => { label: string | null }[];
|
||||||
/** Hands input control of a room share to one member, or back to the owner. */
|
/** Hands input control of a room share to one member, or back to the owner. */
|
||||||
setRoomShareControl: (
|
setRoomShareControl: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -486,7 +486,28 @@ export class TerminalSessionManager {
|
|||||||
participants.every((participant) => participant.isOwner)
|
participants.every((participant) => participant.isOwner)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
this.broadcast(sessionId, { type: "participants", participants });
|
// Link guests are anonymous viewers; they do not get the roster.
|
||||||
|
this.broadcast(
|
||||||
|
sessionId,
|
||||||
|
{ type: "participants", participants },
|
||||||
|
(participant) => participant.userId !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The anonymous link guests watching through one share. */
|
||||||
|
listShareGuests(
|
||||||
|
sessionId: string,
|
||||||
|
shareId: string,
|
||||||
|
): { label: string | null }[] {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return [];
|
||||||
|
return Array.from(session.participants.values())
|
||||||
|
.filter(
|
||||||
|
(participant) =>
|
||||||
|
participant.userId === null &&
|
||||||
|
participant.joinedViaShareId === shareId,
|
||||||
|
)
|
||||||
|
.map((participant) => ({ label: participant.guestLabel ?? null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -554,12 +575,17 @@ export class TerminalSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||||
broadcast(sessionId: string, message: object): void {
|
broadcast(
|
||||||
|
sessionId: string,
|
||||||
|
message: object,
|
||||||
|
include?: (participant: SessionParticipant) => boolean,
|
||||||
|
): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const payload = JSON.stringify(message);
|
const payload = JSON.stringify(message);
|
||||||
for (const participant of session.participants.values()) {
|
for (const participant of session.participants.values()) {
|
||||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||||
|
if (include && !include(participant)) continue;
|
||||||
try {
|
try {
|
||||||
participant.ws.send(payload);
|
participant.ws.send(payload);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -253,6 +253,39 @@ describe("TerminalSessionManager - multiplayer participants", () => {
|
|||||||
sessionManager.destroySession(id);
|
sessionManager.destroySession(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the roster from link guests and lists them per share", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
guestLabel: "Guest 1",
|
||||||
|
shareId: "share-a",
|
||||||
|
});
|
||||||
|
const memberWs = makeFakeWs();
|
||||||
|
sessionManager.joinAsParticipant(id, memberWs, {
|
||||||
|
userId: "alice",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
shareId: "share-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sentTypes = (ws: ReturnType<typeof makeFakeWs>) =>
|
||||||
|
(ws.send as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||||
|
(call) => JSON.parse(call[0] as string).type,
|
||||||
|
);
|
||||||
|
expect(sentTypes(guestWs)).not.toContain("participants");
|
||||||
|
expect(sentTypes(memberWs)).toContain("participants");
|
||||||
|
expect(sessionManager.listShareGuests(id, "share-a")).toEqual([
|
||||||
|
{ label: "Guest 1" },
|
||||||
|
]);
|
||||||
|
expect(sessionManager.listShareGuests(id, "share-b")).toEqual([]);
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
|
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
|
||||||
const id = createConnectedSession();
|
const id = createConnectedSession();
|
||||||
const ownerWs = makeFakeWs();
|
const ownerWs = makeFakeWs();
|
||||||
|
|||||||
Reference in new issue
Block a user