Files
cinny/src/app/features/call-status/MemberGlance.tsx
T
jaredandClaude Opus 5 f50f50be72 feat(call): host half of the 0.25.0-lotus.2 fork changes
- io.lotus.request_state: when the fork's lotus handlers (re)register
  (an EC-side remount that doesn't unmount us) we re-send deafen, quality
  and the focus pin, and the decoration pusher re-pushes its roster —
  decorations and the pin no longer vanish for the rest of the call
  (element-call#17).
- focus_participant carries the per-device media id from call_state
  (speaking device preferred) so a multi-device user pins the right
  device (element-call#30).
- injectAudio returns the fork's reply; when it refuses with
  reason:"muted" the soundboard shows "Unmute your microphone…" instead
  of playing the clip locally as if it went out (element-call#13).

All backwards compatible with the 0.25.0-lotus.1 bundle (unknown action
is acked; missing reply fields default to "played").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 01:26:34 -04:00

222 lines
7.0 KiB
TypeScript

import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React, { useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
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;
userId: string;
room: Room;
callEmbed?: CallEmbed;
onClose: () => void;
profileCords: DOMRect;
};
function ParticipantMenu({
anchor,
name,
userId,
room,
callEmbed,
onClose,
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();
if (isFocused) {
callEmbed?.control.clearFocusParticipant();
} else {
// [EC#30] Pass the fork's per-device media id when we have one (from
// io.lotus.call_state) so a multi-device user pins the active device.
const parts = callEmbed?.getLotusParticipants() ?? [];
const mine = parts.filter((p) => p.userId === userId);
const pick = mine.find((p) => p.speaking) ?? mine.find((p) => p.audioEnabled) ?? mine[0];
callEmbed?.control.focusCameraParticipant(userId, pick?.id ?? null);
}
};
return (
<PopOut
anchor={anchor}
align="Start"
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu variant="Surface" style={{ minWidth: 160, padding: config.space.S100 }}>
<Box direction="Column">
<Text
size="L400"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
opacity: 0.6,
}}
truncate
>
{name}
</Text>
{callEmbed && (
<MenuItem
size="300"
variant="Surface"
radii="300"
before={<Icon size="100" src={Icons.VideoCamera} />}
onClick={handleFocusCamera}
>
<Text size="B300">{isFocused ? 'Unfocus camera' : 'Focus camera'}</Text>
</MenuItem>
)}
<MenuItem
size="300"
variant="Surface"
radii="300"
before={<Icon size="100" src={Icons.User} />}
onClick={handleViewProfile}
>
<Text size="B300">View profile</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
{/* PopOut requires a JSX child even if we anchor externally */}
<span />
</PopOut>
);
}
type MemberGlanceProps = {
room: Room;
members: CallMembership[];
speakers: Set<string>;
callEmbed?: CallEmbed;
max?: number;
};
export function MemberGlance({ room, members, speakers, callEmbed, max = 6 }: MemberGlanceProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [menuState, setMenuState] = useState<{
anchor: RectCords;
profileCords: DOMRect;
userId: string;
name: string;
} | null>(null);
const visibleMembers = members.slice(0, max);
const remainingCount = max && members.length > max ? members.length - max : 0;
return (
<>
<Box alignItems="Center">
{visibleMembers.map((callMember) => {
const { userId } = callMember;
if (!userId) return null;
const name = getMemberName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
return (
<StackedAvatar
key={callMember.memberId}
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
title={name}
as="button"
variant="Background"
size="200"
radii="Pill"
onClick={(evt) => {
const rect = evt.currentTarget.getBoundingClientRect();
setMenuState({
anchor: rect,
profileCords: rect,
userId,
name,
});
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{remainingCount > 0 && (
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
+{remainingCount}
</Text>
)}
</Box>
{menuState && (
<ParticipantMenu
anchor={menuState.anchor}
profileCords={menuState.profileCords}
name={menuState.name}
userId={menuState.userId}
room={room}
callEmbed={callEmbed}
onClose={() => setMenuState(null)}
/>
)}
</>
);
}