From 50b4e2c16c3e3447dfe7765f238257b09957c2a5 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 18 Sep 2026 22:27:13 -0400 Subject: [PATCH] fix(calls): incoming ring stops on every device once answered or declined elsewhere, or when the caller hangs up (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/CallEmbedProvider.tsx | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 9f150adcd..cfb4db070 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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