Skip to content

Commit

Permalink
Distinguish room state and timeline events in embedded clients
Browse files Browse the repository at this point in the history
This change enables room widget clients to take advantage of the more reliable method of communicating room state over the widget API provided by a recent update to MSC2762.
  • Loading branch information
robintown committed Dec 17, 2024
1 parent 349a86c commit 4ff8c25
Show file tree
Hide file tree
Showing 2 changed files with 81 additions and 64 deletions.
56 changes: 40 additions & 16 deletions spec/unit/embedded.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
WidgetApiToWidgetAction,
MatrixCapabilities,
ITurnServer,
IRoomEvent,
IOpenIDCredentials,
ISendEventFromWidgetResponseData,
WidgetApiResponseError,
Expand Down Expand Up @@ -635,12 +634,20 @@ describe("RoomWidgetClient", () => {
});

it("receives", async () => {
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
// Client needs to be told that the room state is loaded
widgetApi.emit(
`action:${WidgetApiToWidgetAction.UpdateState}`,

Check failure on line 642 in spec/unit/embedded.spec.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }),

Check failure on line 643 in spec/unit/embedded.spec.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
);
await init;

const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
// Let's assume that a state event comes in but it doesn't actually
// update the state of the room just yet (maybe it's unauthorized)
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
Expand All @@ -649,26 +656,43 @@ describe("RoomWidgetClient", () => {
// The client should've emitted about the received event
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
expect(await emittedSync).toEqual(SyncState.Syncing);
// It should've also inserted the event into the room object
// However it should not have changed the room state
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toBe(null);

// Now assume that the state event becomes favored by state
// resolution for whatever reason and enters into the current state
// of the room
widgetApi.emit(
`action:${WidgetApiToWidgetAction.UpdateState}`,

Check failure on line 667 in spec/unit/embedded.spec.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, {

Check failure on line 668 in spec/unit/embedded.spec.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
detail: { data: { state: [event] } },
}),
);
// It should now have changed the room state
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
});

it("backfills", async () => {
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
? [event as IRoomEvent]
: [],
it("ignores state updates for other rooms", async () => {
const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
// Client needs to be told that the room state is loaded
widgetApi.emit(
`action:${WidgetApiToWidgetAction.UpdateState}`,

Check failure on line 680 in spec/unit/embedded.spec.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }),

Check failure on line 681 in spec/unit/embedded.spec.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
);
await init;

await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");

const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
// Now a room we're not interested in receives a state update
widgetApi.emit(
`action:${WidgetApiToWidgetAction.UpdateState}`,

Check failure on line 687 in spec/unit/embedded.spec.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, {

Check failure on line 688 in spec/unit/embedded.spec.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
detail: { data: { state: [{ ...event, room_id: "!other-room:example.org" }] } },
}),
);
// No change to the room state
for (const room of client.getRooms()) {
expect(room.currentState.getStateEvents("org.example.foo", "bar")).toBe(null);
}
});
});

Expand Down
89 changes: 41 additions & 48 deletions src/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
WidgetApiAction,
IWidgetApiResponse,
IWidgetApiResponseData,
IUpdateStateToWidgetActionRequest,

Check failure on line 31 in src/embedded.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Module '"matrix-widget-api"' has no exported member 'IUpdateStateToWidgetActionRequest'.
} from "matrix-widget-api";

