feat(calls): room header chip for voice limit / audio-only policy (#134)
CI / Build & Quality Checks (push) Successful in 1m35s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 2m38s

One small Surface chip next to the header controls, only when a rule exists:
'0/10' (participants / io.lotus.voice_limit) and/or a muted-camera glyph when
the room's io.lotus.room_quality forbids camera and screenshare; the tooltip
and aria-label carry the words ('Voice limit 10 (0 in call) · Audio-only
room'). Desktop only — the mobile header is already tight. Verified headless:
appears with the rules set, disappears when they are cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 17:42:00 -04:00
co-authored by Claude Opus 5
parent f5e7fb4746
commit 7f17940d34
+51
View File
@@ -27,6 +27,8 @@ import {
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page'; import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -415,6 +417,54 @@ const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
}, },
); );
/**
* [Gitea #134] One small chip that tells members a room's call rules before
* they hit the server-side guard: "8/10" when a voice limit is set, a muted
* camera glyph when the room is audio-only. Nothing when no rule exists; the
* tooltip carries the words. Desktop only (the mobile header is tight).
*/
function CallRulesChip({ room }: { room: Room }) {
const { current, max } = useVoiceChannelFull(room);
const { allowCamera, allowScreenshare } = useRoomCallPolicy(room);
const audioOnly = !allowCamera && !allowScreenshare;
if (max <= 0 && !audioOnly) return null;
const words = [
max > 0 ? `Voice limit ${max} (${current} in call)` : undefined,
audioOnly ? 'Audio-only room' : undefined,
]
.filter(Boolean)
.join(' · ');
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{words}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Surface"
size="400"
radii="Pill"
aria-label={words}
tabIndex={0}
before={audioOnly ? <Icon size="50" src={Icons.VideoCameraMute} /> : undefined}
>
{max > 0 && (
<Text size="T200">
{current}/{max}
</Text>
)}
</Chip>
)}
</TooltipProvider>
);
}
function CallButton() { function CallButton() {
const room = useRoom(); const room = useRoom();
const direct = useIsDirectRoom(); const direct = useIsDirectRoom();
@@ -731,6 +781,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</FocusTrap> </FocusTrap>
} }
/> />
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
{!room.isCallRoom() && {!room.isCallRoom() &&
livekitSupported && livekitSupported &&
rtcSupported && rtcSupported &&