From 9c64316b4268de4fe3c70d10048a5f3b857bdb8e Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 16 Oct 2026 14:47:10 +0200 Subject: [PATCH] Sticky event handling fixes --- src/home/useGroupCallRooms.ts | 10 +- .../CallNotificationLifecycle.test.ts | 132 ++++++++++-------- .../CallNotificationLifecycle.ts | 32 ++++- 3 files changed, 99 insertions(+), 75 deletions(-) diff --git a/src/home/useGroupCallRooms.ts b/src/home/useGroupCallRooms.ts index 977b59aba..5c39f9a31 100644 --- a/src/home/useGroupCallRooms.ts +++ b/src/home/useGroupCallRooms.ts @@ -141,7 +141,7 @@ const roomHasCallMembershipEvents = (room: Room): boolean => { return true; } - // Check for *active* calls using sticky events. + // Check for recent calls using sticky events. for (const sticky of room._unstable_getStickyEvents()) { if (sticky.getType() === EventType.RTCMembership) { return true; @@ -150,11 +150,9 @@ const roomHasCallMembershipEvents = (room: Room): boolean => { // Otherwise, check recent event history to see if anyone had // sent a call membership in here. - return timeline.getEvents().some( - (e) => - // Membership events only count if both of these are true - e.unstableStickyInfo && e.getType() === EventType.GroupCallMemberPrefix, - ); + return timeline + .getEvents() + .some((e) => e.getType() === EventType.RTCMembership); // Otherwise, it's *unlikely* this room was ever a call. }; diff --git a/src/state/CallViewModel/CallNotificationLifecycle.test.ts b/src/state/CallViewModel/CallNotificationLifecycle.test.ts index a624f5fef..8145afa3f 100644 --- a/src/state/CallViewModel/CallNotificationLifecycle.test.ts +++ b/src/state/CallViewModel/CallNotificationLifecycle.test.ts @@ -5,12 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { test } from "vitest"; +import EventEmitter from "events"; +import { expect, test } from "vitest"; import { EventType, - type IRoomTimelineData, MatrixEvent, type Room, + RoomEvent, + RoomStickyEventsEvent, } from "matrix-js-sdk"; import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs"; @@ -25,6 +27,7 @@ import { import { type CallNotificationWrapper, createCallNotificationLifecycle$, + createReceivedDecline$, type RingAttempt, } from "./CallNotificationLifecycle"; import { constant } from "../Behavior"; @@ -181,22 +184,16 @@ test("ring attempt can be declined", () => { a: mockRingEvent("$notif1", 30), }), receivedDecline$: hot("--d", { - d: [ - new MatrixEvent({ - type: EventType.RTCDecline, - sender: alice.userId, - content: { - "m.relates_to": { - rel_type: "m.reference", - event_id: "$notif1", - }, + d: new MatrixEvent({ + type: EventType.RTCDecline, + sender: alice.userId, + content: { + "m.relates_to": { + rel_type: "m.reference", + event_id: "$notif1", }, - }), - {} as Room, - undefined, - false, - {} as IRoomTimelineData, - ], + }, + }), }), }); @@ -216,22 +213,16 @@ test("ring attempt times out if recipient declines too late", () => { a: mockRingEvent("$notif1", 30), }), receivedDecline$: hot("100ms d", { - d: [ - new MatrixEvent({ - type: EventType.RTCDecline, - sender: alice.userId, - content: { - "m.relates_to": { - rel_type: "m.reference", - event_id: "$notif1", - }, + d: new MatrixEvent({ + type: EventType.RTCDecline, + sender: alice.userId, + content: { + "m.relates_to": { + rel_type: "m.reference", + event_id: "$notif1", }, - }), - {} as Room, - undefined, - false, - {} as IRoomTimelineData, - ], + }, + }), }), }); @@ -251,22 +242,16 @@ test("decline event relating to wrong event is ignored (times out)", () => { a: mockRingEvent("$notif1", 30), }), receivedDecline$: hot("--d", { - d: [ - new MatrixEvent({ - type: EventType.RTCDecline, - sender: alice.userId, - content: { - "m.relates_to": { - rel_type: "m.reference", - event_id: "$other", // <---- WRONG - }, + d: new MatrixEvent({ + type: EventType.RTCDecline, + sender: alice.userId, + content: { + "m.relates_to": { + rel_type: "m.reference", + event_id: "$other", // <---- WRONG }, - }), - {} as Room, - undefined, - false, - {} as IRoomTimelineData, - ], + }, + }), }), }); @@ -286,22 +271,16 @@ test("decline event from wrong sender is ignored (times out)", () => { a: mockRingEvent("$notif1", 30), }), receivedDecline$: hot("--d", { - d: [ - new MatrixEvent({ - type: EventType.RTCDecline, - sender: local.userId, // <---- WRONG - content: { - "m.relates_to": { - rel_type: "m.reference", - event_id: "$notif1", - }, + d: new MatrixEvent({ + type: EventType.RTCDecline, + sender: local.userId, // <---- WRONG + content: { + "m.relates_to": { + rel_type: "m.reference", + event_id: "$notif1", }, - }), - {} as Room, - undefined, - false, - {} as IRoomTimelineData, - ], + }, + }), }), }); @@ -311,3 +290,32 @@ test("decline event from wrong sender is ignored (times out)", () => { }); }); }); + +test("declines are received from the timeline and the sticky section", () => { + const room = new EventEmitter(); + const received: string[] = []; + const subscription = createReceivedDecline$( + room as unknown as Room, + ).subscribe((event) => received.push(event.getId()!)); + const event = (eventId: string, type = EventType.RTCDecline): MatrixEvent => + new MatrixEvent({ event_id: eventId, type, sender: alice.userId }); + + const timelineArgs = [room, false, false, {}]; + room.emit(RoomEvent.Timeline, event("$timeline"), ...timelineArgs); + room.emit( + RoomEvent.Timeline, + event("$message", EventType.RoomMessage), + ...timelineArgs, + ); + // A gappy sync delivers the decline only in the sticky section + room.emit(RoomStickyEventsEvent.Update, [event("$sticky")], [], []); + room.emit( + RoomStickyEventsEvent.Update, + [], + [{ previous: event("$old"), current: event("$replacement") }], + [event("$removed")], + ); + subscription.unsubscribe(); + + expect(received).toEqual(["$timeline", "$sticky", "$replacement"]); +}); diff --git a/src/state/CallViewModel/CallNotificationLifecycle.ts b/src/state/CallViewModel/CallNotificationLifecycle.ts index ec165996e..70ec797e7 100644 --- a/src/state/CallViewModel/CallNotificationLifecycle.ts +++ b/src/state/CallViewModel/CallNotificationLifecycle.ts @@ -25,13 +25,17 @@ import { EMPTY, race, take, + mergeMap, } from "rxjs"; import { type EventTimelineSetHandlerMap, EventType, + type MatrixEvent, type Room as MatrixRoom, RoomEvent, + RoomStickyEventsEvent, } from "matrix-js-sdk"; +import { type RoomStickyEventsMap } from "matrix-js-sdk/lib/models/room-sticky-events"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { type Behavior, type ObservableScope } from "@element-hq/matrixrtc-sdk"; @@ -68,12 +72,28 @@ export function createSentCallNotification$( export function createReceivedDecline$( matrixRoom: MatrixRoom, -): Observable> { - return ( +): Observable { + // A decline is sticky, so after a gappy sync it arrives only in the sticky + // section, which never reaches the timeline. Racing for the first outcome + // makes a decline seen in both places harmless. + const timeline$ = ( fromEvent(matrixRoom, RoomEvent.Timeline) as Observable< Parameters > - ).pipe(filter(([event]) => event.getType() === EventType.RTCDecline)); + ).pipe(map(([event]) => event)); + const sticky$ = ( + fromEvent(matrixRoom, RoomStickyEventsEvent.Update) as Observable< + Parameters + > + ).pipe( + mergeMap(([added, updated]) => [ + ...added, + ...updated.map(({ current }) => current), + ]), + ); + return merge(timeline$, sticky$).pipe( + filter((event) => event.getType() === EventType.RTCDecline), + ); } export interface Props { @@ -83,9 +103,7 @@ export interface Props { /** The user ids of the room's members, ours included. */ roomMemberUserIds$: Behavior; sentCallNotification$: Observable; - receivedDecline$: Observable< - Parameters - >; + receivedDecline$: Observable; options: { waitForCallPickup?: boolean; autoLeaveWhenOthersLeft?: boolean }; localUser: { deviceId: string; userId: string }; } @@ -146,7 +164,7 @@ export function createCallNotificationLifecycle$({ // Call is declined when we receive a decline event const decline$ = receivedDecline$.pipe( filter( - ([event]) => + (event) => event.getRelation()?.rel_type === "m.reference" && event.getRelation()?.event_id === notificationEvent.event_id && event.getSender() === recipient,