Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2d082064e | |||
| 2e9f5b6033 | |||
| fd949fe486 | |||
| 7b3aed8a47 | |||
| b84a73c7cc | |||
| a03cf054a8 | |||
| b3d217717a | |||
| a213d177f9 | |||
| e885ecf08d | |||
| d1d9aba745 | |||
| 52bcc2c955 | |||
| 1994806c72 | |||
| c4d1fd2c67 | |||
| 7ad8288525 | |||
| 31a42964e6 | |||
| 2b1d37813c |
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+17
-13
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user