From 0f7f0100afc2817dc2b90db9faf9d62f08b39fe5 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 20:28:41 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/call-status/MemberGlance.tsx | 37 +++++++++++++++++-- src/app/plugins/call/CallControl.ts | 23 +++++++++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx index 01df75cdb..7e803caea 100644 --- a/src/app/features/call-status/MemberGlance.tsx +++ b/src/app/features/call-status/MemberGlance.tsx @@ -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(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={} onClick={handleFocusCamera} > - Focus camera + {isFocused ? 'Unfocus camera' : 'Focus camera'} )} ; - 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(); }