From 289ac347645c9b3e5e370279f671e1af22b1ca1f Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Wed, 25 Aug 2021 18:16:40 -0600
Subject: [PATCH] Add support for MSC2762's timeline functionality

See https://github.com/matrix-org/matrix-widget-api/pull/41
---
 .../WidgetCapabilitiesPromptDialog.tsx        | 19 +++-
 src/i18n/strings/en_EN.json                   |  2 +
 src/stores/widgets/StopGapWidget.ts           |  6 +-
 src/stores/widgets/StopGapWidgetDriver.ts     | 98 ++++++++++++-------
 src/widgets/CapabilityText.tsx                | 42 +++++++-
 5 files changed, 119 insertions(+), 48 deletions(-)

diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
index ebeab191b1..556dc057f9 100644
--- a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
+++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
@@ -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.
@@ -20,6 +20,7 @@ import { _t } from "../../../languageHandler";
 import { IDialogProps } from "./IDialogProps";
 import {
     Capability,
+    isTimelineCapability,
     Widget,
     WidgetEventCapability,
     WidgetKind,
@@ -30,6 +31,7 @@ import DialogButtons from "../elements/DialogButtons";
 import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
 import { CapabilityText } from "../../../widgets/CapabilityText";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { lexicographicCompare } from "matrix-js-sdk/src/utils";
 
 export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
     return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
@@ -102,7 +104,20 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
     }
 
     public render() {
-        const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
+        // We specifically order the timeline capabilities down to the bottom. The capability text
+        // generation cares strongly about this.
+        const orderedCapabilities = Object.entries(this.state.booleanStates).sort(([capA], [capB]) => {
+            const isTimelineA = isTimelineCapability(capA);
+            const isTimelineB = isTimelineCapability(capB);
+
+            if (!isTimelineA && !isTimelineB) return lexicographicCompare(capA, capB);
+            if (isTimelineA && !isTimelineB) return 1;
+            if (!isTimelineA && isTimelineB) return -1;
+            if (isTimelineA && isTimelineB) return lexicographicCompare(capA, capB);
+
+            return 0;
+        });
+        const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => {
             const text = CapabilityText.for(cap, this.props.widgetKind);
             const byline = text.byline
                 ? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{ text.byline }</span>
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 21859fb1aa..3b67db374c 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -604,6 +604,8 @@
     "See when anyone posts a sticker to your active room": "See when anyone posts a sticker to your active room",
     "with an empty state key": "with an empty state key",
     "with state key %(stateKey)s": "with state key %(stateKey)s",
+    "The above, but in any room you are joined or invited to as well": "The above, but in any room you are joined or invited to as well",
+    "The above, but in <Room /> as well": "The above, but in <Room /> as well",
     "Send <b>%(eventType)s</b> events as you in this room": "Send <b>%(eventType)s</b> events as you in this room",
     "See <b>%(eventType)s</b> events posted to this room": "See <b>%(eventType)s</b> events posted to this room",
     "Send <b>%(eventType)s</b> events as you in your active room": "Send <b>%(eventType)s</b> events as you in your active room",
diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts
index daa1e0e787..49653626c1 100644
--- a/src/stores/widgets/StopGapWidget.ts
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -1,5 +1,5 @@
 /*
- * Copyright 2020, 2021 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.
@@ -408,13 +408,11 @@ export class StopGapWidget extends EventEmitter {
     private onEvent = (ev: MatrixEvent) => {
         MatrixClientPeg.get().decryptEventIfNeeded(ev);
         if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
-        if (ev.getRoomId() !== this.eventListenerRoomId) return;
         this.feedEvent(ev);
     };
 
     private onEventDecrypted = (ev: MatrixEvent) => {
         if (ev.isDecryptionFailure()) return;
-        if (ev.getRoomId() !== this.eventListenerRoomId) return;
         this.feedEvent(ev);
     };
 
@@ -422,7 +420,7 @@ export class StopGapWidget extends EventEmitter {
         if (!this.messaging) return;
 
         const raw = ev.getEffectiveEvent();
-        this.messaging.feedEvent(raw).catch(e => {
+        this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => {
             console.error("Error sending event to widget: ", e);
         });
     }
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
index 13cd260ef0..78d7c9ede0 100644
--- a/src/stores/widgets/StopGapWidgetDriver.ts
+++ b/src/stores/widgets/StopGapWidgetDriver.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 @@ import {
     MatrixCapabilities,
     OpenIDRequestState,
     SimpleObservable,
+    Symbols,
     Widget,
     WidgetDriver,
     WidgetEventCapability,
@@ -44,7 +45,8 @@ import { CHAT_EFFECTS } from "../../effects";
 import { containsEmoji } from "../../effects/utils";
 import dis from "../../dispatcher/dispatcher";
 import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { Room } from "matrix-js-sdk";
 
 // TODO: Purge this from the universe
 
@@ -119,9 +121,9 @@ export class StopGapWidgetDriver extends WidgetDriver {
         return new Set(iterableUnion(allowedSoFar, requested));
     }
 
-    public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise<ISendEventDetails> {
+    public async sendEvent(eventType: string, content: any, stateKey: string = null, targetRoomId: string = null): Promise<ISendEventDetails> {
         const client = MatrixClientPeg.get();
-        const roomId = ActiveRoomObserver.activeRoomId;
+        const roomId = targetRoomId || ActiveRoomObserver.activeRoomId;
 
         if (!client || !roomId) throw new Error("Not in a room or not attached to a client");
 
@@ -145,48 +147,68 @@ export class StopGapWidgetDriver extends WidgetDriver {
         return { roomId, eventId: r.event_id };
     }
 
-    public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise<object[]> {
-        limit = limit > 0 ? Math.min(limit, 25) : 25; // arbitrary choice
-
+    private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] {
         const client = MatrixClientPeg.get();
-        const roomId = ActiveRoomObserver.activeRoomId;
-        const room = client.getRoom(roomId);
-        if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client");
+        if (!client) throw new Error("Not attached to a client");
 
-        const results: MatrixEvent[] = [];
-        const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
-        for (let i = events.length - 1; i > 0; i--) {
-            if (results.length >= limit) break;
-
-            const ev = events[i];
-            if (ev.getType() !== eventType || ev.isState()) continue;
-            if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue;
-            results.push(ev);
-        }
-
-        return results.map(e => e.getEffectiveEvent());
+        const targetRooms = roomIds
+            ? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r)))
+            : [client.getRoom(ActiveRoomObserver.activeRoomId)];
+        return targetRooms.filter(r => !!r);
     }
 
-    public async readStateEvents(eventType: string, stateKey: string | undefined, limit: number): Promise<object[]> {
-        limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice
+    public async readRoomEvents(
+        eventType: string,
+        msgtype: string | undefined,
+        limitPerRoom: number,
+        roomIds: (string | Symbols.AnyRoom)[] = null,
+    ): Promise<object[]> {
+        limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, 25) : 25; // arbitrary choice
 
-        const client = MatrixClientPeg.get();
-        const roomId = ActiveRoomObserver.activeRoomId;
-        const room = client.getRoom(roomId);
-        if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client");
+        const rooms = this.pickRooms(roomIds);
+        const allResults: IEvent[] = [];
+        for (const room of rooms) {
+            const results: MatrixEvent[] = [];
+            const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
+            for (let i = events.length - 1; i > 0; i--) {
+                if (results.length >= limitPerRoom) break;
 
-        const results: MatrixEvent[] = [];
-        const state: Map<string, MatrixEvent> = room.currentState.events.get(eventType);
-        if (state) {
-            if (stateKey === "" || !!stateKey) {
-                const forKey = state.get(stateKey);
-                if (forKey) results.push(forKey);
-            } else {
-                results.push(...Array.from(state.values()));
+                const ev = events[i];
+                if (ev.getType() !== eventType || ev.isState()) continue;
+                if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue;
+                results.push(ev);
             }
-        }
 
-        return results.slice(0, limit).map(e => e.event);
+            results.forEach(e => allResults.push(e.getEffectiveEvent()));
+        }
+        return allResults;
+    }
+
+    public async readStateEvents(
+        eventType: string,
+        stateKey: string | undefined,
+        limitPerRoom: number,
+        roomIds: (string | Symbols.AnyRoom)[] = null,
+    ): Promise<object[]> {
+        limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, 100) : 100; // arbitrary choice
+
+        const rooms = this.pickRooms(roomIds);
+        const allResults: IEvent[] = [];
+        for (const room of rooms) {
+            const results: MatrixEvent[] = [];
+            const state: Map<string, MatrixEvent> = room.currentState.events.get(eventType);
+            if (state) {
+                if (stateKey === "" || !!stateKey) {
+                    const forKey = state.get(stateKey);
+                    if (forKey) results.push(forKey);
+                } else {
+                    results.push(...Array.from(state.values()));
+                }
+            }
+
+            results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent()));
+        }
+        return allResults;
     }
 
     public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>) {
diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx
index 63e34eea7a..30349fe0f6 100644
--- a/src/widgets/CapabilityText.tsx
+++ b/src/widgets/CapabilityText.tsx
@@ -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,11 +14,22 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { Capability, EventDirection, MatrixCapabilities, WidgetEventCapability, WidgetKind } from "matrix-widget-api";
+import {
+    Capability,
+    EventDirection,
+    getTimelineRoomIDFromCapability,
+    isTimelineCapability,
+    isTimelineCapabilityFor,
+    MatrixCapabilities, Symbols,
+    WidgetEventCapability,
+    WidgetKind
+} from "matrix-widget-api";
 import { _t, _td, TranslatedString } from "../languageHandler";
 import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
 import { ElementWidgetCapabilities } from "../stores/widgets/ElementWidgetCapabilities";
 import React from "react";
+import { MatrixClientPeg } from "../MatrixClientPeg";
+import TextWithTooltip from "../components/views/elements/TextWithTooltip";
 
 type GENERIC_WIDGET_KIND = "generic"; // eslint-disable-line @typescript-eslint/naming-convention
 const GENERIC_WIDGET_KIND: GENERIC_WIDGET_KIND = "generic";
@@ -138,8 +149,31 @@ export class CapabilityText {
             if (textForKind[GENERIC_WIDGET_KIND]) return { primary: _t(textForKind[GENERIC_WIDGET_KIND]) };
 
             // ... we'll fall through to the generic capability processing at the end of this
-            // function if we fail to locate a simple string and the capability isn't for an
-            // event.
+            // function if we fail to generate a string for the capability.
+        }
+
+        // Try to handle timeline capabilities. The text here implies that the caller has sorted
+        // the timeline caps to the end for UI purposes.
+        if (isTimelineCapability(capability)) {
+            if (isTimelineCapabilityFor(capability, Symbols.AnyRoom)) {
+                return { primary: _t("The above, but in any room you are joined or invited to as well") };
+            } else {
+                const roomId = getTimelineRoomIDFromCapability(capability);
+                const room = MatrixClientPeg.get().getRoom(roomId);
+                return {
+                    primary: _t("The above, but in <Room /> as well", {}, {
+                        Room: () => {
+                            if (room) {
+                                return <TextWithTooltip tooltip={room.getCanonicalAlias() ?? roomId}>
+                                    <b>{ room.name }</b>
+                                </TextWithTooltip>;
+                            } else {
+                                return <b><code>{ roomId }</code></b>;
+                            }
+                        },
+                    }),
+                };
+            }
         }
 
         // We didn't have a super simple line of text, so try processing the capability as the