diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index fe52db9..cd0b827 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2021 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. @@ -61,6 +61,7 @@ import { SimpleObservable } from "./util/SimpleObservable"; import { IOpenIDCredentialsActionRequestData } from "./interfaces/OpenIDCredentialsAction"; import { INavigateActionRequest } from "./interfaces/NavigateAction"; import { IReadEventFromWidgetActionRequest, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction"; +import { Symbols } from "./Symbols"; /** * API handler for the client side of widgets. This raises events @@ -137,6 +138,11 @@ export class ClientWidgetApi extends EventEmitter { return this.allowedCapabilities.has(capability); } + public canUseRoomTimeline(roomId: string | Symbols.AnyRoom): boolean { + return this.hasCapability(`org.matrix.msc2762.timeline:${Symbols.AnyRoom}`) + || this.hasCapability(`org.matrix.msc2762.timeline:${roomId}`); + } + public canSendRoomEvent(eventType: string, msgtype: string = null): boolean { return this.allowedEvents.some(e => e.matchesAsRoomEvent(eventType, msgtype) && e.direction === EventDirection.Send); @@ -344,6 +350,21 @@ export class ClientWidgetApi extends EventEmitter { }); } + let askRoomIds: string[] = 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]; + } + for (const roomId of askRoomIds) { + if (!this.canUseRoomTimeline(roomId)) { + return this.transport.reply(request, { + error: {message: `Unable to access room timeline: ${roomId}`}, + }); + } + } + } + const limit = request.data.limit || 0; let events: Promise = Promise.resolve([]); @@ -354,14 +375,14 @@ export class ClientWidgetApi extends EventEmitter { error: {message: "Cannot read state events of this type"}, }); } - events = this.driver.readStateEvents(request.data.type, stateKey, limit); + events = this.driver.readStateEvents(request.data.type, stateKey, limit, askRoomIds); } else { if (!this.canReceiveRoomEvent(request.data.type, request.data.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); + events = this.driver.readRoomEvents(request.data.type, request.data.msgtype, limit, askRoomIds); } return events.then(evs => this.transport.reply(request, {events: evs})); @@ -374,6 +395,12 @@ export class ClientWidgetApi extends EventEmitter { }); } + if (!!request.data.room_id && !this.canUseRoomTimeline(request.data.room_id)) { + return this.transport.reply(request, { + error: {message: `Unable to access room timeline: ${request.data.room_id}`}, + }); + } + const isState = request.data.state_key !== null && request.data.state_key !== undefined; let sendEventPromise: Promise; if (isState) { @@ -387,6 +414,7 @@ export class ClientWidgetApi extends EventEmitter { request.data.type, request.data.content || {}, request.data.state_key, + request.data.room_id, ); } else { const content = request.data.content || {}; @@ -401,6 +429,7 @@ export class ClientWidgetApi extends EventEmitter { request.data.type, content, null, // not sending a state event + request.data.room_id, ); } @@ -491,9 +520,15 @@ export class ClientWidgetApi extends EventEmitter { * permissions, this will no-op and return calmly. If the widget failed to handle the * event, this will raise an error. * @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. */ - public feedEvent(rawEvent: IRoomEvent): Promise { + public feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise { + if (rawEvent.room_id !== currentViewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id)) { + return Promise.resolve(); // no-op + } + if (rawEvent.state_key !== undefined && rawEvent.state_key !== null) { // state event if (!this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)) { diff --git a/src/Symbols.ts b/src/Symbols.ts new file mode 100644 index 0000000..85ca12e --- /dev/null +++ b/src/Symbols.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 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. + */ + +export enum Symbols { + AnyRoom = "*", +} diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 490ca99..b7f38ab 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2021 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. @@ -56,6 +56,7 @@ import { ISendEventFromWidgetRequestData, ISendEventFromWidgetResponseData } fro import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability"; import { INavigateActionRequestData } from "./interfaces/NavigateAction"; import { IReadEventFromWidgetRequestData, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction"; +import { Symbols } from "./Symbols"; /** * API handler for widgets. This raises events for each action @@ -143,6 +144,16 @@ export class WidgetApi extends EventEmitter { capabilities.forEach(cap => this.requestCapability(cap)); } + /** + * Requests the capability to interact with rooms other than the user's currently + * viewed room. Applies to event receiving and sending. + * @param {string | Symbols.AnyRoom} roomId The room ID, or `Symbols.AnyRoom` to + * denote all known rooms. + */ + public requestCapabilityForRoomTimeline(roomId: string | Symbols.AnyRoom) { + this.requestCapability(`org.matrix.msc2762.timeline:${roomId}`); + } + /** * Requests the capability to send a given state event with optional explicit * state key. It is not guaranteed to be allowed, but will be asked for if the @@ -327,10 +338,14 @@ export class WidgetApi extends EventEmitter { return this.transport.send(WidgetApiFromWidgetAction.CloseModalWidget, data).then(); } - public sendRoomEvent(eventType: string, content: unknown): Promise { + public sendRoomEvent( + eventType: string, + content: unknown, + roomId?: string, + ): Promise { return this.transport.send( WidgetApiFromWidgetAction.SendEvent, - {type: eventType, content}, + {type: eventType, content, room_id: roomId}, ); } @@ -338,24 +353,55 @@ export class WidgetApi extends EventEmitter { eventType: string, stateKey: string, content: unknown, + roomId?: string, ): Promise { return this.transport.send( WidgetApiFromWidgetAction.SendEvent, - {type: eventType, content, state_key: stateKey}, + {type: eventType, content, state_key: stateKey, room_id: roomId}, ); } - public readRoomEvents(eventType: string, limit = 25, msgtype?: string): Promise { + public readRoomEvents( + eventType: string, + limit = 25, + msgtype?: string, + roomIds?: (string | Symbols.AnyRoom)[], + ): Promise { + const data: IReadEventFromWidgetRequestData = {type: eventType, msgtype: msgtype, limit}; + if (roomIds) { + if (roomIds.includes(Symbols.AnyRoom)) { + data.room_ids = Symbols.AnyRoom; + } else { + data.room_ids = roomIds; + } + } return this.transport.send( WidgetApiFromWidgetAction.MSC2876ReadEvents, - {type: eventType, msgtype: msgtype, limit}, + data, ).then(r => r.events); } - public readStateEvents(eventType: string, limit = 25, stateKey?: string): Promise { + public readStateEvents( + eventType: string, + limit = 25, + stateKey?: string, + roomIds?: (string | Symbols.AnyRoom)[], + ): Promise { + const data: IReadEventFromWidgetRequestData = { + type: eventType, + state_key: stateKey === undefined ? true : stateKey, + limit, + }; + if (roomIds) { + if (roomIds.includes(Symbols.AnyRoom)) { + data.room_ids = Symbols.AnyRoom; + } else { + data.room_ids = roomIds; + } + } return this.transport.send( WidgetApiFromWidgetAction.MSC2876ReadEvents, - {type: eventType, state_key: stateKey === undefined ? true : stateKey, limit}, + data, ).then(r => r.events); } diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 7cf2ce0..f0b4fea 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2021 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. @@ -53,17 +53,25 @@ export abstract class WidgetDriver { } /** - * Sends an event into the room the user is currently looking at. The widget API - * will have already verified that the widget is capable of sending the event. + * Sends an event into a room. If `roomId` is falsy, the client should send the event + * into the room the user is currently looking at. The widget API will have already + * verified that the widget is capable of sending the event to that room. * @param {string} eventType The event type to be sent. * @param {*} content The content for the event. * @param {string|null} stateKey The state key if this is a state event, otherwise null. * May be an empty string. + * @param {string|null} roomId The room ID to send the event to. If falsy, the room the + * user is currently looking at. * @returns {Promise} Resolves when the event has been sent with * details of that event. * @throws Rejected when the event could not be sent. */ - public sendEvent(eventType: string, content: unknown, stateKey: string = null): Promise { + public sendEvent( + eventType: string, + content: unknown, + stateKey: string = null, + roomId: string = null, + ): Promise { return Promise.reject(new Error("Failed to override function")); } @@ -71,14 +79,23 @@ export abstract class WidgetDriver { * 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. + * 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. * @param eventType The event type to be read. * @param msgtype The msgtype of the events to be read, if applicable/defined. - * @param limit The maximum number of events to retrieve. Will be zero to denote "as many + * @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 + * to look within, possibly containing Symbols.AnyRoom to denote all known rooms. * @returns {Promise<*[]>} Resolves to the room events, or an empty array. */ - public readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise { + public readRoomEvents( + eventType: string, + msgtype: string | undefined, + limit: number, + roomIds: string[] = null, + ): Promise { return Promise.resolve([]); } @@ -86,14 +103,23 @@ export abstract class WidgetDriver { * Reads all events of the given type, and optionally state key (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. + * 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. * @param eventType The event type to be read. * @param stateKey The state key of the events to be read, if applicable/defined. * @param limit The maximum number of events to retrieve. 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 + * to look within, possibly containing Symbols.AnyRoom to denote all known rooms. * @returns {Promise<*[]>} Resolves to the state events, or an empty array. */ - public readStateEvents(eventType: string, stateKey: string | undefined, limit: number): Promise { + public readStateEvents( + eventType: string, + stateKey: string | undefined, + limit: number, + roomIds: string[] = null, + ): Promise { return Promise.resolve([]); } diff --git a/src/index.ts b/src/index.ts index 94f1d79..c73a139 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2021 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. @@ -17,6 +17,7 @@ limitations under the License. // Primary structures export * from "./WidgetApi"; export * from "./ClientWidgetApi"; +export * from "./Symbols"; // Transports (not sure why you'd use these directly, but might as well export all the things) export * from "./transport/ITransport"; diff --git a/src/interfaces/Capabilities.ts b/src/interfaces/Capabilities.ts index 653a9c4..f158171 100644 --- a/src/interfaces/Capabilities.ts +++ b/src/interfaces/Capabilities.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2021 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. @@ -14,6 +14,8 @@ * limitations under the License. */ +import { Symbols } from "../Symbols"; + export enum MatrixCapabilities { Screenshots = "m.capability.screenshot", StickerSending = "m.sticker", @@ -29,3 +31,32 @@ export type Capability = MatrixCapabilities | string; export const StickerpickerCapabilities: Capability[] = [MatrixCapabilities.StickerSending]; export const VideoConferenceCapabilities: Capability[] = [MatrixCapabilities.AlwaysOnScreen]; + +/** + * Determines if a capability is a capability for a timeline. + * @param {Capability} capability The capability to test. + * @returns {boolean} True if a timeline capability, false otherwise. + */ +export function isTimelineCapability(capability: Capability): boolean { + // TODO: Change when MSC2762 becomes stable. + return capability?.startsWith("org.matrix.msc2762.timeline:"); +} + +/** + * Determines if a capability is a timeline capability for the given room. + * @param {Capability} capability The capability to test. + * @param {string | Symbols.AnyRoom} roomId The room ID, or `Symbols.AnyRoom` for that designation. + * @returns {boolean} True if a matching capability, false otherwise. + */ +export function isTimelineCapabilityFor(capability: Capability, roomId: string | Symbols.AnyRoom): boolean { + return capability === `org.matrix.msc2762.timeline:${roomId}`; +} + +/** + * Gets the room ID described by a timeline capability. + * @param {string} capability The capability to parse. + * @returns {string} The room ID. + */ +export function getTimelineRoomIDFromCapability(capability: Capability): string { + return capability.substring(capability.indexOf(":") + 1); +} diff --git a/src/interfaces/IRoomEvent.ts b/src/interfaces/IRoomEvent.ts index 2504f6f..5e90005 100644 --- a/src/interfaces/IRoomEvent.ts +++ b/src/interfaces/IRoomEvent.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2021 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. @@ -18,6 +18,7 @@ export interface IRoomEvent { type: string; sender: string; event_id: string; // eslint-disable-line camelcase + room_id: string; // eslint-disable-line camelcase state_key?: string; // eslint-disable-line camelcase origin_server_ts: number; // eslint-disable-line camelcase content: unknown; diff --git a/src/interfaces/ReadEventAction.ts b/src/interfaces/ReadEventAction.ts index 72b040a..213a7d0 100644 --- a/src/interfaces/ReadEventAction.ts +++ b/src/interfaces/ReadEventAction.ts @@ -17,12 +17,14 @@ import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest"; import { WidgetApiFromWidgetAction } from "./WidgetApiAction"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; +import { Symbols } from "../Symbols"; export interface IReadEventFromWidgetRequestData extends IWidgetApiRequestData { state_key?: string | boolean; // eslint-disable-line camelcase msgtype?: string; type: string; limit?: number; + room_ids?: Symbols.AnyRoom | string[]; // eslint-disable-line camelcase } export interface IReadEventFromWidgetActionRequest extends IWidgetApiRequest { diff --git a/src/interfaces/SendEventAction.ts b/src/interfaces/SendEventAction.ts index 9e926cb..8fe6da0 100644 --- a/src/interfaces/SendEventAction.ts +++ b/src/interfaces/SendEventAction.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2021 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. @@ -23,6 +23,7 @@ export interface ISendEventFromWidgetRequestData extends IWidgetApiRequestData { state_key?: string; // eslint-disable-line camelcase type: string; content: unknown; + room_id?: string; // eslint-disable-line camelcase } export interface ISendEventFromWidgetActionRequest extends IWidgetApiRequest {