fix: show meeting guests to members and hide the roster from guests (#1353)

This commit is contained in:
LukeGus committed 2026-09-30 12:09:05 -05:00
1 parent ab43563247
commit f896e121e2
14 files changed
+254 -19

No files matched your search

+3
View File
@@ -71,6 +71,9 @@
"members": "Members",
"membersPanel": "Room members and control requests",
"membersWithCount": "Members ({{count}})",
"guestsWithCount": "Guests ({{count}})",
"removeGuests": "Remove guests",
"anonymousGuest": "Guest",
"online": "online",
"offline": "offline",
"you": "(you)",
@@ -34,6 +34,10 @@ export interface LiveSessionProvider {
shareId: string,
controllerUserId: string | null,
) => void;
listGuests?: (
sessionId: string,
shareId: string,
) => { label: string | null }[];
createViewerToken?: (sessionId: string, readOnly: boolean) => string;
}
@@ -104,6 +108,24 @@ export function createLiveSessions(ctx: PluginContext) {
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. */
async ownerEndSession(
protocol: string,
@@ -364,6 +364,16 @@ export function registerRoomRoutes(
}
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 controlRequests = await store.listRequests(roomId);
res.json({
@@ -373,6 +383,7 @@ export function registerRoomRoutes(
members,
online: await hub.onlineUsers(roomId),
stage: stagePayload(access.room),
guests,
controllerUserId: await store.getController(roomId),
controlRequests: mayReview
? 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
* /plugin-api/session-sharing/rooms/{id}/guest-link:
@@ -1,11 +1,8 @@
import { useEffect, useRef, useState } from "react";
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 {
GuestTerminalView,
type SessionParticipantInfo,
} from "./SharedSessionView";
import { GuestTerminalView } from "./SharedSessionView";
import { resolveCollabGuestStage, type CollabGuestStage } from "./api";
const POLL_MS = 5000;
@@ -21,7 +18,6 @@ export default function CollabGuestView() {
const [roomName, setRoomName] = useState<string | null>(null);
const [stage, setStage] = useState<CollabGuestStage | null>(null);
const [error, setError] = useState<string | null>(null);
const [participantCount, setParticipantCount] = useState(0);
const stageShareIdRef = useRef<string | null>(null);
useEffect(() => {
@@ -41,7 +37,6 @@ export default function CollabGuestView() {
if (nextShareId !== stageShareIdRef.current) {
stageShareIdRef.current = nextShareId;
setStage(result.stage);
setParticipantCount(0);
}
} catch {
if (!cancelled) setError(t("collab.guest.linkInvalid"));
@@ -66,12 +61,6 @@ export default function CollabGuestView() {
{roomName ?? t("collab.guest.title")}
</span>
<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">
<Eye className="size-3.5" />
{t("sessionSharing.guestView.readOnlyBadge")}
@@ -92,9 +81,6 @@ export default function CollabGuestView() {
share={{ permissionLevel: "read-only" }}
wsPath={stage.wsPath ?? ""}
hideBadges
onParticipantsChange={(participants: SessionParticipantInfo[]) =>
setParticipantCount(participants.length)
}
/>
) : stage.connectParams?.token ? (
<RemoteDisplay
@@ -3,6 +3,7 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Check,
Crown,
Eye,
Hand,
MonitorUp,
MousePointerClick,
@@ -31,6 +32,7 @@ interface CollabMembersSidebarProps {
onControl: (userId: string | null) => Promise<void>;
onDismissRequest: (userId: string) => Promise<void>;
onRemoveMember: (userId: string) => Promise<void>;
onRemoveGuests: () => Promise<void>;
}
export function CollabMembersSidebar({
@@ -40,6 +42,7 @@ export function CollabMembersSidebar({
onControl,
onDismissRequest,
onRemoveMember,
onRemoveGuests,
}: CollabMembersSidebarProps) {
const { t } = useTranslation();
const [removing, setRemoving] = useState<{
@@ -47,6 +50,7 @@ export function CollabMembersSidebar({
username: 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 presenterUserId = detail.stage.presenterUserId;
const canManageControl = detail.isHost || presenterUserId === detail.me;
@@ -252,6 +256,40 @@ export function CollabMembersSidebar({
);
})}
</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>
<AlertDialog
@@ -103,6 +103,7 @@ export function CollabRoomTab({
presentCollabStage,
requestCollabStageControl,
removeCollabMember,
removeCollabGuests,
setCollabGuestLink,
setCollabStageControl,
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) {
if (!roomId) return;
try {
@@ -607,7 +618,7 @@ export function CollabRoomTab({
onClick={() => setMembersOpen((open) => !open)}
>
<Users className="mr-1 size-3.5" />
{detail?.members.length ?? 0}
{(detail?.members.length ?? 0) + (detail?.guests?.length ?? 0)}
</Button>
<Button
size="sm"
@@ -779,6 +790,7 @@ export function CollabRoomTab({
onControl={changeControl}
onDismissRequest={dismissControlRequest}
onRemoveMember={removeMember}
onRemoveGuests={removeGuests}
/>
)}
</div>
@@ -259,6 +259,8 @@ export interface CollabRoomDetail {
members: CollabRoomMember[];
online: CollabOnlineUser[];
stage: CollabStage;
/** Anonymous guests watching the stage through the guest link. */
guests?: { label: string | null }[];
controllerUserId: string | null;
controlRequests: CollabControlRequest[];
/** 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() {
return call(
() =>
@@ -202,6 +209,7 @@ export function createMeetingApi(
endCollabRoom,
deleteCollabRoom,
setCollabGuestLink,
removeCollabGuests,
getDirectory,
resolveHost,
};
@@ -50,6 +50,7 @@ export function createFakeLive() {
setRoomShareControl: (...args: unknown[]) => {
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 () => {
server = await startServer();
const oneOff = await roomWithBob(server);
@@ -142,6 +142,8 @@ export async function activate(ctx: PluginContext) {
sessionManager.ownerEndSession(sessionId, reason),
disconnectParticipants: (sessionId, shareId, options) =>
sessionManager.disconnectShareParticipants(sessionId, shareId, options),
listGuests: (sessionId, shareId) =>
sessionManager.listShareGuests(sessionId, shareId),
setRoomShareControl: (sessionId, shareId, controllerUserId) =>
sessionManager.setRoomShareControl(sessionId, shareId, controllerUserId),
subscribe: (sessionId, onData) => {
@@ -36,6 +36,11 @@ export interface LiveSessionsV1 {
shareId: string,
options: { reason: string; userId?: string | null },
) => 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. */
setRoomShareControl: (
sessionId: string,
@@ -486,7 +486,28 @@ export class TerminalSessionManager {
participants.every((participant) => participant.isOwner)
)
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. */
broadcast(sessionId: string, message: object): void {
broadcast(
sessionId: string,
message: object,
include?: (participant: SessionParticipant) => boolean,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
const payload = JSON.stringify(message);
for (const participant of session.participants.values()) {
if (participant.ws.readyState !== WebSocket.OPEN) continue;
if (include && !include(participant)) continue;
try {
participant.ws.send(payload);
} catch {
@@ -253,6 +253,39 @@ describe("TerminalSessionManager - multiplayer participants", () => {
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", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();