Give RoomWidgetClient the ability to send and receive sticky events (#5142)

* Give RoomWidgetClient the ability to send and receive sticky events

* linter

* Fix existing tests

* Add tests for sticky event support in embedded clients

* Update sticky event widget capability identifiers

In matrix-widget-api 0.16.1 they are updated to use the new unstable prefix from MSC4407.

* Explicitly require matrix-widget-api ≥ 1.16.1

* remove TODO comment

* simplify type lint checks
This is needed for EW donwstream tests. Otherwise it will through:
Error: matrix-js-sdk/src/embedded.ts(417,21): error TS2345: Argument of
type 'string | number | boolean | string[]' is not assignable to
parameter of type 'number'.

---------

Co-authored-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Robin
2026-01-22 17:07:13 +01:00
committed by GitHub
parent c8032a214e
commit dbb2ae5c07
4 changed files with 228 additions and 11 deletions
+1 -1
View File
@@ -56,7 +56,7 @@
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.14.0",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"p-retry": "7",
"sdp-transform": "^3.0.0",
+171 -1
View File
@@ -32,7 +32,14 @@ import {
type IRoomEvent,
} from "matrix-widget-api";
import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix";
import {
createRoomWidgetClient,
EventType,
type IEvent,
MatrixError,
MsgType,
UpdateDelayedEventAction,
} from "../../src/matrix";
import { MatrixClient, ClientEvent, type ITurnServer as IClientTurnServer } from "../../src/client";
import { SyncState } from "../../src/sync";
import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded";
@@ -42,6 +49,7 @@ import { sleep } from "../../src/utils";
import { SlidingSync } from "../../src/sliding-sync";
import { logger } from "../../src/logger";
import { flushPromises } from "../test-utils/flushPromises";
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../../src/models/room-sticky-events";
const testOIDCToken = {
access_token: "12345678",
@@ -49,6 +57,7 @@ const testOIDCToken = {
matrix_server_name: "homeserver.oabc",
token_type: "Bearer",
};
class MockWidgetApi extends EventEmitter {
public start = vi.fn().mockResolvedValue(undefined);
public getClientVersions = vi.fn();
@@ -167,6 +176,9 @@ describe("RoomWidgetClient", () => {
"org.matrix.rageshake_request",
{ request_id: 123 },
"!1:example.org",
undefined,
undefined,
undefined,
);
});
@@ -422,6 +434,7 @@ describe("RoomWidgetClient", () => {
"!1:example.org",
2000,
undefined,
undefined,
);
});
@@ -442,6 +455,7 @@ describe("RoomWidgetClient", () => {
"!1:example.org",
undefined,
parentDelayId,
undefined,
);
});
@@ -855,6 +869,162 @@ describe("RoomWidgetClient", () => {
});
});
describe("sticky events", () => {
describe("when supported", () => {
const doesServerSupportUnstableFeatureMock = vi.fn((feature) =>
Promise.resolve(feature === "org.matrix.msc4354"),
);
beforeAll(() => {
MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock;
});
afterAll(() => {
doesServerSupportUnstableFeatureMock.mockReset();
});
it("requests capabilities when set", async () => {
await makeClient({ sendSticky: true, receiveSticky: true });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407ReceiveStickyEvent);
});
it("does not request capabilities when unset", async () => {
await makeClient({});
expect(widgetApi.requestCapability).not.toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
expect(widgetApi.requestCapability).not.toHaveBeenCalledWith(
MatrixCapabilities.MSC4407ReceiveStickyEvent,
);
});
it("sends", async () => {
await makeClient({ sendEvent: [EventType.RTCMembership], sendSticky: true });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToSendEvent).toHaveBeenCalledWith(EventType.RTCMembership);
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
await client._unstable_sendStickyEvent("!1:example.org", 2000, null, EventType.RTCMembership, {
msc4354_sticky_key: "test",
});
expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith(
EventType.RTCMembership,
{ msc4354_sticky_key: "test" },
"!1:example.org",
undefined,
undefined,
2000,
);
});
it("receives (adds, updates, then removes when redacted)", async () => {
await makeClient({ receiveEvent: [EventType.RTCMembership, EventType.RoomRedaction] });
const room = client.getRoom("!1:example.org")!;
function expectStickyEvents(events: IEvent[]) {
expect([...room._unstable_getStickyEvents()].map((e) => e.getEffectiveEvent())).toEqual(events);
}
async function sendAndExpectStickyUpdate(
eventToSend: IEvent,
added: IEvent[],
updated: { current: IEvent; previous: IEvent }[],
removed: IEvent[],
) {
const emittedStickyUpdate = new Promise<
Parameters<RoomStickyEventsMap[RoomStickyEventsEvent.Update]>
>((resolve) => room.once(RoomStickyEventsEvent.Update, (...args) => resolve(args)));
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, {
detail: { data: eventToSend },
}),
);
const [addedReceived, updatedReceived, removedReceived] = await emittedStickyUpdate;
expect(addedReceived.map((e) => e.getEffectiveEvent())).toEqual(added);
expect(
updatedReceived.map(({ current, previous }) => ({
current: current.getEffectiveEvent(),
previous: previous.getEffectiveEvent(),
})),
).toEqual(updated);
expect(removedReceived.map((e) => e.getEffectiveEvent())).toEqual(removed);
}
// First, add a new sticky event to the map. The client should emit.
const event1 = new MatrixEvent({
type: EventType.RTCMembership,
event_id: "$pduhfiidph",
room_id: "!1:example.org",
sender: "@alice:example.org",
msc4354_sticky: { duration_ms: 1200000 },
content: { msc4354_sticky_key: "test" },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(event1, [event1], [], []);
// It should remain cached in the sticky map
expectStickyEvents([event1]);
// Next, update the same key in the sticky map
const event2 = new MatrixEvent({
type: EventType.RTCMembership,
event_id: "$zshgyutptfh",
room_id: "!1:example.org",
sender: "@alice:example.org",
msc4354_sticky: { duration_ms: 1200000 },
content: { msc4354_sticky_key: "test" },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(event2, [], [{ current: event2, previous: event1 }], []);
expectStickyEvents([event2]);
// Next, redact the second event. Because it has the first as a predecessor, the map should revert to
// the first event.
const redaction1 = new MatrixEvent({
type: EventType.RoomRedaction,
event_id: "$cimoexnvz",
room_id: "!1:example.org",
sender: "@alice:example.org",
redacts: event2.event_id,
content: { redacts: event2.event_id },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(redaction1, [], [{ current: event1, previous: event2 }], []);
expectStickyEvents([event1]);
// Finally, redact the first event. Now everything should be gone from the map.
const redaction2 = new MatrixEvent({
type: EventType.RoomRedaction,
event_id: "$drgzmenlh",
room_id: "!1:example.org",
sender: "@alice:example.org",
redacts: event1.event_id,
content: { redacts: event1.event_id },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(redaction2, [], [], [event1]);
expectStickyEvents([]);
});
});
describe("when unsupported", () => {
const doesServerSupportUnstableFeatureMock = vi.fn().mockResolvedValue(false);
beforeAll(() => {
MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock;
});
afterAll(() => {
doesServerSupportUnstableFeatureMock.mockReset();
});
it("fails to send", async () => {
await makeClient({ sendEvent: [EventType.RTCMembership], sendSticky: true });
await expect(
client._unstable_sendStickyEvent("!1:example.org", 2000, null, EventType.RTCMembership, {
msc4354_sticky_key: "test",
}),
).rejects.toThrow("Server does not support");
});
});
});
describe("to-device messages", () => {
const unencryptedContentMap = new Map([
["@alice:example.org", new Map([["*", { hello: "alice!" }]])],
+52 -5
View File
@@ -38,6 +38,7 @@ import {
type SendDelayedEventRequestOpts,
type SendDelayedEventResponse,
UpdateDelayedEventAction,
isSendDelayedEventRequestOpts,
} from "./@types/requests.ts";
import { EventType, type StateEvents } from "./@types/event.ts";
import { logger } from "./logger.ts";
@@ -56,7 +57,7 @@ import { ConnectionError, MatrixError } from "./http-api/errors.ts";
import { User } from "./models/user.ts";
import { type Room } from "./models/room.ts";
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
import { MapWithDefault, recursiveMapToObject } from "./utils.ts";
import { MapWithDefault, type QueryDict, recursiveMapToObject } from "./utils.ts";
import { type EmptyObject, TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "./matrix.ts";
interface IStateEventRequest {
@@ -122,6 +123,20 @@ export interface ICapabilities {
* @defaultValue false
*/
updateDelayedEvents?: boolean;
/**
* Whether this client needs to be able to send sticky events.
* @experimental Part of MSC4354 & MSC4407
* @defaultValue false
*/
sendSticky?: boolean;
/**
* Whether this client needs to be able to receive sticky events.
* @experimental Part of MSC4354 & MSC4407
* @defaultValue false
*/
receiveSticky?: boolean;
}
export enum RoomWidgetClientEvent {
@@ -242,6 +257,12 @@ export class RoomWidgetClient extends MatrixClient {
if (capabilities.updateDelayedEvents) {
widgetApi.requestCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent);
}
if (capabilities.sendSticky) {
widgetApi.requestCapability(MatrixCapabilities.MSC4407SendStickyEvent);
}
if (capabilities.receiveSticky) {
widgetApi.requestCapability(MatrixCapabilities.MSC4407ReceiveStickyEvent);
}
if (capabilities.turnServers) {
widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
}
@@ -346,17 +367,41 @@ export class RoomWidgetClient extends MatrixClient {
throw new Error(`Unknown room: ${roomIdOrAlias}`);
}
protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
delayOpts: SendDelayedEventRequestOpts,
): Promise<SendDelayedEventResponse>;
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
delayOpts?: SendDelayedEventRequestOpts,
delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict,
queryDict?: QueryDict,
): Promise<ISendEventResponse | SendDelayedEventResponse> {
let queryOpts = queryDict;
let delayOpts: SendDelayedEventRequestOpts | undefined;
if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) {
delayOpts = delayOptsOrQuery;
} else if (!queryOpts) {
queryOpts = delayOptsOrQuery;
}
const stickyDurationMs = queryOpts?.["org.matrix.msc4354.sticky_duration_ms"];
if (stickyDurationMs !== undefined && typeof stickyDurationMs !== "number") {
throw new Error("Sticky duration must be a number when defined");
}
// This is save since we just checked that above
// We need the additional as assertion for the EW linter to be happy.
// It is not capable of implying the type based on the throw if `stickyDurationMs !== undefined && typeof stickyDurationMs !== "number"`
// above
const stickyDurationMsAsNumber: number | undefined = stickyDurationMs as number | undefined;
// We need to extend the content with the redacts parameter
// The js sdk uses event.redacts but the widget api uses event.content.redacts
// This will be converted back to event.redacts in the widget driver.
@@ -374,6 +419,7 @@ export class RoomWidgetClient extends MatrixClient {
room.roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
stickyDurationMsAsNumber,
)
.catch(timeoutToConnectionError);
return this.validateSendDelayedEventResponse(response);
@@ -386,7 +432,7 @@ export class RoomWidgetClient extends MatrixClient {
let response: ISendEventFromWidgetResponseData;
try {
response = await this.widgetApi
.sendRoomEvent(event.getType(), content, room.roomId)
.sendRoomEvent(event.getType(), content, room.roomId, undefined, undefined, stickyDurationMsAsNumber)
.catch(timeoutToConnectionError);
} catch (e) {
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
@@ -704,6 +750,7 @@ export class RoomWidgetClient extends MatrixClient {
}
this.emit(ClientEvent.Event, event);
if (event.unstableStickyInfo !== undefined) this.room!._unstable_addStickyEvents([event]);
this.setSyncState(SyncState.Syncing);
logger.info(`Received event ${event.getId()} ${event.getType()}`);
} else {
+4 -4
View File
@@ -4416,10 +4416,10 @@ matrix-mock-request@^2.5.0:
dependencies:
expect "^28.1.0"
matrix-widget-api@^1.14.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.16.0.tgz#e232f1ed6b840feea58d693d877fb8a05b181aee"
integrity sha512-OCsCzEN54jWamvWkBa7PqcKdlOhLA+nJbUyqsATHvzb4/NMcjdUZWSDurZxyNE5eYlNwxClA6Hw20mzJEKJbvg==
matrix-widget-api@^1.16.1:
version "1.16.1"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz#a447f28f0af07e1bdc960881971de7d1ec9e6464"
integrity sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw==
dependencies:
"@types/events" "^3.0.0"
events "^3.2.0"