Compare commits

...
Author SHA1 Message Date
Johannes Marbach 783bd946e8 Add tests 2026-10-26 11:58:54 +01:00
Johannes Marbach 9c64316b42 Sticky event handling fixes 2026-10-16 14:47:10 +02:00
4 changed files with 221 additions and 75 deletions
+122
View File
@@ -0,0 +1,122 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { renderHook } from "@testing-library/react";
import {
EventType,
JoinRule,
KnownMembership,
type MatrixClient,
MatrixEvent,
type Room,
} from "matrix-js-sdk";
import { describe, expect, test } from "vitest";
import { useGroupCallRooms } from "./useGroupCallRooms";
import { mockMatrixRoom } from "../utils/test";
describe("useGroupCallRooms", () => {
test("lists a joined room with legacy call member state", () => {
const room = mockCallRoom({ legacyMemberState: true });
expect(listedRoomIds([room])).toEqual([room.roomId]);
});
test("lists a joined room with a sticky RTC membership", () => {
const room = mockCallRoom({
stickyEvents: [new MatrixEvent({ type: EventType.RTCMembership })],
});
expect(listedRoomIds([room])).toEqual([room.roomId]);
});
test("lists a joined room with an RTC membership in its timeline", () => {
const room = mockCallRoom({
timelineEvents: [new MatrixEvent({ type: EventType.RTCMembership })],
});
expect(listedRoomIds([room])).toEqual([room.roomId]);
});
test("lists a knocked room without any call events", () => {
const room = mockCallRoom({
membership: KnownMembership.Knock,
joinRule: JoinRule.Knock,
});
expect(listedRoomIds([room])).toEqual([room.roomId]);
});
test("omits a joined room without any call events", () => {
const room = mockCallRoom({
stickyEvents: [new MatrixEvent({ type: EventType.RoomMessage })],
timelineEvents: [new MatrixEvent({ type: EventType.RoomMessage })],
});
expect(listedRoomIds([room])).toEqual([]);
});
test.each([KnownMembership.Invite, KnownMembership.Leave])(
"omits a room with %s membership despite call events",
(membership) => {
const room = mockCallRoom({
membership,
legacyMemberState: true,
stickyEvents: [new MatrixEvent({ type: EventType.RTCMembership })],
timelineEvents: [new MatrixEvent({ type: EventType.RTCMembership })],
});
expect(listedRoomIds([room])).toEqual([]);
},
);
});
function listedRoomIds(rooms: Room[]): string[] {
const client = {
getRooms: () => rooms,
getUserId: () => "@alice:example.org",
on: () => {},
off: () => {},
matrixRTC: {
getRoomSession: () => ({ memberships: [] }),
on: () => {},
off: () => {},
},
} as unknown as MatrixClient;
const { result } = renderHook(() => useGroupCallRooms(client));
return result.current.map(({ room }) => room.roomId);
}
let nextRoomId = 0;
function mockCallRoom({
membership = KnownMembership.Join,
joinRule = JoinRule.Public,
legacyMemberState = false,
stickyEvents = [],
timelineEvents = [],
}: {
membership?: KnownMembership;
joinRule?: JoinRule;
legacyMemberState?: boolean;
stickyEvents?: MatrixEvent[];
timelineEvents?: MatrixEvent[];
}): Room {
const stateEvents = new Map(
legacyMemberState ? [[EventType.GroupCallMemberPrefix, new Map()]] : [],
);
return mockMatrixRoom({
roomId: `!room${nextRoomId++}:example.org`,
name: "Call room",
getMyMembership: () => membership,
getJoinRule: () => joinRule,
hasEncryptionStateEvent: () => true,
getCanonicalAlias: () => null,
getMxcAvatarUrl: () => null,
getLiveTimeline: () =>
({
getState: () => ({ events: stateEvents }),
getEvents: () => timelineEvents,
}) as unknown as ReturnType<Room["getLiveTimeline"]>,
_unstable_getStickyEvents: () =>
stickyEvents as unknown as ReturnType<Room["_unstable_getStickyEvents"]>,
});
}
+4 -6
View File
@@ -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.
};
@@ -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"]);
});
@@ -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<Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>> {
return (
): Observable<MatrixEvent> {
// 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<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>
).pipe(filter(([event]) => event.getType() === EventType.RTCDecline));
).pipe(map(([event]) => event));
const sticky$ = (
fromEvent(matrixRoom, RoomStickyEventsEvent.Update) as Observable<
Parameters<RoomStickyEventsMap[RoomStickyEventsEvent.Update]>
>
).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<string[]>;
sentCallNotification$: Observable<CallNotificationWrapper | null>;
receivedDecline$: Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>;
receivedDecline$: Observable<MatrixEvent>;
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,