Skip to content

Commit c500c79

Browse files
committed
feat: add Share Screen functionality and enhance participant control features
- Implemented Share Screen button in ViewerPage that navigates to presenter mode. - Updated ViewerContent to include Share Screen button with appropriate navigation. - Enhanced LiveKit token generation to allow authenticated participants to publish in SFU mode. - Added tests for new Share Screen functionality and participant control updates. - Created new API routes for managing participant control and transferring host roles. - Updated HostSessionPage to handle host transfer and moderation actions based on user roles. - Improved session management and error handling in various API routes. - Refactored chat and participant components to support new features and improve type safety.
1 parent a3716a0 commit c500c79

18 files changed

Lines changed: 917 additions & 161 deletions

File tree

apps/desktop/src/renderer/components/ParticipantList.test.tsx

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ describe('ParticipantList', () => {
248248
expect(screen.queryByTitle('Remove participant')).not.toBeInTheDocument();
249249
});
250250

251-
it('does not show actions when isHost is false', () => {
251+
it('does not show actions when isHost is false and no action callbacks are provided', () => {
252252
const participants = [
253253
createMockParticipant({
254254
id: 'p-1',
@@ -257,15 +257,7 @@ describe('ParticipantList', () => {
257257
}),
258258
];
259259

260-
render(
261-
<ParticipantList
262-
participants={participants}
263-
isHost={false}
264-
onGrantControl={vi.fn()}
265-
onRevokeControl={vi.fn()}
266-
onKickParticipant={vi.fn()}
267-
/>
268-
);
260+
render(<ParticipantList participants={participants} isHost={false} />);
269261

270262
expect(screen.queryByTitle('Grant control')).not.toBeInTheDocument();
271263
expect(screen.queryByTitle('Remove participant')).not.toBeInTheDocument();
@@ -353,6 +345,41 @@ describe('ParticipantList', () => {
353345
expect(onKickParticipant).toHaveBeenCalledWith('p-1');
354346
});
355347
});
348+
349+
it('shows and calls make host button when onTransferHost is provided', async () => {
350+
const onTransferHost = vi.fn().mockResolvedValue(undefined);
351+
const participants = [
352+
createMockParticipant({
353+
id: 'p-1',
354+
display_name: 'Viewer',
355+
role: 'viewer',
356+
}),
357+
];
358+
359+
render(<ParticipantList participants={participants} onTransferHost={onTransferHost} />);
360+
361+
expect(screen.getByTitle('Make host')).toBeInTheDocument();
362+
fireEvent.click(screen.getByTitle('Make host'));
363+
364+
await waitFor(() => {
365+
expect(onTransferHost).toHaveBeenCalledWith('p-1');
366+
});
367+
});
368+
369+
it('hides kick button when only transfer-host is available (presenter, not host)', () => {
370+
const participants = [
371+
createMockParticipant({
372+
id: 'p-1',
373+
display_name: 'Viewer',
374+
role: 'viewer',
375+
}),
376+
];
377+
378+
render(<ParticipantList participants={participants} onTransferHost={vi.fn()} />);
379+
380+
expect(screen.getByTitle('Make host')).toBeInTheDocument();
381+
expect(screen.queryByTitle('Remove participant')).not.toBeInTheDocument();
382+
});
356383
});
357384

358385
describe('Mute Actions', () => {

apps/desktop/src/renderer/components/ParticipantList.tsx

Lines changed: 70 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface ParticipantListProps {
2121
onGrantControl?: (participantId: string) => Promise<void>;
2222
onRevokeControl?: (participantId: string) => Promise<void>;
2323
onKickParticipant?: (participantId: string) => Promise<void>;
24+
onTransferHost?: (participantId: string) => Promise<void>;
2425
onMuteParticipant?: (participantId: string, muted: boolean) => void;
2526
mutedParticipants?: Set<string>;
2627
}
@@ -81,6 +82,7 @@ export function ParticipantList({
8182
onGrantControl,
8283
onRevokeControl,
8384
onKickParticipant,
85+
onTransferHost,
8486
onMuteParticipant,
8587
mutedParticipants,
8688
}: ParticipantListProps) {
@@ -126,6 +128,19 @@ export function ParticipantList({
126128
[onKickParticipant]
127129
);
128130

131+
const handleTransferHost = useCallback(
132+
async (participantId: string) => {
133+
if (!onTransferHost) return;
134+
setLoadingAction(`host-${participantId}`);
135+
try {
136+
await onTransferHost(participantId);
137+
} finally {
138+
setLoadingAction(null);
139+
}
140+
},
141+
[onTransferHost]
142+
);
143+
129144
if (activeParticipants.length === 0) {
130145
return (
131146
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
@@ -150,7 +165,13 @@ export function ParticipantList({
150165
{activeParticipants.map((participant) => {
151166
const isCurrentUser = participant.user_id === currentUserId;
152167
const isParticipantHost = participant.role === 'host';
153-
const showActions = isHost && !isParticipantHost && !isCurrentUser;
168+
const hasAnyActions =
169+
Boolean(onGrantControl) ||
170+
Boolean(onRevokeControl) ||
171+
Boolean(onKickParticipant) ||
172+
Boolean(onTransferHost) ||
173+
Boolean(onMuteParticipant);
174+
const showActions = (isHost || hasAnyActions) && !isParticipantHost && !isCurrentUser;
154175

155176
return (
156177
<div
@@ -215,50 +236,69 @@ export function ParticipantList({
215236
})()}
216237

217238
{/* Grant/Revoke control button */}
218-
{participant.control_state === 'granted' ? (
239+
{(participant.control_state === 'granted' ? onRevokeControl : onGrantControl) &&
240+
(participant.control_state === 'granted' ? (
241+
<button
242+
onClick={() => void handleRevokeControl(participant.id)}
243+
disabled={loadingAction !== null}
244+
className="rounded p-1.5 text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-50"
245+
title="Revoke control"
246+
aria-label="Revoke control"
247+
>
248+
{loadingAction === `revoke-${participant.id}` ? (
249+
<Loader2 className="h-4 w-4 animate-spin" />
250+
) : (
251+
<Shield className="h-4 w-4" />
252+
)}
253+
</button>
254+
) : (
255+
<button
256+
onClick={() => void handleGrantControl(participant.id)}
257+
disabled={loadingAction !== null}
258+
className="rounded p-1.5 text-green-500 transition-colors hover:bg-green-500/20 disabled:opacity-50"
259+
title="Grant control"
260+
aria-label="Grant control"
261+
>
262+
{loadingAction === `grant-${participant.id}` ? (
263+
<Loader2 className="h-4 w-4 animate-spin" />
264+
) : (
265+
<Shield className="h-4 w-4" />
266+
)}
267+
</button>
268+
))}
269+
270+
{/* Kick button */}
271+
{onKickParticipant && (
219272
<button
220-
onClick={() => void handleRevokeControl(participant.id)}
273+
onClick={() => void handleKick(participant.id)}
221274
disabled={loadingAction !== null}
222-
className="rounded p-1.5 text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-50"
223-
title="Revoke control"
224-
aria-label="Revoke control"
275+
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive disabled:opacity-50"
276+
title="Remove participant"
277+
aria-label="Remove participant"
225278
>
226-
{loadingAction === `revoke-${participant.id}` ? (
279+
{loadingAction === `kick-${participant.id}` ? (
227280
<Loader2 className="h-4 w-4 animate-spin" />
228281
) : (
229-
<Shield className="h-4 w-4" />
282+
<UserX className="h-4 w-4" />
230283
)}
231284
</button>
232-
) : (
285+
)}
286+
287+
{onTransferHost && (
233288
<button
234-
onClick={() => void handleGrantControl(participant.id)}
289+
onClick={() => void handleTransferHost(participant.id)}
235290
disabled={loadingAction !== null}
236-
className="rounded p-1.5 text-green-500 transition-colors hover:bg-green-500/20 disabled:opacity-50"
237-
title="Grant control"
238-
aria-label="Grant control"
291+
className="rounded p-1.5 text-yellow-500 transition-colors hover:bg-yellow-500/20 disabled:opacity-50"
292+
title="Make host"
293+
aria-label="Make host"
239294
>
240-
{loadingAction === `grant-${participant.id}` ? (
295+
{loadingAction === `host-${participant.id}` ? (
241296
<Loader2 className="h-4 w-4 animate-spin" />
242297
) : (
243-
<Shield className="h-4 w-4" />
298+
<Crown className="h-4 w-4" />
244299
)}
245300
</button>
246301
)}
247-
248-
{/* Kick button */}
249-
<button
250-
onClick={() => void handleKick(participant.id)}
251-
disabled={loadingAction !== null}
252-
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive disabled:opacity-50"
253-
title="Remove participant"
254-
aria-label="Remove participant"
255-
>
256-
{loadingAction === `kick-${participant.id}` ? (
257-
<Loader2 className="h-4 w-4 animate-spin" />
258-
) : (
259-
<UserX className="h-4 w-4" />
260-
)}
261-
</button>
262302
</div>
263303
)}
264304
</div>

0 commit comments

Comments
 (0)