Compare commits

..

16 Commits

Author SHA1 Message Date
RiotRobot f2d082064e v39.0.0 2025-10-21 11:18:29 +00:00
RiotRobot 2e9f5b6033 v39.0.0-rc.0 2025-10-14 13:34:57 +00:00
Timo fd949fe486 [MatrixRTC] Multi SFU support + m.rtc.member event type support (#5022)
* WIP

* temp

Signed-off-by: Timo K <toger5@hotmail.de>

* Fix imports

* Fix checkSessionsMembershipData thinking foci_preferred is required

* incorporate CallMembership changes
 - rename Focus -> Transport
 - add RtcMembershipData (next to `sessionMembershipData`)
 - make `new CallMembership` initializable with both
 - move oldest member calculation into CallMembership

Signed-off-by: Timo K <toger5@hotmail.de>

* use correct event type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix sonar cube conerns

Signed-off-by: Timo K <toger5@hotmail.de>

* callMembership tests

Signed-off-by: Timo K <toger5@hotmail.de>

* make test correct

Signed-off-by: Timo K <toger5@hotmail.de>

* make sonar cube happy (it does not know about the type constraints...)

Signed-off-by: Timo K <toger5@hotmail.de>

* remove created_ts from RtcMembership

Signed-off-by: Timo K <toger5@hotmail.de>

* fix imports

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/IMembershipManager.ts

Co-authored-by: Robin <robin@robin.town>

* rename LivekitFocus.ts -> LivekitTransport.ts

Signed-off-by: Timo K <toger5@hotmail.de>

* add details to `getTransport`

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* use DEFAULT_EXPIRE_DURATION in tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test `does not provide focus if the selection method is unknown`

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* Move `m.call.intent` into the `application` section for rtc member events.

Signed-off-by: Timo K <toger5@hotmail.de>

* review on rtc object validation code.

Signed-off-by: Timo K <toger5@hotmail.de>

* user id check

Signed-off-by: Timo K <toger5@hotmail.de>

* review: Refactor RTC membership handling and improve error handling

Signed-off-by: Timo K <toger5@hotmail.de>

* docstring updates

Signed-off-by: Timo K <toger5@hotmail.de>

* add back deprecated `getFocusInUse` & `getActiveFocus`

Signed-off-by: Timo K <toger5@hotmail.de>

* ci

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* lint

Signed-off-by: Timo K <toger5@hotmail.de>

* make test less strict for ew tests

Signed-off-by: Timo K <toger5@hotmail.de>

* Typescript downstream test adjustments

Signed-off-by: Timo K <toger5@hotmail.de>

* err

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
2025-10-08 19:12:29 +00:00
Michael Telatynski 7b3aed8a47 Tidy knip config (#5036)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-08 14:19:25 +00:00
Will Hunt b84a73c7cc Implement Sticky Events MSC4354 (#5028)
* Implement Sticky Events MSC

* Renames

* lint

* some review work

* Update for support for 4-ples

* fix lint

* pull through method

* Fix the mistake

* More tests to appease SC

* Cleaner code

* Review cleanup

* Refactors based on review.

* lint

* Store sticky event expiry TS at insertion time.

* proper type
2025-10-07 17:24:10 +00:00
Michael Telatynski a03cf054a8 Only use the first 3 viaServers specified (#5034)
* Only use the first 3 viaServers specified

To avoid HTTP 414 URI Too Long error

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-07 13:14:13 +00:00
RiotRobot b3d217717a Merge branch 'master' into develop 2025-10-07 12:06:14 +00:00
Andy Balaam a213d177f9 Fetch the user's device info before processing a verification request (#5030)
* Use checked way to get OlmMachine

* Factor out two variables in onKeyVerificationEvent

* Make sure verification test waits for the request to be processed

* Fetch the user's device info before processing a verification request

If we don't have the device info for a user when we receive their
verification request, we ignore it. This change gives us the best
possible chance of having the right device data before we try to process
the verification.

Fixes #30693
Fixes #27819
2025-10-07 08:13:31 +00:00
Will Hunt e885ecf08d Stabilise extended profiles (now part of Matrix v1.16) (#5013)
* Stabilise extended profiles (v1.16)

* lint

* Add capacity

* Update profile keys

* copyright

* lint
2025-10-06 15:10:15 +00:00
pkuzco d1d9aba745 feat(client): allow disabling VoIP support (#5021)
* feat(client): allow disabling VoIP support

* feat(client): allow disabling VoIP support

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

* add a unit-test for disableVoip option

* fix lint issue

---------

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>
Co-authored-by: Bahaa Naamneh <glimm.no@gmail.com>
2025-10-01 16:52:36 +00:00
Michael Telatynski 52bcc2c955 Update community PR check to exclude dependabot (#5029) 2025-10-01 12:01:33 +00:00
renovate[bot] 1994806c72 Update typescript-eslint monorepo to v8.44.1 (#5025)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:31:31 +00:00
renovate[bot] c4d1fd2c67 Update guibranco/github-status-action-v2 digest to 5530c59 (#5023)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:18:48 +00:00
renovate[bot] 7ad8288525 Update dependency @types/node to v18.19.127 (#5024)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:08:41 +00:00
renovate[bot] 31a42964e6 Update all non-major dependencies (#5026)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:07:57 +00:00
renovate[bot] 2b1d37813c Update dependency @stylistic/eslint-plugin to v5.4.0 (#5027)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:07:29 +00:00
40 changed files with 2432 additions and 722 deletions
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -87,7 +87,7 @@ jobs:
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -116,7 +116,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+18
View File
@@ -1,3 +1,21 @@
Changes in [39.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.0.0) (2025-10-21)
==================================================================================================
## 🚨 BREAKING CHANGES
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
## ✨ Features
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
* Implement Sticky Events MSC4354 ([#5028](https://github.com/matrix-org/matrix-js-sdk/pull/5028)). Contributed by @Half-Shot.
* feat(client): allow disabling VoIP support ([#5021](https://github.com/matrix-org/matrix-js-sdk/pull/5021)). Contributed by @pkuzco.
## 🐛 Bug Fixes
* Only use the first 3 viaServers specified ([#5034](https://github.com/matrix-org/matrix-js-sdk/pull/5034)). Contributed by @t3chguy.
* Fetch the user's device info before processing a verification request ([#5030](https://github.com/matrix-org/matrix-js-sdk/pull/5030)). Contributed by @andybalaam.
Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07)
==================================================================================================
## ✨ Features
-4
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "38.4.0",
"version": "39.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
+26 -7
View File
@@ -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(
+55
View File
@@ -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`.
+274 -11
View File
@@ -19,6 +19,7 @@ import {
CallMembership,
type SessionMembershipData,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
} from "../../../src/matrixrtc/CallMembership";
import { membershipTemplate } from "./mocks";
@@ -26,6 +27,7 @@ function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: jest.fn().mockReturnValue(originTs),
getSender: jest.fn().mockReturnValue("@alice:example.org"),
getId: jest.fn().mockReturnValue("$eventid"),
} as unknown as MatrixEvent;
}
@@ -40,12 +42,13 @@ describe("CallMembership", () => {
});
const membershipTemplate: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
focus_active: { type: "livekit" },
foci_preferred: [{ type: "livekit" }],
"call_id": "",
"scope": "m.room",
"application": "m.call",
"device_id": "AAAAAAA",
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
"foci_preferred": [{ type: "livekit" }],
"m.call.intent": "voice",
};
it("rejects membership with no device_id", () => {
@@ -94,11 +97,271 @@ describe("CallMembership", () => {
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
);
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] });
expect(membership.transports).toEqual([mockFocus]);
});
describe("getTransport", () => {
const mockFocus = { type: "this_is_a_mock_focus" };
const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate);
it("gets the correct active transport with oldest_membership", () => {
const membership = new CallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we use its focus.
expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]);
});
it("gets the correct active transport with multi_sfu", () => {
const membership = new CallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we still use our own focus in multi sfu.
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
});
it("does not provide focus if the selection method is unknown", () => {
const membership = new CallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "unknown" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toBeUndefined();
});
});
describe("correct values from computed fields", () => {
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
it("returns correct eventId", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
});
it("returns correct call intent", () => {
expect(membership.callIntent).toBe("voice");
});
it("returns correct application", () => {
expect(membership.application).toStrictEqual("m.call");
});
it("returns correct applicationData", () => {
expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" });
});
it("returns correct scope", () => {
expect(membership.scope).toBe("m.room");
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("0");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now());
expect(membership.isExpired()).toBe(true);
});
});
});
describe("RtcMembershipData", () => {
const membershipTemplate: 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" }],
versions: [],
msc4354_sticky_key: "abc123",
};
it("rejects membership with no slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
}).toThrow();
});
it("rejects membership with invalid slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
}).not.toThrow();
});
it("rejects membership with no application", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
}).toThrow();
});
it("rejects membership with incorrect application", () => {
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
application: { wrong_type_key: "unknown" },
});
}).toThrow();
});
it("rejects membership with no member", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
}).toThrow();
});
it("rejects membership with incorrect member", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@@test" },
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
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(), membershipTemplate);
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: 1,
msc4354_sticky_key: undefined,
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: "1",
msc4354_sticky_key: undefined,
});
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: 1,
sticky_key: "valid",
});
}).toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid",
sticky_key: "valid",
});
}).not.toThrow();
expect(() => {
new CallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid_but_different",
sticky_key: "valid",
});
}).toThrow();
});
it("considers memberships unexpired if local age low enough", () => {
// TODO link prev event
});
it("considers memberships expired if local age large enough", () => {
// TODO link prev event
});
describe("getTransport", () => {
it("gets the correct active transport with oldest_membership", () => {
const oldestMembership = new CallMembership(makeMockEvent(), {
...membershipTemplate,
rtc_transports: [{ type: "oldest_transport" }],
});
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
// If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu)
expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" });
});
});
describe("correct values from computed fields", () => {
const membership = new CallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
it("returns correct eventId", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
});
it("returns correct call intent", () => {
expect(membership.callIntent).toBe("voice");
});
it("returns correct application", () => {
expect(membership.application).toStrictEqual("m.call");
});
it("returns correct applicationData", () => {
expect(membership.applicationData).toStrictEqual({
"type": "m.call",
"m.call.id": "",
"m.call.intent": "voice",
});
});
it("returns correct scope", () => {
expect(membership.scope).toBe(undefined);
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("xyzHASHxyz");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(undefined);
expect(membership.getMsUntilExpiry()).toBe(undefined);
expect(membership.isExpired()).toBe(false);
});
});
});
@@ -14,47 +14,51 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
import {
isLivekitTransport,
isLivekitFocusSelection,
isLivekitTransportConfig,
} from "../../../src/matrixrtc/LivekitTransport";
describe("LivekitFocus", () => {
it("isLivekitFocus", () => {
expect(
isLivekitFocus({
isLivekitTransport({
type: "livekit",
livekit_service_url: "http://test.com",
livekit_alias: "test",
}),
).toBeTruthy();
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
expect(isLivekitTransport({ type: "livekit" })).toBeFalsy();
expect(
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
isLivekitTransport({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
isLivekitTransport({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
).toBeFalsy();
});
it("isLivekitFocusActive", () => {
expect(
isLivekitFocusActive({
isLivekitFocusSelection({
type: "livekit",
focus_selection: "oldest_membership",
}),
).toBeTruthy();
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
});
it("isLivekitFocusConfig", () => {
expect(
isLivekitFocusConfig({
isLivekitTransportConfig({
type: "livekit",
livekit_service_url: "http://test.com",
}),
).toBeTruthy();
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
});
});
+3 -4
View File
@@ -53,12 +53,12 @@ describe("MatrixRTCSession", () => {
sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession);
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].sessionDescription.id).toEqual("");
expect(sess?.memberships[0].slotDescription.id).toEqual("");
expect(sess?.memberships[0].scope).toEqual("m.room");
expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.sessionDescription.id).toEqual("");
expect(sess?.slotDescription.id).toEqual("");
});
it("ignores memberships where application is not m.call", () => {
@@ -268,7 +268,6 @@ describe("MatrixRTCSession", () => {
type: "livekit",
focus_selection: "oldest_membership",
});
expect(sess.getActiveFocus()).toBe(firstPreferredFocus);
jest.useRealTimers();
});
it("does not provide focus if the selection method is unknown", () => {
@@ -288,7 +287,7 @@ describe("MatrixRTCSession", () => {
type: "livekit",
focus_selection: "unknown",
});
expect(sess.getActiveFocus()).toBe(undefined);
expect(sess.memberships.length).toBe(0);
});
});
+86 -103
View File
@@ -27,12 +27,11 @@ import {
import {
MembershipManagerEvent,
Status,
type Focus,
type LivekitFocusActive,
type Transport,
type SessionMembershipData,
type LivekitFocusSelection,
} from "../../../src/matrixrtc";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
import { logger } from "../../../src/logger.ts";
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
/**
@@ -76,11 +75,11 @@ const callSession = { id: "", application: "m.call" };
describe("MembershipManager", () => {
let client: MockClient;
let room: Room;
const focusActive: LivekitFocusActive = {
const focusActive: LivekitFocusSelection = {
focus_selection: "oldest_membership",
type: "livekit",
};
const focus: Focus = {
const focus: Transport = {
type: "livekit",
livekit_service_url: "https://active.url",
livekit_alias: "!active:active.url",
@@ -104,12 +103,12 @@ describe("MembershipManager", () => {
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
});
@@ -123,8 +122,8 @@ describe("MembershipManager", () => {
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
// Test
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
memberManager.join([focus], focusActive);
const memberManager = new MembershipManager(undefined, room, client, callSession);
memberManager.join([focus], undefined);
// expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
@@ -152,8 +151,45 @@ describe("MembershipManager", () => {
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("sends a rtc membership event when using `useRtcMemberFormat`", async () => {
// Spys/Mocks
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
// Test
const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession);
memberManager.join([], focus);
// expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
"org.matrix.msc4143.rtc.member",
{
application: { type: "m.call" },
member: {
user_id: "@alice:example.org",
id: "_@alice:example.org_AAAAAAA_m.call",
device_id: "AAAAAAA",
},
slot_id: "m.call#",
rtc_transports: [focus],
versions: [],
},
"_@alice:example.org_AAAAAAA_m.call",
);
updateDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 8000 },
"org.matrix.msc4143.rtc.member",
{},
"_@alice:example.org_AAAAAAA_m.call",
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
const memberManager = new MembershipManager(undefined, room, client, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
@@ -228,10 +264,9 @@ describe("MembershipManager", () => {
},
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
manager.join([focus]);
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
await sendDelayedStateAttempt;
@@ -286,8 +321,8 @@ describe("MembershipManager", () => {
describe("delayed leave event", () => {
it("does not try again to schedule a delayed leave event if not supported", () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
@@ -298,21 +333,15 @@ describe("MembershipManager", () => {
});
it("does try to schedule a delayed leave event again if rate limited", async () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
await jest.advanceTimersByTimeAsync(5000);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
it("uses delayedLeaveEventDelayMs from config", () => {
const manager = new MembershipManager(
{ delayedLeaveEventDelayMs: 123456 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession);
manager.join([focus]);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 123456 },
@@ -329,11 +358,11 @@ describe("MembershipManager", () => {
{ delayedLeaveEventRestartMs: RESTART_DELAY },
room,
client,
() => undefined,
callSession,
);
// Join with the membership manager
manager.join([focus], focusActive);
manager.join([focus]);
expect(manager.status).toBe(Status.Connecting);
// Let the scheduler run one iteration so that we can send the join state event
await jest.runOnlyPendingTimersAsync();
@@ -367,11 +396,11 @@ describe("MembershipManager", () => {
{ membershipEventExpiryMs: 1234567 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
manager.join([focus]);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
@@ -393,11 +422,11 @@ describe("MembershipManager", () => {
});
it("does nothing if join called when already joined", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
manager.join([focus], focusActive);
manager.join([focus]);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
@@ -405,16 +434,16 @@ describe("MembershipManager", () => {
describe("leave()", () => {
// TODO add rate limit cases.
it("resolves delayed leave event when leave is called", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
await manager.leave();
@@ -428,60 +457,16 @@ describe("MembershipManager", () => {
);
});
it("does nothing if not joined", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
expect(async () => await manager.leave()).not.toThrow();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("getsActiveFocus", () => {
it("gets the correct active focus with oldest_membership", () => {
const getOldestMembership = jest.fn();
const manager = new MembershipManager({}, room, client, getOldestMembership, callSession);
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
expect(manager.getActiveFocus()).toBe(undefined);
manager.join([focus], focusActive);
// After joining we want our own focus to be the one we select.
getOldestMembership.mockReturnValue(
mockCallMembership(
{
...membershipTemplate,
foci_preferred: [
{
livekit_alias: "!active:active.url",
livekit_service_url: "https://active.url",
type: "livekit",
},
],
user_id: client.getUserId()!,
device_id: client.getDeviceId()!,
created_ts: 1000,
},
room.roomId,
),
);
expect(manager.getActiveFocus()).toStrictEqual(focus);
getOldestMembership.mockReturnValue(
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
room.roomId,
),
);
// If there is an older member we use its focus.
expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]);
});
it("does not provide focus if the selection method is unknown", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
expect(manager.getActiveFocus()).toBe(undefined);
});
});
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
@@ -489,7 +474,7 @@ describe("MembershipManager", () => {
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("does nothing if own membership still present", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
@@ -513,7 +498,7 @@ describe("MembershipManager", () => {
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("recreates membership if it is missing", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
@@ -531,7 +516,7 @@ describe("MembershipManager", () => {
});
it("updates the UpdateExpiry entry in the action scheduler", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
@@ -564,7 +549,6 @@ describe("MembershipManager", () => {
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
@@ -596,7 +580,7 @@ describe("MembershipManager", () => {
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
room,
client,
() => undefined,
{ id: "", application: "m.call" },
);
manager.join([focus], focusActive);
@@ -621,14 +605,14 @@ describe("MembershipManager", () => {
describe("status updates", () => {
it("starts 'Disconnected'", () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
expect(manager.status).toBe(Status.Disconnected);
});
it("emits 'Connection' and 'Connected' after join", async () => {
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
@@ -642,7 +626,7 @@ describe("MembershipManager", () => {
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
});
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
@@ -658,7 +642,7 @@ describe("MembershipManager", () => {
it("sends retry if call membership event is still valid at time of retry", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
@@ -685,7 +669,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
// RateLimit error.
manager.join([focus], focusActive);
@@ -705,7 +689,7 @@ describe("MembershipManager", () => {
it("abandons retry loop if leave() was called before sending state event", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
handle.reject?.(
new MatrixError(
@@ -740,7 +724,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
// Hit rate limit
@@ -773,7 +757,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "2" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
@@ -793,7 +777,7 @@ describe("MembershipManager", () => {
new Headers({ "Retry-After": "1" }),
),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) {
@@ -804,7 +788,7 @@ describe("MembershipManager", () => {
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, unrecoverableError);
await waitForMockCall(client.sendStateEvent);
expect(unrecoverableError).not.toHaveBeenCalledWith();
@@ -817,7 +801,6 @@ describe("MembershipManager", () => {
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive, unrecoverableError);
@@ -836,7 +819,7 @@ describe("MembershipManager", () => {
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
);
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
@@ -850,7 +833,7 @@ describe("MembershipManager", () => {
{ delayedLeaveEventDelayMs: 10000 },
room,
client,
() => undefined,
callSession,
);
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
@@ -904,7 +887,7 @@ describe("MembershipManager", () => {
describe("updateCallIntent()", () => {
it("should fail if the user has not joined the call", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
// After joining we want our own focus to be the one we select.
try {
await manager.updateCallIntent("video");
@@ -913,7 +896,7 @@ describe("MembershipManager", () => {
});
it("can adjust the intent", async () => {
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
@@ -926,7 +909,7 @@ describe("MembershipManager", () => {
});
it("does nothing if the intent doesn't change", async () => {
const manager = new MembershipManager({ callIntent: "video" }, room, client, () => undefined, callSession);
const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership(
@@ -944,7 +927,7 @@ it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom([membershipTemplate]);
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
const membershipManager = new MembershipManager(undefined, room, client, callSession);
const spy = jest.spyOn(console, "error");
// Double join
+34
View File
@@ -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<string> {
+262
View File
@@ -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]);
});
});
});
+129
View File
@@ -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<OlmMachine> {
return {
queryKeysForUsers: jest.fn(),
getVerificationRequest: jest.fn(),
receiveVerificationEvent: jest.fn(),
} as unknown as Mocked<OlmMachine>;
}
function makeRustCrypto(
olmMachine: OlmMachine,
outgoingRequestProcessor: OutgoingRequestProcessor,
): RustCrypto {
const rustCrypto = new RustCrypto(
new DebugLogger(debug("test Verification requests")),
olmMachine,
{} as unknown as MatrixHttpApi<IHttpOpts & { onlyData: true }>,
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<OutgoingRequestProcessor>;
}
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 */
+62
View File
@@ -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(
+3 -2
View File
@@ -58,7 +58,7 @@ import {
type ICallNotifyContent,
} from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
import { type IPushRules } from "./PushRules.ts";
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
@@ -151,6 +151,7 @@ export enum EventType {
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
// MatrixRTC events
RTCMembership = "org.matrix.msc4143.rtc.member",
CallNotify = "org.matrix.msc4075.call.notify",
RTCNotification = "org.matrix.msc4075.rtc.notification",
RTCDecline = "org.matrix.msc4310.rtc.decline",
@@ -369,7 +370,7 @@ export interface StateEvents {
// MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
[EventType.RTCMembership]: RtcMembershipData | EmptyObject;
// MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
+15 -12
View File
@@ -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<ParentDelayId>;
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;
};
+174 -64
View File
@@ -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<EmittedEvents, ClientEventHa
public idBaseUrl?: string;
public baseUrl: string;
public readonly isVoipWithNoMediaAllowed;
public disableVoip: boolean;
public useLivekitForGroupCalls: boolean;
@@ -1346,7 +1355,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
}
if (supportsMatrixCall()) {
this.disableVoip = opts.disableVoip ?? false;
if (!this.disableVoip && supportsMatrixCall()) {
this.callEventHandler = new CallEventHandler(this);
this.groupCallEventHandler = new GroupCallEventHandler(this);
this.canSupportVoip = true;
@@ -1433,7 +1444,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
// periodically poll for turn servers if we support voip
if (this.canSupportVoip) {
if (this.supportsVoip()) {
this.checkTurnServersIntervalID = setInterval(() => {
this.checkTurnServers();
}, TURN_CHECK_INTERVAL);
@@ -1670,7 +1681,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns True if VoIP is supported.
*/
public supportsVoip(): boolean {
return this.canSupportVoip;
return !this.disableVoip && this.canSupportVoip;
}
/**
@@ -2382,8 +2393,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const queryParams: QueryDict = {};
if (opts.viaServers) {
// server_name has been deprecated in favour of via with Matrix >1.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 TypedEventEmitter<EmittedEvents, ClientEventHa
const queryParams: QueryDict = {};
if (opts.viaServers) {
// We only use the first 3 servers, to avoid URI length issues.
const viaServers = Array.isArray(opts.viaServers) ? opts.viaServers.slice(0, 3) : [opts.viaServers];
// server_name has been deprecated in favour of via with Matrix >1.11 (MSC4156)
queryParams.server_name = opts.viaServers;
queryParams.via = opts.viaServers;
queryParams.server_name = viaServers;
queryParams.via = viaServers;
}
const body: Record<string, string> = {};
@@ -2672,7 +2685,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
this.addThreadRelationIfNeeded(content, threadId, roomId);
return this.sendCompleteEvent(roomId, threadId, { type: eventType, content }, txnId);
return this.sendCompleteEvent({ roomId, threadId, eventObject: { type: eventType, content }, txnId });
}
/**
@@ -2709,12 +2722,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to an empty object `{}`
* @returns Rejects: with an error response.
*/
private sendCompleteEvent(
roomId: string,
threadId: string | null,
eventObject: Partial<IEvent>,
txnId?: string,
): Promise<ISendEventResponse>;
private sendCompleteEvent(params: {
roomId: string;
threadId: string | null;
eventObject: Partial<IEvent>;
queryDict?: QueryDict;
txnId?: string;
}): Promise<ISendEventResponse>;
/**
* 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<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to an empty object `{}`
* @returns Rejects: with an error response.
*/
private sendCompleteEvent(
roomId: string,
threadId: string | null,
eventObject: Partial<IEvent>,
delayOpts: SendDelayedEventRequestOpts,
txnId?: string,
): Promise<SendDelayedEventResponse>;
private sendCompleteEvent(
roomId: string,
threadId: string | null,
eventObject: Partial<IEvent>,
delayOptsOrTxnId?: SendDelayedEventRequestOpts | string,
txnIdOrVoid?: string,
): Promise<ISendEventResponse | SendDelayedEventResponse> {
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<IEvent>;
delayOpts: SendDelayedEventRequestOpts;
queryDict?: QueryDict;
txnId?: string;
}): Promise<SendDelayedEventResponse>;
private sendCompleteEvent({
roomId,
threadId,
eventObject,
delayOpts,
queryDict,
txnId,
}: {
roomId: string;
threadId: string | null;
eventObject: Partial<IEvent>;
delayOpts?: SendDelayedEventRequestOpts;
queryDict?: QueryDict;
txnId?: string;
}): Promise<SendDelayedEventResponse | ISendEventResponse> {
if (!txnId) {
txnId = this.makeTxnId();
}
@@ -2788,7 +2802,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const type = localEvent.getType();
this.logger.debug(
`sendEvent of type ${type} in ${roomId} with txnId ${txnId}${delayOpts ? " (delayed event)" : ""}`,
`sendEvent of type ${type} in ${roomId} with txnId ${txnId}${delayOpts ? " (delayed event)" : ""}${queryDict ? " query params: " + JSON.stringify(queryDict) : ""}`,
);
localEvent.setTxnId(txnId);
@@ -2806,9 +2820,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return Promise.reject(new Error("Event blocked by other events not yet sent"));
}
return this.encryptAndSendEvent(room, localEvent);
return this.encryptAndSendEvent(room, localEvent, queryDict);
} else {
return this.encryptAndSendEvent(room, localEvent, delayOpts);
return this.encryptAndSendEvent(room, localEvent, delayOpts, queryDict);
}
}
@@ -2816,7 +2830,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent
* @returns returns a promise which resolves with the result of the send request
*/
protected async encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room | null,
event: MatrixEvent,
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
/**
* 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<EmittedEvents, ClientEventHa
room: Room | null,
event: MatrixEvent,
delayOpts: SendDelayedEventRequestOpts,
): Promise<SendDelayedEventResponse>;
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room | null,
event: MatrixEvent,
delayOpts?: SendDelayedEventRequestOpts,
delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict,
queryDict?: QueryDict,
): Promise<ISendEventResponse | SendDelayedEventResponse> {
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<EmittedEvents, ClientEventHa
}
if (!promise) {
promise = this.sendEventHttpRequest(event);
promise = this.sendEventHttpRequest(event, queryOpts);
if (room) {
promise = promise.then((res) => {
room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]);
@@ -2987,14 +3009,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
private sendEventHttpRequest(event: MatrixEvent): Promise<ISendEventResponse>;
private sendEventHttpRequest(event: MatrixEvent, queryDict?: QueryDict): Promise<ISendEventResponse>;
private sendEventHttpRequest(
event: MatrixEvent,
delayOpts: SendDelayedEventRequestOpts,
queryDict?: QueryDict,
): Promise<SendDelayedEventResponse>;
private sendEventHttpRequest(
event: MatrixEvent,
delayOpts?: SendDelayedEventRequestOpts,
queryOrDelayOpts?: SendDelayedEventRequestOpts | QueryDict,
queryDict?: QueryDict,
): Promise<ISendEventResponse | SendDelayedEventResponse> {
let txnId = event.getTxnId();
if (!txnId) {
@@ -3027,19 +3051,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams);
}
const delayOpts =
queryOrDelayOpts && isSendDelayedEventRequestOpts(queryOrDelayOpts) ? queryOrDelayOpts : undefined;
const queryOpts = !delayOpts ? queryOrDelayOpts : queryDict;
const content = event.getWireContent();
if (!delayOpts) {
return this.http.authedRequest<ISendEventResponse>(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<SendDelayedEventResponse>(
Method.Put,
path,
getUnstableDelayQueryOpts(delayOpts),
{ ...getUnstableDelayQueryOpts(delayOpts), ...queryOpts },
content,
);
} else {
return this.http.authedRequest<ISendEventResponse>(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<EmittedEvents, ClientEventHa
content[withRelTypesPropName] = opts.with_rel_types;
}
return this.sendCompleteEvent(
return this.sendCompleteEvent({
roomId,
threadId,
{
eventObject: {
type: EventType.RoomRedaction,
content,
redacts: eventId,
},
txnId as string,
);
txnId: txnId as string,
});
}
/**
@@ -3393,7 +3420,54 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
this.addThreadRelationIfNeeded(content, threadId, roomId);
return this.sendCompleteEvent(roomId, threadId, { type: eventType, content }, delayOpts, txnId);
return this.sendCompleteEvent({
roomId,
threadId,
eventObject: { type: eventType, content },
delayOpts,
txnId,
});
}
/**
* Send a delayed sticky timeline event.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) and
* [MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) for more details.
*/
// eslint-disable-next-line
public async _unstable_sendStickyDelayedEvent<K extends keyof TimelineEvents>(
roomId: string,
stickDuration: number,
delayOpts: SendDelayedEventRequestOpts,
threadId: string | null,
eventType: K,
content: TimelineEvents[K] & { msc4354_sticky_key: string },
txnId?: string,
): Promise<SendDelayedEventResponse> {
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<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Put, path, getUnstableDelayQueryOpts(delayOpts), content as Body, opts);
}
/**
* Send a sticky timeline event.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) for more details.
*/
// eslint-disable-next-line
public async _unstable_sendStickyEvent<K extends keyof TimelineEvents>(
roomId: string,
stickDuration: number,
threadId: string | null,
eventType: K,
content: TimelineEvents[K] & { msc4354_sticky_key: string },
txnId?: string,
): Promise<ISendEventResponse> {
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<EmittedEvents, ClientEventHa
// XXX: Intended private, used in code.
public async checkTurnServers(): Promise<boolean | undefined> {
if (!this.canSupportVoip) {
if (!this.supportsVoip()) {
return;
}
@@ -6971,6 +7077,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public async doesServerSupportExtendedProfiles(): Promise<boolean> {
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<EmittedEvents, ClientEventHa
* @returns The prefix for use with `authedRequest`
*/
private async getExtendedProfileRequestPrefix(): Promise<string> {
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";
+14 -1
View File
@@ -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";
}
}
+343 -62
View File
@@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MatrixEvent } from "../matrix.ts";
import { MXID_PATTERN } from "../models/room-member.ts";
import { deepCompare } from "../utils.ts";
import { type Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type SessionDescription } from "./MatrixRTCSession.ts";
import { type RTCCallIntent } from "./types.ts";
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
import type { RTCCallIntent, Transport } from "./types.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.
@@ -29,6 +31,106 @@ import { type RTCCallIntent } from "./types.ts";
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
type CallScope = "m.room" | "m.user";
type Member = { user_id: string; device_id: string; id: string };
export interface RtcMembershipData {
"slot_id": string;
"member": Member;
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": {
type: string;
// other application specific keys
[key: string]: unknown;
};
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
}
const checkRtcMembershipData = (
data: IContent,
errors: string[],
referenceUserId: string,
): data is RtcMembershipData => {
const prefix = " - ";
// required fields
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");
} 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" || t === null || typeof (t as any).type !== "string") {
errors.push(prefix + "rtc_transports entries must be objects with a string type");
break;
}
}
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
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.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"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
return errors.length === 0;
};
/**
* MSC4143 (MatrixRTC) session membership data.
@@ -56,13 +158,13 @@ export type SessionMembershipData = {
/**
* The focus selection system this user/membership is using.
*/
"focus_active": Focus;
"focus_active": LivekitFocusSelection;
/**
* A list of possible foci this uses knows about. One of them might be used based on the focus_active
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
"foci_preferred": Focus[];
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
@@ -77,7 +179,7 @@ export type SessionMembershipData = {
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scroped call but multiple user owned calls (breakout sessions)
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
"scope"?: CallScope;
@@ -95,16 +197,26 @@ export type SessionMembershipData = {
"m.call.intent"?: RTCCallIntent;
};
const checkSessionsMembershipData = (
data: Partial<Record<keyof SessionMembershipData, any>>,
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 (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
if (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
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") {
errors.push(prefix + "created_ts must be number");
@@ -120,109 +232,278 @@ const checkSessionsMembershipData = (
return errors.length === 0;
};
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
export class CallMembership {
public static equal(a: CallMembership, b: CallMembership): boolean {
return deepCompare(a.membershipData, b.membershipData);
public static equal(a?: CallMembership, b?: CallMembership): boolean {
return deepCompare(a?.membershipData, b?.membershipData);
}
private membershipData: SessionMembershipData;
private membershipData: MembershipData;
/** The parsed data from the Matrix event.
* To access checked eventId and sender from the matrixEvent.
* Class construction will fail if these values cannot get obtained. */
private readonly matrixEventData: { eventId: string; sender: string };
public constructor(
private parentEvent: MatrixEvent,
data: any,
/** The Matrix event that this membership is based on */
private readonly matrixEvent: MatrixEvent,
data: IContent,
) {
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[] = [];
if (!checkSessionsMembershipData(data, sessionErrors)) {
throw Error(
`unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`,
);
const rtcErrors: string[] = [];
if (checkSessionsMembershipData(data, sessionErrors)) {
this.membershipData = { kind: "session", data };
} else if (checkRtcMembershipData(data, rtcErrors, sender)) {
this.membershipData = { kind: "rtc", data };
} else {
this.membershipData = 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);
}
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":
return data.member.user_id;
case "session":
default:
return this.matrixEventData.sender;
}
}
public get sender(): string | undefined {
return this.parentEvent.getSender();
}
public get eventId(): string | undefined {
return this.parentEvent.getId();
public get eventId(): string {
return this.matrixEventData.eventId;
}
/**
* @deprecated Use sessionDescription.id instead.
* The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`).
* This is computed in case SessionMembershipData is used.
*/
public get callId(): string {
return this.membershipData.call_id;
public get slotId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.slot_id;
case "session":
default:
return slotDescriptionToId({ application: this.application, id: data.call_id });
}
}
public get deviceId(): string {
return this.membershipData.device_id;
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.member.device_id;
case "session":
default:
return data.device_id;
}
}
public get callIntent(): RTCCallIntent | undefined {
return this.membershipData["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"];
}
}
public get sessionDescription(): SessionDescription {
return {
application: this.membershipData.application,
id: this.membershipData.call_id,
};
/**
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
*/
public get slotDescription(): SlotDescription {
return slotIdToDescription(this.slotId);
}
public get application(): string | undefined {
return this.membershipData.application;
public get application(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application.type;
case "session":
default:
return data.application;
}
}
public get applicationData(): { type: string; [key: string]: unknown } {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application;
case "session":
default:
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
}
}
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
public get scope(): CallScope | undefined {
return this.membershipData.scope;
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
return data.scope;
}
}
public get membershipID(): string {
// the createdTs behaves equivalent to the membershipID.
// we only need the field for the legacy member envents where we needed to update them
// we only need the field for the legacy member events where we needed to update them
// synapse ignores sending state events if they have the same content.
return this.createdTs().toString();
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.member.id;
case "session":
default:
return (this.createdTs() ?? "").toString();
}
}
public createdTs(): number {
return this.membershipData.created_ts ?? this.parentEvent.getTs();
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
// TODO we need to read the referenced (relation) event if available to get the real created_ts
return this.matrixEvent.getTs();
case "session":
default:
return data.created_ts ?? this.matrixEvent.getTs();
}
}
/**
* Gets the absolute expiry timestamp of the membership.
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
*/
public getAbsoluteExpiry(): number {
// TODO: calculate this from the MatrixRTCSession join configuration directly
return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION);
public getAbsoluteExpiry(): number | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
// TODO: calculate this from the MatrixRTCSession join configuration directly
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
}
}
/**
* @returns The number of milliseconds until the membership expires or undefined if applicable
*/
public getMsUntilExpiry(): number {
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
return this.getAbsoluteExpiry() - Date.now();
public getMsUntilExpiry(): number | undefined {
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
return this.getAbsoluteExpiry()! - Date.now();
}
}
/**
* @returns true if the membership has expired, otherwise false
*/
public isExpired(): boolean {
return this.getMsUntilExpiry() <= 0;
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
return false;
case "session":
default:
return this.getMsUntilExpiry()! <= 0;
}
}
public getPreferredFoci(): Focus[] {
return this.membershipData.foci_preferred;
/**
* ## RTC Membership
* Gets the primary transport to use for this RTC membership (m.rtc.member).
* This will return the primary transport that is used by this call membership to publish their media.
* Directly relates to the `rtc_transports` field.
*
* ## Legacy session membership
* In case of a legacy session membership (m.call.member) this will return the selected transport where
* media is published. How this selection happens depends on the `focus_active` field of the session membership.
* If the `focus_selection` is `oldest_membership` this will return the transport of the oldest membership
* in the room (based on the `created_ts` field of the session membership).
* If the `focus_selection` is `multi_sfu` it will return the first transport of the `foci_preferred` list.
* (`multi_sfu` is equivalent to how `m.rtc.member` `rtc_transports` work).
* @param oldestMembership For backwards compatibility with session membership (legacy). Unused in case of RTC membership.
* Always required to make the consumer not care if it deals with RTC or session memberships.
* @returns The transport this membership uses to publish media or undefined if no transport is available.
*/
public getTransport(oldestMembership: CallMembership): Transport | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.rtc_transports[0];
case "session":
switch (data.focus_active.focus_selection) {
case "multi_sfu":
return data.foci_preferred[0];
case "oldest_membership":
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
break;
}
}
return undefined;
}
public getFocusSelection(): string | undefined {
const focusActive = this.membershipData.focus_active;
if (isLivekitFocusActive(focusActive)) {
return focusActive.focus_selection;
/**
* 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).
*/
public get transports(): Transport[] {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.rtc_transports;
case "session":
default:
return data.foci_preferred;
}
}
}
+6 -9
View File
@@ -15,8 +15,7 @@ limitations under the License.
*/
import type { CallMembership } from "./CallMembership.ts";
import type { Focus } from "./focus.ts";
import type { RTCCallIntent, Status } from "./types.ts";
import type { RTCCallIntent, Status, Transport } from "./types.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
export enum MembershipManagerEvent {
@@ -80,10 +79,13 @@ 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.
* @param fociActive the active focus 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.
* @throws can throw if it exceeds a configured maximum retry.
*/
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void;
/**
* Send all necessary events to make this user leave the RTC session.
* @param timeout the maximum duration in ms until the promise is forced to resolve.
@@ -95,11 +97,6 @@ export interface IMembershipManager
* Call this if the MatrixRTC session members have changed.
*/
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
/**
* The used active focus in the currently joined session.
* @returns the used active focus in the currently joined session or undefined if not joined.
*/
getActiveFocus(): Focus | undefined;
/**
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
-39
View File
@@ -1,39 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Focus } from "./focus.ts";
export interface LivekitFocusConfig extends Focus {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitFocus extends LivekitFocusConfig {
livekit_alias: string;
}
export const isLivekitFocus = (object: any): object is LivekitFocus =>
isLivekitFocusConfig(object) && "livekit_alias" in object;
export interface LivekitFocusActive extends Focus {
type: "livekit";
focus_selection: "oldest_membership";
}
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
object.type === "livekit" && "focus_selection" in object;
+46
View File
@@ -0,0 +1,46 @@
/*
Copyright 2025 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Transport } from "./types.ts";
export interface LivekitTransportConfig extends Transport {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitTransport extends LivekitTransportConfig {
livekit_alias: string;
}
export const isLivekitTransport = (object: any): object is LivekitTransport =>
isLivekitTransportConfig(object) && "livekit_alias" in object;
/**
* @deprecated, this is just needed for the old focus active / focus fields of a call membership.
* Not needed for new implementations.
*/
export interface LivekitFocusSelection extends Transport {
type: "livekit";
focus_selection: "oldest_membership" | "multi_sfu";
}
/**
* @deprecated see LivekitFocusSelection
*/
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
object.type === "livekit" && "focus_selection" in object;
+92 -65
View File
@@ -24,17 +24,17 @@ import { KnownMembership } from "../@types/membership.ts";
import { type ISendEventResponse } from "../@types/requests.ts";
import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts";
import { MembershipManager } from "./MembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { deepCompare, logDurationSync } from "../utils.ts";
import {
type Statistics,
type RTCNotificationType,
type Status,
type IRTCNotificationContent,
type ICallNotifyContent,
type RTCCallIntent,
import type {
Statistics,
RTCNotificationType,
Status,
IRTCNotificationContent,
ICallNotifyContent,
RTCCallIntent,
Transport,
} from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import {
@@ -103,10 +103,17 @@ export interface SessionConfig {
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SessionDescription {
export interface SlotDescription {
id: string;
application: string;
}
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id] = slotId.split("#");
return { application, id };
}
export function slotDescriptionToId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}
// The names follow these principles:
// - we use the technical term delay if the option is related to delayed events.
@@ -185,6 +192,7 @@ export interface MembershipConfig {
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
delayedLeaveEventRestartLocalTimeoutMs?: number;
useRtcMemberFormat?: boolean;
}
export interface EncryptionConfig {
@@ -240,8 +248,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 _callId: string | undefined;
private joinConfig?: SessionConfig;
private logger: Logger;
@@ -279,33 +285,53 @@ export class MatrixRTCSession extends TypedEventEmitter<
*
* It can be undefined since the callId is only known once the first membership joins.
* The callId is the property that, per definition, groups memberships into one call.
* @deprecated use `slotId` instead.
*/
public get callId(): string | undefined {
return this._callId;
return this.slotDescription?.id;
}
/**
* The slotId of the call.
* `{application}#{appSpecificId}`
* It can be undefined since the slotId is only known once the first membership joins.
* The slotId is the property that, per definition, groups memberships into one call.
*/
public get slotId(): string | undefined {
return slotDescriptionToId(this.slotDescription);
}
/**
* Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first.
*
* @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead.
* @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead.
*/
public static callMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
): CallMembership[] {
return MatrixRTCSession.sessionMembershipsForRoom(room, {
return MatrixRTCSession.sessionMembershipsForSlot(room, {
id: "",
application: "m.call",
});
}
/**
* Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first.
* @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead.
*/
public static sessionMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
sessionDescription: SessionDescription,
sessionDescription: SlotDescription,
): CallMembership[] {
return this.sessionMembershipsForSlot(room, sessionDescription);
}
/**
* Returns all the call memberships for a room that match the provided `sessionDescription`,
* oldest first.
*/
public static sessionMembershipsForSlot(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
slotDescription: SlotDescription,
): CallMembership[] {
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
@@ -335,12 +361,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
if (membershipContents.length === 0) continue;
for (const membershipData of membershipContents) {
if (!("application" in membershipData)) {
// This is a left membership event, ignore it here to not log warnings.
continue;
}
try {
const membership = new CallMembership(memberEvent, membershipData);
if (!deepCompare(membership.sessionDescription, sessionDescription)) {
if (!deepCompare(membership.slotDescription, slotDescription)) {
logger.info(
`Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`,
`Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
);
continue;
}
@@ -379,26 +409,29 @@ export class MatrixRTCSession extends TypedEventEmitter<
* This method is an alias for `MatrixRTCSession.sessionForRoom` with
* sessionDescription `{ id: "", application: "m.call" }`.
*
* @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead.
* @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead.
*/
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" });
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call" });
return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" });
}
/**
* @deprecated Use `MatrixRTCSession.sessionForSlot` instead.
*/
public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession {
return this.sessionForSlot(client, room, slotDescription);
}
/**
* Return the MatrixRTC session for the room.
* This returned session can be used to find out if there are active sessions
* for the requested room and `sessionDescription`.
* for the requested room and `slotDescription`.
*/
public static sessionForRoom(
client: MatrixClient,
room: Room,
sessionDescription: SessionDescription,
): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription);
public static sessionForSlot(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession {
const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription);
return new MatrixRTCSession(client, room, callMemberships, sessionDescription);
return new MatrixRTCSession(client, room, callMemberships, slotDescription);
}
/**
@@ -444,14 +477,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
>,
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`, `sessionDescription.application`, `sessionDescription.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 sessionDescription: SessionDescription,
public readonly slotDescription: SlotDescription,
) {
super();
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
this._callId = memberships[0]?.sessionDescription.id;
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);
@@ -490,14 +523,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: Focus[], fociActive?: Focus, 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;
@@ -508,8 +545,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
joinConfig,
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.sessionDescription,
this.slotDescription,
this.logger,
);
@@ -571,7 +607,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());
@@ -606,16 +642,23 @@ export class MatrixRTCSession extends TypedEventEmitter<
return await leavePromise;
}
/**
* Get the active focus from the current CallMemberState event
* @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.
* 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 getActiveFocus(): Focus | undefined {
return this.membershipManager?.getActiveFocus();
public getFocusInUse(): Transport | undefined {
const oldestMembership = this.getOldestMembership();
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];
}
@@ -646,20 +689,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
await this.membershipManager?.updateCallIntent(callIntent);
}
/**
* This method is used when the user is not yet connected to the Session but wants to know what focus
* the users in the session are using to make a decision how it wants/should connect.
*
* See also `getActiveFocus`
* @returns The focus which should be used when joining this session.
*/
public getFocusInUse(): Focus | undefined {
const oldestMembership = this.getOldestMembership();
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
return oldestMembership.getPreferredFoci()[0];
}
}
/**
* Re-emit an EncryptionKeyChanged event for each tracked encryption key. This can be used to export
* the keys.
@@ -777,9 +806,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
*/
private recalculateSessionMembers = (): void => {
const oldMemberships = this.memberships;
this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription);
this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id;
this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription);
const changed =
oldMemberships.length != this.memberships.length ||
+4 -4
View File
@@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts";
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts";
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
export enum MatrixRTCSessionManagerEvents {
@@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
public constructor(
rootLogger: Logger,
private client: MatrixClient,
private readonly sessionDescription: SessionDescription = { id: "", application: "m.call" }, // Default to the Matrix Call application
private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
@@ -66,7 +66,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
// returning nothing, and breaks tests if you change it to return an empty array :'(
for (const room of this.client.getRooms() ?? []) {
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription);
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription);
if (session.memberships.length > 0) {
this.roomSessions.set(room.roomId, session);
}
@@ -102,7 +102,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
if (!this.roomSessions.has(room.roomId)) {
this.roomSessions.set(
room.roomId,
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription),
);
}
+90 -65
View File
@@ -15,20 +15,28 @@ limitations under the License.
*/
import { AbortError } from "p-retry";
import { EventType } from "../@types/event.ts";
import { EventType, RelationType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts";
import { type MatrixClient } from "../client.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import type { MatrixClient } from "../client.ts";
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type Room } from "../models/room.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
import { type Focus } from "./focus.ts";
import { isMyMembership, type RTCCallIntent, Status } from "./types.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type SessionDescription, type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts";
import {
type CallMembership,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
type SessionMembershipData,
} from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
import {
type SlotDescription,
type MembershipConfig,
type SessionConfig,
slotDescriptionToId,
} from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import {
MembershipManagerEvent,
type IMembershipManager,
@@ -36,12 +44,11 @@ import {
} from "./IMembershipManager.ts";
/* MembershipActionTypes:
On Join: (1)
SendDelayedEvent (2)
(3)
@@ -52,9 +59,9 @@ On Join: ───────────────┐ ┌─────
UpdateExpiry (s) (s)|RestartDelayedEvent
On Leave: STOP ALL ABOVE
@@ -169,18 +176,21 @@ export class MembershipManager
/**
* Puts the MembershipManager in a state where it tries to be joined.
* It will send delayed events and membership events
* @param fociPreferred
* @param focusActive
* @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 onError This will be called once the membership manager encounters an unrecoverable error.
* This should bubble up the the frontend to communicate that the call does not work in the current environment.
*/
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
public join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void {
if (this.scheduler.running) {
this.logger.error("MembershipManager is already running. Ignoring join request.");
return;
}
this.fociPreferred = fociPreferred;
this.focusActive = focusActive;
this.rtcTransport = multiSfuFocus;
this.leavePromiseResolvers = undefined;
this.activated = true;
this.oldStatus = this.status;
@@ -266,25 +276,6 @@ export class MembershipManager
return Promise.resolve();
}
public getActiveFocus(): Focus | undefined {
if (this.focusActive) {
// A livekit active focus
if (isLivekitFocusActive(this.focusActive)) {
if (this.focusActive.focus_selection === "oldest_membership") {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
} else {
this.logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
}
} else {
// We do not understand the membership format (could be legacy). We default to oldestMembership
// Once there are other methods this is a hard error!
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
public async updateCallIntent(callIntent: RTCCallIntent): Promise<void> {
if (!this.activated || !this.ownMembership) {
throw Error("You cannot update your intent before joining the call");
@@ -302,7 +293,6 @@ export class MembershipManager
* @param joinConfig
* @param room
* @param client
* @param getOldestMembership
*/
public constructor(
private joinConfig: (SessionConfig & MembershipConfig) | undefined,
@@ -315,8 +305,7 @@ export class MembershipManager
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
>,
private getOldestMembership: () => CallMembership | undefined,
public readonly sessionDescription: SessionDescription,
public readonly slotDescription: SlotDescription,
parentLogger?: Logger,
) {
super();
@@ -325,7 +314,9 @@ export class MembershipManager
if (userId === null) throw Error("Missing userId in client");
if (deviceId === null) throw Error("Missing deviceId in client");
this.deviceId = deviceId;
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
// this needs to become a uuid so that consecutive join/leaves result in a key rotation.
// we keep it as a string for now for backwards compatibility.
this.memberId = this.makeMembershipStateKey(userId, deviceId);
this.state = MembershipManager.defaultState;
this.callIntent = joinConfig?.callIntent;
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
@@ -371,9 +362,10 @@ export class MembershipManager
}
// Membership Event static parameters:
private deviceId: string;
private stateKey: string;
private fociPreferred?: Focus[];
private focusActive?: Focus;
private memberId: string;
/** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */
private fociPreferred?: Transport[];
private rtcTransport?: Transport;
// Config:
private delayedLeaveEventDelayMsOverride?: number;
@@ -406,6 +398,9 @@ export class MembershipManager
private get delayedLeaveEventRestartLocalTimeoutMs(): number {
return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000;
}
private get useRtcMemberFormat(): boolean {
return this.joinConfig?.useRtcMemberFormat ?? false;
}
// LOOP HANDLER:
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
switch (type) {
@@ -472,9 +467,9 @@ export class MembershipManager
{
delay: this.delayedLeaveEventDelayMs,
},
EventType.GroupCallMemberPrefix,
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
{}, // leave event
this.stateKey,
this.memberId,
)
.then((response) => {
this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs;
@@ -659,9 +654,9 @@ export class MembershipManager
return await this.client
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryMs),
this.stateKey,
this.memberId,
)
.then(() => {
this.setAndEmitProbablyLeft(false);
@@ -705,9 +700,9 @@ export class MembershipManager
return await this.client
.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration),
this.stateKey,
this.memberId,
)
.then(() => {
// Success, we reset retries and schedule update.
@@ -731,7 +726,12 @@ export class MembershipManager
}
private async sendFallbackLeaveEvent(): Promise<ActionUpdate> {
return await this.client
.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey)
.sendStateEvent(
this.room.roomId,
this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
{},
this.memberId,
)
.then(() => {
this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent);
this.state.hasMemberStateEvent = false;
@@ -746,7 +746,7 @@ export class MembershipManager
// HELPERS
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`;
const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
@@ -757,20 +757,45 @@ export class MembershipManager
/**
* Constructs our own membership
*/
private makeMyMembership(expires: number): SessionMembershipData {
const hasPreviousEvent = !!this.ownMembership;
return {
// TODO: use the new format for m.rtc.member events where call_id becomes session.id
"application": this.sessionDescription.application,
"call_id": this.sessionDescription.id,
"scope": "m.room",
"device_id": this.deviceId,
expires,
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
"foci_preferred": this.fociPreferred ?? [],
"m.call.intent": this.callIntent,
...(hasPreviousEvent ? { created_ts: this.ownMembership?.createdTs() } : undefined),
};
private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const ownMembership = this.ownMembership;
if (this.useRtcMemberFormat) {
const relationObject = ownMembership?.eventId
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
: {};
return {
application: {
type: this.slotDescription.application,
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
},
slot_id: slotDescriptionToId(this.slotDescription),
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId },
versions: [],
...relationObject,
};
} else {
const focusObjects =
this.rtcTransport === undefined
? {
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
foci_preferred: this.fociPreferred ?? [],
}
: {
focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const,
foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])],
};
return {
"application": this.slotDescription.application,
"call_id": this.slotDescription.id,
"scope": "m.room",
"device_id": this.deviceId,
expires,
"m.call.intent": this.callIntent,
...focusObjects,
...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined),
};
}
}
// Error checks and handlers
-25
View File
@@ -1,25 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Information about a MatrixRTC conference focus. The only attribute that
* the js-sdk (currently) knows about is the type: applications can extend
* this class for different types of focus.
*/
export interface Focus {
type: string;
[key: string]: unknown;
}
+1 -2
View File
@@ -15,8 +15,7 @@ limitations under the License.
*/
export * from "./CallMembership.ts";
export type * from "./focus.ts";
export * from "./LivekitFocus.ts";
export * from "./LivekitTransport.ts";
export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts";
+8
View File
@@ -156,3 +156,11 @@ export type Statistics = {
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
m.sender === userId && m.deviceId === deviceId;
/**
* A RTC transport is a JSON object that describes how to connect to a RTC member.
*/
export interface Transport {
type: string;
[key: string]: unknown;
}
+42 -1
View File
@@ -75,6 +75,7 @@ export interface IUnsigned {
"transaction_id"?: string;
"invite_room_state"?: StrippedState[];
"m.relations"?: Record<RelationType | string, any>; // 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<MatrixEventEmittedEvents, Mat
private readonly reEmitter: TypedReEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap>;
/**
* 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<MatrixEventEmittedEvents, Mat
// The fallback in these cases will be to use the origin_server_ts.
// For EDUs, the origin_server_ts also is not defined so we use Date.now().
const age = this.getAge();
this.localTimestamp = age !== undefined ? Date.now() - age : (this.getTs() ?? Date.now());
const now = Date.now();
this.localTimestamp = age !== undefined ? now - age : (this.getTs() ?? now);
this.reEmitter = new TypedReEmitter(this);
if (this.unstableStickyInfo) {
if (this.unstableStickyInfo.duration_ttl_ms) {
this.unstableStickyExpiresAt = now + this.unstableStickyInfo.duration_ttl_ms;
} else {
// Bound the timestamp so it doesn't come from the future.
this.unstableStickyExpiresAt = Math.min(now, this.getTs()) + this.unstableStickyInfo.duration_ms;
}
}
}
/**
@@ -1739,6 +1762,24 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
public setThreadId(threadId?: string): void {
this.threadId = threadId;
}
/**
* Unstable getter to try and get the sticky information for the event.
* If the event is not a sticky event (or not supported by the server),
* then this returns `undefined`.
*
* `duration_ms` is safely bounded to a hour.
*/
public get unstableStickyInfo(): { duration_ms: number; duration_ttl_ms?: number } | undefined {
if (!this.event.msc4354_sticky?.duration_ms) {
return undefined;
}
return {
duration_ms: Math.min(MAX_STICKY_DURATION_MS, this.event.msc4354_sticky.duration_ms),
// This is assumed to be bounded server-side.
duration_ttl_ms: this.event.unsigned?.msc4354_sticky_duration_ttl_ms,
};
}
}
/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted
+25
View File
@@ -1,8 +1,33 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* The timezone the user is currently in. The value of this property should
* match a timezone provided in https://www.iana.org/time-zones.
*
* This key was introduced in Matrix v1.16.
*/
export const ProfileKeyTimezone = "m.tz";
/**
* The timezone the user is currently in. The value of this property should
* match a timezone provided in https://www.iana.org/time-zones.
*
* @see https://github.com/matrix-org/matrix-spec-proposals/blob/clokep/profile-tz/proposals/4175-profile-field-time-zone.md
* @experimental
* @deprecated Unstable MSC field - Use `ProfileKeyTimezone`
*/
export const ProfileKeyMSC4175Timezone = "us.cloke.msc4175.tz";
+1 -1
View File
@@ -388,7 +388,7 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
}
}
const MXID_PATTERN = /@.+:.+/;
export const MXID_PATTERN = /@.+:.+/;
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?: RoomState): boolean {
+241
View File
@@ -0,0 +1,241 @@
import { logger as loggerInstance } from "../logger.ts";
import { type MatrixEvent } from "./event.ts";
import { TypedEventEmitter } from "./typed-event-emitter.ts";
const logger = loggerInstance.getChild("RoomStickyEvents");
export enum RoomStickyEventsEvent {
Update = "RoomStickyEvents.Update",
}
type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
export type RoomStickyEventsMap = {
/**
* Fires when any sticky event changes happen in a room.
* @param added Any new sticky events with no predecessor events (matching sender, type, and sticky_key)
* @param updated Any sticky events that supersede an existing event (matching sender, type, and sticky_key)
* @param removed The events that were removed from the map due to expiry.
*/
[RoomStickyEventsEvent.Update]: (
added: StickyMatrixEvent[],
updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[],
removed: StickyMatrixEvent[],
) => 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<RoomStickyEventsEvent, RoomStickyEventsMap> {
private readonly stickyEventsMap = new Map<string, Map<string, StickyMatrixEvent>>(); // (type -> stickyKey+userId) -> event
private readonly unkeyedStickyEvents = new Set<StickyMatrixEvent>();
private stickyEventTimer?: ReturnType<typeof setTimeout>;
private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER;
/**
* Get all sticky events that are currently active.
* @returns An iterable set of events.
*/
public *getStickyEvents(): Iterable<StickyMatrixEvent> {
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 @<user_id>
// 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();
}
}
+58
View File
@@ -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<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> &
EventTimelineSetHandlerMap &
Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> &
Pick<RoomStickyEventsMap, RoomStickyEventsEvent.Update> &
Pick<
RoomStateEventHandlerMap,
| RoomStateEvent.Events
@@ -446,6 +449,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/
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<RoomEmittedEvents, RoomEventHandlerMap> {
// 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<RoomEmittedEvents, RoomEventHandlerMap> {
return this.accountData.get(type);
}
/**
* Get an iterator of currently active sticky events.
*/
// eslint-disable-next-line
public _unstable_getStickyEvents(): ReturnType<RoomStickyEventsStore["getStickyEvents"]> {
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<RoomStickyEventsStore["getKeyedStickyEvent"]> {
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<RoomStickyEventsStore["getUnkeyedStickyEvent"]> {
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<RoomStickyEventsStore["addStickyEvents"]> {
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
+22 -7
View File
@@ -2053,20 +2053,38 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
*/
private async onKeyVerificationEvent(event: MatrixEvent): Promise<void> {
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<RustCryptoEvents, CryptoEventH
new RustSdkCryptoJs.RoomId(roomId),
);
if (
event.getType() === EventType.RoomMessage &&
event.getContent().msgtype === MsgType.KeyVerificationRequest
) {
this.onIncomingKeyVerificationRequest(event.getSender()!, event.getId()!);
if (isRoomVerificationRequest) {
this.onIncomingKeyVerificationRequest(senderId, event.getId()!);
}
// that may have caused us to queue up outgoing requests, so make sure we send them.
+4
View File
@@ -64,6 +64,10 @@ export interface Capabilities {
"m.set_displayname"?: ISetDisplayNameCapability;
"m.set_avatar_url"?: ISetAvatarUrlCapability;
"uk.tcpip.msc4133.profile_fields"?: IProfileFieldsCapability;
/**
* Since Matrix v1.16
*/
"m.profile_fields"?: IProfileFieldsCapability;
}
type CapabilitiesResponse = {
+51 -1
View File
@@ -20,7 +20,7 @@ limitations under the License.
import { logger } from "./logger.ts";
import { deepCopy } from "./utils.ts";
import { type IContent, type IUnsigned } from "./models/event.ts";
import { MAX_STICKY_DURATION_MS, type IContent, type IUnsigned } from "./models/event.ts";
import { type IRoomSummary } from "./models/room-summary.ts";
import { type EventType } from "./@types/event.ts";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
@@ -76,11 +76,25 @@ export interface ITimeline {
prev_batch: string | null;
}
type StickyEventFields = {
msc4354_sticky: { duration_ms: number };
content: { msc4354_sticky_key?: string };
};
export type IStickyEvent = IRoomEvent & StickyEventFields;
export type IStickyStateEvent = IStateEvent & StickyEventFields;
export interface ISticky {
events: Array<IStickyEvent | IStickyStateEvent>;
}
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<UnreadNotificationCounts>;
_unreadThreadNotifications?: Record<string, Partial<UnreadNotificationCounts>>;
_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) => {
+26 -1
View File
@@ -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
+195 -214
View File
@@ -1387,7 +1387,7 @@
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/eslint-utils@^4.7.0":
"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.9.0":
version "4.9.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3"
integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==
@@ -1839,102 +1839,102 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@oxc-resolver/binding-android-arm-eabi@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.8.0.tgz#57cfab36823e3e03c7f65254f34fda658bae250a"
integrity sha512-St1Muhuw4EVprhV3/D7M9KmKZZiOUTrRu1UTRKFjJAzuec9eCq4OAEk3bPRXCJlKtdmS2ijvywTN++bjJUmYvg==
"@oxc-resolver/binding-android-arm-eabi@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.8.4.tgz#6db60f4996c1f4947731352dbcd7b900d4ac1ec3"
integrity sha512-6BjMji0TcvQfJ4EoSunOSyu/SiyHKficBD0V3Y0NxF0beaNnnZ7GYEi2lHmRNnRCuIPK8IuVqQ6XizYau+CkKw==
"@oxc-resolver/binding-android-arm64@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.8.0.tgz#4459d27c6163b2d96ef549ebf2ad5335f28142b8"
integrity sha512-aNq7fIcJN1+hdWL2vTLd8E/MNIaYa8hL6L2TFwIEo0VuIuaaYxk4/GiZK8w50z7QLDbD5GUMlXwb2ccI5RqL7A==
"@oxc-resolver/binding-android-arm64@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.8.4.tgz#51e06144d0c272cdcb691c103265aa956b0d3ea0"
integrity sha512-SxF4X6rzCBS9XNPXKZGoIHIABjfGmtQpEgRBDzpDHx5VTuLAUmwLTHXnVBAZoX5bmnhF79RiMElavzFdJ2cA1A==
"@oxc-resolver/binding-darwin-arm64@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.8.0.tgz#9af1067bf35cd1f704934a7f40ed3b4e2d6c9399"
integrity sha512-/mX3v6M9gyyAyV1VZSCbo5A5cJE32zrST5Qs3QxK+S9gc/8d1uNTmPgkoki8E6s561OA9S3KAepeTz+AtxD62w==
"@oxc-resolver/binding-darwin-arm64@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.8.4.tgz#dc83eb372519ab6a9975d89ed4d6200b6fd0d70f"
integrity sha512-8zWeERrzgscAniE6kh1TQ4E7GJyglYsvdoKrHYLBCbHWD+0/soffiwAYxZuckKEQSc2RXMSPjcu+JTCALaY0Dw==
"@oxc-resolver/binding-darwin-x64@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.8.0.tgz#f021ce22d0e86f570437b5f40812d27eb90c9ad2"
integrity sha512-OWwbNzDS0adva5xS5H/Lx5t/duek555cgP2j0cF+EdfAhQOEEm5ObSEBJmwipzyq/wAA+pjk5m5XEpFVrXDm5A==
"@oxc-resolver/binding-darwin-x64@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.8.4.tgz#0e54eb8198073fde5b765e6ba9a49073153c8783"
integrity sha512-BUwggKz8Hi5uEQ0AeVTSun1+sp4lzNcItn+L7fDsHu5Cx0Zueuo10BtVm+dIwmYVVPL5oGYOeD0fS7MKAazKiw==
"@oxc-resolver/binding-freebsd-x64@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.8.0.tgz#58ad06d193957d39579a6ec296e1548b8c77a911"
integrity sha512-G6NDJ1WbndODtMAvTOeW5PnF0GkGWUjBR5WdkR+MLn54mSwhCu/y+TqXs9QPoTBUlvU9qxCAfA2i9DFfQQFxlQ==
"@oxc-resolver/binding-freebsd-x64@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.8.4.tgz#d385b26b21993796d7d575bc906d648b32956248"
integrity sha512-fPO5TQhnn8gA6yP4o49lc4Gn8KeDwAp9uYd4PlE3Q00JVqU6cY9WecDhYHrWtiFcyoZ8UVBlIxuhRqT/DP4Z4A==
"@oxc-resolver/binding-linux-arm-gnueabihf@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.8.0.tgz#8caadabb202118174fd6d092bed3ec19f210cac6"
integrity sha512-/ZazyRR+K0DsJDdzEg8LfgQ8L93oG2nqw388VoAuMW9pP6c8eZpvuqw3z7gwFur7QUb6ZMS6lrUneiPFERbAew==
"@oxc-resolver/binding-linux-arm-gnueabihf@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.8.4.tgz#84b0661cdd706342fb7638bcaba46c6eecd49e11"
integrity sha512-QuNbdUaVGiP0W0GrXsvCDZjqeL4lZGU7aXlx/S2tCvyTk3wh6skoiLJgqUf/eeqXfUPnzTfntYqyfolzCAyBYA==
"@oxc-resolver/binding-linux-arm-musleabihf@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.8.0.tgz#83ed80f3a020277ec1f977e93ce052db6372861b"
integrity sha512-qWreuwBb7+d8oxcqkgyq1lRT3YPsL9Z69LFC6zIrE/evTcYHAbOa6reSQzpVNsivbVZUYwMY12rAl/lWBP3FpA==
"@oxc-resolver/binding-linux-arm-musleabihf@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.8.4.tgz#417f9c7818e55aad195785482a707e83b94d335b"
integrity sha512-p/zLMfza8OsC4BDKxqeZ9Qel+4eA/oiMSyKLRkMrTgt6OWQq1d5nHntjfG35Abcw4ev6Q9lRU3NOW5hj0xlUbw==
"@oxc-resolver/binding-linux-arm64-gnu@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.8.0.tgz#09a2f00c1aeaf29fbf112dd561e3f3a74d1e241e"
integrity sha512-m3p8ujvcjf7UOqI1AmOqMRmwhDQBXB/Z99FSCW5U4PY8f0CHBBX+o7uqirOsb7+iIwW7jyDQ7EoqSw79dQMvkA==
"@oxc-resolver/binding-linux-arm64-gnu@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.8.4.tgz#eb3c7b23a10dd20ab307f7a116df3de5172a39ef"
integrity sha512-bvJF9wWxF1+a5YZATlS5JojpOMC7OsnTatA6sXVHoOb7MIigjledYB5ZMAeRrnWWexRMiEX3YSaA46oSfOzmOg==
"@oxc-resolver/binding-linux-arm64-musl@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.8.0.tgz#c867c4489e46023f9df95dcdf53845b7ae6c0636"
integrity sha512-YuZQpvoRxUU+6pbqAZmvvKvhYtfOlCc6wunBrIau62xW0oD2xA67KVdq6E9FpvHFjnpgVZW7gcu5PrucgkiEXQ==
"@oxc-resolver/binding-linux-arm64-musl@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.8.4.tgz#6125023a9c24b6c37a8ea166762831323a921ce9"
integrity sha512-gf4nwGBfu+EFwOn5p7/T7VF4jmIdfodwJS9MRkOBHvuAm3LQgCX7O6d3Y80mm0TV7ZMRD/trfW628rHfd5++vQ==
"@oxc-resolver/binding-linux-ppc64-gnu@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.8.0.tgz#f3d63b4ab1b8c5704a3597f1465f2052a8e93af0"
integrity sha512-O9JETvqjywxeM0VZZaRz8HLSQcC94JBTFT668xaeKIyFDG1QCos8RZC5jJ6u2RJIM2h28pf3LkinINBfayZYOg==
"@oxc-resolver/binding-linux-ppc64-gnu@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.8.4.tgz#8475face8fd588614b82860a22eebf8995bebc26"
integrity sha512-T120R5GIzRd41rYWWKCI6cSYrZjmRQzf3X4xeE1WX396Uabz5DX8KU7RnVHihSK+KDxccCVOFBxcH3ITd+IEpw==
"@oxc-resolver/binding-linux-riscv64-gnu@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.8.0.tgz#e10e6dfe87bfb5bdbd4ab8cc8a1156c114864c9c"
integrity sha512-aaaqf/lbiQBquj/OO56Z0eFnlLHtmwaJ6gCJs9a4koobv2KII8XOb+0f/cn04Ix2elWFmRetKpOoDofhCoYvug==
"@oxc-resolver/binding-linux-riscv64-gnu@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.8.4.tgz#4cb7f523d2e8c1f80df219adda3ca25635c3bbfa"
integrity sha512-PVG7SxBFFjAaQ76p9O/0Xt5mTBlziRwpck+6cRNhy/hbWY/hSt8BFfPqw0EDSfnl40Uuh+NPsHFMnaWWyxbQEg==
"@oxc-resolver/binding-linux-riscv64-musl@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.8.0.tgz#efa8d1c6836c40255c4b7165910a5b110b8bd7cd"
integrity sha512-xFYcrvf6J+PWwmqNyA7ktnVTGid0SNkd8Ru+Uv7CxVMK9TF9nIEqTMM+66f8XxcWxWsczwqV7YYnRonIU+Wpyg==
"@oxc-resolver/binding-linux-riscv64-musl@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.8.4.tgz#2d1ea0d74450ce257820de82b6a2240619ee902c"
integrity sha512-L0OklUhM2qLGaKvPSyKmwWpoijfc++VJtPyVgz031ShOXyo0WjD0ZGzusyJMsA1a/gdulAmN6CQ/0Sf4LGXEcw==
"@oxc-resolver/binding-linux-s390x-gnu@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.8.0.tgz#817e6aa98b626ee7f1c3df5b90d10694105ab703"
integrity sha512-wvZQyx4FjCBf4IvFnR5k3H0BW7aiBAkqxYlHV+C6n4zaEHhhaVSCrXgLsvtVt7ma4Cgay1NQGEA0fXx5W2YiWA==
"@oxc-resolver/binding-linux-s390x-gnu@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.8.4.tgz#44ec8e96cff6b63ce5c1743607be0c8e5363e326"
integrity sha512-18Ajz5hqO4cRGuoHzLFUsIPod9GIaIRDiXFg2m6CS3NgVdHx7iCZscplYH7KtjdE42M8nGWYMyyq5BOk7QVgPw==
"@oxc-resolver/binding-linux-x64-gnu@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.8.0.tgz#03e9850e277c2c4d338d3d3c87a6b82b2de38589"
integrity sha512-Ihvel1igcoP9BFiVmiwy/CSqCIQzF9ROpdxRLWn1DbZKXNC5TjfiQJjlWrjECc8cPGZwv9yGDZR80oAOgHTE3Q==
"@oxc-resolver/binding-linux-x64-gnu@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.8.4.tgz#7cebe95af63f781a8b35315635caad0186baa399"
integrity sha512-uHvH4RyYBdQ/lFGV9H+R1ScHg6EBnAhE3mnX+u+mO/btnalvg7j80okuHf8Qw0tLQiP5P1sEBoVeE6zviXY9IA==
"@oxc-resolver/binding-linux-x64-musl@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.8.0.tgz#1d7b5d08a0e1fa829719775f4ff27642bc3de40c"
integrity sha512-GSa6/PSd5JTq/h1IIagWiKEMkWOtFJn5mpbbGsoOYEBml4FPIZ6WTZPzKOzOWnVfYDzM+8+xba6QTyIBP4bifg==
"@oxc-resolver/binding-linux-x64-musl@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.8.4.tgz#e655d381bc22be3ccff538016a6ef008490ad043"
integrity sha512-X5z44qh5DdJfVhcqXAQFTDFUpcxdpf6DT/lHL5CFcdQGIZxatjc7gFUy05IXPI9xwfq39RValjJBvFovUk9XBw==
"@oxc-resolver/binding-wasm32-wasi@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.8.0.tgz#fcfa31af1ac278c26d7dce04a5cc38ada36754db"
integrity sha512-z4S+KT5Yfyv+nhimBHKYEi/4ZgkkxRlXkk839g+p9r347RavGUo9hzm3qHkzTxacrDFcGhXLBLN+kSt5B1AWeQ==
"@oxc-resolver/binding-wasm32-wasi@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.8.4.tgz#e03e1930de885684fde2869883b4f554b762f2b5"
integrity sha512-z3906y+cd8RRhBGNwHRrRAFxnKjXsBeL3+rdQjZpBrUyrhhsaV5iKD/ROx64FNJ9GjL/9mfon8A5xx/McYIqHA==
dependencies:
"@napi-rs/wasm-runtime" "^1.0.5"
"@oxc-resolver/binding-win32-arm64-msvc@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.8.0.tgz#0dd616aede1afb47d14a27ff0d4bf1faaab0f34e"
integrity sha512-BnoBbPHuin8n0fqi1y+Psvur3Y1q6WrJUxKmmBJ2QxQFFq3aaegph5uYINP+i91bokHgwetV9lZMqBmYpu+Itg==
"@oxc-resolver/binding-win32-arm64-msvc@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.8.4.tgz#fb0332c8a541a2c0d9d1efd326bde0b1df5b4404"
integrity sha512-70vXFs74uA3X5iYOkpclbkWlQEF+MI325uAQ+Or2n8HJip2T0SEmuBlyw/sRL2E8zLC4oocb+1g25fmzlDVkmg==
"@oxc-resolver/binding-win32-ia32-msvc@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.8.0.tgz#54e5850b6c00ad26531af6b195f34606abae2861"
integrity sha512-SUfay8dZz10OJFOtD7GCkr3/7cKvC6vC00+NI5iVSGOTtzyo6vfXXU9SXyX7D/V/9gX8IkbeEOcN+JuYtwVETA==
"@oxc-resolver/binding-win32-ia32-msvc@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.8.4.tgz#fde4b74d5caf06c9bd2c6f9473dd179be22cb3f5"
integrity sha512-SEOUAzTvr+nyMia3nx1dMtD7YUxZwuhQ3QAPnxy21261Lj0yT3JY4EIfwWH54lAWWfMdRSRRMFuGeF/dq7XjEw==
"@oxc-resolver/binding-win32-x64-msvc@11.8.0":
version "11.8.0"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.8.0.tgz#4428d320bcc0aab1afda3252c2e43d0e40d2c7b4"
integrity sha512-FA6zw1RV8SsEP4oYJvK6RhTMDpf0Inw5w9/+8ihT0dzOZCIb1hzWcCe5EzDyRvV/Wyqqu43LqVydAYAK3l+rLw==
"@oxc-resolver/binding-win32-x64-msvc@11.8.4":
version "11.8.4"
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.8.4.tgz#b7478e2124034a8168128d29f3f201b455cf73e5"
integrity sha512-1gARIQsOPOU7LJ7jvMyPmZEVMapL/PymeG3J7naOdLZDrIZKX6CTvgawJmETYKt+8icP8M6KbBinrVkKVqFd+A==
"@peculiar/asn1-schema@^2.3.8":
version "2.3.8"
@@ -2033,12 +2033,12 @@
"@sinonjs/commons" "^3.0.0"
"@stylistic/eslint-plugin@^5.0.0":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-5.3.1.tgz#1aead935023b708ca6a27d079b1a96b726a38fe2"
integrity sha512-Ykums1VYonM0TgkD0VteVq9mrlO2FhF48MDJnPyv3MktIB2ydtuhlO0AfWm7xnW1kyf5bjOqA6xc7JjviuVTxg==
version "5.4.0"
resolved "https://registry.yarnpkg.com/@stylistic/eslint-plugin/-/eslint-plugin-5.4.0.tgz#4cd51beb5602a8978a9a956c3568180efffcabe5"
integrity sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/types" "^8.41.0"
"@eslint-community/eslint-utils" "^4.9.0"
"@typescript-eslint/types" "^8.44.0"
eslint-visitor-keys "^4.2.1"
espree "^10.4.0"
estraverse "^5.3.0"
@@ -2199,9 +2199,9 @@
undici-types "~5.26.4"
"@types/node@18":
version "18.19.125"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.125.tgz#179a577373347f29560cc4dfef9ee36229a09647"
integrity sha512-4TWNu0IxTQcszliYdW2mxrVvhHeERUeDCUwVuvQFn9JCU02kxrUDs8v52yOazPo7wLHKgqEd2FKxlSN6m8Deqg==
version "18.19.128"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.128.tgz#e55ec96fe0b8ae57c374c22238e7844908b2aadc"
integrity sha512-m7wxXGpPpqxp2QDi/rpih5O772APRuBIa/6XiGqLNoM1txkjI8Sz1V4oSXJxQLTz/yP5mgy9z6UXEO6/lP70Gg==
dependencies:
undici-types "~5.26.4"
@@ -2243,38 +2243,38 @@
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@^8.0.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz#d72bf8b2d3052afee919ba38f38c57138eee0396"
integrity sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz#9f251d4e85ec5089e7cccb09257ce93dbf0d7744"
integrity sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.44.0"
"@typescript-eslint/type-utils" "8.44.0"
"@typescript-eslint/utils" "8.44.0"
"@typescript-eslint/visitor-keys" "8.44.0"
"@typescript-eslint/scope-manager" "8.45.0"
"@typescript-eslint/type-utils" "8.45.0"
"@typescript-eslint/utils" "8.45.0"
"@typescript-eslint/visitor-keys" "8.45.0"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@^8.0.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.44.0.tgz#0436fbe0a72f86d3366d2d157d480524b0ab3f26"
integrity sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.45.0.tgz#571660c98824aefb4a6ec3b3766655d1348520a4"
integrity sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==
dependencies:
"@typescript-eslint/scope-manager" "8.44.0"
"@typescript-eslint/types" "8.44.0"
"@typescript-eslint/typescript-estree" "8.44.0"
"@typescript-eslint/visitor-keys" "8.44.0"
"@typescript-eslint/scope-manager" "8.45.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/typescript-estree" "8.45.0"
"@typescript-eslint/visitor-keys" "8.45.0"
debug "^4.3.4"
"@typescript-eslint/project-service@8.44.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.44.0.tgz#89060651dcecde946e758441fe94dceb6f769a29"
integrity sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==
"@typescript-eslint/project-service@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.45.0.tgz#f83dda1bca31dae2fd6821f9131daf1edebfd46c"
integrity sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.44.0"
"@typescript-eslint/types" "^8.44.0"
"@typescript-eslint/tsconfig-utils" "^8.45.0"
"@typescript-eslint/types" "^8.45.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.21.0":
@@ -2285,27 +2285,27 @@
"@typescript-eslint/types" "8.21.0"
"@typescript-eslint/visitor-keys" "8.21.0"
"@typescript-eslint/scope-manager@8.44.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.44.0.tgz#c37f1e786fd0e5b40607985c769a61c24c761c26"
integrity sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==
"@typescript-eslint/scope-manager@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz#59615ba506a9e3479d1efb0d09d6ab52f2a19142"
integrity sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==
dependencies:
"@typescript-eslint/types" "8.44.0"
"@typescript-eslint/visitor-keys" "8.44.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/visitor-keys" "8.45.0"
"@typescript-eslint/tsconfig-utils@8.44.0", "@typescript-eslint/tsconfig-utils@^8.44.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.0.tgz#8c0601372bf889f0663a08df001ad666442aa3a8"
integrity sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==
"@typescript-eslint/tsconfig-utils@8.45.0", "@typescript-eslint/tsconfig-utils@^8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz#63d38282790a2566c571bad423e7c1cad1f3d64c"
integrity sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==
"@typescript-eslint/type-utils@8.44.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.44.0.tgz#5b875f8a961d15bb47df787cbfde50baea312613"
integrity sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==
"@typescript-eslint/type-utils@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz#04004bdf2598844faa29fb936fb6b0ee10d6d3f3"
integrity sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==
dependencies:
"@typescript-eslint/types" "8.44.0"
"@typescript-eslint/typescript-estree" "8.44.0"
"@typescript-eslint/utils" "8.44.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/typescript-estree" "8.45.0"
"@typescript-eslint/utils" "8.45.0"
debug "^4.3.4"
ts-api-utils "^2.1.0"
@@ -2314,10 +2314,10 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.21.0.tgz#58f30aec8db8212fd886835dc5969cdf47cb29f5"
integrity sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==
"@typescript-eslint/types@8.44.0", "@typescript-eslint/types@^8.41.0", "@typescript-eslint/types@^8.44.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.0.tgz#4b9154ab164a0beff22d3217ff0fdc8d10bce924"
integrity sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==
"@typescript-eslint/types@8.45.0", "@typescript-eslint/types@^8.44.0", "@typescript-eslint/types@^8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.45.0.tgz#fc01cd2a4690b9713b02f895e82fb43f7d960684"
integrity sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==
"@typescript-eslint/typescript-estree@8.21.0":
version "8.21.0"
@@ -2333,15 +2333,15 @@
semver "^7.6.0"
ts-api-utils "^2.0.0"
"@typescript-eslint/typescript-estree@8.44.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.0.tgz#e23e9946c466cf5f53b7e46ecdd9789fd8192daa"
integrity sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==
"@typescript-eslint/typescript-estree@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz#3498500f109a89b104d2770497c707e56dfe062d"
integrity sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==
dependencies:
"@typescript-eslint/project-service" "8.44.0"
"@typescript-eslint/tsconfig-utils" "8.44.0"
"@typescript-eslint/types" "8.44.0"
"@typescript-eslint/visitor-keys" "8.44.0"
"@typescript-eslint/project-service" "8.45.0"
"@typescript-eslint/tsconfig-utils" "8.45.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/visitor-keys" "8.45.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -2349,15 +2349,15 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.44.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.44.0.tgz#2c0650a1e8a832ed15658e7ca3c7bd2818d92c7c"
integrity sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==
"@typescript-eslint/utils@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.45.0.tgz#6e68e92d99019fdf56018d0e6664c76a70470c95"
integrity sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.44.0"
"@typescript-eslint/types" "8.44.0"
"@typescript-eslint/typescript-estree" "8.44.0"
"@typescript-eslint/scope-manager" "8.45.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/typescript-estree" "8.45.0"
"@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.21.0"
@@ -2377,12 +2377,12 @@
"@typescript-eslint/types" "8.21.0"
eslint-visitor-keys "^4.2.0"
"@typescript-eslint/visitor-keys@8.44.0":
version "8.44.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.0.tgz#0d9d5647e005c2ff8acc391d1208ab37d08850aa"
integrity sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==
"@typescript-eslint/visitor-keys@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz#4e3bcc55da64ac61069ebfe62ca240567ac7d784"
integrity sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==
dependencies:
"@typescript-eslint/types" "8.44.0"
"@typescript-eslint/types" "8.45.0"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.2.0":
@@ -2562,9 +2562,9 @@ ansi-escapes@^4.2.1:
type-fest "^0.21.3"
ansi-escapes@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.1.0.tgz#91983a524b64e49f8e46fb962bfb7f375ced2ad5"
integrity sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g==
version "7.1.1"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.1.1.tgz#fdd39427a7e5a26233e48a8b4366351629ffea1b"
integrity sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==
dependencies:
environment "^1.0.0"
@@ -2976,11 +2976,6 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chalk@^5.6.0:
version "5.6.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea"
integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==
char-regex@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
@@ -3093,7 +3088,7 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
commander@^14.0.0:
commander@^14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.1.tgz#2f9225c19e6ebd0dc4404dd45821b2caa17ea09b"
integrity sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==
@@ -5136,10 +5131,10 @@ jest@^29.0.0:
import-local "^3.0.2"
jest-cli "^29.7.0"
jiti@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.5.1.tgz#bd099c1c2be1c59bbea4e5adcd127363446759d0"
integrity sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==
jiti@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.0.tgz#b831fdc4440c0a4944c34456643c555afe09d36d"
integrity sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==
jju@~1.4.0:
version "1.4.0"
@@ -5278,23 +5273,22 @@ kleur@^3.0.3:
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
knip@^5.0.0:
version "5.63.1"
resolved "https://registry.yarnpkg.com/knip/-/knip-5.63.1.tgz#6159af8863b2eb7da4c5823f42f006ac385a007b"
integrity sha512-wSznedUAzcU4o9e0O2WPqDnP7Jttu8cesq/R23eregRY8QYQ9NLJ3aGt9fadJfRzPBoU4tRyutwVQu6chhGDlA==
version "5.64.1"
resolved "https://registry.yarnpkg.com/knip/-/knip-5.64.1.tgz#313e5de65e89ba747e8acea999df8be0e46b5d33"
integrity sha512-80XnLsyeXuyxj1F4+NBtQFHxaRH0xWRw8EKwfQ6EkVZZ0bSz/kqqan08k/Qg8ajWsFPhFq+0S2RbLCBGIQtuOg==
dependencies:
"@nodelib/fs.walk" "^1.2.3"
fast-glob "^3.3.3"
formatly "^0.3.0"
jiti "^2.5.1"
jiti "^2.6.0"
js-yaml "^4.1.0"
minimist "^1.2.8"
oxc-resolver "^11.6.2"
oxc-resolver "^11.8.3"
picocolors "^1.1.1"
picomatch "^4.0.1"
smol-toml "^1.4.1"
strip-json-comments "5.0.2"
zod "^3.25.0"
zod-validation-error "^3.0.3"
zod "^4.1.11"
leven@^3.1.0:
version "3.1.0"
@@ -5309,11 +5303,6 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lilconfig@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
lines-and-columns@^1.1.6:
version "1.2.4"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
@@ -5327,22 +5316,19 @@ linkify-it@^5.0.0:
uc.micro "^2.0.0"
lint-staged@^16.0.0:
version "16.1.6"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.1.6.tgz#b0830df339a71f4207979a47c7be8ab0f38543ad"
integrity sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==
version "16.2.3"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.3.tgz#790866221d75602510507b5be40b2c7963715960"
integrity sha512-1OnJEESB9zZqsp61XHH2fvpS1es3hRCxMplF/AJUDa8Ho8VrscYDIuxGrj3m8KPXbcWZ8fT9XTMUhEQmOVKpKw==
dependencies:
chalk "^5.6.0"
commander "^14.0.0"
debug "^4.4.1"
lilconfig "^3.1.3"
listr2 "^9.0.3"
commander "^14.0.1"
listr2 "^9.0.4"
micromatch "^4.0.8"
nano-spawn "^1.0.2"
nano-spawn "^1.0.3"
pidtree "^0.6.0"
string-argv "^0.3.2"
yaml "^2.8.1"
listr2@^9.0.3:
listr2@^9.0.4:
version "9.0.4"
resolved "https://registry.yarnpkg.com/listr2/-/listr2-9.0.4.tgz#2916e633ae6e09d1a3f981172937ac1c5a8fa64f"
integrity sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==
@@ -5576,7 +5562,7 @@ ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
nano-spawn@^1.0.2:
nano-spawn@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/nano-spawn/-/nano-spawn-1.0.3.tgz#ef8d89a275eebc8657e67b95fc312a6527a05b8d"
integrity sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==
@@ -5723,32 +5709,32 @@ optionator@^0.9.3:
type-check "^0.4.0"
word-wrap "^1.2.5"
oxc-resolver@^11.6.2:
version "11.8.0"
resolved "https://registry.yarnpkg.com/oxc-resolver/-/oxc-resolver-11.8.0.tgz#d204a03f0bf55665a33247c16dacb28098bdf8d8"
integrity sha512-iTBXOucxbG40DGURl0cXChZn1WbvXteW4F/U3SZNbhlixFhd3+ZoHU8cmavOSS+Ob9GUPEUoZhGpj1empxwAiQ==
oxc-resolver@^11.8.3:
version "11.8.4"
resolved "https://registry.yarnpkg.com/oxc-resolver/-/oxc-resolver-11.8.4.tgz#84d1c5352893bf13a3817f8fe7ba6f8eb9b15f8a"
integrity sha512-qpimS3tHHEf+kgESMAme+q+rj7aCzMya00u9YdKOKyX2o7q4lozjPo6d7ZTTi979KHEcVOPWdNTueAKdeNq72w==
dependencies:
napi-postinstall "^0.3.0"
optionalDependencies:
"@oxc-resolver/binding-android-arm-eabi" "11.8.0"
"@oxc-resolver/binding-android-arm64" "11.8.0"
"@oxc-resolver/binding-darwin-arm64" "11.8.0"
"@oxc-resolver/binding-darwin-x64" "11.8.0"
"@oxc-resolver/binding-freebsd-x64" "11.8.0"
"@oxc-resolver/binding-linux-arm-gnueabihf" "11.8.0"
"@oxc-resolver/binding-linux-arm-musleabihf" "11.8.0"
"@oxc-resolver/binding-linux-arm64-gnu" "11.8.0"
"@oxc-resolver/binding-linux-arm64-musl" "11.8.0"
"@oxc-resolver/binding-linux-ppc64-gnu" "11.8.0"
"@oxc-resolver/binding-linux-riscv64-gnu" "11.8.0"
"@oxc-resolver/binding-linux-riscv64-musl" "11.8.0"
"@oxc-resolver/binding-linux-s390x-gnu" "11.8.0"
"@oxc-resolver/binding-linux-x64-gnu" "11.8.0"
"@oxc-resolver/binding-linux-x64-musl" "11.8.0"
"@oxc-resolver/binding-wasm32-wasi" "11.8.0"
"@oxc-resolver/binding-win32-arm64-msvc" "11.8.0"
"@oxc-resolver/binding-win32-ia32-msvc" "11.8.0"
"@oxc-resolver/binding-win32-x64-msvc" "11.8.0"
"@oxc-resolver/binding-android-arm-eabi" "11.8.4"
"@oxc-resolver/binding-android-arm64" "11.8.4"
"@oxc-resolver/binding-darwin-arm64" "11.8.4"
"@oxc-resolver/binding-darwin-x64" "11.8.4"
"@oxc-resolver/binding-freebsd-x64" "11.8.4"
"@oxc-resolver/binding-linux-arm-gnueabihf" "11.8.4"
"@oxc-resolver/binding-linux-arm-musleabihf" "11.8.4"
"@oxc-resolver/binding-linux-arm64-gnu" "11.8.4"
"@oxc-resolver/binding-linux-arm64-musl" "11.8.4"
"@oxc-resolver/binding-linux-ppc64-gnu" "11.8.4"
"@oxc-resolver/binding-linux-riscv64-gnu" "11.8.4"
"@oxc-resolver/binding-linux-riscv64-musl" "11.8.4"
"@oxc-resolver/binding-linux-s390x-gnu" "11.8.4"
"@oxc-resolver/binding-linux-x64-gnu" "11.8.4"
"@oxc-resolver/binding-linux-x64-musl" "11.8.4"
"@oxc-resolver/binding-wasm32-wasi" "11.8.4"
"@oxc-resolver/binding-win32-arm64-msvc" "11.8.4"
"@oxc-resolver/binding-win32-ia32-msvc" "11.8.4"
"@oxc-resolver/binding-win32-x64-msvc" "11.8.4"
p-limit@^2.2.0:
version "2.3.0"
@@ -7174,12 +7160,7 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zod-validation-error@^3.0.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.5.3.tgz#85ba33290200d8db9f043621e284f40dddefb7e5"
integrity sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==
zod@^3.25.0:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
zod@^4.1.11:
version "4.1.11"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5"
integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==