Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion src/ClientWidgetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import {
import { IThemeChangeActionRequestData } from "./interfaces/ThemeChangeAction";
import { IUpdateStateToWidgetRequestData } from "./interfaces/UpdateStateAction";
import { IToDeviceMessage } from "./interfaces/IToDeviceMessage";
import { IPushInitialStickyStateToWidgetRequestData } from "./interfaces/PushInitialStickyStateAction";

/**
* API handler for the client side of widgets. This raises events
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<IWidgetApiAcknowledgeResponseData> {
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<IPushInitialStickyStateToWidgetRequestData>(
WidgetApiToWidgetAction.MSC4407PushInitialStickyState,
{
roomId,
stickyEvents,
},
);
});
}

/**
* Read the room's state and push all entries that the widget is allowed to
* read through to the widget.
Expand Down
18 changes: 18 additions & 0 deletions src/driver/WidgetDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface ISearchUserDirectoryResult {

export interface IGetMediaConfigResult {
[key: string]: unknown;

"m.upload.size"?: number;
}

Expand Down Expand Up @@ -238,6 +239,7 @@ export abstract class WidgetDriver {
): Promise<void> {
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
Expand Down Expand Up @@ -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<IRoomEvent[]> {
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
Expand Down
38 changes: 38 additions & 0 deletions src/interfaces/PushInitialStickyStateAction.ts
Original file line number Diff line number Diff line change
@@ -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 IPushInitialStickyStateToWidgetRequestData extends IWidgetApiRequestData {
roomId: string;
stickyEvents: IRoomEvent[];
}

export interface IPushInitialStickyStateToWidgetActionRequest extends IWidgetApiRequest {
action: WidgetApiToWidgetAction.MSC4407PushInitialStickyState;
data: IPushInitialStickyStateToWidgetRequestData;
}

export interface IPushInitialStickyStateToWidgetResponseData extends IWidgetApiResponseData {
// nothing
}

export interface IPushInitialStickyStateToWidgetActionResponse extends IPushInitialStickyStateToWidgetActionRequest {
response: IPushInitialStickyStateToWidgetResponseData;
}
7 changes: 7 additions & 0 deletions src/interfaces/WidgetApiAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
107 changes: 107 additions & 0 deletions test/ClientWidgetApi-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down