fix(calls): incoming ring stops on every device once answered or declined elsewhere, or when the caller hangs up (#161)
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 1m39s

The incoming-call dialog only went away on Ignore/Answer/Reject on THAT
device or when the notification lifetime expired, so a DM call answered
on the desktop kept the phone ringing for up to two minutes, and a caller
who gave up left everyone ringing. While a ring is showing we now watch
the room's MatrixRTC session and timeline: our own membership from any
device (answered elsewhere), our own RTCDecline for this ring (declined
elsewhere), or an empty session after it has settled (caller hung up)
all dismiss it. Verified with two alice devices + bob on the local
LiveKit stack: answer elsewhere → dismissed; decline elsewhere →
dismissed; caller End → both dialogs gone in 0.5 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-18 22:27:13 -04:00
co-authored by Claude Opus 5
parent 230d147ec1
commit 50b4e2c16c
+45
View File
@@ -26,6 +26,7 @@ import {
RoomEvent,
} from 'matrix-js-sdk';
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import {
CallEmbedContextProvider,
@@ -559,6 +560,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
setCallInfo(undefined);
}, []);
// [Gitea #161] Stop ringing here when the call was handled ELSEWHERE:
// - answered on another of our devices (our own m.call.member appears),
// - declined on another of our devices (our own RTCDecline for this ring),
// - the caller hung up before we picked up (nobody left in the session).
// Without this every other device kept ringing for the full lifetime.
useEffect(() => {
if (!callInfo) return undefined;
const { room, refEventId } = callInfo;
const myUserId = mx.getSafeUserId();
const dismiss = () =>
setCallInfo((current) => (current?.refEventId === refEventId ? undefined : current));
const session = mx.matrixRTC.getRoomSession(room);
const checkMemberships = () => {
const { memberships } = session;
if (memberships.some((m) => m.sender === myUserId))
dismiss(); // answered elsewhere
else if (memberships.length === 0) dismiss(); // caller gone
};
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
event,
eventRoom,
_s,
_r,
data,
) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
const related = event.getRelation()?.event_id;
if (!related || related === refEventId) dismiss(); // declined elsewhere
}
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
mx.on(RoomEvent.Timeline, onTimeline);
// The caller's membership may not have arrived yet when the ring starts —
// only treat "empty" as hung-up after it has had a moment to sync.
const settle = setTimeout(checkMemberships, 5000);
return () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
mx.removeListener(RoomEvent.Timeline, onTimeline);
clearTimeout(settle);
};
}, [mx, callInfo]);
const handleReject = useCallback(
(room: Room, eventId: string) => {
// Best-effort: the local UI dismisses regardless (below), but a failed