feat(calls): live-call dot on the space rail (#148)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

A small red dot on a space tab when any room in that space (recursively)
has an active MatrixRTC session, so a live call is visible even when you
are looking at a different space or your DMs. Hidden while the space is
selected — the room list already shows the Live badge there.

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 23:25:49 -04:00
co-authored by Claude Opus 5
parent b69099a862
commit 4fe9c87010
3 changed files with 81 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useMemo, useState } from 'react';
import { MatrixEvent, RoomStateEvent } from 'matrix-js-sdk';
import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
import { useMatrixClient } from './useMatrixClient';
import { StateEvent } from '../../types/matrix/room';
/**
* [Gitea #148] Whether any of the given rooms has a live MatrixRTC call.
* Derived from the same session memberships the sidebar voice rows use;
* recomputed only when a session starts/ends or a call-member state event
* lands in one of the rooms — no per-render scans.
*/
export function useAnyRoomLiveCall(roomIds: string[]): boolean {
const mx = useMatrixClient();
const idSet = useMemo(() => new Set(roomIds), [roomIds]);
const compute = () =>
roomIds.some((id) => {
const room = mx.getRoom(id);
return !!room && mx.matrixRTC.getRoomSession(room).memberships.length > 0;
});
const [live, setLive] = useState(compute);
useEffect(() => {
setLive(compute());
const onSession = (roomId: string) => {
if (idSet.has(roomId)) setLive(compute());
};
const onState = (event: MatrixEvent) => {
if (event.getType() !== StateEvent.GroupCallMemberPrefix) return;
const roomId = event.getRoomId();
if (roomId && idSet.has(roomId)) setLive(compute());
};
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onSession);
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onSession);
mx.on(RoomStateEvent.Events, onState);
return () => {
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onSession);
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onSession);
mx.off(RoomStateEvent.Events, onState);
};
// compute closes over roomIds; idSet changes exactly when roomIds does
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, idSet]);
return live;
}
@@ -0,0 +1,16 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
/** [Gitea #148] Bottom-right live-call dot on a space tab. */
export const LiveDot = style({
position: 'absolute',
right: toRem(-2),
bottom: toRem(-2),
width: toRem(10),
height: toRem(10),
borderRadius: '50%',
backgroundColor: color.Critical.Main,
boxShadow: `0 0 0 ${toRem(2)} ${color.Background.Container}`,
pointerEvents: 'none',
zIndex: 1,
});
@@ -47,6 +47,8 @@ import {
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useAnyRoomLiveCall } from '../../../hooks/useSpaceLiveCall';
import { LiveDot } from './SpaceTabs.css';
import { import {
getOriginBaseUrl, getOriginBaseUrl,
getSpaceLobbyPath, getSpaceLobbyPath,
@@ -450,6 +452,15 @@ function SpaceTab({
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
// [Gitea #148] Subtle live-call dot for a space you're not looking at.
const roomToParentsForLive = useAtomValue(roomToParentsAtom);
const liveChildren = useSpaceChildren(
allRoomsAtom,
space.roomId,
useRecursiveChildScopeFactory(mx, roomToParentsForLive),
);
const liveCall = useAnyRoomLiveCall(liveChildren);
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect(); const cords = evt.currentTarget.getBoundingClientRect();
@@ -497,6 +508,14 @@ function SpaceTab({
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} /> <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge> </SidebarItemBadge>
)} )}
{liveCall && !selected && (
<span
className={LiveDot}
role="img"
aria-label="A call is live in this space"
title="A call is live in this space"
/>
)}
{menuAnchor && ( {menuAnchor && (
<PopOut <PopOut
anchor={menuAnchor} anchor={menuAnchor}