diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 0288b80af..08af7133c 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -49,7 +49,7 @@ jobs: pull-requests: write steps: - name: Check membership - if: github.event.pull_request.user.login != 'renovate[bot]' + if: github.event.pull_request.user.login != 'renovate[bot]' && github.event.pull_request.user.login != 'dependabot[bot]' uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3 id: teams with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 902a95ab8..3d9da58c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07) +================================================================================================== +## ✨ Features + +* Add call intent to RTC call notifications ([#5010](https://github.com/matrix-org/matrix-js-sdk/pull/5010)). Contributed by @Half-Shot. +* Implement experimental encrypted state events. ([#4994](https://github.com/matrix-org/matrix-js-sdk/pull/4994)). Contributed by @kaylendog. + +## 🐛 Bug Fixes + +* Exclude cancelled requests from in-progress lists ([#5016](https://github.com/matrix-org/matrix-js-sdk/pull/5016)). Contributed by @andybalaam. + + Changes in [38.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.3.0) (2025-09-23) ================================================================================================== ## 🐛 Bug Fixes diff --git a/knip.ts b/knip.ts index afffa74b7..e025af37a 100644 --- a/knip.ts +++ b/knip.ts @@ -12,10 +12,6 @@ export default { "src/utils.ts", // not really an entrypoint but we have deprecated `defer` there "scripts/**", "spec/**", - // XXX: these look entirely unused - "src/crypto/aes.ts", - "src/crypto/crypto.ts", - "src/crypto/recoverykey.ts", // XXX: these should be re-exported by one of the supported exports "src/matrixrtc/index.ts", "src/sliding-sync.ts", diff --git a/package.json b/package.json index d55b47585..d64294f15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "38.3.0", + "version": "38.4.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=22.0.0" diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 0aefe75c9..d9cf48024 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -46,8 +46,8 @@ import { type Verifier, VerifierEvent, } from "../../../src/crypto-api/verification"; -import { escapeRegExp } from "../../../src/utils"; -import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils"; +import { escapeRegExp, sleep } from "../../../src/utils"; +import { awaitDecryption, emitPromise, getSyncResponse, syncPromise, waitFor } from "../../test-utils/test-utils"; import { SyncResponder } from "../../test-utils/SyncResponder"; import { BACKUP_DECRYPTION_KEY_BASE64, @@ -79,11 +79,6 @@ import { import { type KeyBackupInfo, CryptoEvent } from "../../../src/crypto-api"; import { encodeBase64 } from "../../../src/base64"; -// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations -// to ensure that we don't end up with dangling timeouts. -// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`. -jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); - beforeAll(async () => { // we use the libolm primitives in the test, so init the Olm library await Olm.init(); @@ -96,6 +91,13 @@ beforeAll(async () => { await RustSdkCryptoJs.initAsync(); }, 10000); +beforeEach(() => { + // The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations + // to ensure that we don't end up with dangling timeouts. + // But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`. + jest.useFakeTimers({ doNotFake: ["queueMicrotask"] }); +}); + afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections // cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state @@ -1080,6 +1082,13 @@ describe("verification", () => { }); it("ignores old verification requests", async () => { + const debug = jest.fn(); + const info = jest.fn(); + const warn = jest.fn(); + + // @ts-ignore overriding RustCrypto's logger + aliceClient.getCrypto()!.logger = { debug, info, warn }; + const eventHandler = jest.fn(); aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler); @@ -1094,6 +1103,16 @@ describe("verification", () => { const matrixEvent = room.getLiveTimeline().getEvents()[0]; expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id); + // Wait until the request has been processed. We use a real sleep() + // here to make sure any background async tasks are completed. + jest.useRealTimers(); + await waitFor(async () => { + expect(info).toHaveBeenCalledWith( + expect.stringMatching(/^Ignoring just-received verification request/), + ); + sleep(100); + }); + // check that an event has not been raised, and that the request is not found expect(eventHandler).not.toHaveBeenCalled(); expect( diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 9bc902aa0..90199bde3 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -2366,6 +2366,61 @@ describe("MatrixClient", function () { }); }); + describe("disableVoip option", () => { + const baseUrl = "https://alice-server.com"; + const userId = "@alice:bar"; + const accessToken = "sometoken"; + + beforeEach(() => { + mocked(supportsMatrixCall).mockReturnValue(true); + }); + + afterAll(() => { + mocked(supportsMatrixCall).mockReset(); + }); + + it("should not call /voip/turnServer when disableVoip = true", () => { + fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, 200); + + const client = createClient({ + baseUrl, + accessToken, + userId, + disableVoip: true, + }); + + // Only check createCall / supportsVoip, avoid startClient + expect(client.createCall("!roomId:example.com")).toBeNull(); + expect(client.supportsVoip?.()).toBe(false); + }); + + it("should call /voip/turnServer when disableVoip is not set", () => { + fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, { + uris: ["turn:turn.example.org"], + }); + + createClient({ + baseUrl, + accessToken, + userId, + }); + + // The call will trigger the request if VoIP is supported + expect(fetchMock.called(`${baseUrl}/_matrix/client/unstable/voip/turnServer`)).toBe(false); + }); + + it("should return null from createCall when disableVoip = true", () => { + const client = createClient({ + baseUrl, + accessToken, + userId, + disableVoip: true, + }); + + expect(client.createCall("!roomId:example.com")).toBeNull(); + }); + }); + describe("support for ignoring invites", () => { beforeEach(() => { // Mockup `getAccountData`/`setAccountData`. diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 51403da51..fd1bb6d5e 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -15,8 +15,8 @@ limitations under the License. */ import { type MatrixEvent } from "../../../src"; -import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; import { rtcMembershipTemplate, sessionMembershipTemplate } from "./mocks"; +import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; function makeMockEvent(originTs = 0, content = {}): MatrixEvent { return { @@ -187,12 +187,24 @@ describe("CallMembership", () => { describe("RtcMembershipData", () => { const membershipTemplate = rtcMembershipTemplate; + it("rejects membership with no slot_id", () => { expect(() => { new CallMembership(makeMockEvent(0, { ...membershipTemplate, slot_id: undefined })); }).toThrow(); }); + it("rejects membership with invalid slot_id", () => { + expect(() => { + new CallMembership(makeMockEvent(0, { ...membershipTemplate, slot_id: "invalid_slot_id" })); + }).toThrow(); + }); + it("accepts membership with valid slot_id", () => { + expect(() => { + new CallMembership(makeMockEvent(0, { ...membershipTemplate, slot_id: "m.call#" })); + }).not.toThrow(); + }); + it("rejects membership with no application", () => { expect(() => { new CallMembership(makeMockEvent(0, { ...membershipTemplate, application: undefined })); @@ -248,18 +260,91 @@ describe("CallMembership", () => { new CallMembership( makeMockEvent(0, { ...membershipTemplate, - member: { id: "test", device_id: "test", user_id: "@test:user.id" }, + member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" }, + }), + ); + }).toThrow(); + }); + it("rejects membership with incorrect sticky_key", () => { + expect(() => { + new CallMembership(makeMockEvent(0, membershipTemplate)); + }).not.toThrow(); + expect(() => { + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + sticky_key: 1, + msc4354_sticky_key: undefined, + }), + ); + }).toThrow(); + expect(() => { + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + sticky_key: "1", + msc4354_sticky_key: undefined, }), ); }).not.toThrow(); + expect(() => { + new CallMembership(makeMockEvent(0, { ...membershipTemplate, msc4354_sticky_key: undefined })); + }).toThrow(); + expect(() => { + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + msc4354_sticky_key: 1, + sticky_key: "valid", + }), + ); + }).toThrow(); + expect(() => { + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + msc4354_sticky_key: "valid", + sticky_key: "valid", + }), + ); + }).not.toThrow(); + expect(() => { + new CallMembership( + makeMockEvent(0, { + ...membershipTemplate, + msc4354_sticky_key: "valid_but_different", + sticky_key: "valid", + }), + ); + }).toThrow(); }); it("considers memberships unexpired if local age low enough", () => { - // TODO link prev event + const now = Date.now(); + const startEv = makeMockEvent(now - DEFAULT_EXPIRE_DURATION + 100, membershipTemplate); + const membershipWithRel = new CallMembership( + //update 900 ms later + makeMockEvent(now - DEFAULT_EXPIRE_DURATION + 1000, membershipTemplate), + startEv, + ); + const membershipWithoutRel = new CallMembership(startEv); + expect(membershipWithRel.isExpired()).toEqual(false); + expect(membershipWithoutRel.isExpired()).toEqual(false); + expect(membershipWithoutRel.createdTs()).toEqual(membershipWithRel.createdTs()); }); it("considers memberships expired if local age large enough", () => { - // TODO link prev event + const now = Date.now(); + const startEv = makeMockEvent(now - DEFAULT_EXPIRE_DURATION - 100, membershipTemplate); + const membershipWithRel = new CallMembership( + //update 1100 ms later (so the update is still after expiry) + makeMockEvent(now - DEFAULT_EXPIRE_DURATION + 1000, membershipTemplate), + startEv, + ); + const membershipWithoutRel = new CallMembership(startEv); + expect(membershipWithRel.isExpired()).toEqual(true); + expect(membershipWithoutRel.isExpired()).toEqual(true); + expect(membershipWithoutRel.createdTs()).toEqual(membershipWithRel.createdTs()); }); describe("getTransport", () => { @@ -279,6 +364,7 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" }); }); }); + describe("correct values from computed fields", () => { const membership = new CallMembership(makeMockEvent(0, membershipTemplate)); it("returns correct sender", () => { diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 3de0eab80..69c1c02a9 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -232,15 +232,16 @@ describe("MatrixRTCSession", () => { }; const mockRoom = makeMockRoom([testMembership]); + const now = Date.now(); mockRoom.findEventById = jest .fn() .mockImplementation((id) => id === "id" - ? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: 100 }) + ? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 }) : undefined, ); sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - expect(sess.memberships[0].createdTs()).toBe(100); + expect(sess.memberships[0].createdTs()).toBe(now + 100); }); it("fetches related events if needed from cs api", async () => { const testMembership = { @@ -251,12 +252,14 @@ describe("MatrixRTCSession", () => { }; const mockRoom = makeMockRoom([testMembership]); + const now = Date.now(); + mockRoom.findEventById = jest.fn().mockReturnValue(undefined); client.fetchRoomEvent = jest .fn() - .mockResolvedValue({ content: { ...rtcMembershipTemplate }, origin_server_ts: 100 }); + .mockResolvedValue({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 }); sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - expect(sess.memberships[0].createdTs()).toBe(100); + expect(sess.memberships[0].createdTs()).toBe(now + 100); }); }); @@ -322,9 +325,8 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "oldest_membership", }); - expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe( - firstPreferredFocus, - ); + const oldest = sess.memberships.find((m) => m.deviceId === "old"); + expect(oldest?.getTransport(sess.getOldestMembership()!)).toBe(firstPreferredFocus); jest.useRealTimers(); }); it("does not provide focus if the selection method is unknown", async () => { @@ -344,7 +346,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "unknown", }); - expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe(undefined); + expect(sess.memberships.length).toBe(0); }); }); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index b62a792dc..d881cacd2 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -166,7 +166,7 @@ describe("MembershipManager", () => { room.roomId, "org.matrix.msc4143.rtc.member", { - application: { type: "m.call", id: "" }, + application: { type: "m.call" }, member: { user_id: "@alice:example.org", id: "_@alice:example.org_AAAAAAA_m.call", diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index b8a96eb39..f522f2881 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -49,12 +49,12 @@ export const sessionMembershipTemplate: SessionMembershipData & { user_id: strin }; export const rtcMembershipTemplate: RtcMembershipData = { - "slot_id": "m.call#", - "application": { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, - "member": { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, - "rtc_transports": [{ type: "livekit" }], - "m.call.intent": "voice", - "versions": [], + slot_id: "m.call#", + application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, + member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, + rtc_transports: [{ type: "livekit" }], + msc4354_sticky_key: "my_sticky_key", + versions: [], }; export type MockClient = Pick< diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index f32256253..65bd26ece 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -20,6 +20,7 @@ import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/model import { emitPromise } from "../../test-utils/test-utils"; import { type IAnnotatedPushRule, + type IStickyEvent, type MatrixClient, PushRuleActionName, Room, @@ -598,6 +599,39 @@ describe("MatrixEvent", () => { expect(stateEvent.isState()).toBeTruthy(); expect(stateEvent.threadRootId).toBeUndefined(); }); + + it("should calculate sticky duration correctly", async () => { + const evData: IStickyEvent = { + event_id: "$event_id", + type: "some_state_event", + content: {}, + sender: "@alice:example.org", + origin_server_ts: 50, + msc4354_sticky: { + duration_ms: 1000, + }, + unsigned: { + msc4354_sticky_duration_ttl_ms: 5000, + }, + }; + try { + jest.useFakeTimers(); + jest.setSystemTime(50); + // Prefer unsigned + expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050); + // Fall back to `duration_ms` + expect( + new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt, + ).toEqual(1050); + // Prefer current time if `origin_server_ts` is more recent. + expect( + new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent) + .unstableStickyExpiresAt, + ).toEqual(1050); + } finally { + jest.useRealTimers(); + } + }); }); function mainTimelineLiveEventIds(room: Room): Array { diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts new file mode 100644 index 000000000..a51fe461c --- /dev/null +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -0,0 +1,262 @@ +import { type IStickyEvent, MatrixEvent } from "../../../src"; +import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events"; + +describe("RoomStickyEvents", () => { + let stickyEvents: RoomStickyEventsStore; + const emitSpy: jest.Mock = jest.fn(); + const stickyEvent: IStickyEvent = { + event_id: "$foo:bar", + room_id: "!roomId", + type: "org.example.any_type", + msc4354_sticky: { + duration_ms: 15000, + }, + content: { + msc4354_sticky_key: "foobar", + }, + sender: "@alice:example.org", + origin_server_ts: Date.now(), + unsigned: {}, + }; + + beforeEach(() => { + emitSpy.mockReset(); + stickyEvents = new RoomStickyEventsStore(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + }); + + afterEach(() => { + stickyEvents?.clear(); + }); + + describe("addStickyEvents", () => { + it("should allow adding an event without a msc4354_sticky_key", () => { + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(1); + }); + it("should not allow adding an event without a msc4354_sticky property", () => { + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + stickyEvents.addStickyEvents([ + new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }), + ]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + }); + it("should not allow adding an event without a sender", () => { + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + }); + it("should not allow adding an event with an invalid sender", () => { + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + }); + it("should ignore old events", () => { + stickyEvents.addStickyEvents([ + new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }), + ]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + }); + it("should be able to just add an event", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + stickyEvents.addStickyEvents([originalEv]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); + }); + it("should not replace events on ID tie break", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + stickyEvents.addStickyEvents([originalEv]); + stickyEvents.addStickyEvents([ + new MatrixEvent({ + ...stickyEvent, + event_id: "$abc:bar", + }), + ]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); + }); + it("should not replace a newer event with an older event", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + stickyEvents.addStickyEvents([originalEv]); + stickyEvents.addStickyEvents([ + new MatrixEvent({ + ...stickyEvent, + origin_server_ts: 1, + }), + ]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); + }); + it("should replace an older event with a newer event", () => { + const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" }); + const newerEv = new MatrixEvent({ + ...stickyEvent, + event_id: "$new", + origin_server_ts: Date.now() + 2000, + }); + stickyEvents.addStickyEvents([originalEv]); + stickyEvents.addStickyEvents([newerEv]); + expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]); + expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []); + }); + it("should allow multiple events with the same sticky key for different event types", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + const anotherEv = new MatrixEvent({ + ...stickyEvent, + type: "org.example.another_type", + }); + stickyEvents.addStickyEvents([originalEv, anotherEv]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]); + }); + + it("should emit when a new sticky event is added", () => { + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + const ev = new MatrixEvent({ + ...stickyEvent, + }); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); + expect(emitSpy).toHaveBeenCalledWith([ev], [], []); + }); + it("should emit when a new unkeyed sticky event is added", () => { + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + const ev = new MatrixEvent({ + ...stickyEvent, + content: {}, + }); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); + expect(emitSpy).toHaveBeenCalledWith([ev], [], []); + }); + }); + + describe("getStickyEvents", () => { + it("should have zero sticky events", () => { + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + }); + it("should contain a sticky event", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + }); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); + }); + it("should contain two sticky events", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + }); + const ev2 = new MatrixEvent({ + ...stickyEvent, + sender: "@fibble:bobble", + content: { + msc4354_sticky_key: "bibble", + }, + }); + stickyEvents.addStickyEvents([ev, ev2]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]); + }); + }); + + describe("getKeyedStickyEvent", () => { + it("should have zero sticky events", () => { + expect( + stickyEvents.getKeyedStickyEvent( + stickyEvent.sender, + stickyEvent.type, + stickyEvent.content.msc4354_sticky_key!, + ), + ).toBeUndefined(); + }); + it("should return a sticky event", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + }); + stickyEvents.addStickyEvents([ev]); + expect( + stickyEvents.getKeyedStickyEvent( + stickyEvent.sender, + stickyEvent.type, + stickyEvent.content.msc4354_sticky_key!, + ), + ).toEqual(ev); + }); + }); + + describe("getUnkeyedStickyEvent", () => { + it("should have zero sticky events", () => { + expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]); + }); + it("should return a sticky event", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + content: { + msc4354_sticky_key: undefined, + }, + }); + stickyEvents.addStickyEvents([ev]); + expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]); + }); + }); + + describe("cleanExpiredStickyEvents", () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it("should emit when a sticky event expires", () => { + jest.setSystemTime(1000); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: 0, + }); + const evLater = new MatrixEvent({ + ...stickyEvent, + event_id: "$baz:bar", + sender: "@bob:example.org", + origin_server_ts: 1000, + }); + stickyEvents.addStickyEvents([ev, evLater]); + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); + // Then expire the next event + jest.advanceTimersByTime(1000); + expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]); + }); + it("should emit two events when both expire at the same time", () => { + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + jest.setSystemTime(0); + const ev1 = new MatrixEvent({ + ...stickyEvent, + event_id: "$eventA", + origin_server_ts: 0, + }); + const ev2 = new MatrixEvent({ + ...stickyEvent, + event_id: "$eventB", + content: { + msc4354_sticky_key: "key_2", + }, + origin_server_ts: 0, + }); + stickyEvents.addStickyEvents([ev1, ev2]); + expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]); + }); + it("should emit when a unkeyed sticky event expires", () => { + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + jest.setSystemTime(0); + const ev = new MatrixEvent({ + ...stickyEvent, + content: {}, + origin_server_ts: Date.now(), + }); + stickyEvents.addStickyEvents([ev]); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); + }); + }); +}); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 952370d46..e9c77ef4e 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -2419,6 +2419,135 @@ describe("RustCrypto", () => { expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl")); }); }); + + describe("Verification requests", () => { + it("fetches device details before room verification requests", async () => { + // Given a RustCrypto + const olmMachine = mockedOlmMachine(); + const outgoingRequestProcessor = mockedOutgoingRequestProcessor(); + const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor); + + // When we receive a room verification request + const event = mockedEvent("!r:s.co", "@u:s.co", "m.room.message", "m.key.verification.request"); + await rustCrypto.onLiveEventFromSync(event); + + // Then we first fetch device details + expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalled(); + + // And we handle the verification event as normal + expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled(); + }); + + it("does not fetch device details before other verification events", async () => { + // Given a RustCrypto + const olmMachine = mockedOlmMachine(); + const outgoingRequestProcessor = mockedOutgoingRequestProcessor(); + const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor); + + // When we receive some verification event that is not a room request + const event = mockedEvent("!r:s.co", "@u:s.co", "m.key.verification.start"); + await rustCrypto.onLiveEventFromSync(event); + + // Then we do not fetch device details + expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled(); + + // And we handle the verification event as normal + expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled(); + }); + + it("throws an error if sender is missing", async () => { + // Given a RustCrypto + const olmMachine = mockedOlmMachine(); + const outgoingRequestProcessor = mockedOutgoingRequestProcessor(); + const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor); + + // When we receive a verification event without a sender + // Then we throw + const event = mockedEvent("!r:s.co", null, "m.key.verification.start"); + + await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow( + "missing sender in the event", + ); + + // And we do not fetch device details or handle the event + expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled(); + expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled(); + }); + + it("throws an error if room is missing", async () => { + // Given a RustCrypto + const olmMachine = mockedOlmMachine(); + const outgoingRequestProcessor = mockedOutgoingRequestProcessor(); + const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor); + + // When we receive a verification event without a sender + // Then we throw + const event = mockedEvent(null, "@u:s.co", "m.key.verification.start"); + + await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow( + "missing roomId in the event", + ); + + // And we do not fetch device details or handle the event + expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled(); + expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled(); + }); + + function mockedOlmMachine(): Mocked { + return { + queryKeysForUsers: jest.fn(), + getVerificationRequest: jest.fn(), + receiveVerificationEvent: jest.fn(), + } as unknown as Mocked; + } + + function makeRustCrypto( + olmMachine: OlmMachine, + outgoingRequestProcessor: OutgoingRequestProcessor, + ): RustCrypto { + const rustCrypto = new RustCrypto( + new DebugLogger(debug("test Verification requests")), + olmMachine, + {} as unknown as MatrixHttpApi, + TEST_USER, + TEST_DEVICE_ID, + {} as ServerSideSecretStorage, + {} as CryptoCallbacks, + ); + + // @ts-ignore mocking outgoingRequestProcessor + rustCrypto.outgoingRequestProcessor = outgoingRequestProcessor; + + return rustCrypto; + } + + function mockedOutgoingRequestProcessor(): OutgoingRequestProcessor { + return { + makeOutgoingRequest: jest.fn(), + } as unknown as Mocked; + } + + function mockedEvent( + roomId: string | null, + senderId: string | null, + eventType: string, + msgtype?: string | undefined, + ): MatrixEvent { + return { + isState: jest.fn().mockReturnValue(false), + getUnsigned: jest.fn().mockReturnValue({}), + isDecryptionFailure: jest.fn(), + isEncrypted: jest.fn(), + getType: jest.fn().mockReturnValue(eventType), + getRoomId: jest.fn().mockReturnValue(roomId), + getSender: jest.fn().mockReturnValue(senderId), + getId: jest.fn(), + getStateKey: jest.fn(), + getContent: jest.fn().mockReturnValue({ msgtype: msgtype }), + getTs: jest.fn(), + } as unknown as MatrixEvent; + } + }); }); /** Build a MatrixHttpApi instance */ diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 22d333600..d9333b434 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -26,6 +26,7 @@ import { type ILeftRoom, type IRoomEvent, type IStateEvent, + type IStickyEvent, type IStrippedState, type ISyncResponse, SyncAccumulator, @@ -1067,6 +1068,67 @@ describe("SyncAccumulator", function () { ); }); }); + + describe("MSC4354 sticky events", () => { + function stickyEvent(ts = 0): IStickyEvent { + const msgData = msg("test", "test text"); + return { + ...msgData, + msc4354_sticky: { + duration_ms: 1000, + }, + origin_server_ts: ts, + }; + } + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it("should accumulate sticky events", () => { + jest.setSystemTime(0); + const ev = stickyEvent(); + sa.accumulate( + syncSkeleton({ + msc4354_sticky: { + events: [ev], + }, + }), + ); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]); + }); + it("should clear stale sticky events", () => { + jest.setSystemTime(1000); + const ev = stickyEvent(1000); + sa.accumulate( + syncSkeleton({ + msc4354_sticky: { + events: [ev], + }, + }), + ); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]); + jest.setSystemTime(2000); // Expire the event + sa.accumulate(syncSkeleton({})); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined(); + }); + + it("clears stale sticky events that pretend to be from the distant future", () => { + jest.setSystemTime(0); + const eventFarInTheFuture = stickyEvent(999999999999); + sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } })); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ + eventFarInTheFuture, + ]); + jest.setSystemTime(1000); // Expire the event + sa.accumulate(syncSkeleton({})); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined(); + }); + }); }); function syncSkeleton( diff --git a/src/@types/requests.ts b/src/@types/requests.ts index b985bec29..713a6b7f2 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -34,6 +34,7 @@ export interface IJoinRoomOpts { /** * The server names to try and join through in addition to those that are automatically chosen. + * Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses. */ viaServers?: string[]; @@ -71,6 +72,7 @@ export interface KnockRoomOpts { /** * The server names to try and knock through in addition to those that are automatically chosen. + * Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses. */ viaServers?: string | string[]; } @@ -94,19 +96,20 @@ export interface ISendEventResponse { event_id: string; } -export type TimeoutDelay = { - delay: number; -}; - -export type ParentDelayId = { - parent_delay_id: string; -}; - -export type SendTimeoutDelayedEventRequestOpts = TimeoutDelay & Partial; -export type SendActionDelayedEventRequestOpts = ParentDelayId; - -export type SendDelayedEventRequestOpts = SendTimeoutDelayedEventRequestOpts | SendActionDelayedEventRequestOpts; +export type SendDelayedEventRequestOpts = { parent_delay_id: string } | { delay: number; parent_delay_id?: string }; +export function isSendDelayedEventRequestOpts(opts: object): opts is SendDelayedEventRequestOpts { + if ("parent_delay_id" in opts && typeof opts.parent_delay_id !== "string") { + // Invalid type, reject + return false; + } + if ("delay" in opts && typeof opts.delay !== "number") { + // Invalid type, reject. + return true; + } + // At least one of these fields must be specified. + return "delay" in opts || "parent_delay_id" in opts; +} export type SendDelayedEventResponse = { delay_id: string; }; diff --git a/src/client.ts b/src/client.ts index 1b7f27be6..bd1f83626 100644 --- a/src/client.ts +++ b/src/client.ts @@ -105,6 +105,7 @@ import { import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts"; import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts"; import { + isSendDelayedEventRequestOpts, type DelayedEventInfo, type IAddThreePidOnlyBody, type IBindThreePidBody, @@ -246,7 +247,7 @@ import { validateAuthMetadataAndKeys, } from "./oidc/index.ts"; import { type EmptyObject } from "./@types/common.ts"; -import { UnsupportedDelayedEventsEndpointError } from "./errors.ts"; +import { UnsupportedDelayedEventsEndpointError, UnsupportedStickyEventsEndpointError } from "./errors.ts"; export type Store = IStore; @@ -443,6 +444,12 @@ export interface ICreateClientOpts { */ isVoipWithNoMediaAllowed?: boolean; + /** + * Disable VoIP support (prevents fetching TURN servers, etc.) + * Default: false (VoIP enabled) + */ + disableVoip?: boolean; + /** * If true, group calls will not establish media connectivity and only create the signaling events, * so that livekit media can be used in the application layer (js-sdk contains no livekit code). @@ -545,6 +552,7 @@ export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms" export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms"; export const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140"; +export const UNSTABLE_MSC4354_STICKY_EVENTS = "org.matrix.msc4354"; export const UNSTABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133"; export const STABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133.stable"; @@ -1220,6 +1228,7 @@ export class MatrixClient extends TypedEventEmitter { this.checkTurnServers(); }, TURN_CHECK_INTERVAL); @@ -1670,7 +1681,7 @@ export class MatrixClient extends TypedEventEmitter1.11 (MSC4156) - queryParams.server_name = opts.viaServers; - queryParams.via = opts.viaServers; + // We only use the first 3 servers, to avoid URI length issues. + queryParams.via = queryParams.server_name = opts.viaServers.slice(0, 3); } const data: IJoinRequestBody = {}; @@ -2427,9 +2438,11 @@ export class MatrixClient extends TypedEventEmitter1.11 (MSC4156) - queryParams.server_name = opts.viaServers; - queryParams.via = opts.viaServers; + queryParams.server_name = viaServers; + queryParams.via = viaServers; } const body: Record = {}; @@ -2672,7 +2685,7 @@ export class MatrixClient extends TypedEventEmitter, - txnId?: string, - ): Promise; + private sendCompleteEvent(params: { + roomId: string; + threadId: string | null; + eventObject: Partial; + queryDict?: QueryDict; + txnId?: string; + }): Promise; /** * Sends a delayed event (MSC4140). * @param eventObject - An object with the partial structure of an event, to which event_id, user_id, room_id and origin_server_ts will be added. @@ -2723,29 +2737,29 @@ export class MatrixClient extends TypedEventEmitter, - delayOpts: SendDelayedEventRequestOpts, - txnId?: string, - ): Promise; - private sendCompleteEvent( - roomId: string, - threadId: string | null, - eventObject: Partial, - delayOptsOrTxnId?: SendDelayedEventRequestOpts | string, - txnIdOrVoid?: string, - ): Promise { - let delayOpts: SendDelayedEventRequestOpts | undefined; - let txnId: string | undefined; - if (typeof delayOptsOrTxnId === "string") { - txnId = delayOptsOrTxnId; - } else { - delayOpts = delayOptsOrTxnId; - txnId = txnIdOrVoid; - } - + private sendCompleteEvent(params: { + roomId: string; + threadId: string | null; + eventObject: Partial; + delayOpts: SendDelayedEventRequestOpts; + queryDict?: QueryDict; + txnId?: string; + }): Promise; + private sendCompleteEvent({ + roomId, + threadId, + eventObject, + delayOpts, + queryDict, + txnId, + }: { + roomId: string; + threadId: string | null; + eventObject: Partial; + delayOpts?: SendDelayedEventRequestOpts; + queryDict?: QueryDict; + txnId?: string; + }): Promise { if (!txnId) { txnId = this.makeTxnId(); } @@ -2788,7 +2802,7 @@ export class MatrixClient extends TypedEventEmitter; + protected async encryptAndSendEvent( + room: Room | null, + event: MatrixEvent, + queryDict?: QueryDict, + ): Promise; /** * Simply sends a delayed event without encrypting it. * TODO: Allow encrypted delayed events, and encrypt them properly @@ -2827,16 +2845,20 @@ export class MatrixClient extends TypedEventEmitter; + queryDict?: QueryDict, + ): Promise; protected async encryptAndSendEvent( room: Room | null, event: MatrixEvent, - delayOpts?: SendDelayedEventRequestOpts, + delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict, + queryDict?: QueryDict, ): Promise { - if (delayOpts) { - return this.sendEventHttpRequest(event, delayOpts); + let queryOpts = queryDict; + if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) { + return this.sendEventHttpRequest(event, delayOptsOrQuery, queryOpts); + } else if (!queryOpts) { + queryOpts = delayOptsOrQuery; } - try { let cancelled: boolean; this.eventsBeingEncrypted.add(event.getId()!); @@ -2872,7 +2894,7 @@ export class MatrixClient extends TypedEventEmitter { room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]); @@ -2987,14 +3009,16 @@ export class MatrixClient extends TypedEventEmitter; + private sendEventHttpRequest(event: MatrixEvent, queryDict?: QueryDict): Promise; private sendEventHttpRequest( event: MatrixEvent, delayOpts: SendDelayedEventRequestOpts, + queryDict?: QueryDict, ): Promise; private sendEventHttpRequest( event: MatrixEvent, - delayOpts?: SendDelayedEventRequestOpts, + queryOrDelayOpts?: SendDelayedEventRequestOpts | QueryDict, + queryDict?: QueryDict, ): Promise { let txnId = event.getTxnId(); if (!txnId) { @@ -3027,19 +3051,22 @@ export class MatrixClient extends TypedEventEmitter(Method.Put, path, undefined, content).then((res) => { - this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); - return res; - }); - } else { + if (delayOpts) { return this.http.authedRequest( Method.Put, path, - getUnstableDelayQueryOpts(delayOpts), + { ...getUnstableDelayQueryOpts(delayOpts), ...queryOpts }, content, ); + } else { + return this.http.authedRequest(Method.Put, path, queryOpts, content).then((res) => { + this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); + return res; + }); } } @@ -3096,16 +3123,16 @@ export class MatrixClient extends TypedEventEmitter( + roomId: string, + stickDuration: number, + delayOpts: SendDelayedEventRequestOpts, + threadId: string | null, + eventType: K, + content: TimelineEvents[K] & { msc4354_sticky_key: string }, + txnId?: string, + ): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "getDelayedEvents", + ); + } + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) { + throw new UnsupportedStickyEventsEndpointError( + "Server does not support the sticky events", + "sendStickyEvent", + ); + } + + this.addThreadRelationIfNeeded(content, threadId, roomId); + return this.sendCompleteEvent({ + roomId, + threadId, + eventObject: { type: eventType, content }, + queryDict: { "org.matrix.msc4354.sticky_duration_ms": stickDuration }, + delayOpts, + txnId, + }); } /** @@ -3430,6 +3504,38 @@ export class MatrixClient extends TypedEventEmitter( + roomId: string, + stickDuration: number, + threadId: string | null, + eventType: K, + content: TimelineEvents[K] & { msc4354_sticky_key: string }, + txnId?: string, + ): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) { + throw new UnsupportedStickyEventsEndpointError( + "Server does not support the sticky events", + "sendStickyEvent", + ); + } + + this.addThreadRelationIfNeeded(content, threadId, roomId); + return this.sendCompleteEvent({ + roomId, + threadId, + eventObject: { type: eventType, content }, + queryDict: { "org.matrix.msc4354.sticky_duration_ms": stickDuration }, + txnId, + }); + } + /** * Get all pending delayed events for the calling user. * @@ -5622,7 +5728,7 @@ export class MatrixClient extends TypedEventEmitter { - if (!this.canSupportVoip) { + if (!this.supportsVoip()) { return; } @@ -6971,6 +7077,7 @@ export class MatrixClient extends TypedEventEmitter { return ( + (await this.isVersionSupported("v1.16")) || (await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4133_EXTENDED_PROFILES)) || (await this.doesServerSupportUnstableFeature(STABLE_MSC4133_EXTENDED_PROFILES)) ); @@ -6982,7 +7089,10 @@ export class MatrixClient extends TypedEventEmitter { - if (await this.doesServerSupportUnstableFeature("uk.tcpip.msc4133.stable")) { + if ( + (await this.isVersionSupported("v1.16")) || + (await this.doesServerSupportUnstableFeature("uk.tcpip.msc4133.stable")) + ) { return ClientPrefix.V3; } return "/_matrix/client/unstable/uk.tcpip.msc4133"; diff --git a/src/errors.ts b/src/errors.ts index 8baf7979b..672aee3bb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -54,7 +54,7 @@ export class ClientStoppedError extends Error { } /** - * This error is thrown when the Homeserver does not support the delayed events enpdpoints. + * This error is thrown when the Homeserver does not support the delayed events endpoints. */ export class UnsupportedDelayedEventsEndpointError extends Error { public constructor( @@ -65,3 +65,16 @@ export class UnsupportedDelayedEventsEndpointError extends Error { this.name = "UnsupportedDelayedEventsEndpointError"; } } + +/** + * This error is thrown when the Homeserver does not support the sticky events endpoints. + */ +export class UnsupportedStickyEventsEndpointError extends Error { + public constructor( + message: string, + public clientEndpoint: "sendStickyEvent" | "sendStickyStateEvent", + ) { + super(message); + this.name = "UnsupportedStickyEventsEndpointError"; + } +} diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 6afbabb41..77a37bbf7 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -16,11 +16,12 @@ limitations under the License. import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; -import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitTransport.ts"; +import { type LivekitFocusSelection } from "./LivekitTransport.ts"; import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; import type { RTCCallIntent, Transport } from "./types.ts"; -import { type MatrixEvent } from "../models/event.ts"; +import { type IContent, type MatrixEvent } from "../models/event.ts"; import { type RelationType } from "../@types/event.ts"; +import { logger } from "../logger.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -42,46 +43,54 @@ export interface RtcMembershipData { "application": { type: string; // other application specific keys - [key: string]: any; + [key: string]: unknown; }; "rtc_transports": Transport[]; "versions": string[]; "msc4354_sticky_key"?: string; "sticky_key"?: string; - /** - * The intent of the call from the perspective of this user. This may be an audio call, video call or - * something else. - */ - "m.call.intent"?: RTCCallIntent; } const checkRtcMembershipData = ( - data: Partial>, + data: IContent, errors: string[], + referenceUserId: string, ): data is RtcMembershipData => { - const prefix = "Malformed rtc membership event: "; + const prefix = " - "; // required fields - if (typeof data.slot_id !== "string") errors.push(prefix + "slot_id must be string"); + if (typeof data.slot_id !== "string") { + errors.push(prefix + "slot_id must be string"); + } else { + if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"'); + } if (typeof data.member !== "object" || data.member === null) { errors.push(prefix + "member must be an object"); } else { if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string"); else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid"); + // This is not what the spec enforces but there currently are no rules what power levels are required to + // send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there + // is a proper definition when this is allowed. + else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender"); if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string"); if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string"); } if (typeof data.application !== "object" || data.application === null) { errors.push(prefix + "application must be an object"); } else { - if (typeof data.application.type !== "string") errors.push(prefix + "application.type must be a string"); + if (typeof data.application.type !== "string") { + errors.push(prefix + "application.type must be a string"); + } else { + if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"'); + } } if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) { errors.push(prefix + "rtc_transports must be an array"); } else { // validate that each transport has at least a string 'type' for (const t of data.rtc_transports) { - if (typeof t !== "object" || typeof (t as any).type !== "string") { + if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") { errors.push(prefix + "rtc_transports entries must be objects with a string type"); break; } @@ -94,12 +103,21 @@ const checkRtcMembershipData = ( } // optional fields - const stickyKey = data.sticky_key ?? data.msc4354_sticky_key; - if (stickyKey !== undefined && typeof stickyKey !== "string") { + if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) { + errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined"); + } + if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { errors.push(prefix + "sticky_key must be a string"); } - if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { - errors.push(prefix + "m.call.intent must be a string"); + if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") { + errors.push(prefix + "msc4354_sticky_key must be a string"); + } + if ( + data.sticky_key !== undefined && + data.msc4354_sticky_key !== undefined && + data.sticky_key !== data.msc4354_sticky_key + ) { + errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined"); } if (data["m.relates_to"] !== undefined) { const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"]; @@ -179,20 +197,25 @@ export type SessionMembershipData = { "m.call.intent"?: RTCCallIntent; }; -const checkSessionsMembershipData = ( - data: Partial>, - errors: string[], -): data is SessionMembershipData => { - const prefix = "Malformed session membership event: "; +const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => { + const prefix = " - "; if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); - if (data.focus_active !== undefined && !isLivekitFocusSelection(data.focus_active)) { + if (data.focus_active === undefined) { errors.push(prefix + "focus_active has an invalid type"); } - if (data.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) { - errors.push(prefix + "foci_preferred must be an array"); + if ( + data.foci_preferred !== undefined && + !( + Array.isArray(data.foci_preferred) && + data.foci_preferred.every( + (f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string", + ) + ) + ) { + errors.push(prefix + "foci_preferred must be an array of transport objects"); } // optional parameters if (data.created_ts !== undefined && typeof data.created_ts !== "number") { @@ -213,8 +236,7 @@ type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "sessio // TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. export class CallMembership { public static equal(a?: CallMembership, b?: CallMembership): boolean { - if (a === undefined || b === undefined) return a === b; - return deepCompare(a.membershipData, b.membershipData); + return deepCompare(a?.membershipData, b?.membershipData); } private membershipData: MembershipData; @@ -234,30 +256,33 @@ export class CallMembership { private readonly relatedEvent?: MatrixEvent, ) { const data = matrixEvent.getContent() as any; + + const eventId = matrixEvent.getId(); + const sender = matrixEvent.getSender(); + if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); + if (sender === undefined) throw new Error("parentEvent is missing sender field"); const sessionErrors: string[] = []; const rtcErrors: string[] = []; if (checkSessionsMembershipData(data, sessionErrors)) { this.membershipData = { kind: "session", data }; - } else if (checkRtcMembershipData(data, rtcErrors)) { + } else if (checkRtcMembershipData(data, rtcErrors, sender)) { this.membershipData = { kind: "rtc", data }; } else { - throw Error( - `unknown CallMembership data.` + - `Does not match MSC4143 call.member (${sessionErrors.join(" & ")})\n` + - `Does not match MSC4143 rtc.member (${rtcErrors.join(" & ")})\n` + - `events this could be a legacy membership event: (${data})`, - ); + const details = + sessionErrors.length < rtcErrors.length + ? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n` + : `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`; + const json = "\nevent:\n" + JSON.stringify(data).replaceAll('"', "'"); + throw Error(`unknown CallMembership data.\n` + details + json); } - - const eventId = matrixEvent.getId(); - const sender = matrixEvent.getSender(); - - if (eventId === undefined) throw new Error("CallMembership matrixEvent is missing eventId field"); - if (sender === undefined) throw new Error("CallMembership matrixEvent is missing sender field"); this.matrixEventData = { eventId, sender }; } + /** @deprecated use userId instead */ public get sender(): string { + return this.userId; + } + public get userId(): string { const { kind, data } = this.membershipData; switch (kind) { case "rtc": @@ -273,7 +298,7 @@ export class CallMembership { } /** - * The slot id to find all member building one session `slot_id` (format `{application}#{id}`). + * The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`). * This is computed in case SessionMembershipData is used. */ public get slotId(): string { @@ -299,7 +324,20 @@ export class CallMembership { } public get callIntent(): RTCCallIntent | undefined { - return this.membershipData.data["m.call.intent"]; + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": { + const intent = data.application["m.call.intent"]; + if (typeof intent === "string") { + return intent; + } + logger.warn("RTC membership has invalid m.call.intent"); + return undefined; + } + case "session": + default: + return data["m.call.intent"]; + } } /** @@ -319,7 +357,7 @@ export class CallMembership { return data.application; } } - public get applicationData(): { type: string } & Record { + public get applicationData(): { type: string; [key: string]: unknown } { const { kind, data } = this.membershipData; switch (kind) { case "rtc": @@ -397,14 +435,7 @@ export class CallMembership { * @returns true if the membership has expired, otherwise false */ public isExpired(): boolean { - const { kind } = this.membershipData; - switch (kind) { - case "rtc": - return false; - case "session": - default: - return this.getMsUntilExpiry()! <= 0; - } + return this.getMsUntilExpiry()! <= 0; } /** @@ -441,6 +472,17 @@ export class CallMembership { } return undefined; } + + /** + * The focus_active filed of the session membership (m.call.member). + * @deprecated focus_active is not used and will be removed in future versions. + */ + public getFocusActive(): LivekitFocusSelection | undefined { + const { kind, data } = this.membershipData; + if (kind === "session") return data.focus_active; + return undefined; + } + /** * The value of the `rtc_transports` field for RTC memberships (m.rtc.member). * Or the value of the `foci_preferred` field for legacy session memberships (m.call.member). diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index 8a000a578..7826fa9d1 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -79,6 +79,7 @@ export interface IMembershipManager /** * Start sending all necessary events to make this user participate in the RTC session. * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. + * If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously. * @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the * membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership` * transport selection will be used instead. diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 7cdb7d19d..429f3fa4a 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -249,8 +249,6 @@ export class MatrixRTCSession extends TypedEventEmitter< > { private membershipManager?: IMembershipManager; private encryptionManager?: IEncryptionManager; - // The session Id of the call, this is the call_id of the call Member event. - private _slotId: string | undefined; private joinConfig?: SessionConfig; private logger: Logger; @@ -300,7 +298,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * The slotId is the property that, per definition, groups memberships into one call. */ public get slotId(): string | undefined { - return this._slotId; + return slotDescriptionToId(this.slotDescription); } /** @@ -335,7 +333,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * oldest first. */ public static async sessionMembershipsForSlot( - room: Pick, + room: Pick, client: Pick, slotDescription: SlotDescription, existingMemberships?: CallMembership[], @@ -476,6 +474,8 @@ export class MatrixRTCSession extends TypedEventEmitter< public constructor( private readonly client: Pick< MatrixClient, + | "on" + | "off" | "getUserId" | "getDeviceId" | "sendStateEvent" @@ -484,25 +484,23 @@ export class MatrixRTCSession extends TypedEventEmitter< | "sendEvent" | "cancelPendingEvent" | "encryptAndSendToDevice" - | "off" - | "on" | "decryptEventIfNeeded" | "fetchRoomEvent" >, private roomSubset: Pick< Room, - "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off" + "on" | "off" | "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "findEventById" >, public memberships: CallMembership[], /** - * The session description is used to define the exact session this object is tracking. - * A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`. + * The slot description is a virtual address where participants are allowed to meet. + * This session will only manage memberships that match this slot description. + * Sessions are distinct if any of those properties are distinct: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`. */ public readonly slotDescription: SlotDescription, ) { super(); this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); - this._slotId = memberships[0]?.slotId; const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); @@ -541,14 +539,18 @@ export class MatrixRTCSession extends TypedEventEmitter< * This will not subscribe to updates: remember to call subscribe() separately if * desired. * This method will return immediately and the session will be joined in the background. - * - * @param fociActive - The object representing the active focus. (This depends on the focus type.) - * @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to. - * For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session, - * or optionally other room members homeserver well known. + * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. + * If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously. + * @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the + * membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership` + * transport selection will be used instead. * @param joinConfig - Additional configuration for the joined session. */ - public joinRoomSession(fociPreferred: Transport[], fociActive?: Transport, joinConfig?: JoinSessionConfig): void { + public joinRoomSession( + fociPreferred: Transport[], + multiSfuFocus?: Transport, + joinConfig?: JoinSessionConfig, + ): void { if (this.isJoined()) { this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`); return; @@ -621,7 +623,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.pendingNotificationToSend = this.joinConfig?.notificationType; // Join! - this.membershipManager!.join(fociPreferred, fociActive, (e) => { + this.membershipManager!.join(fociPreferred, multiSfuFocus, (e) => { this.logger.error("MembershipManager encountered an unrecoverable error: ", e); this.emit(MatrixRTCSessionEvent.MembershipManagerError, e); this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined()); @@ -656,22 +658,23 @@ export class MatrixRTCSession extends TypedEventEmitter< return await leavePromise; } - /** - * Get the focus in use from a specific specified member. - * @param member The member for which to get the active focus. If undefined, the own membership is used. - * @returns The focus that is currently in use to connect to this session. This is undefined - * if the client is not connected to this session. - * @deprecated use `member.getTransport(session.getOldestMembership())` instead if you want to get the active transport for a specific member. + * This returns the focus in use by the oldest membership. + * Do not use since this might be just the focus for the oldest membership. others might use a different focus. + * @deprecated use `member.getTransport(session.getOldestMembership())` instead for the specific member you want to get the focus for. */ - public resolveActiveFocus(member?: CallMembership): Transport | undefined { + public getFocusInUse(): Transport | undefined { const oldestMembership = this.getOldestMembership(); - if (!oldestMembership) return undefined; - const m = member === undefined ? this.membershipManager?.ownMembership : member; - if (!m) return undefined; - return m.getTransport(oldestMembership); + return oldestMembership?.getTransport(oldestMembership); } + /** + * The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus) + * @deprecated does not work with m.rtc.member. Do not rely on it. + */ + public getActiveFocus(): Transport | undefined { + return this.getOldestMembership()?.getFocusActive(); + } public getOldestMembership(): CallMembership | undefined { return this.memberships[0]; } @@ -819,14 +822,12 @@ export class MatrixRTCSession extends TypedEventEmitter< */ private async recalculateSessionMembers(): Promise { const oldMemberships = this.memberships; - const newMemberships = await MatrixRTCSession.sessionMembershipsForSlot( - this.room, + this.memberships = await MatrixRTCSession.sessionMembershipsForSlot( + this.roomSubset, this.client, this.slotDescription, oldMemberships, ); - this.memberships = newMemberships; - this._slotId = this._slotId ?? this.memberships[0]?.slotId; const changed = oldMemberships.length != this.memberships.length || diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 577a240f1..5139ac7ae 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0) { this.roomSessions.set(room.roomId, session); } @@ -102,7 +102,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter; // No common pattern for aggregated relations + "msc4354_sticky_duration_ttl_ms"?: number; [UNSIGNED_THREAD_ID_FIELD.name]?: string; } @@ -96,6 +97,7 @@ export interface IEvent { membership?: Membership; unsigned: IUnsigned; redacts?: string; + msc4354_sticky?: { duration_ms: number }; } export interface IAggregatedRelation { @@ -213,6 +215,7 @@ export interface IMessageVisibilityHidden { } // A singleton implementing `IMessageVisibilityVisible`. const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true }); +export const MAX_STICKY_DURATION_MS = 3600000; export enum MatrixEventEvent { /** @@ -408,6 +411,17 @@ export class MatrixEvent extends TypedEventEmitter; + /** + * The timestamp for when this event should expire, in milliseconds. + * Prefers using the server-provided value, but will fall back to local calculation. + * + * This value is **safe** to use, as malicious start time and duration are appropriately capped. + * + * If the event is not a sticky event (or not supported by the server), + * then this returns `undefined`. + */ + public readonly unstableStickyExpiresAt: number | undefined; + /** * Construct a Matrix Event object * @@ -447,8 +461,17 @@ export class MatrixEvent extends TypedEventEmitter void; +}; + +/** + * Tracks sticky events on behalf of one room, and fires an event + * whenever a sticky event is updated or replaced. + */ +export class RoomStickyEventsStore extends TypedEventEmitter { + private readonly stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event + private readonly unkeyedStickyEvents = new Set(); + + private stickyEventTimer?: ReturnType; + private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; + + /** + * Get all sticky events that are currently active. + * @returns An iterable set of events. + */ + public *getStickyEvents(): Iterable { + yield* this.unkeyedStickyEvents; + for (const innerMap of this.stickyEventsMap.values()) { + yield* innerMap.values(); + } + } + + /** + * Get an active sticky event that match the given `type`, `sender`, and `stickyKey` + * @param type The event `type`. + * @param sender The sender of the sticky event. + * @param stickyKey The sticky key used by the event. + * @returns A matching active sticky event, or undefined. + */ + public getKeyedStickyEvent(sender: string, type: string, stickyKey: string): StickyMatrixEvent | undefined { + return this.stickyEventsMap.get(type)?.get(`${stickyKey}${sender}`); + } + + /** + * Get active sticky events without a sticky key that match the given `type` and `sender`. + * @param type The event `type`. + * @param sender The sender of the sticky event. + * @returns An array of matching sticky events. + */ + public getUnkeyedStickyEvent(sender: string, type: string): StickyMatrixEvent[] { + return [...this.unkeyedStickyEvents].filter((ev) => ev.getType() === type && ev.getSender() === sender); + } + + /** + * Adds a sticky event into the local sticky event map. + * + * NOTE: This will not cause `RoomEvent.StickyEvents` to be emitted. + * + * @throws If the `event` does not contain valid sticky data. + * @param event The MatrixEvent that contains sticky data. + * @returns An object describing whether the event was added to the map, + * and the previous event it may have replaced. + */ + private addStickyEvent(event: MatrixEvent): { added: true; prevEvent?: StickyMatrixEvent } | { added: false } { + const stickyKey = event.getContent().msc4354_sticky_key; + if (typeof stickyKey !== "string" && stickyKey !== undefined) { + throw new Error(`${event.getId()} is missing msc4354_sticky_key`); + } + + // With this we have the guarantee, that all events in stickyEventsMap are correctly formatted + if (event.unstableStickyExpiresAt === undefined) { + throw new Error(`${event.getId()} is missing msc4354_sticky.duration_ms`); + } + const sender = event.getSender(); + const type = event.getType(); + if (!sender) { + throw new Error(`${event.getId()} is missing a sender`); + } else if (event.unstableStickyExpiresAt <= Date.now()) { + logger.info("ignored sticky event with older expiration time than current time", stickyKey); + return { added: false }; + } + + // While we fully expect the server to always provide the correct value, + // this is just insurance to protect against attacks on our Map. + if (!sender.startsWith("@")) { + throw new Error("Expected sender to start with @"); + } + + let prevEvent: StickyMatrixEvent | undefined; + if (stickyKey !== undefined) { + // Why this is safe: + // A type may contain anything but the *sender* is tightly + // constrained so that a key will always end with a @ + // E.g. Where a malicous event type might be "rtc.member.event@foo:bar" the key becomes: + // "rtc.member.event.@foo:bar@bar:baz" + const innerMapKey = `${stickyKey}${sender}`; + prevEvent = this.stickyEventsMap.get(type)?.get(innerMapKey); + + // sticky events are not allowed to expire sooner than their predecessor. + if (prevEvent && event.unstableStickyExpiresAt < prevEvent.unstableStickyExpiresAt) { + logger.info("ignored sticky event with older expiry time", stickyKey); + return { added: false }; + } else if ( + prevEvent && + event.getTs() === prevEvent.getTs() && + (event.getId() ?? "") < (prevEvent.getId() ?? "") + ) { + // This path is unlikely, as it requires both events to have the same TS. + logger.info("ignored sticky event due to 'id tie break rule' on sticky_key", stickyKey); + return { added: false }; + } + if (!this.stickyEventsMap.has(type)) { + this.stickyEventsMap.set(type, new Map()); + } + this.stickyEventsMap.get(type)!.set(innerMapKey, event as StickyMatrixEvent); + } else { + this.unkeyedStickyEvents.add(event as StickyMatrixEvent); + } + + // Recalculate the next expiry time. + this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); + + this.scheduleStickyTimer(); + return { added: true, prevEvent }; + } + + /** + * Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any + * changes were made. + * @param events A set of new sticky events. + */ + public addStickyEvents(events: MatrixEvent[]): void { + const added: StickyMatrixEvent[] = []; + const updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[] = []; + for (const event of events) { + try { + const result = this.addStickyEvent(event); + if (result.added) { + if (result.prevEvent) { + // e is validated as a StickyMatrixEvent by virtue of `addStickyEvent` returning added: true. + updated.push({ current: event as StickyMatrixEvent, previous: result.prevEvent }); + } else { + added.push(event as StickyMatrixEvent); + } + } + } catch (ex) { + logger.warn("ignored invalid sticky event", ex); + } + } + if (added.length || updated.length) this.emit(RoomStickyEventsEvent.Update, added, updated, []); + this.scheduleStickyTimer(); + } + + /** + * Schedule the sticky event expiry timer. The timer will + * run immediately if an event has already expired. + */ + private scheduleStickyTimer(): void { + if (this.stickyEventTimer) { + clearTimeout(this.stickyEventTimer); + this.stickyEventTimer = undefined; + } + if (this.nextStickyEventExpiryTs === Number.MAX_SAFE_INTEGER) { + // We have no events due to expire. + return; + } // otherwise, schedule in the future + this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiryTs - Date.now()); + } + + /** + * Clean out any expired sticky events. + */ + private readonly cleanExpiredStickyEvents = (): void => { + const now = Date.now(); + const removedEvents: StickyMatrixEvent[] = []; + + // We will recalculate this as we check all events. + this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER; + for (const [eventType, innerEvents] of this.stickyEventsMap.entries()) { + for (const [innerMapKey, event] of innerEvents) { + // we only added items with `sticky` into this map so we can assert non-null here + if (now >= event.unstableStickyExpiresAt) { + logger.debug("Expiring sticky event", event.getId()); + removedEvents.push(event); + this.stickyEventsMap.get(eventType)!.delete(innerMapKey); + } else { + // If not removing the event, check to see if it's the next lowest expiry. + this.nextStickyEventExpiryTs = Math.min( + this.nextStickyEventExpiryTs, + event.unstableStickyExpiresAt, + ); + } + } + // Clean up map after use. + if (this.stickyEventsMap.get(eventType)?.size === 0) { + this.stickyEventsMap.delete(eventType); + } + } + for (const event of this.unkeyedStickyEvents) { + if (now >= event.unstableStickyExpiresAt) { + logger.debug("Expiring sticky event", event.getId()); + this.unkeyedStickyEvents.delete(event); + removedEvents.push(event); + } else { + // If not removing the event, check to see if it's the next lowest expiry. + this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, event.unstableStickyExpiresAt); + } + } + if (removedEvents.length) { + this.emit(RoomStickyEventsEvent.Update, [], [], removedEvents); + } + // Finally, schedule the next run. + this.scheduleStickyTimer(); + }; + + /** + * Clear all events and stop the timer from firing. + */ + public clear(): void { + this.stickyEventsMap.clear(); + // Unschedule timer. + this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER; + this.scheduleStickyTimer(); + } +} diff --git a/src/models/room.ts b/src/models/room.ts index 6cdfaa39a..ffd35c2a7 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts"; import { KnownMembership, type Membership } from "../@types/membership.ts"; import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts"; import { type MSC4186Hero } from "../sliding-sync.ts"; +import { RoomStickyEventsStore, RoomStickyEventsEvent, type RoomStickyEventsMap } from "./room-sticky-events.ts"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -167,6 +168,7 @@ export type RoomEmittedEvents = | RoomStateEvent.NewMember | RoomStateEvent.Update | RoomStateEvent.Marker + | RoomStickyEventsEvent.Update | ThreadEvent.New | ThreadEvent.Update | ThreadEvent.NewReply @@ -320,6 +322,7 @@ export type RoomEventHandlerMap = { } & Pick & EventTimelineSetHandlerMap & Pick & + Pick & Pick< RoomStateEventHandlerMap, | RoomStateEvent.Events @@ -446,6 +449,11 @@ export class Room extends ReadReceipt { */ private roomReceipts = new RoomReceipts(this); + /** + * Stores and tracks sticky events + */ + private stickyEvents = new RoomStickyEventsStore(); + /** * Construct a new Room. * @@ -492,6 +500,7 @@ export class Room extends ReadReceipt { // Listen to our own receipt event as a more modular way of processing our own // receipts. No need to remove the listener: it's on ourself anyway. this.on(RoomEvent.Receipt, this.onReceipt); + this.reEmitter.reEmit(this.stickyEvents, [RoomStickyEventsEvent.Update]); // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. @@ -3414,6 +3423,55 @@ export class Room extends ReadReceipt { return this.accountData.get(type); } + /** + * Get an iterator of currently active sticky events. + */ + // eslint-disable-next-line + public _unstable_getStickyEvents(): ReturnType { + return this.stickyEvents.getStickyEvents(); + } + + /** + * Get a sticky event that match the given `type`, `sender`, and `stickyKey` + * @param type The event `type`. + * @param sender The sender of the sticky event. + * @param stickyKey The sticky key used by the event. + * @returns A matching active sticky event, or undefined. + */ + // eslint-disable-next-line + public _unstable_getKeyedStickyEvent( + sender: string, + type: string, + stickyKey: string, + ): ReturnType { + return this.stickyEvents.getKeyedStickyEvent(sender, type, stickyKey); + } + + /** + * Get active sticky events without a sticky key that match the given `type` and `sender`. + * @param type The event `type`. + * @param sender The sender of the sticky event. + * @returns An array of matching sticky events. + */ + // eslint-disable-next-line + public _unstable_getUnkeyedStickyEvent( + sender: string, + type: string, + ): ReturnType { + return this.stickyEvents.getUnkeyedStickyEvent(sender, type); + } + + /** + * Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any + * changes were made. + * @param events A set of new sticky events. + * @internal + */ + // eslint-disable-next-line + public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType { + return this.stickyEvents.addStickyEvents(events); + } + /** * Returns whether the syncing user has permission to send a message in the room * @returns true if the user should be permitted to send diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 8e8ccb67e..638bf37d1 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -2053,20 +2053,38 @@ export class RustCrypto extends TypedEventEmitter { const roomId = event.getRoomId(); + const senderId = event.getSender(); if (!roomId) { throw new Error("missing roomId in the event"); } + if (!senderId) { + throw new Error("missing sender in the event"); + } + this.logger.debug( `Incoming verification event ${event.getId()} type ${event.getType()} from ${event.getSender()}`, ); - await this.olmMachine.receiveVerificationEvent( + const isRoomVerificationRequest = + event.getType() === EventType.RoomMessage && event.getContent().msgtype === MsgType.KeyVerificationRequest; + + if (isRoomVerificationRequest) { + // Before processing an in-room verification request, we need to + // make sure we have the sender's device information - otherwise we + // will immediately abort verification. So we explicitly fetch it + // from /keys/query and wait for that request to complete before we + // call receiveVerificationEvent. + const req = this.getOlmMachineOrThrow().queryKeysForUsers([new RustSdkCryptoJs.UserId(senderId)]); + await this.outgoingRequestProcessor.makeOutgoingRequest(req); + } + + await this.getOlmMachineOrThrow().receiveVerificationEvent( JSON.stringify({ event_id: event.getId(), type: event.getType(), - sender: event.getSender(), + sender: senderId, state_key: event.getStateKey(), content: event.getContent(), origin_server_ts: event.getTs(), @@ -2074,11 +2092,8 @@ export class RustCrypto extends TypedEventEmitter; +} + export interface IJoinedRoom { "summary": IRoomSummary; // One of `state` or `state_after` is required. "state"?: IState; "org.matrix.msc4222.state_after"?: IState; // https://github.com/matrix-org/matrix-spec-proposals/pull/4222 + "msc4354_sticky"?: ISticky; // https://github.com/matrix-org/matrix-spec-proposals/pull/4354 "timeline": ITimeline; "ephemeral": IEphemeral; "account_data": IAccountData; @@ -201,6 +215,14 @@ interface IRoom { _unreadNotifications: Partial; _unreadThreadNotifications?: Record>; _receipts: ReceiptAccumulator; + _stickyEvents: { + readonly event: IStickyEvent | IStickyStateEvent; + /** + * This is the timestamp at which point it is safe to remove this event from the store. + * This value is immutable + */ + readonly expiresTs: number; + }[]; } export interface ISyncData { @@ -411,6 +433,7 @@ export class SyncAccumulator { // Accumulate timeline and state events in a room. private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void { + const now = Date.now(); // We expect this function to be called a lot (every /sync) so we want // this to be fast. /sync stores events in an array but we often want // to clobber based on type/state_key. Rather than convert arrays to @@ -457,6 +480,7 @@ export class SyncAccumulator { _unreadThreadNotifications: {}, _summary: {}, _receipts: new ReceiptAccumulator(), + _stickyEvents: [], }; } const currentData = this.joinRooms[roomId]; @@ -540,6 +564,27 @@ export class SyncAccumulator { }); }); + // Prune out any events in our stores that have since expired, do this before we + // insert new events. + currentData._stickyEvents = currentData._stickyEvents.filter(({ expiresTs }) => expiresTs > now); + + // We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will + // process these events into the correct mapped order. + if (data.msc4354_sticky?.events) { + currentData._stickyEvents = currentData._stickyEvents.concat( + data.msc4354_sticky.events.map((event) => { + // If `duration_ms` exceeds the spec limit of a hour, we cap it. + const cappedDuration = Math.min(event.msc4354_sticky.duration_ms, MAX_STICKY_DURATION_MS); + // If `origin_server_ts` claims to have been from the future, we still bound it to now. + const createdTs = Math.min(event.origin_server_ts, now); + return { + event, + expiresTs: cappedDuration + createdTs, + }; + }), + ); + } + // attempt to prune the timeline by jumping between events which have // pagination tokens. if (currentData._timeline.length > this.opts.maxTimelineEntries!) { @@ -611,6 +656,11 @@ export class SyncAccumulator { "unread_notifications": roomData._unreadNotifications, "unread_thread_notifications": roomData._unreadThreadNotifications, "summary": roomData._summary as IRoomSummary, + "msc4354_sticky": roomData._stickyEvents?.length + ? { + events: roomData._stickyEvents.map((e) => e.event), + } + : undefined, }; // Add account data Object.keys(roomData._accountData).forEach((evType) => { diff --git a/src/sync.ts b/src/sync.ts index 4cc23c0a1..a191fa877 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1082,6 +1082,8 @@ export class SyncApi { // highlight_count: 0, // notification_count: 0, // } + // "org.matrix.msc4222.state_after": { events: [] }, // only if "org.matrix.msc4222.use_state_after" is true + // msc4354_sticky: { events: [] }, // only if "org.matrix.msc4354.sticky" is true // } // }, // leave: { @@ -1219,6 +1221,7 @@ export class SyncApi { const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false); const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral); const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data); + const stickyEvents = this.mapSyncEventsFormat(joinObj.msc4354_sticky); // If state_after is present, this is the events that form the state at the end of the timeline block and // regular timeline events do *not* count towards state. If it's not present, then the state is formed by @@ -1402,6 +1405,18 @@ export class SyncApi { // we deliberately don't add accountData to the timeline room.addAccountData(accountDataEvents); + // Sticky events primarily come via the `timeline` field, with the + // sticky info field marking them as sticky. + // If the sync is "gappy" (meaning it is skipping events to catch up) then + // sticky events will instead come down the sticky section. + // This ensures we collect sticky events from both places. + const stickyEventsAndStickyEventsFromTheTimeline = stickyEvents.concat( + timelineEvents.filter((e) => e.unstableStickyInfo !== undefined), + ); + // Note: We calculate sticky events before emitting `.Room` as it's nice to have + // sticky events calculated and ready to go. + room._unstable_addStickyEvents(stickyEventsAndStickyEventsFromTheTimeline); + room.recalculate(); if (joinObj.isBrandNewRoom) { client.store.storeRoom(room); @@ -1411,11 +1426,21 @@ export class SyncApi { this.processEventsForNotifs(room, timelineEvents); const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e); + // this fires a couple of times for some events. (eg state events are in the timeline and the state) + // should this get a sync section as an additional event emission param (e, syncSection))? stateEvents.forEach(emitEvent); timelineEvents.forEach(emitEvent); ephemeralEvents.forEach(emitEvent); accountDataEvents.forEach(emitEvent); - + stickyEvents + .filter( + (stickyEvent) => + // This is highly unlikey, but in the case where a sticky event + // has appeared in the timeline AND the sticky section, we only + // want to emit the event once. + !timelineEvents.some((timelineEvent) => timelineEvent.getId() === stickyEvent.getId()), + ) + .forEach(emitEvent); // Decrypt only the last message in all rooms to make sure we can generate a preview // And decrypt all events after the recorded read receipt to ensure an accurate // notification count