import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event.ts";
Expand Down Expand Up @@ -136,6 +137,7 @@ export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: ()
export class RoomWidgetClient extends MatrixClient {
private room?: Room;
private readonly widgetApiReady: Promise<void>;
private readonly roomStateSynced: Promise<void>;
private lifecycle?: AbortController;
private syncState: SyncState | null = null;

Expand Down Expand Up @@ -189,6 +191,11 @@ export class RoomWidgetClient extends MatrixClient {
};

this.widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
this.roomStateSynced = capabilities.receiveState?.length
? new Promise<void>((resolve) =>
this.widgetApi.once(`action:${WidgetApiToWidgetAction.UpdateState}`, resolve),

Check failure on line 196 in src/embedded.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Property 'UpdateState' does not exist on type 'typeof WidgetApiToWidgetAction'.
)
: Promise.resolve();

// Request capabilities for the functionality this client needs to support
if (
Expand Down Expand Up @@ -241,6 +248,7 @@ export class RoomWidgetClient extends MatrixClient {

widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
widgetApi.on(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);

// Open communication with the host
widgetApi.start();
Expand Down Expand Up @@ -276,37 +284,16 @@ export class RoomWidgetClient extends MatrixClient {

await this.widgetApiReady;

// Backfill the requested events
// We only get the most recent event for every type + state key combo,
// so it doesn't really matter what order we inject them in
await Promise.all(
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]);
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));

if (this.syncApi instanceof SyncApi) {
// Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode
// -> state events in `timelineEventList` will update the state.
await this.syncApi.injectRoomEvents(this.room!, undefined, events);
} else {
await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync
}
events.forEach((event) => {
this.emit(ClientEvent.Event, event);
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
});
}) ?? [],
);

if (opts.clientWellKnownPollPeriod !== undefined) {
this.clientWellKnownIntervalID = setInterval(() => {
this.fetchClientWellKnown();
}, 1000 * opts.clientWellKnownPollPeriod);
this.fetchClientWellKnown();
}

await this.roomStateSynced;
this.setSyncState(SyncState.Syncing);
logger.info("Finished backfilling events");
logger.info("Finished initial sync");

this.matrixRTC.start();

Expand All @@ -317,6 +304,7 @@ export class RoomWidgetClient extends MatrixClient {
public stopClient(): void {
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
this.widgetApi.off(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);

super.stopClient();
this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped
Expand Down Expand Up @@ -574,36 +562,15 @@ export class RoomWidgetClient extends MatrixClient {
// Only inject once we have update the txId
await this.updateTxId(event);

// The widget API does not tell us whether a state event came from `state_after` or not so we assume legacy behaviour for now.
if (this.syncApi instanceof SyncApi) {
// The code will want to be something like:
// ```
// if (!params.addToTimeline && !params.addToState) {
// // Passing undefined for `stateAfterEventList` makes `injectRoomEvents` run in "legacy mode"
// // -> state events part of the `timelineEventList` parameter will update the state.
// this.injectRoomEvents(this.room!, [], undefined, [event]);
// } else {
// this.injectRoomEvents(this.room!, undefined, params.addToState ? [event] : [], params.addToTimeline ? [event] : []);
// }
// ```

// Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode
// -> state events in `timelineEventList` will update the state.
await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]);
this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]);
} else {
// The code will want to be something like:
// ```
// if (!params.addToTimeline && !params.addToState) {
// this.injectRoomEvents(this.room!, [], [event]);
// } else {
// this.injectRoomEvents(this.room!, params.addToState ? [event] : [], params.addToTimeline ? [event] : []);
// }
// ```
await this.syncApi!.injectRoomEvents(this.room!, [], [event]); // Sliding Sync
// Sliding Sync
this.syncApi!.injectRoomEvents(this.room!, [], [event]);
}
this.emit(ClientEvent.Event, event);
this.setSyncState(SyncState.Syncing);
logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
logger.info(`Received event ${event.getId()} ${event.getType()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
Expand All @@ -628,6 +595,32 @@ export class RoomWidgetClient extends MatrixClient {
await this.ack(ev);
};

private onStateUpdate = async (ev: CustomEvent<IUpdateStateToWidgetActionRequest>): Promise<void> => {
ev.preventDefault();

for (const rawEvent of ev.detail.data.state) {
// Verify the room ID matches, since it's possible for the client to
// send us state updates from other rooms if this widget is always
// on screen
if (rawEvent.room_id === this.roomId) {
const event = new MatrixEvent(rawEvent as Partial<IEvent>);

if (this.syncApi instanceof SyncApi) {
this.syncApi.injectRoomEvents(this.room!, undefined, [event]);
} else {
// Sliding Sync
this.syncApi!.injectRoomEvents(this.room!, [event]);
}
logger.info(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
}
}

await this.ack(ev);
};

private async watchTurnServers(): Promise<void> {
const servers = this.widgetApi.getTurnServers();
const onClientStopped = (): void => {
Expand Down

0 comments on commit 4ff8c25

Please sign in to comment.