fix(calls): "Focus camera" toggles to "Unfocus"; pin cleared on dispose; drop dead setPipMode

clearFocusParticipant() had no callers, so a spotlight pin was permanent.
CallControl now tracks focusedUserId, the member menu toggles, and
dispose() clears the pin. Removes _pipMode/setPipMode (never read).

Fixes #56
Fixes #59

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 20:28:41 -04:00
co-authored by Claude Opus 5
parent 91def3ad34
commit 0f7f0100af
2 changed files with 52 additions and 8 deletions
+34 -3
View File
@@ -1,6 +1,6 @@
import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar';
@@ -12,8 +12,31 @@ import { StackedAvatar } from '../../components/stacked-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import { CallControlEvent } from '../../plugins/call/CallControl';
import * as css from './styles.css';
// [Gitea #56] Subscribes to CallControl's focus pin so the menu can render a
// "Focus camera" / "Unfocus camera" toggle instead of a one-way pin.
function useFocusedUserId(callEmbed?: CallEmbed): string | null {
const control = callEmbed?.control;
const [focusedUserId, setFocusedUserId] = useState<string | null>(control?.focusedUserId ?? null);
useEffect(() => {
if (!control) {
setFocusedUserId(null);
return undefined;
}
setFocusedUserId(control.focusedUserId);
const handleUpdate = () => setFocusedUserId(control.focusedUserId);
control.on(CallControlEvent.StateUpdate, handleUpdate);
return () => {
control.off(CallControlEvent.StateUpdate, handleUpdate);
};
}, [control]);
return focusedUserId;
}
type ParticipantMenuProps = {
anchor: RectCords;
name: string;
@@ -33,15 +56,23 @@ function ParticipantMenu({
profileCords,
}: ParticipantMenuProps) {
const openUserProfile = useOpenUserRoomProfile();
const focusedUserId = useFocusedUserId(callEmbed);
const isFocused = focusedUserId === userId;
const handleViewProfile = () => {
onClose();
openUserProfile(room.roomId, undefined, userId, profileCords, 'Top');
};
// [Gitea #56] Toggle: focusing the already-focused participant clears the
// pin and returns EC to speaker-follows, instead of leaving no way back.
const handleFocusCamera = () => {
onClose();
callEmbed?.control.focusCameraParticipant(userId);
if (isFocused) {
callEmbed?.control.clearFocusParticipant();
} else {
callEmbed?.control.focusCameraParticipant(userId);
}
};
return (
@@ -78,7 +109,7 @@ function ParticipantMenu({
before={<Icon size="100" src={Icons.VideoCamera} />}
onClick={handleFocusCamera}
>
<Text size="B300">Focus camera</Text>
<Text size="B300">{isFocused ? 'Unfocus camera' : 'Focus camera'}</Text>
</MenuItem>
)}
<MenuItem
+18 -5
View File
@@ -33,7 +33,10 @@ export class CallControl extends EventEmitter implements CallControlState {
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
private _pipMode = false;
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
// so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle
// instead of a one-way pin with no way back. null == no manual pin (speaker-follows).
private _focusedUserId: string | null = null;
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
// it once joined (io.lotus.set_quality must not be sent before call-join — a
@@ -422,10 +425,6 @@ export class CallControl extends EventEmitter implements CallControlState {
this.spotlightButton?.click();
}
public setPipMode(pip: boolean) {
this._pipMode = pip;
}
public toggleReactions() {
this.reactionsButton?.click();
}
@@ -446,17 +445,29 @@ export class CallControl extends EventEmitter implements CallControlState {
* participant has their camera off and EC didn't render a video tile for
* them yet).
*/
public get focusedUserId(): string | null {
return this._focusedUserId;
}
public focusCameraParticipant(userId: string): void {
// [lotus #4] Pin the participant via the fork's widget action instead of
// DOM-poking tiles. EC's layout honors it — including surfacing the camera
// alongside a screenshare (A5) — and it's version-stable. The fork always
// acks, so the promise resolves regardless.
this._focusedUserId = userId;
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
// [Gitea #56] Notify state-update listeners so the menu can flip to "Unfocus camera".
this.emitStateUpdate();
}
/** [lotus #4] Clear any manual spotlight pin and return to speaker-follows. */
public clearFocusParticipant(): void {
// [Gitea #56] No-op (and no redundant widget send) if nothing is pinned —
// dispose() calls this unconditionally on every call teardown.
if (this._focusedUserId === null) return;
this._focusedUserId = null;
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
this.emitStateUpdate();
}
/**
@@ -502,6 +513,8 @@ export class CallControl extends EventEmitter implements CallControlState {
clearTimeout(this.bodyMutationTimer);
this.bodyMutationTimer = undefined;
}
// [Gitea #56] Don't let a manual focus pin outlive the call.
this.clearFocusParticipant();
this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect();
}