From a31991c01d4acb346e568c6b99b8456f85ad7074 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 5 Dec 2024 19:05:44 -0500 Subject: [PATCH] Distinguish room state and timeline events This is an implementation of an update to MSC2762, which provides a new action for informing widgets of changes to room state. --- src/ClientWidgetApi.ts | 274 +++++++++++++++++++++----- src/driver/WidgetDriver.ts | 52 +++++ src/index.ts | 1 + src/interfaces/UpdateStateAction.ts | 37 ++++ src/interfaces/WidgetApiAction.ts | 1 + test/ClientWidgetApi-test.ts | 293 ++++++++++++++++++++++++++-- 6 files changed, 602 insertions(+), 56 deletions(-) create mode 100644 src/interfaces/UpdateStateAction.ts diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 8fcc662..666b668 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -23,7 +23,12 @@ import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./interfaces/IWid import { IContentLoadedActionRequest } from "./interfaces/ContentLoadedAction"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse"; -import { Capability, MatrixCapabilities } from "./interfaces/Capabilities"; +import { + Capability, + MatrixCapabilities, + getTimelineRoomIDFromCapability, + isTimelineCapability, +} from "./interfaces/Capabilities"; import { IOpenIDUpdate, ISendEventDetails, ISendDelayedEventDetails, WidgetDriver } from "./driver/WidgetDriver"; import { ICapabilitiesActionResponseData, @@ -54,7 +59,7 @@ import { ISendToDeviceFromWidgetResponseData, ISendToDeviceToWidgetRequestData, } from "./interfaces/SendToDeviceAction"; -import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability"; +import { EventDirection, EventKind, WidgetEventCapability } from "./models/WidgetEventCapability"; import { IRoomEvent } from "./interfaces/IRoomEvent"; import { IRoomAccountData } from "./interfaces/IRoomAccountData"; import { @@ -102,6 +107,7 @@ import { IDownloadFileActionFromWidgetActionRequest, IDownloadFileActionFromWidgetResponseData, } from "./interfaces/DownloadFileAction"; +import { IUpdateStateToWidgetRequestData } from "./interfaces/UpdateStateAction"; /** * API handler for the client side of widgets. This raises events @@ -137,6 +143,11 @@ export class ClientWidgetApi extends EventEmitter { private isStopped = false; private turnServers: AsyncGenerator | null = null; private contentLoadedWaitTimer?: ReturnType; + // Stores pending requests to push a room's state to the widget + private pushRoomStateTasks = new Set>() + // Room ID → event type → state key → events to be pushed + private pushRoomStateResult = new Map>>(); + private flushRoomStateTask: Promise | null = null; /** * Creates a new client widget API. This will instantiate the transport @@ -228,25 +239,46 @@ export class ClientWidgetApi extends EventEmitter { requestedCaps = caps.capabilities; return this.driver.validateCapabilities(new Set(caps.capabilities)); }).then(allowedCaps => { - console.log(`Widget ${this.widget.id} is allowed capabilities:`, Array.from(allowedCaps)); - this.allowedCapabilities = allowedCaps; - this.allowedEvents = WidgetEventCapability.findEventCapabilities(allowedCaps); - this.notifyCapabilities(requestedCaps); + this.allowCapabilities([...allowedCaps], requestedCaps); this.emit("ready"); }).catch(e => { this.emit("error:preparing", e); }); } - private notifyCapabilities(requested: Capability[]) { + private allowCapabilities(allowed: string[], requested: string[]): void { + console.log(`Widget ${this.widget.id} is allowed capabilities:`, allowed); + + for (const c of allowed) this.allowedCapabilities.add(c); + const allowedEvents = WidgetEventCapability.findEventCapabilities(allowed); + this.allowedEvents.push(...allowedEvents); + this.transport.send(WidgetApiToWidgetAction.NotifyCapabilities, { - requested: requested, + requested, approved: Array.from(this.allowedCapabilities), }).catch(e => { console.warn("non-fatal error notifying widget of approved capabilities:", e); }).then(() => { this.emit("capabilitiesNotified") }); + + // Push the initial room state for all rooms with a timeline capability + for (const c of allowed) { + if (isTimelineCapability(c)) { + const roomId = getTimelineRoomIDFromCapability(c); + if (roomId === Symbols.AnyRoom) { + for (const roomId of this.driver.getKnownRooms()) this.pushRoomState(roomId); + } else { + this.pushRoomState(roomId); + } + } + } + // If new events are allowed and the currently viewed room isn't covered + // by a timeline capability, then this is the widget's opportunity to + // learn the state of the viewed room + if (allowedEvents.length > 0 && this.viewedRoomId !== null && !this.canUseRoomTimeline(this.viewedRoomId)) { + this.pushRoomState(this.viewedRoomId); + } } private onIframeLoad(ev: Event) { @@ -303,18 +335,12 @@ export class ClientWidgetApi extends EventEmitter { const requested = request.data?.capabilities || []; const newlyRequested = new Set(requested.filter(r => !this.hasCapability(r))); if (newlyRequested.size === 0) { - // Nothing to do - notify capabilities - return this.notifyCapabilities([]); + // Nothing to do - skip validation + this.allowCapabilities([], []); } - this.driver.validateCapabilities(newlyRequested).then(allowed => { - allowed.forEach(c => this.allowedCapabilities.add(c)); - - const allowedEvents = WidgetEventCapability.findEventCapabilities(allowed); - allowedEvents.forEach(c => this.allowedEvents.push(c)); - - return this.notifyCapabilities(Array.from(newlyRequested)); - }); + this.driver.validateCapabilities(newlyRequested) + .then(allowed => this.allowCapabilities([...allowed], [...newlyRequested])); } private handleNavigate(request: INavigateActionRequest) { @@ -419,7 +445,7 @@ export class ClientWidgetApi extends EventEmitter { }); } - private handleReadEvents(request: IReadEventFromWidgetActionRequest) { + private async handleReadEvents(request: IReadEventFromWidgetActionRequest) { if (!request.data.type) { return this.transport.reply(request, { error: {message: "Invalid request - missing event type"}, @@ -431,12 +457,13 @@ export class ClientWidgetApi extends EventEmitter { }); } - let askRoomIds: string[] | null = null; // null denotes current room only - if (request.data.room_ids) { - askRoomIds = request.data.room_ids as string[]; - if (!Array.isArray(askRoomIds)) { - askRoomIds = [askRoomIds as any as string]; - } + let askRoomIds: string[]; + if (request.data.room_ids === undefined) { + askRoomIds = this.viewedRoomId === null ? [] : [this.viewedRoomId]; + } else if (request.data.room_ids === Symbols.AnyRoom) { + askRoomIds = this.driver.getKnownRooms().filter(roomId => this.canUseRoomTimeline(roomId)); + } else { + askRoomIds = request.data.room_ids for (const roomId of askRoomIds) { if (!this.canUseRoomTimeline(roomId)) { return this.transport.reply(request, { @@ -449,25 +476,39 @@ export class ClientWidgetApi extends EventEmitter { const limit = request.data.limit || 0; const since = request.data.since; - let events: Promise = Promise.resolve([]); + let stateKey: string | undefined = undefined; + let msgtype: string | undefined = undefined; if (request.data.state_key !== undefined) { - const stateKey = request.data.state_key === true ? undefined : request.data.state_key.toString(); + stateKey = request.data.state_key === true ? undefined : request.data.state_key.toString(); if (!this.canReceiveStateEvent(request.data.type, stateKey ?? null)) { return this.transport.reply(request, { error: {message: "Cannot read state events of this type"}, }); } - events = this.driver.readStateEvents(request.data.type, stateKey, limit, askRoomIds); } else { - if (!this.canReceiveRoomEvent(request.data.type, request.data.msgtype)) { + msgtype = request.data.msgtype; + if (!this.canReceiveRoomEvent(request.data.type, msgtype)) { return this.transport.reply(request, { error: {message: "Cannot read room events of this type"}, }); } - events = this.driver.readRoomEvents(request.data.type, request.data.msgtype, limit, askRoomIds, since); } - return events.then(evs => this.transport.reply(request, {events: evs})); + // For backwards compatibility we still call the deprecated + // readRoomEvents and readStateEvents methods in case the client isn't + // letting us know the currently viewed room via setViewedRoomId + const events = request.data.room_ids === undefined && askRoomIds.length === 0 + ? await ( + request.data.state_key === undefined + ? this.driver.readRoomEvents(request.data.type, msgtype, limit, null, since) + : this.driver.readStateEvents(request.data.type, stateKey, limit, null) + ) + : ( + await Promise.all(askRoomIds.map(roomId => + this.driver.readRoomTimeline(roomId, request.data.type, msgtype, stateKey, limit, since), + )) + ).flat(1); + this.transport.reply(request, { events }); } private handleSendEvent(request: ISendEventFromWidgetActionRequest) { @@ -933,16 +974,31 @@ export class ClientWidgetApi extends EventEmitter { } /** - * Feeds an event to the widget. If the widget is not able to accept the event due to - * permissions, this will no-op and return calmly. If the widget failed to handle the - * event, this will raise an error. + * Feeds an event to the widget. As a client you are expected to call this + * for every new event in every room to which you are joined or invited. + * @param {IRoomEvent} rawEvent The event to (try to) send to the widget. + * @param {string} currentViewedRoomId The room ID the user is currently + * interacting with. Not the room ID of the event. + * @returns {Promise} Resolves when delivered or if the widget is not + * able to read the event due to permissions, rejects if the widget failed + * to handle the event. + * @deprecated It is recommended to communicate the viewed room ID by calling + * {@link ClientWidgetApi.setViewedRoomId} rather than passing it to this + * method. + */ + public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise + /** + * Feeds an event to the widget. As a client you are expected to call this + * for every new event in every room to which you are joined or invited. * @param {IRoomEvent} rawEvent The event to (try to) send to the widget. - * @param {string} currentViewedRoomId The room ID the user is currently interacting with. - * Not the room ID of the event. - * @returns {Promise} Resolves when complete, rejects if there was an error sending. + * @returns {Promise} Resolves when delivered or if the widget is not + * able to read the event due to permissions, rejects if the widget failed + * to handle the event. */ - public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise { - if (rawEvent.room_id !== currentViewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id)) { + public async feedEvent(rawEvent: IRoomEvent): Promise + public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId?: string): Promise { + if (currentViewedRoomId !== undefined) this.setViewedRoomId(currentViewedRoomId); + if (rawEvent.room_id !== this.viewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id)) { return; // no-op } @@ -961,17 +1017,19 @@ export class ClientWidgetApi extends EventEmitter { // Feed the event into the widget await this.transport.send( WidgetApiToWidgetAction.SendEvent, - rawEvent as ISendEventToWidgetRequestData, // it's compatible, but missing the index signature + // it's compatible, but missing the index signature + rawEvent as ISendEventToWidgetRequestData, ); } /** - * Feeds a to-device event to the widget. If the widget is not able to accept the - * event due to permissions, this will no-op and return calmly. If the widget failed - * to handle the event, this will raise an error. + * Feeds a to-device event to the widget. As a client you are expected to + * call this for every to-device event you receive. * @param {IRoomEvent} rawEvent The event to (try to) send to the widget. * @param {boolean} encrypted Whether the event contents were encrypted. - * @returns {Promise} Resolves when complete, rejects if there was an error sending. + * @returns {Promise} Resolves when delivered or if the widget is not + * able to receive the event due to permissions, rejects if the widget + * failed to handle the event. */ public async feedToDevice(rawEvent: IRoomEvent, encrypted: boolean): Promise { if (this.canReceiveToDeviceEvent(rawEvent.type)) { @@ -982,4 +1040,132 @@ export class ClientWidgetApi extends EventEmitter { ); } } + + private viewedRoomId: string | null = null; + + /** + * Indicate that a room is being viewed (making it possible for the widget + * to interact with it). + */ + public setViewedRoomId(roomId: string | null): void { + this.viewedRoomId = roomId; + // If the widget doesn't have timeline permissions for the room then + // this is its opportunity to learn the room state. We push the entire + // room state, which could be redundant if this room had been viewed + // once before, but it's easier than selectively pushing just the bits + // of state that changed while the room was in the background. + if (roomId !== null && !this.canUseRoomTimeline(roomId)) this.pushRoomState(roomId); + } + + private async flushRoomState(): Promise { + try { + // Only send a single action once all concurrent tasks have completed + do await Promise.all([...this.pushRoomStateTasks]); + while (this.pushRoomStateTasks.size > 0) + + const events: IRoomEvent[] = []; + for (const eventTypeMap of this.pushRoomStateResult.values()) { + for (const stateKeyMap of eventTypeMap.values()) { + events.push(...stateKeyMap.values()); + } + } + await this.transport.send( + WidgetApiToWidgetAction.UpdateState, + { state: events }, + ); + } finally { + this.flushRoomStateTask = null; + } + } + + /** + * Read the room's state and push all entries that the widget is allowed to + * read through to the widget. + */ + private pushRoomState(roomId: string): void { + for (const cap of this.allowedEvents) { + if (cap.kind === EventKind.State && cap.direction === EventDirection.Receive) { + // Initiate the task + const events = this.driver.readRoomState(roomId, cap.eventType, cap.keyStr ?? undefined); + const task = events.then( + events => { + // When complete, queue the resulting events to be + // pushed to the widget + for (const event of events) { + let eventTypeMap = this.pushRoomStateResult.get(roomId); + if (eventTypeMap === undefined) { + eventTypeMap = new Map(); + this.pushRoomStateResult.set(roomId, eventTypeMap); + } + let stateKeyMap = eventTypeMap.get(cap.eventType); + if (stateKeyMap === undefined) { + stateKeyMap = new Map(); + eventTypeMap.set(cap.eventType, stateKeyMap); + } + if (!stateKeyMap.has(event.state_key!)) stateKeyMap.set(event.state_key!, event); + } + }, + e => console.error(`Failed to read room state for ${roomId} (${ + cap.eventType + }, ${cap.keyStr})`, e), + ).then(() => { + // Mark request as no longer pending + this.pushRoomStateTasks.delete(task); + }); + + // Mark task as pending + this.pushRoomStateTasks.add(task); + // Assuming no other tasks are already happening concurrently, + // schedule the widget action that actually pushes the events + this.flushRoomStateTask ??= this.flushRoomState(); + this.flushRoomStateTask.catch(e => console.error('Failed to push room state', e)); + } + } + } + + /** + * Feeds a room state update to the widget. As a client you are expected to + * call this for every state update in every room to which you are joined or + * invited. + * @param {IRoomEvent} rawEvent The state event corresponding to the updated + * room state entry. + * @returns {Promise} Resolves when delivered or if the widget is not + * able to receive the room state due to permissions, rejects if the + widget failed to handle the update. + */ + public async feedStateUpdate(rawEvent: IRoomEvent): Promise { + if (rawEvent.state_key === undefined) throw new Error('Not a state event'); + if ( + (rawEvent.room_id === this.viewedRoomId || this.canUseRoomTimeline(rawEvent.room_id)) + && this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key) + ) { + // Updates could race with the initial push of the room's state + if (this.pushRoomStateTasks.size === 0) { + // No initial push tasks are pending; safe to send immediately + await this.transport.send( + WidgetApiToWidgetAction.UpdateState, + { state: [rawEvent] }, + ); + } else { + // Lump the update in with whatever data will be sent in the + // initial push later. Even if we set it to an "outdated" entry + // here, we can count on any newer entries being passed to this + // same method eventually; this won't cause stuck state. + let eventTypeMap = this.pushRoomStateResult.get(rawEvent.room_id); + if (eventTypeMap === undefined) { + eventTypeMap = new Map(); + this.pushRoomStateResult.set(rawEvent.room_id, eventTypeMap); + } + let stateKeyMap = eventTypeMap.get(rawEvent.type); + if (stateKeyMap === undefined) { + stateKeyMap = new Map(); + eventTypeMap.set(rawEvent.type, stateKeyMap); + } + if (!stateKeyMap.has(rawEvent.type)) stateKeyMap.set(rawEvent.state_key, rawEvent); + do await Promise.all([...this.pushRoomStateTasks]); + while (this.pushRoomStateTasks.size > 0) + await this.flushRoomStateTask; + } + } + } } diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 4edc933..da63812 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -196,6 +196,7 @@ export abstract class WidgetDriver { * the client will return all the events. * @param eventType The event type to be read. * @param msgtype The msgtype of the events to be read, if applicable/defined. + * @param stateKey The state key of the events to be read, if applicable/defined. * @param limit The maximum number of events to retrieve per room. Will be zero to denote "as many * as possible". * @param roomIds When null, the user's currently viewed room. Otherwise, the list of room IDs @@ -204,6 +205,7 @@ export abstract class WidgetDriver { * Otherwise, the event ID at which only subsequent events will be returned, as many as specified * in "limit". * @returns {Promise} Resolves to the room events, or an empty array. + * @deprecated Clients are advised to implement {@link WidgetDriver.readRoomTimeline} instead. */ public readRoomEvents( eventType: string, @@ -229,6 +231,7 @@ export abstract class WidgetDriver { * @param roomIds When null, the user's currently viewed room. Otherwise, the list of room IDs * to look within, possibly containing Symbols.AnyRoom to denote all known rooms. * @returns {Promise} Resolves to the state events, or an empty array. + * @deprecated Clients are advised to implement {@link WidgetDriver.readRoomTimeline} instead. */ public readStateEvents( eventType: string, @@ -239,6 +242,46 @@ export abstract class WidgetDriver { return Promise.resolve([]); } + /** + * 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 + * capable of receiving the events. Less events than the limit are allowed to be returned, + * but not more. If `roomIds` is supplied, it may contain `Symbols.AnyRoom` to denote that + * `limit` in each of the client's known rooms should be returned. When `null`, only the + * room the user is currently looking at should be considered. If `since` is specified but + * the event ID isn't present in the number of events fetched by the client due to `limit`, + * the client will return all the events. + * @param roomId The ID of the room to look within. + * @param eventType The event type to be read. + * @param msgtype The msgtype of the events to be read, if applicable/defined. + * @param stateKey The state key of the events to be read, if applicable/defined. + * @param limit The maximum number of events to retrieve per room. Will be zero to denote "as many + * as possible". + * @param since When null, retrieves the number of events specified by the "limit" parameter. + * Otherwise, the event ID at which only subsequent events will be returned, as many as specified + * in "limit". + * @returns {Promise} Resolves to the room events, or an empty array. + */ + public readRoomTimeline( + roomId: string, + eventType: string, + msgtype: string | undefined, + stateKey: string | undefined, + limit: number, + since: string | undefined, + ): Promise { + if (stateKey === undefined) return this.readRoomEvents(eventType, msgtype, limit, [roomId], since); + else return this.readStateEvents(eventType, stateKey, limit, [roomId]); + } + + public readRoomState( + roomId: string, + eventType: string, + stateKey: string | undefined, + ): Promise { + return Promise.resolve([]); + } + /** * Reads all events that are related to a given event. The widget API will * have already verified that the widget is capable of receiving the event, @@ -360,6 +403,15 @@ export abstract class WidgetDriver { throw new Error("Download file is not implemented"); } + /** + * Gets the IDs of all joined or invited rooms currently known to the + * client. + * @returns The room IDs. + */ + public getKnownRooms(): string[] { + throw new Error("Querying known rooms is not implemented"); + } + /** * Expresses an error thrown by this driver in a format compatible with the Widget API. * @param error The error to handle. diff --git a/src/index.ts b/src/index.ts index 114781e..b404789 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,7 @@ export * from "./interfaces/TurnServerActions"; export * from "./interfaces/ReadRelationsAction"; export * from "./interfaces/GetMediaConfigAction"; export * from "./interfaces/UpdateDelayedEventAction"; +export * from "./interfaces/UpdateStateAction"; export * from "./interfaces/UploadFileAction"; export * from "./interfaces/DownloadFileAction"; diff --git a/src/interfaces/UpdateStateAction.ts b/src/interfaces/UpdateStateAction.ts new file mode 100644 index 0000000..c497caf --- /dev/null +++ b/src/interfaces/UpdateStateAction.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2024 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 IUpdateStateToWidgetRequestData extends IWidgetApiRequestData { + state: IRoomEvent[]; +} + +export interface IUpdateStateToWidgetActionRequest extends IWidgetApiRequest { + action: WidgetApiToWidgetAction.UpdateState; + data: IUpdateStateToWidgetRequestData; +} + +export interface IUpdateStateToWidgetResponseData extends IWidgetApiResponseData { + // nothing +} + +export interface IUpdateStateToWidgetActionResponse extends IUpdateStateToWidgetActionRequest { + response: IUpdateStateToWidgetResponseData; +} diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index 1aa7c17..ae21265 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -26,6 +26,7 @@ export enum WidgetApiToWidgetAction { ButtonClicked = "button_clicked", SendEvent = "send_event", SendToDevice = "send_to_device", + UpdateState = "update_state", UpdateTurnServers = "update_turn_servers", } diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index c8c9b11..8c158be 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -25,7 +25,7 @@ import { IWidgetApiRequest } from '../src/interfaces/IWidgetApiRequest'; import { IReadRelationsFromWidgetActionRequest } from '../src/interfaces/ReadRelationsAction'; import { ISupportedVersionsActionRequest } from '../src/interfaces/SupportedVersionsAction'; import { IUserDirectorySearchFromWidgetActionRequest } from '../src/interfaces/UserDirectorySearchAction'; -import { WidgetApiFromWidgetAction } from '../src/interfaces/WidgetApiAction'; +import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from '../src/interfaces/WidgetApiAction'; import { WidgetApiDirection } from '../src/interfaces/WidgetApiDirection'; import { Widget } from '../src/models/Widget'; import { PostmessageTransport } from '../src/transport/PostmessageTransport'; @@ -39,6 +39,7 @@ import { IUpdateDelayedEventFromWidgetActionRequest, IUploadFileActionFromWidgetActionRequest, IWidgetApiErrorResponseDataDetails, + Symbols, UpdateDelayedEventAction, } from '../src'; import { IGetMediaConfigActionFromWidgetActionRequest } from '../src/interfaces/GetMediaConfigAction'; @@ -115,7 +116,8 @@ describe('ClientWidgetApi', () => { driver = { navigate: jest.fn(), - readStateEvents: jest.fn(), + readRoomTimeline: jest.fn(), + readRoomState: jest.fn(() => Promise.resolve([])), readEventRelations: jest.fn(), sendEvent: jest.fn(), sendDelayedEvent: jest.fn(), @@ -126,6 +128,7 @@ describe('ClientWidgetApi', () => { getMediaConfig: jest.fn(), uploadFile: jest.fn(), downloadFile: jest.fn(), + getKnownRooms: jest.fn(() => []), processError: jest.fn(), } as Partial as jest.Mocked; @@ -713,6 +716,187 @@ describe('ClientWidgetApi', () => { }); }); + describe('receiving events', () => { + const roomId = '!room:example.org'; + const otherRoomId = '!other-room:example.org'; + const event = createRoomEvent({ room_id: roomId, type: 'm.room.message', content: 'hello' }); + const eventFromOtherRoom = createRoomEvent({ + room_id: otherRoomId, + type: 'm.room.message', + content: 'test', + }); + + it('forwards events to the widget from one room only', async () => { + // Give the widget capabilities to receive from just one room + await loadIframe([ + `org.matrix.msc2762.timeline:${roomId}`, + 'org.matrix.msc2762.receive.event:m.room.message', + ]); + + // Event from the matching room should be forwarded + clientWidgetApi.feedEvent(event); + expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, event); + + // Event from the other room should not be forwarded + clientWidgetApi.feedEvent(eventFromOtherRoom); + expect(transport.send).not.toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, eventFromOtherRoom); + }); + + it('forwards events to the widget from the currently viewed room', async () => { + clientWidgetApi.setViewedRoomId(roomId); + // Give the widget capabilities to receive events without specifying + // any rooms that it can read + await loadIframe([ + `org.matrix.msc2762.timeline:${roomId}`, + 'org.matrix.msc2762.receive.event:m.room.message', + ]); + + // Event from the viewed room should be forwarded + clientWidgetApi.feedEvent(event); + expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, event); + + // Event from the other room should not be forwarded + clientWidgetApi.feedEvent(eventFromOtherRoom); + expect(transport.send).not.toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, eventFromOtherRoom); + + // View the other room; now the event can be forwarded + clientWidgetApi.setViewedRoomId(otherRoomId); + clientWidgetApi.feedEvent(eventFromOtherRoom); + expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, eventFromOtherRoom); + }); + + it('forwards events to the widget from all rooms', async () => { + // Give the widget capabilities to receive from any known room + await loadIframe([ + `org.matrix.msc2762.timeline:${Symbols.AnyRoom}`, + 'org.matrix.msc2762.receive.event:m.room.message', + ]); + + // Events from both rooms should be forwarded + clientWidgetApi.feedEvent(event); + clientWidgetApi.feedEvent(eventFromOtherRoom); + expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, event); + expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendEvent, eventFromOtherRoom); + }); + }); + + describe('receiving room state', () => { + it('syncs initial state and feeds updates', async () => { + const roomId = '!room:example.org'; + const otherRoomId = '!other-room:example.org'; + clientWidgetApi.setViewedRoomId(roomId); + const topicEvent = createRoomEvent({ + room_id: roomId, + type: 'm.room.topic', + state_key: '', + content: { topic: 'Hello world!' }, + }); + const nameEvent = createRoomEvent({ + room_id: roomId, + type: 'm.room.name', + state_key: '', + content: { name: 'Test room' }, + }); + const joinRulesEvent = createRoomEvent({ + room_id: roomId, + type: 'm.room.join_rules', + state_key: '', + content: { join_rule: 'public' }, + }); + const otherRoomNameEvent = createRoomEvent({ + room_id: otherRoomId, + type: 'm.room.name', + state_key: '', + content: { name: 'Other room' }, + }); + + // Artificially delay the delivery of the join rules event + let resolveJoinRules: () => void; + const joinRules = new Promise(resolve => resolveJoinRules = resolve); + + driver.readRoomState.mockImplementation(async (rId, eventType, stateKey) => { + if (rId === roomId) { + if (eventType === 'm.room.topic' && stateKey === '') return [topicEvent]; + if (eventType === 'm.room.name' && stateKey === '') return [nameEvent]; + if (eventType === 'm.room.join_rules' && stateKey === '') { + await joinRules; + return [joinRulesEvent]; + } + } else if (rId === otherRoomId) { + if (eventType === 'm.room.name' && stateKey === '') return [otherRoomNameEvent]; + } + return []; + }) + + await loadIframe([ + 'org.matrix.msc2762.receive.state_event:m.room.topic#', + 'org.matrix.msc2762.receive.state_event:m.room.name#', + 'org.matrix.msc2762.receive.state_event:m.room.join_rules#', + ]); + + // Simulate a race between reading the original join rules event and + // the join rules being updated at the same time + const newJoinRulesEvent = createRoomEvent({ + room_id: roomId, + type: 'm.room.join_rules', + state_key: '', + content: { join_rule: 'invite' }, + }); + clientWidgetApi.feedStateUpdate(newJoinRulesEvent); + // What happens if the original join rules are delivered after the + // updated ones? + resolveJoinRules!(); + + await waitFor(() => { + // The initial topic and name should have been pushed + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { state: [topicEvent, nameEvent, newJoinRulesEvent] }, + ); + // Only the updated join rules should have been delivered + expect(transport.send).not.toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { state: expect.arrayContaining([joinRules]) }, + ) + }); + + // Check that further updates to room state are pushed to the widget + // as expected + const newTopicEvent = createRoomEvent({ + room_id: roomId, + type: 'm.room.topic', + state_key: '', + content: { topic: 'Our new topic' }, + }); + clientWidgetApi.feedStateUpdate(newTopicEvent); + + await waitFor(() => { + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { state: [newTopicEvent] }, + ); + }); + + // Up to this point we should not have received any state for the + // other (unviewed) room + expect(transport.send).not.toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { state: expect.arrayContaining([otherRoomNameEvent]) }, + ); + // Now view the other room + clientWidgetApi.setViewedRoomId(otherRoomId); + (transport.send as unknown as jest.SpyInstance).mockClear(); + + await waitFor(() => { + // The state of the other room should now be pushed + expect(transport.send).toHaveBeenCalledWith( + WidgetApiToWidgetAction.UpdateState, + { state: expect.arrayContaining([otherRoomNameEvent]) }, + ); + }); + }); + }); + describe('update_delayed_event action', () => { it('fails to update delayed events', async () => { const event: IUpdateDelayedEventFromWidgetActionRequest = { @@ -1119,8 +1303,91 @@ describe('ClientWidgetApi', () => { }); describe('org.matrix.msc2876.read_events action', () => { + it('reads events from a specific room', async () => { + const roomId = '!room:example.org'; + const event = createRoomEvent({ room_id: roomId, type: 'net.example.test', content: 'test' }); + driver.readRoomTimeline.mockImplementation(async (rId) => { + if (rId === roomId) return [event]; + return []; + }); + + const request: IReadEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2876ReadEvents, + data: { + type: 'net.example.test', + room_ids: [roomId], + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${roomId}`, + 'org.matrix.msc2762.receive.event:net.example.test', + ]); + clientWidgetApi.setViewedRoomId(roomId); + + emitEvent(new CustomEvent('', { detail: request })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(request, { + events: [event], + }); + }); + + expect(driver.readRoomTimeline).toHaveBeenCalledWith( + roomId, 'net.example.test', undefined, undefined, 0, undefined, + ); + }); + + it('reads events from all rooms', async () => { + const roomId = '!room:example.org'; + const otherRoomId = '!other-room:example.org'; + const event = createRoomEvent({ room_id: roomId, type: 'net.example.test', content: 'test' }); + const otherRoomEvent = createRoomEvent({ room_id: otherRoomId, type: 'net.example.test', content: 'hi' }); + driver.getKnownRooms.mockReturnValue([roomId, otherRoomId]); + driver.readRoomTimeline.mockImplementation(async (rId) => { + if (rId === roomId) return [event]; + if (rId === otherRoomId) return [otherRoomEvent]; + return []; + }); + + const request: IReadEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2876ReadEvents, + data: { + type: 'net.example.test', + room_ids: Symbols.AnyRoom, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${Symbols.AnyRoom}`, + 'org.matrix.msc2762.receive.event:net.example.test', + ]); + clientWidgetApi.setViewedRoomId(roomId); + + emitEvent(new CustomEvent('', { detail: request })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(request, { + events: [event, otherRoomEvent], + }); + }); + + expect(driver.readRoomTimeline).toHaveBeenCalledWith( + roomId, 'net.example.test', undefined, undefined, 0, undefined, + ); + expect(driver.readRoomTimeline).toHaveBeenCalledWith( + otherRoomId, 'net.example.test', undefined, undefined, 0, undefined, + ); + }); + it('reads state events with any state key', async () => { - driver.readStateEvents.mockResolvedValue([ + driver.readRoomTimeline.mockResolvedValue([ createRoomEvent({ type: 'net.example.test', state_key: 'A' }), createRoomEvent({ type: 'net.example.test', state_key: 'B' }), ]) @@ -1137,6 +1404,7 @@ describe('ClientWidgetApi', () => { }; await loadIframe(['org.matrix.msc2762.receive.state_event:net.example.test']); + clientWidgetApi.setViewedRoomId('!room-id'); emitEvent(new CustomEvent('', { detail: event })); @@ -1149,9 +1417,9 @@ describe('ClientWidgetApi', () => { }); }); - expect(driver.readStateEvents).toBeCalledWith( - 'net.example.test', undefined, 0, null, - ) + expect(driver.readRoomTimeline).toBeCalledWith( + '!room-id', 'net.example.test', undefined, undefined, 0, undefined, + ); }); it('fails to read state events with any state key', async () => { @@ -1176,11 +1444,11 @@ describe('ClientWidgetApi', () => { }); }); - expect(driver.readStateEvents).not.toBeCalled() + expect(driver.readRoomTimeline).not.toBeCalled(); }); it('reads state events with a specific state key', async () => { - driver.readStateEvents.mockResolvedValue([ + driver.readRoomTimeline.mockResolvedValue([ createRoomEvent({ type: 'net.example.test', state_key: 'B' }), ]) @@ -1196,6 +1464,7 @@ describe('ClientWidgetApi', () => { }; await loadIframe(['org.matrix.msc2762.receive.state_event:net.example.test#B']); + clientWidgetApi.setViewedRoomId('!room-id'); emitEvent(new CustomEvent('', { detail: event })); @@ -1207,9 +1476,9 @@ describe('ClientWidgetApi', () => { }); }); - expect(driver.readStateEvents).toBeCalledWith( - 'net.example.test', 'B', 0, null, - ) + expect(driver.readRoomTimeline).toBeCalledWith( + '!room-id', 'net.example.test', undefined, 'B', 0, undefined, + ); }); it('fails to read state events with a specific state key', async () => { @@ -1235,7 +1504,7 @@ describe('ClientWidgetApi', () => { }); }); - expect(driver.readStateEvents).not.toBeCalled() + expect(driver.readRoomTimeline).not.toBeCalled(); }); })