diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 0660d27e8..17e054da5 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -18,8 +18,10 @@ import { toRem, } from 'folds'; import { + ClientEvent, EventTimelineSetHandlerMap, EventType, + SyncState, RelationType, Room, RoomEvent, @@ -72,7 +74,7 @@ import { useSetting } from '../state/hooks/settings'; import { useCallPreferences } from '../state/hooks/callPreferences'; import { settingsAtom } from '../state/settings'; import { getStateEvent, getMemberName } from '../utils/room'; -import { isRingableRoom } from '../utils/callRing'; +import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing'; import { StateEvent } from '../../types/matrix/room'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; @@ -596,11 +598,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) refEventId, }; - setCallInfo(info); + // [Gitea #312] Don't ring for a call that is already over or handled: after + // a reload the first sync replays the ring together with the caller's + // hang-up and our own decline. Check the room STATE (not the possibly + // stale RTC session). The caller's membership can arrive just after the + // ring, so an absent one gets a short grace period before giving up. + const myUserId = mx.getSafeUserId(); + if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) { + return; + } + if (hasActiveCallMembership(room, sender)) { + setCallInfo(info); + return; + } + setTimeout(() => { + if (Date.now() >= senderTs + lifetime) return; + if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) { + return; + } + if (hasActiveCallMembership(room, sender)) setCallInfo(info); + }, 4000); }, [mx, directs, callEmbed, setToast], ); + // [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a + // call's join and leave in one batch, and the RTC session may keep the join + // (stale "1 Live", call screen instead of chat). Once sync is live again, + // recompute every active session from the room's current state. + useEffect(() => { + const onSync = (state: SyncState, prev: SyncState | null) => { + if (state !== SyncState.Syncing || prev === SyncState.Syncing) return; + mx.getRooms().forEach((r) => { + mx.matrixRTC + .getActiveRoomSession(r) + ?._onRTCSessionMemberUpdate() + .catch(() => undefined); + }); + }; + mx.on(ClientEvent.Sync, onSync); + return () => { + mx.removeListener(ClientEvent.Sync, onSync); + }; + }, [mx]); + useEffect(() => { mx.on(RoomEvent.Timeline, handleTimelineEvent); return () => { @@ -629,7 +670,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) const { memberships } = session; if (memberships.some((m) => m.sender === myUserId)) dismiss(); // answered elsewhere - else if (memberships.length === 0) dismiss(); // caller gone + else if (memberships.length === 0) + dismiss(); // caller gone + // [Gitea #312] The session can be stale after a reload; the room state isn't. + else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss(); }; const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( event, diff --git a/src/app/utils/callRing.test.ts b/src/app/utils/callRing.test.ts index 22a5c0c1d..ac96ae3ee 100644 --- a/src/app/utils/callRing.test.ts +++ b/src/app/utils/callRing.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { Room } from 'matrix-js-sdk'; -import { isRingableRoom } from './callRing'; +import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing'; const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room => ({ @@ -27,3 +27,43 @@ test('space channels, public and restricted rooms do not ring', () => { assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false); assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false); }); + +const stateRoom = ( + members: Array<{ sender: string; content: Record }>, + timeline: unknown[] = [], +): Room => + ({ + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: () => + members.map((m) => ({ + getSender: () => m.sender, + getStateKey: () => `_${m.sender}_DEV_m.call`, + getContent: () => m.content, + })), + }), + getEvents: () => timeline, + }), + }) as unknown as Room; + +test('hasActiveCallMembership reads the room state: joined vs left (#312)', () => { + const room = stateRoom([ + { sender: '@bob:x', content: { application: 'm.call', call_id: '' } }, + { sender: '@carol:x', content: {} }, + ]); + assert.equal(hasActiveCallMembership(room, '@bob:x'), true); + assert.equal(hasActiveCallMembership(room, '@carol:x'), false, 'left = empty content'); + assert.equal(hasActiveCallMembership(room, '@dave:x'), false); +}); + +test('declinedRing finds our own decline of that ring only (#312)', () => { + const decline = (sender: string, rel: string) => ({ + getType: () => 'org.matrix.msc4310.rtc.decline', + getSender: () => sender, + getRelation: () => ({ event_id: rel }), + }); + const room = stateRoom([], [decline('@me:x', '$ring1'), decline('@bob:x', '$ring2')]); + assert.equal(declinedRing(room, '$ring1', '@me:x'), true); + assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline"); + assert.equal(declinedRing(room, '$ring3', '@me:x'), false); +}); diff --git a/src/app/utils/callRing.ts b/src/app/utils/callRing.ts index c705a3df8..adbdd5e42 100644 --- a/src/app/utils/callRing.ts +++ b/src/app/utils/callRing.ts @@ -16,3 +16,37 @@ export const isRingableRoom = (room: Room, isDirect: boolean): boolean => { const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0; return !isSpaceChild && room.getJoinRule() === JoinRule.Invite; }; + +/** A call-membership state event that is still "in the call" (left = empty content). */ +const isActiveMembership = (content: Record | undefined): boolean => + !!content && + (typeof content.application === 'string' || + (Array.isArray(content.memberships) && content.memberships.length > 0)); + +/** + * [Gitea #312] Whether `userId` is in a call in `room` according to the room's + * current STATE. After a reload the MatrixRTC session can be stale (a join and + * leave replayed in one catch-up batch), but the state is right. + */ +export const hasActiveCallMembership = (room: Room, userId: string): boolean => + getStateEvents(room, StateEvent.GroupCallMemberPrefix).some( + (ev) => + (ev.getSender() === userId || ev.getStateKey()?.includes(userId)) && + isActiveMembership(ev.getContent()), + ); + +/** + * [Gitea #312] Whether we already handled the ring `refEventId` points at: + * declined it (our own RTCDecline relates to it) — e.g. replayed together with + * the ring in the first sync after a restart, before any dismiss listener. + */ +export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean => + room + .getLiveTimeline() + .getEvents() + .some( + (ev) => + ev.getType() === 'org.matrix.msc4310.rtc.decline' && + ev.getSender() === myUserId && + ev.getRelation()?.event_id === refEventId, + );