From 4fe9c870101e894d8f5a16f4c0ae06bf42d42995 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 23:25:49 -0400 Subject: [PATCH] feat(calls): live-call dot on the space rail (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/hooks/useSpaceLiveCall.ts | 46 +++++++++++++++++++ src/app/pages/client/sidebar/SpaceTabs.css.ts | 16 +++++++ src/app/pages/client/sidebar/SpaceTabs.tsx | 19 ++++++++ 3 files changed, 81 insertions(+) create mode 100644 src/app/hooks/useSpaceLiveCall.ts create mode 100644 src/app/pages/client/sidebar/SpaceTabs.css.ts diff --git a/src/app/hooks/useSpaceLiveCall.ts b/src/app/hooks/useSpaceLiveCall.ts new file mode 100644 index 000000000..de438e573 --- /dev/null +++ b/src/app/hooks/useSpaceLiveCall.ts @@ -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; +} diff --git a/src/app/pages/client/sidebar/SpaceTabs.css.ts b/src/app/pages/client/sidebar/SpaceTabs.css.ts new file mode 100644 index 000000000..0c5f162b6 --- /dev/null +++ b/src/app/pages/client/sidebar/SpaceTabs.css.ts @@ -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, +}); diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index c3d5e6ecd..584290c94 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -47,6 +47,8 @@ import { import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { useAnyRoomLiveCall } from '../../../hooks/useSpaceLiveCall'; +import { LiveDot } from './SpaceTabs.css'; import { getOriginBaseUrl, getSpaceLobbyPath, @@ -450,6 +452,15 @@ function SpaceTab({ const [menuAnchor, setMenuAnchor] = useState(); + // [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 = (evt) => { evt.preventDefault(); const cords = evt.currentTarget.getBoundingClientRect(); @@ -497,6 +508,14 @@ function SpaceTab({ 0} count={unread.total} /> )} + {liveCall && !selected && ( + + )} {menuAnchor && (