From 3025f116026d24bf662069d7f74034f3d1ff8a2b Mon Sep 17 00:00:00 2001 From: Valere Date: Fri, 23 Jan 2026 17:37:56 +0100 Subject: [PATCH 1/2] feat: Push current sticky state when widgets wants to read sticky events --- src/ClientWidgetApi.ts | 63 ++++++++++++- src/driver/WidgetDriver.ts | 18 ++++ src/interfaces/UpdateStickyStateAction.ts | 38 ++++++++ src/interfaces/WidgetApiAction.ts | 7 ++ test/ClientWidgetApi-test.ts | 107 ++++++++++++++++++++++ 5 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/interfaces/UpdateStickyStateAction.ts diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 540b41b..8a77155 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -110,6 +110,7 @@ import { import { IThemeChangeActionRequestData } from "./interfaces/ThemeChangeAction"; import { IUpdateStateToWidgetRequestData } from "./interfaces/UpdateStateAction"; import { IToDeviceMessage } from "./interfaces/IToDeviceMessage"; +import { IUpdateStickyStateToWidgetRequestData } from "./interfaces/UpdateStickyStateAction"; /** * API handler for the client side of widgets. This raises events @@ -295,12 +296,39 @@ export class ClientWidgetApi extends EventEmitter { if (isTimelineCapability(c)) { const roomId = getTimelineRoomIDFromCapability(c); if (roomId === Symbols.AnyRoom) { - for (const roomId of this.driver.getKnownRooms()) this.pushRoomState(roomId); + for (const roomId of this.driver.getKnownRooms()) { + this.pushRoomState(roomId); + } } else { this.pushRoomState(roomId); } } } + + if (allowed.includes(MatrixCapabilities.MSC4407ReceiveStickyEvent)) { + console.debug(`Widget ${this.widget.id} is allowed to receive sticky events, check current sticky state.`); + // If the widget can receive sticky events, push all sticky events in known rooms now. + // Sticky events are like a state, and passed history is needed to get the full state. + const roomIds = allowed + .filter((capability) => isTimelineCapability(capability)) + .map((timelineCapability) => getTimelineRoomIDFromCapability(timelineCapability)) + .flatMap((roomIdOrWildcard) => { + if (roomIdOrWildcard === Symbols.AnyRoom) { + // Do we support getting sticky state for any room? + return this.driver.getKnownRooms(); + } else { + return roomIdOrWildcard; + } + }); + console.debug(`Widget ${this.widget.id} is allowed to receive sticky events in rooms:`, roomIds); + + for (const roomId of roomIds) { + this.pushStickyState(roomId).catch((err) => { + console.error(`Failed to push sticky events to widget ${this.widget.id} for room ${roomId}:`, err); + }); + } + } + // If new events are allowed and the currently viewed room isn't covered // by a timeline capability, then we know that there could be some state // in the viewed room that the widget hasn't learned about yet- push it. @@ -1202,6 +1230,39 @@ export class ClientWidgetApi extends EventEmitter { } } + /** + * Reads the current sticky state of the room and pushes it to the widget. + * + * It will only push events that the widget is allowed to receive. + * @param roomId + * @private + */ + private async pushStickyState(roomId: string): Promise { + console.debug("Pushing sticky state to widget for room", roomId); + return this.driver + .readStickyEvents(roomId) + .then((events) => { + // filter to the allowed sticky events + const filtered = events.filter((e) => { + return this.canReceiveRoomEvent( + e.type, + typeof e.content?.msgtype === "string" ? e.content.msgtype : null, + ); + }); + return { roomId, stickyEvents: filtered }; + }) + .then(({ roomId, stickyEvents }) => { + console.debug("Pushing", stickyEvents.length, "sticky events to widget for room", roomId); + return this.transport.send( + WidgetApiToWidgetAction.MSC4407PushInitialStickyState, + { + roomId, + stickyEvents, + }, + ); + }); + } + /** * Read the room's state and push all entries that the widget is allowed to * read through to the widget. diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 947c5c2..94ef7f8 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -57,6 +57,7 @@ export interface ISearchUserDirectoryResult { export interface IGetMediaConfigResult { [key: string]: unknown; + "m.upload.size"?: number; } @@ -238,6 +239,7 @@ export abstract class WidgetDriver { ): Promise { return Promise.reject(new Error("Failed to override function")); } + /** * Reads an element of room account data. The widget API will have already verified that the widget is * capable of receiving the `eventType` of the requested information. If `roomIds` is supplied, it may @@ -309,6 +311,22 @@ export abstract class WidgetDriver { return Promise.resolve([]); } + /** + * Gets all sticky events of the given type the user has access to. + * The widget API will have already verified that the widget is capable of receiving the events. + * + * This is needed because widgets will get only live messages as they appear in the timeline. + * However, sticky events act like a state, and the current state is made by events that may have been + * sent before the widget was loaded. + * Events are sticky for 1h maximum, so the widget has access to the past hour of sticky events maximum. + * + * @experimental Part of MSC4407 - Sticky Events (Widget API) + * @param roomId - The ID of the room. + */ + public readStickyEvents(roomId: string): Promise { + throw new Error("readStickyEvents is not implemented"); + } + /** * Reads all events of the given type, and optionally `msgtype` (if applicable/defined), * the user has access to. The widget API will have already verified that the widget is diff --git a/src/interfaces/UpdateStickyStateAction.ts b/src/interfaces/UpdateStickyStateAction.ts new file mode 100644 index 0000000..f1ff3aa --- /dev/null +++ b/src/interfaces/UpdateStickyStateAction.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2026 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. + */ + +import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest"; +import { WidgetApiToWidgetAction } from "./WidgetApiAction"; +import { IWidgetApiResponseData } from "./IWidgetApiResponse"; +import { IRoomEvent } from "./IRoomEvent"; + +export interface IUpdateStickyStateToWidgetRequestData extends IWidgetApiRequestData { + roomId: string; + stickyEvents: IRoomEvent[]; +} + +export interface IUpdateStickyStateToWidgetActionRequest extends IWidgetApiRequest { + action: WidgetApiToWidgetAction.MSC4407PushInitialStickyState; + data: IUpdateStickyStateToWidgetRequestData; +} + +export interface IUpdateStickyStateToWidgetResponseData extends IWidgetApiResponseData { + // nothing +} + +export interface IUpdateStickyStateToWidgetActionResponse extends IUpdateStickyStateToWidgetActionRequest { + response: IUpdateStickyStateToWidgetResponseData; +} diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index b6ac75d..9e4b67a 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -30,6 +30,13 @@ export enum WidgetApiToWidgetAction { SendToDevice = "send_to_device", UpdateState = "update_state", UpdateTurnServers = "update_turn_servers", + + /** + * When a widget is approved to receive sticky events, this action is sent to notify it of the current + * sticky state (i.e., the events that are currently sticky in the room and sent before the widget was active). + * @experimental - Part of MSC4407 and may change or be removed without warning. + */ + MSC4407PushInitialStickyState = "org.matrix.msc4407.initial_sticky_state", } export enum WidgetApiFromWidgetAction { diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 3e08ea8..3e566cc 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -884,6 +884,113 @@ describe("ClientWidgetApi", () => { }); }); + describe("Sticky events updates", () => { + // Some testing contants for sticky events + const ROOM_A = "!here:example.org"; + const ROOM_B = "!there:example.org"; + + const ALICE_RTC_MEMBER_EVENT = createRoomEvent({ + sender: "@alice:example.org", + room_id: ROOM_A, + type: "org.matrix.msc4143.rtc.member", + content: { + member: { + device_id: "HXKHKJSLZI", + }, + msc4354_sticky_key: "001", + }, + }); + + const BOB_RTC_MEMBER_EVENT = createRoomEvent({ + sender: "@bob:example.org", + room_id: ROOM_A, + type: "org.matrix.msc4143.rtc.member", + content: { + msc4354_sticky_key: "002", + }, + }); + + const ANOTHER_STICKY_EVENT = createRoomEvent({ + type: "org.example.active_poll", + room_id: ROOM_A, + content: { + q: "How are you?", + options: ["Good", "Bad", "Okay"], + msc4354_sticky_key: "ac_000", + }, + }); + + const OTHER_ROOM_EVENT = { + ...ALICE_RTC_MEMBER_EVENT, + room_id: ROOM_B, + }; + + beforeEach(() => { + driver.readStickyEvents = jest.fn().mockImplementation((roomId) => { + if (roomId === ROOM_A) { + return Promise.resolve([ALICE_RTC_MEMBER_EVENT, BOB_RTC_MEMBER_EVENT, ANOTHER_STICKY_EVENT]); + } else if (roomId === ROOM_B) { + return Promise.resolve([OTHER_ROOM_EVENT]); + } + return Promise.resolve([]); + }); + }); + + it("Feed current sticky events to the widget when loaded", async () => { + // Load + await loadIframe([ + `org.matrix.msc2762.timeline:${ROOM_A}`, + "org.matrix.msc2762.receive.event:org.matrix.msc4143.rtc.member", + MatrixCapabilities.MSC4407ReceiveStickyEvent, + ]); + + await waitFor(() => { + // The initial topic and name should have been pushed + expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.MSC4407PushInitialStickyState, { + roomId: ROOM_A, + stickyEvents: [ALICE_RTC_MEMBER_EVENT, BOB_RTC_MEMBER_EVENT], + }); + }); + }); + + it("Should not push sticky events of type that the widget cannot receive", async () => { + // Load + await loadIframe([ + `org.matrix.msc2762.timeline:${ROOM_A}`, + "org.matrix.msc2762.receive.event:org.matrix.msc4143.rtc.member", + MatrixCapabilities.MSC4407ReceiveStickyEvent, + ]); + + // -- ASSERT + // The sticky events of the unrequested type should not be pushed + await waitFor(() => { + expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.MSC4407PushInitialStickyState, { + roomId: ROOM_A, + stickyEvents: [ALICE_RTC_MEMBER_EVENT, BOB_RTC_MEMBER_EVENT], + }); + }); + }); + + it("Should not push past sticky event if sticky capability not requested", async () => { + // -- ACT + // Request permission to read `org.matrix.msc4143.rtc.member` but without + // the permission to read sticky events + await loadIframe([ + `org.matrix.msc2762.timeline:${ROOM_A}`, + "org.matrix.msc2762.receive.event:org.matrix.msc4143.rtc.member", + ]); + + // -- ASSERT + // No sticky events should be pushed! + await waitFor(() => { + expect(transport.send).not.toHaveBeenCalledWith( + WidgetApiToWidgetAction.MSC4407PushInitialStickyState, + expect.anything(), + ); + }); + }); + }); + describe("receiving events", () => { const roomId = "!room:example.org"; const otherRoomId = "!other-room:example.org"; From 31773e2b421525c719aa8a4052386eb30e5ab817 Mon Sep 17 00:00:00 2001 From: Valere Date: Fri, 23 Jan 2026 18:21:27 +0100 Subject: [PATCH 2/2] Rename widget data interfaces to match action name --- src/ClientWidgetApi.ts | 4 ++-- ...tateAction.ts => PushInitialStickyStateAction.ts} | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) rename src/interfaces/{UpdateStickyStateAction.ts => PushInitialStickyStateAction.ts} (66%) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 8a77155..40e7555 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -110,7 +110,7 @@ import { import { IThemeChangeActionRequestData } from "./interfaces/ThemeChangeAction"; import { IUpdateStateToWidgetRequestData } from "./interfaces/UpdateStateAction"; import { IToDeviceMessage } from "./interfaces/IToDeviceMessage"; -import { IUpdateStickyStateToWidgetRequestData } from "./interfaces/UpdateStickyStateAction"; +import { IPushInitialStickyStateToWidgetRequestData } from "./interfaces/PushInitialStickyStateAction"; /** * API handler for the client side of widgets. This raises events @@ -1253,7 +1253,7 @@ export class ClientWidgetApi extends EventEmitter { }) .then(({ roomId, stickyEvents }) => { console.debug("Pushing", stickyEvents.length, "sticky events to widget for room", roomId); - return this.transport.send( + return this.transport.send( WidgetApiToWidgetAction.MSC4407PushInitialStickyState, { roomId, diff --git a/src/interfaces/UpdateStickyStateAction.ts b/src/interfaces/PushInitialStickyStateAction.ts similarity index 66% rename from src/interfaces/UpdateStickyStateAction.ts rename to src/interfaces/PushInitialStickyStateAction.ts index f1ff3aa..1ec4030 100644 --- a/src/interfaces/UpdateStickyStateAction.ts +++ b/src/interfaces/PushInitialStickyStateAction.ts @@ -19,20 +19,20 @@ import { WidgetApiToWidgetAction } from "./WidgetApiAction"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; import { IRoomEvent } from "./IRoomEvent"; -export interface IUpdateStickyStateToWidgetRequestData extends IWidgetApiRequestData { +export interface IPushInitialStickyStateToWidgetRequestData extends IWidgetApiRequestData { roomId: string; stickyEvents: IRoomEvent[]; } -export interface IUpdateStickyStateToWidgetActionRequest extends IWidgetApiRequest { +export interface IPushInitialStickyStateToWidgetActionRequest extends IWidgetApiRequest { action: WidgetApiToWidgetAction.MSC4407PushInitialStickyState; - data: IUpdateStickyStateToWidgetRequestData; + data: IPushInitialStickyStateToWidgetRequestData; } -export interface IUpdateStickyStateToWidgetResponseData extends IWidgetApiResponseData { +export interface IPushInitialStickyStateToWidgetResponseData extends IWidgetApiResponseData { // nothing } -export interface IUpdateStickyStateToWidgetActionResponse extends IUpdateStickyStateToWidgetActionRequest { - response: IUpdateStickyStateToWidgetResponseData; +export interface IPushInitialStickyStateToWidgetActionResponse extends IPushInitialStickyStateToWidgetActionRequest { + response: IPushInitialStickyStateToWidgetResponseData; }