From 1f219d8530bd3d5fbe383a9f4235fd648be872f8 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 17:40:32 -0700 Subject: [PATCH 01/21] Simple support for generics off the settings store Ideally the settings store returns the right type, but for now we can feed it the type we need/expect. --- src/settings/SettingsStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 1b718a72b3..6dc2a76ae8 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -276,7 +276,7 @@ export default class SettingsStore { * @param {boolean} excludeDefault True to disable using the default value. * @return {*} The value, or null if not found */ - public static getValue(settingName: string, roomId: string = null, excludeDefault = false): any { + public static getValue(settingName: string, roomId: string = null, excludeDefault = false): T { // Verify that the setting is actually a setting if (!SETTINGS[settingName]) { throw new Error("Setting '" + settingName + "' does not appear to be a setting."); From 39ce5d06e6246338a41b70c543293a5d38480361 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 17:40:52 -0700 Subject: [PATCH 02/21] Describe how the widget layouts are supposed to look in time --- docs/widget-layouts.md | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/widget-layouts.md diff --git a/docs/widget-layouts.md b/docs/widget-layouts.md new file mode 100644 index 0000000000..e49f2a2e60 --- /dev/null +++ b/docs/widget-layouts.md @@ -0,0 +1,61 @@ +# Widget layout support + +Rooms can have a default widget layout to auto-pin certain widgets, make the container different +sizes, etc. These are defined through the `io.element.widgets.layout` state event (empty state key). + +Full example content: +```json5 +{ + "widgets": { + "first-widget-id": { + "container": "top", + "index": 0, + "width": 60, + "height": 40 + }, + "second-widget-id": { + "container": "right" + } + } +} +``` + +As shown, there are two containers possible for widgets. These containers have different behaviour +and interpret the other options differently. + +## `top` container + +This is the "App Drawer" or any pinned widgets in a room. This is by far the most versatile container +though does introduce potential usability issues upon members of the room (widgets take up space and +therefore less messages can be shown). + +The `index` for a widget determines which order the widgets show up in from left to right. Widgets +without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined +without an `index`) are resolved by comparing widget IDs. A maximum of 3 widgets can be in the top +container - any which exceed this will be ignored. Smaller numbers represent leftmost widgets. + +The `width` is relative width within the container in percentage points. This will be clamped to a +range of 0-100 (inclusive). The rightmost widget will have its percentage adjusted to fill the +container appropriately, shrinking and growing if required. For example, if three widgets are in the +top container at 40% width each then the 3rd widget will be shrunk to 20% because 120% > 100%. +Similarly, if all three widgets were set to 10% width each then the 3rd widget would grow to be 80%. + +Note that the client may impose minimum widths on the widgets, such as a 10% minimum to avoid pinning +hidden widgets. In general, widgets defined in the 30-70% range each will be free of these restrictions. + +The `height` is not in fact applied per-widget but is recorded per-widget for potential future +capabilities in future containers. The top container will take the tallest `height` and use that for +the height of the whole container, and thus all widgets in that container. The `height` is relative +to the container, like with `width`, meaning that 100% will consume as much space as the client is +willing to sacrifice to the widget container. Like with `width`, the client may impose minimums to avoid +the container being uselessly small. Heights in the 30-100% range are generally acceptable. The height +is also clamped to be within 0-100, inclusive. + +## `right` container + +This is the default container and has no special configuration. Widgets which overflow from the top +container will be put in this container instead. Putting a widget in the right container does not +automatically show it - it only mentions that widgets should not be in another container. + +The behaviour of this container may change in the future. + From 4ee29d4e638b6dc5af66582bc44e243237d12655 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 17:41:17 -0700 Subject: [PATCH 03/21] Split out ready states of stores to its own store --- src/stores/AsyncStoreWithClient.ts | 54 ++++++++------------ src/stores/ReadyWatchingStore.ts | 81 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 src/stores/ReadyWatchingStore.ts diff --git a/src/stores/AsyncStoreWithClient.ts b/src/stores/AsyncStoreWithClient.ts index 1ed7c6a547..3050555ad4 100644 --- a/src/stores/AsyncStoreWithClient.ts +++ b/src/stores/AsyncStoreWithClient.ts @@ -18,22 +18,33 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import { AsyncStore } from "./AsyncStore"; import { ActionPayload } from "../dispatcher/payloads"; import { Dispatcher } from "flux"; -import { MatrixClientPeg } from "../MatrixClientPeg"; +import { ReadyWatchingStore } from "./ReadyWatchingStore"; export abstract class AsyncStoreWithClient extends AsyncStore { - protected matrixClient: MatrixClient; - - protected abstract async onAction(payload: ActionPayload); + private readyStore: ReadyWatchingStore; protected constructor(dispatcher: Dispatcher, initialState: T = {}) { super(dispatcher, initialState); - if (MatrixClientPeg.get()) { - this.matrixClient = MatrixClientPeg.get(); + // Create an anonymous class to avoid code duplication + const asyncStore = this; + this.readyStore = new (class extends ReadyWatchingStore { + public get mxClient(): MatrixClient { + return this.matrixClient; + } - // noinspection JSIgnoredPromiseFromCall - this.onReady(); - } + protected async onReady(): Promise { + return asyncStore.onReady(); + } + + protected async onNotReady(): Promise { + return asyncStore.onNotReady(); + } + })(dispatcher); + } + + protected get matrixClient(): MatrixClient { + return this.readyStore.mxClient; } protected async onReady() { @@ -44,30 +55,9 @@ export abstract class AsyncStoreWithClient extends AsyncStore< // Default implementation is to do nothing. } + protected abstract async onAction(payload: ActionPayload); + protected async onDispatch(payload: ActionPayload) { await this.onAction(payload); - - if (payload.action === 'MatrixActions.sync') { - // Only set the client on the transition into the PREPARED state. - // Everything after this is unnecessary (we only need to know once we have a client) - // and we intentionally don't set the client before this point to avoid stores - // updating for every event emitted during the cached sync. - if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) { - return; - } - - if (this.matrixClient !== payload.matrixClient) { - if (this.matrixClient) { - await this.onNotReady(); - } - this.matrixClient = payload.matrixClient; - await this.onReady(); - } - } else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') { - if (this.matrixClient) { - await this.onNotReady(); - this.matrixClient = null; - } - } } } diff --git a/src/stores/ReadyWatchingStore.ts b/src/stores/ReadyWatchingStore.ts new file mode 100644 index 0000000000..9a28fe75fa --- /dev/null +++ b/src/stores/ReadyWatchingStore.ts @@ -0,0 +1,81 @@ +/* + * 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. + */ + +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { ActionPayload } from "../dispatcher/payloads"; +import { Dispatcher } from "flux"; +import { IDestroyable } from "../utils/IDestroyable"; +import { EventEmitter } from "events"; + +export abstract class ReadyWatchingStore extends EventEmitter implements IDestroyable { + protected matrixClient: MatrixClient; + private readonly dispatcherRef: string; + + constructor(protected readonly dispatcher: Dispatcher) { + super(); + + this.dispatcherRef = this.dispatcher.register(this.onAction); + + if (MatrixClientPeg.get()) { + this.matrixClient = MatrixClientPeg.get(); + + // noinspection JSIgnoredPromiseFromCall + this.onReady(); + } + } + + public get mxClient(): MatrixClient { + return this.matrixClient; // for external readonly access + } + + public destroy() { + this.dispatcher.unregister(this.dispatcherRef); + } + + protected async onReady() { + // Default implementation is to do nothing. + } + + protected async onNotReady() { + // Default implementation is to do nothing. + } + + private onAction = async (payload: ActionPayload) => { + if (payload.action === 'MatrixActions.sync') { + // Only set the client on the transition into the PREPARED state. + // Everything after this is unnecessary (we only need to know once we have a client) + // and we intentionally don't set the client before this point to avoid stores + // updating for every event emitted during the cached sync. + if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) { + return; + } + + if (this.matrixClient !== payload.matrixClient) { + if (this.matrixClient) { + await this.onNotReady(); + } + this.matrixClient = payload.matrixClient; + await this.onReady(); + } + } else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') { + if (this.matrixClient) { + await this.onNotReady(); + this.matrixClient = null; + } + } + }; +} From 0001e1e684d890efdd18daacef9e4e99c94aa0a9 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 17:41:42 -0700 Subject: [PATCH 04/21] Support initial ordering and calculation for widgets by layout --- src/@types/global.d.ts | 2 + src/settings/Settings.ts | 6 +- src/stores/widgets/WidgetLayoutStore.ts | 274 ++++++++++++++++++++++++ src/utils/numbers.ts | 30 +++ 4 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/stores/widgets/WidgetLayoutStore.ts create mode 100644 src/utils/numbers.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 741798761f..2a28c8e43f 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -36,6 +36,7 @@ import {Analytics} from "../Analytics"; import CountlyAnalytics from "../CountlyAnalytics"; import UserActivity from "../UserActivity"; import {ModalWidgetStore} from "../stores/ModalWidgetStore"; +import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; declare global { interface Window { @@ -59,6 +60,7 @@ declare global { mxNotifier: typeof Notifier; mxRightPanelStore: RightPanelStore; mxWidgetStore: WidgetStore; + mxWidgetLayoutStore: WidgetLayoutStore; mxCallHandler: CallHandler; mxAnalytics: Analytics; mxCountlyAnalytics: typeof CountlyAnalytics; diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 6ca009df61..25d7682033 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -633,7 +633,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Show chat effects"), default: true, }, - "Widgets.pinned": { + "Widgets.pinned": { // deprecated + supportedLevels: LEVELS_ROOM_OR_ACCOUNT, + default: {}, + }, + "Widgets.layout": { supportedLevels: LEVELS_ROOM_OR_ACCOUNT, default: {}, }, diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts new file mode 100644 index 0000000000..fddfaf65b3 --- /dev/null +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -0,0 +1,274 @@ +/* + * 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. + */ + +import SettingsStore from "../../settings/SettingsStore"; +import { Room } from "matrix-js-sdk/src/models/room"; +import WidgetStore, { IApp } from "../WidgetStore"; +import { WidgetType } from "../../widgets/WidgetType"; +import { clamp, defaultNumber } from "../../utils/numbers"; +import { EventEmitter } from "events"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ReadyWatchingStore } from "../ReadyWatchingStore"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout"; + +export enum Container { + // "Top" is the app drawer, and currently the only sensible value. + Top = "top", + + // "Right" is the right panel, and the default for widgets. Setting + // this as a container on a widget is essentially like saying "no + // changes needed", though this may change in the future. + Right = "right", + + // ... more as needed. Note that most of this code assumes that there + // are only two containers, and that only the top container is special. +} + +interface IStoredLayout { + // Where to store the widget. Required. + container: Container; + + // The index (order) to position the widgets in. Only applies for + // ordered containers (like the top container). Smaller numbers first, + // and conflicts resolved by comparing widget IDs. + index?: number; + + // Percentage (integer) for relative width of the container to consume. + // Clamped to 0-100 and may have minimums imposed upon it. Only applies + // to containers which support inner resizing (currently only the top + // container). + width?: number; + + // Percentage (integer) for relative height of the container. Note that + // this only applies to the top container currently, and that container + // will take the highest value among widgets in the container. Clamped + // to 0-100 and may have minimums imposed on it. + height?: number; + + // TODO: [Deferred] Maximizing (fullscreen) widgets by default. +} + +interface ILayoutStateEvent { + // TODO: [Deferred] Forced layout (fixed with no changes) + + // The widget layouts. + widgets: { + [widgetId: string]: IStoredLayout; + }; +} + +interface ILayoutSettings extends ILayoutStateEvent { + overrides?: string; // event ID for layout state event, if present +} + +// Dev note: "Pinned" widgets are ones in the top container. +const MAX_PINNED = 3; + +const MIN_WIDGET_WIDTH_PCT = 10; // Don't make anything smaller than 10% width +const MIN_WIDGET_HEIGHT_PCT = 20; + +export class WidgetLayoutStore extends ReadyWatchingStore { + private static internalInstance: WidgetLayoutStore; + + private byRoom: { + [roomId: string]: { + // @ts-ignore - TS wants a string key, but we know better + [container: Container]: { + ordered: IApp[]; + height?: number; + distributions?: number[]; + }; + }; + } = {}; + + private constructor() { + super(defaultDispatcher); + } + + public static get instance(): WidgetLayoutStore { + if (!WidgetLayoutStore.internalInstance) { + WidgetLayoutStore.internalInstance = new WidgetLayoutStore(); + } + return WidgetLayoutStore.internalInstance; + } + + public static emissionForRoom(room: Room): string { + return `update_${room.roomId}`; + } + + private emitFor(room: Room) { + this.emit(WidgetLayoutStore.emissionForRoom(room)); + } + + protected async onReady(): Promise { + this.byRoom = {}; + for (const room of this.matrixClient.getVisibleRooms()) { + this.recalculateRoom(room); + } + + this.matrixClient.on("RoomState.events", this.updateRoomFromState); + // TODO: Register settings listeners + // TODO: Register WidgetStore listener + } + + protected async onNotReady(): Promise { + this.byRoom = {}; + } + + private updateRoomFromState = (ev: MatrixEvent) => { + if (ev.getType() !== WIDGET_LAYOUT_EVENT_TYPE) return; + const room = this.matrixClient.getRoom(ev.getRoomId()); + this.recalculateRoom(room); + }; + + private recalculateRoom(room: Room) { + const widgets = WidgetStore.instance.getApps(room.roomId); + if (!widgets?.length) { + this.byRoom[room.roomId] = {}; + this.emitFor(room); + return; + } + + const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, ""); + const legacyPinned = SettingsStore.getValue("Widgets.pinned", room.roomId); + let userLayout = SettingsStore.getValue("Widgets.layout", room.roomId); + + if (layoutEv && userLayout && userLayout.overrides !== layoutEv.getId()) { + // For some other layout that we don't really care about. The user can reset this + // by updating their personal layout. + userLayout = null; + } + + const roomLayout: ILayoutStateEvent = layoutEv ? layoutEv.getContent() : null; + + // We essentially just need to find the top container's widgets because we + // only have two containers. Anything not in the top widget by the end of this + // function will go into the right container. + const topWidgets: IApp[] = []; + const rightWidgets: IApp[] = []; + for (const widget of widgets) { + if (WidgetType.JITSI.matches(widget.type)) { + topWidgets.push(widget); + continue; + } + + const stateContainer = roomLayout?.widgets?.[widget.id]?.container; + const manualContainer = userLayout?.widgets?.[widget.id]?.container; + const isLegacyPinned = !!legacyPinned?.[widget.id]; + const defaultContainer = WidgetType.JITSI.matches(widget.type) ? Container.Top : Container.Right; + + if (manualContainer === Container.Right) { + rightWidgets.push(widget); + } else if (manualContainer === Container.Top || stateContainer === Container.Top) { + topWidgets.push(widget); + } else if (isLegacyPinned && !stateContainer) { + topWidgets.push(widget); + } else { + (defaultContainer === Container.Top ? topWidgets : rightWidgets).push(widget); + } + } + + // Trim to MAX_PINNED + const runoff = topWidgets.slice(MAX_PINNED); + rightWidgets.push(...runoff); + + // Order the widgets in the top container, putting autopinned Jitsi widgets first + // unless they have a specific order in mind + topWidgets.sort((a, b) => { + const layoutA = roomLayout?.widgets?.[a.id]; + const layoutB = roomLayout?.widgets?.[b.id]; + + const userLayoutA = userLayout?.widgets?.[a.id]; + const userLayoutB = userLayout?.widgets?.[b.id]; + + // Jitsi widgets are defaulted to be the leftmost widget whereas other widgets + // default to the right side. + const defaultA = WidgetType.JITSI.matches(a.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER; + const defaultB = WidgetType.JITSI.matches(b.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER; + + const orderA = defaultNumber(userLayoutA, defaultNumber(layoutA?.index, defaultA)); + const orderB = defaultNumber(userLayoutB, defaultNumber(layoutB?.index, defaultB)); + + if (orderA === orderB) { + // We just need a tiebreak + return a.id.localeCompare(b.id); + } + + return orderA - orderB; + }); + + // Determine width distribution and height of the top container now (the only relevant one) + const widths: number[] = []; + let maxHeight = 0; + let doAutobalance = true; + for (let i = 0; i < topWidgets.length; i++) { + const widget = topWidgets[i]; + const widgetLayout = roomLayout?.widgets?.[widget.id]; + const userWidgetLayout = userLayout?.widgets?.[widget.id]; + + if (Number.isFinite(userWidgetLayout?.width) || Number.isFinite(widgetLayout?.width)) { + const val = userWidgetLayout?.width || widgetLayout?.width; + const normalized = clamp(val, MIN_WIDGET_WIDTH_PCT, 100); + widths.push(normalized); + doAutobalance = false; // a manual width was specified + } else { + widths.push(100); // we'll figure this out later + } + + const defRoomHeight = defaultNumber(widgetLayout?.height, MIN_WIDGET_HEIGHT_PCT); + const h = defaultNumber(userWidgetLayout?.height, defRoomHeight); + maxHeight = Math.max(maxHeight, clamp(h, MIN_WIDGET_HEIGHT_PCT, 100)); + } + let remainingWidth = 100; + for (const width of widths) { + remainingWidth -= width; + } + if (topWidgets.length > 1 && remainingWidth < MIN_WIDGET_WIDTH_PCT) { + const toReclaim = MIN_WIDGET_WIDTH_PCT - remainingWidth; + for (let i = 0; i < widths.length - 1; i++) { + widths[i] = widths[i] - (toReclaim / (widths.length - 1)); + } + widths[widths.length - 1] = MIN_WIDGET_WIDTH_PCT; + } + if (doAutobalance) { + for (let i = 0; i < widths.length; i++) { + widths[i] = 100 / widths.length; + } + } + + // Finally, fill in our cache and update + this.byRoom[room.roomId] = { + [Container.Top]: { + ordered: topWidgets, + distributions: widths, + height: maxHeight, + }, + [Container.Right]: { + ordered: rightWidgets, + }, + }; + this.emitFor(room); + } + + public getContainerWidgets(room: Room, container: Container): IApp[] { + return this.byRoom[room.roomId]?.[container]?.ordered || []; + } +} + +window.mxWidgetLayoutStore = WidgetLayoutStore.instance; diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts new file mode 100644 index 0000000000..1bf48c5117 --- /dev/null +++ b/src/utils/numbers.ts @@ -0,0 +1,30 @@ +/* +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. +*/ + +/** + * Returns the default number if the given value, i, is not a number. Otherwise + * returns the given value. + * @param {*} i The value to check. + * @param {number} def The default value. + * @returns {number} Either the value or the default value, whichever is a number. + */ +export function defaultNumber(i: unknown, def: number): number { + return Number.isFinite(i) ? Number(i) : def; +} + +export function clamp(i: number, min: number, max: number): number { + return Math.min(Math.max(i, min), max); +} From 2548a438ae810444b379e6c760dbb305f63a2f7e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 17:41:54 -0700 Subject: [PATCH 05/21] Render ordering changes in the AppsDrawer --- src/components/views/rooms/AppsDrawer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 3208844bc5..924cda0b64 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -30,10 +30,10 @@ import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import {useLocalStorageState} from "../../../hooks/useLocalStorageState"; import ResizeNotifier from "../../../utils/ResizeNotifier"; -import WidgetStore from "../../../stores/WidgetStore"; import ResizeHandle from "../elements/ResizeHandle"; import Resizer from "../../../resizer/resizer"; import PercentageDistributor from "../../../resizer/distributors/percentage"; +import {Container, WidgetLayoutStore} from "../../../stores/widgets/WidgetLayoutStore"; export default class AppsDrawer extends React.Component { static propTypes = { @@ -62,13 +62,13 @@ export default class AppsDrawer extends React.Component { componentDidMount() { ScalarMessaging.startListening(); - WidgetStore.instance.on(this.props.room.roomId, this._updateApps); + WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps); this.dispatcherRef = dis.register(this.onAction); } componentWillUnmount() { ScalarMessaging.stopListening(); - WidgetStore.instance.off(this.props.room.roomId, this._updateApps); + WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps); if (this.dispatcherRef) dis.unregister(this.dispatcherRef); if (this._resizeContainer) { this.resizer.detach(); @@ -190,7 +190,7 @@ export default class AppsDrawer extends React.Component { } }; - _getApps = () => WidgetStore.instance.getPinnedApps(this.props.room.roomId); + _getApps = () => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top); _updateApps = () => { this.setState({ From cfb583d193d2dab9f5b2244bf12e4258741a7705 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 18:48:07 -0700 Subject: [PATCH 06/21] Calculate widget widths in the WidgetLayoutStore Note that this ditches all previously set width values, however this is probably acceptable for now. Trying to remain backwards compatible gets tricky on top of already tricky code, and the impact of Element forgetting widths is not as severe as forgetting which widgets were/are pinned. --- src/components/views/rooms/AppsDrawer.js | 39 +++----- src/stores/widgets/WidgetLayoutStore.ts | 115 ++++++++++++++++++----- src/utils/WidgetUtils.ts | 2 +- src/utils/numbers.ts | 4 + 4 files changed, 111 insertions(+), 49 deletions(-) diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 924cda0b64..5982c52d98 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -102,11 +102,10 @@ export default class AppsDrawer extends React.Component { }, onResizeStop: () => { this._resizeContainer.classList.remove("mx_AppsDrawer_resizing"); - // persist to localStorage - localStorage.setItem(this._getStorageKey(), JSON.stringify([ - this.state.apps.map(app => app.id), - ...this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size), - ])); + WidgetLayoutStore.instance.setResizerDistributions( + this.props.room, Container.Top, + this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size), + ); }, }; // pass a truthy container for now, we won't call attach until we update it @@ -128,8 +127,6 @@ export default class AppsDrawer extends React.Component { this._loadResizerPreferences(); }; - _getStorageKey = () => `mx_apps_drawer-${this.props.room.roomId}`; - _getAppsHash = (apps) => apps.map(app => app.id).join("~"); componentDidUpdate(prevProps, prevState) { @@ -147,24 +144,16 @@ export default class AppsDrawer extends React.Component { }; _loadResizerPreferences = () => { - try { - const [[...lastIds], ...sizes] = JSON.parse(localStorage.getItem(this._getStorageKey())); - // Every app was included in the last split, reuse the last sizes - if (this.state.apps.length <= lastIds.length && this.state.apps.every((app, i) => lastIds[i] === app.id)) { - sizes.forEach((size, i) => { - const distributor = this.resizer.forHandleAt(i); - if (distributor) { - distributor.size = size; - distributor.finish(); - } - }); - return; - } - } catch (e) { - // this is expected - } - - if (this.state.apps) { + const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top); + if (this.state.apps && (this.state.apps.length - 1) === distributions.length) { + distributions.forEach((size, i) => { + const distributor = this.resizer.forHandleAt(i); + if (distributor) { + distributor.size = size; + distributor.finish(); + } + }); + } else if (this.state.apps) { const distributors = this.resizer.getDistributors(); distributors.forEach(d => d.item.clearSize()); distributors.forEach(d => d.start()); diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index fddfaf65b3..7d22f6729e 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -18,12 +18,11 @@ import SettingsStore from "../../settings/SettingsStore"; import { Room } from "matrix-js-sdk/src/models/room"; import WidgetStore, { IApp } from "../WidgetStore"; import { WidgetType } from "../../widgets/WidgetType"; -import { clamp, defaultNumber } from "../../utils/numbers"; -import { EventEmitter } from "events"; -import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import { clamp, defaultNumber, sum } from "../../utils/numbers"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ReadyWatchingStore } from "../ReadyWatchingStore"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { SettingLevel } from "../../settings/SettingLevel"; export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout"; @@ -117,13 +116,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } protected async onReady(): Promise { - this.byRoom = {}; - for (const room of this.matrixClient.getVisibleRooms()) { - this.recalculateRoom(room); - } + this.updateAllRooms(); this.matrixClient.on("RoomState.events", this.updateRoomFromState); - // TODO: Register settings listeners + SettingsStore.watchSetting("Widgets.pinned", null, this.updateFromSettings); + SettingsStore.watchSetting("Widgets.layout", null, this.updateFromSettings); // TODO: Register WidgetStore listener } @@ -131,10 +128,26 @@ export class WidgetLayoutStore extends ReadyWatchingStore { this.byRoom = {}; } + private updateAllRooms() { + this.byRoom = {}; + for (const room of this.matrixClient.getVisibleRooms()) { + this.recalculateRoom(room); + } + } + private updateRoomFromState = (ev: MatrixEvent) => { if (ev.getType() !== WIDGET_LAYOUT_EVENT_TYPE) return; const room = this.matrixClient.getRoom(ev.getRoomId()); - this.recalculateRoom(room); + if (room) this.recalculateRoom(room); + }; + + private updateFromSettings = (settingName: string, roomId: string, /* and other stuff */) => { + if (roomId) { + const room = this.matrixClient.getRoom(roomId); + if (room) this.recalculateRoom(room); + } else { + this.updateAllRooms(); + } }; private recalculateRoom(room: Room) { @@ -145,6 +158,8 @@ export class WidgetLayoutStore extends ReadyWatchingStore { return; } + const beforeChanges = JSON.stringify(this.byRoom[room.roomId]); + const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, ""); const legacyPinned = SettingsStore.getValue("Widgets.pinned", room.roomId); let userLayout = SettingsStore.getValue("Widgets.layout", room.roomId); @@ -239,36 +254,90 @@ export class WidgetLayoutStore extends ReadyWatchingStore { for (const width of widths) { remainingWidth -= width; } - if (topWidgets.length > 1 && remainingWidth < MIN_WIDGET_WIDTH_PCT) { - const toReclaim = MIN_WIDGET_WIDTH_PCT - remainingWidth; - for (let i = 0; i < widths.length - 1; i++) { - widths[i] = widths[i] - (toReclaim / (widths.length - 1)); - } - widths[widths.length - 1] = MIN_WIDGET_WIDTH_PCT; - } if (doAutobalance) { for (let i = 0; i < widths.length; i++) { widths[i] = 100 / widths.length; } } + // TODO: There is probably a more efficient way to do this. + // All we're doing is making sure that our widths sum up to 100 and take + // any excess width off all widgets equally to keep the proportions. + let toReclaim = sum(...widths) - 100; + while (toReclaim > 0 && topWidgets.length > 0) { + for (let i = 0; i < widths.length; i++) { + if (toReclaim <= 0) break; + const w = widths[i]; + const adjusted = clamp(w - 1, MIN_WIDGET_WIDTH_PCT, 100); + if (adjusted !== w) { + toReclaim -= 1; + widths[i] = adjusted; + } + } + } + // Finally, fill in our cache and update - this.byRoom[room.roomId] = { - [Container.Top]: { + this.byRoom[room.roomId] = {}; + if (topWidgets.length) { + this.byRoom[room.roomId][Container.Top] = { ordered: topWidgets, distributions: widths, height: maxHeight, - }, - [Container.Right]: { + }; + } + if (rightWidgets.length) { + this.byRoom[room.roomId][Container.Right] = { ordered: rightWidgets, - }, - }; - this.emitFor(room); + }; + } + + const afterChanges = JSON.stringify(this.byRoom[room.roomId]); + if (afterChanges !== beforeChanges) { + this.emitFor(room); + } } public getContainerWidgets(room: Room, container: Container): IApp[] { return this.byRoom[room.roomId]?.[container]?.ordered || []; } + + public getResizerDistributions(room: Room, container: Container): string[] { // yes, string. + let distributions = this.byRoom[room.roomId]?.[container]?.distributions; + if (!distributions || distributions.length < 2) return []; + + // The distributor actually expects to be fed N-1 sizes and expands the middle section + // instead of the edges. Therefore, we need to return [0] when there's two widgets or + // [0, 2] when there's three (skipping [1] because it's irrelevant). + + if (distributions.length === 2) distributions = [distributions[0]]; + if (distributions.length === 3) distributions = [distributions[0], distributions[2]]; + return distributions.map(d => `${d.toFixed(1)}%`); // actual percents - these are decoded later + } + + public setResizerDistributions(room: Room, container: Container, distributions: string[]) { + if (container !== Container.Top) return; // ignore - not relevant + + const numbers = distributions.map(d => Number(Number(d.substring(0, d.length - 1)).toFixed(1))); + const widgets = this.getContainerWidgets(room, container); + + // From getResizerDistributions, we need to fill in the middle size if applicable. + const remaining = 100 - sum(...numbers); + if (numbers.length === 2) numbers.splice(1, 0, remaining); + if (numbers.length === 1) numbers.push(remaining); + + const localLayout = {}; + widgets.forEach((w, i) => { + localLayout[w.id] = { + width: numbers[i], + index: i, + }; + }); + const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, ""); + SettingsStore.setValue("Widgets.layout", room.roomId, SettingLevel.ROOM_ACCOUNT, { + overrides: layoutEv?.getId(), + widgets: localLayout, + }); + } } window.mxWidgetLayoutStore = WidgetLayoutStore.instance; diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 986c68342c..815900d97a 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -28,7 +28,7 @@ import {WidgetType} from "../widgets/WidgetType"; import {objectClone} from "./objects"; import {_t} from "../languageHandler"; import {Capability, IWidgetData, MatrixCapabilities} from "matrix-widget-api"; -import {IApp} from "../stores/WidgetStore"; // TODO @@ +import {IApp} from "../stores/WidgetStore"; // How long we wait for the state event echo to come back from the server // before waitFor[Room/User]Widget rejects its promise diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index 1bf48c5117..e26db0d5aa 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -28,3 +28,7 @@ export function defaultNumber(i: unknown, def: number): number { export function clamp(i: number, min: number, max: number): number { return Math.min(Math.max(i, min), max); } + +export function sum(...i: number[]): number { + return [...i].reduce((p, c) => c + p, 0); +} From 0d29d15a464eab3a66278c963026033ca8be9f1d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 19:27:11 -0700 Subject: [PATCH 07/21] Support room-defined height as well Much like widget widths, it is acceptable for us to forget what everyone's height was previously at. --- src/components/views/rooms/AppsDrawer.js | 26 ++++++++++-- src/hooks/useStateCallback.ts | 28 +++++++++++++ src/stores/widgets/WidgetLayoutStore.ts | 50 +++++++++++++++++++----- src/utils/numbers.ts | 8 ++++ 4 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 src/hooks/useStateCallback.ts diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 5982c52d98..5efcefc4bc 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -28,12 +28,13 @@ import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetEchoStore from "../../../stores/WidgetEchoStore"; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; -import {useLocalStorageState} from "../../../hooks/useLocalStorageState"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeHandle from "../elements/ResizeHandle"; import Resizer from "../../../resizer/resizer"; import PercentageDistributor from "../../../resizer/distributors/percentage"; import {Container, WidgetLayoutStore} from "../../../stores/widgets/WidgetLayoutStore"; +import {clamp, percentageOf, percentageWithin} from "../../../utils/numbers"; +import {useStateCallback} from "../../../hooks/useStateCallback"; export default class AppsDrawer extends React.Component { static propTypes = { @@ -237,7 +238,7 @@ export default class AppsDrawer extends React.Component { return (
{ - const [height, setHeight] = useLocalStorageState("pvr_" + id, 280); // old fixed height was 273px + let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, Container.Top); + + // Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window. + if (!minHeight) minHeight = 100; + if (!maxHeight) maxHeight = (window.innerHeight / 4) * 3; + + // Convert from percentage to height. Note that the default height is 280px. + if (defaultHeight) { + defaultHeight = clamp(defaultHeight, 0, 100); + defaultHeight = percentageWithin(defaultHeight / 100, minHeight, maxHeight); + } else { + defaultHeight = 280; + } + + const [height, setHeight] = useStateCallback(defaultHeight, newHeight => { + newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100; + WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight) + }); return (initialValue: T, callback: (v: T) => void): [T, Dispatch>] => { + const [value, setValue] = useState(initialValue); + const interceptSetValue = (newVal: T) => { + setValue(newVal); + callback(newVal); + }; + return [value, interceptSetValue]; +}; diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 7d22f6729e..12051d35bc 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -63,13 +63,15 @@ interface IStoredLayout { // TODO: [Deferred] Maximizing (fullscreen) widgets by default. } +interface IWidgetLayouts { + [widgetId: string]: IStoredLayout; +} + interface ILayoutStateEvent { // TODO: [Deferred] Forced layout (fixed with no changes) // The widget layouts. - widgets: { - [widgetId: string]: IStoredLayout; - }; + widgets: IWidgetLayouts; } interface ILayoutSettings extends ILayoutStateEvent { @@ -79,8 +81,11 @@ interface ILayoutSettings extends ILayoutStateEvent { // Dev note: "Pinned" widgets are ones in the top container. const MAX_PINNED = 3; -const MIN_WIDGET_WIDTH_PCT = 10; // Don't make anything smaller than 10% width -const MIN_WIDGET_HEIGHT_PCT = 20; +// These two are whole percentages and don't really mean anything. Later values will decide +// minimum, but these help determine proportions during our calculations here. In fact, these +// values should be *smaller* than the actual minimums imposed by later components. +const MIN_WIDGET_WIDTH_PCT = 10; // 10% +const MIN_WIDGET_HEIGHT_PCT = 2; // 2% export class WidgetLayoutStore extends ReadyWatchingStore { private static internalInstance: WidgetLayoutStore; @@ -230,7 +235,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { // Determine width distribution and height of the top container now (the only relevant one) const widths: number[] = []; - let maxHeight = 0; + let maxHeight = null; // null == default let doAutobalance = true; for (let i = 0; i < topWidgets.length; i++) { const widget = topWidgets[i]; @@ -246,9 +251,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore { widths.push(100); // we'll figure this out later } - const defRoomHeight = defaultNumber(widgetLayout?.height, MIN_WIDGET_HEIGHT_PCT); - const h = defaultNumber(userWidgetLayout?.height, defRoomHeight); - maxHeight = Math.max(maxHeight, clamp(h, MIN_WIDGET_HEIGHT_PCT, 100)); + if (widgetLayout?.height || userWidgetLayout?.height) { + const defRoomHeight = defaultNumber(widgetLayout?.height, MIN_WIDGET_HEIGHT_PCT); + const h = defaultNumber(userWidgetLayout?.height, defRoomHeight); + maxHeight = Math.max(maxHeight, clamp(h, MIN_WIDGET_HEIGHT_PCT, 100)); + } } let remainingWidth = 100; for (const width of widths) { @@ -330,12 +337,35 @@ export class WidgetLayoutStore extends ReadyWatchingStore { localLayout[w.id] = { width: numbers[i], index: i, + height: this.byRoom[room.roomId]?.[container]?.height || MIN_WIDGET_HEIGHT_PCT, }; }); + this.updateUserLayout(room, localLayout); + } + + public getContainerHeight(room: Room, container: Container): number { + return this.byRoom[room.roomId]?.[container]?.height; // let the default get returned if needed + } + + public setContainerHeight(room: Room, container: Container, height: number) { + const widgets = this.getContainerWidgets(room, container); + const widths = this.byRoom[room.roomId]?.[container]?.distributions; + const localLayout = {}; + widgets.forEach((w, i) => { + localLayout[w.id] = { + width: widths[i], + index: i, + height: height, + }; + }); + this.updateUserLayout(room, localLayout); + } + + private updateUserLayout(room: Room, newLayout: IWidgetLayouts) { const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, ""); SettingsStore.setValue("Widgets.layout", room.roomId, SettingLevel.ROOM_ACCOUNT, { overrides: layoutEv?.getId(), - widgets: localLayout, + widgets: newLayout, }); } } diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index e26db0d5aa..c8b9e7248f 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -32,3 +32,11 @@ export function clamp(i: number, min: number, max: number): number { export function sum(...i: number[]): number { return [...i].reduce((p, c) => c + p, 0); } + +export function percentageWithin(pct: number, min: number, max: number): number { + return (pct * (max - min)) + min; +} + +export function percentageOf(val: number, min: number, max: number): number { + return (val - min) / max; +} From 5b5c338200f0666ebf50bb9d644001b3e8a0fdf3 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 19:31:11 -0700 Subject: [PATCH 08/21] Render layout changes in the timeline --- src/TextForEvent.js | 7 +++++++ src/components/views/rooms/EventTile.js | 2 ++ src/i18n/strings/en_EN.json | 1 + 3 files changed, 10 insertions(+) diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 56e9abc0f2..3afe41d216 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -19,6 +19,7 @@ import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; import SettingsStore from "./settings/SettingsStore"; import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; +import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore"; function textForMemberEvent(ev) { // XXX: SYJS-16 "sender is sometimes null for join messages" @@ -477,6 +478,11 @@ function textForWidgetEvent(event) { } } +function textForWidgetLayoutEvent(event) { + const senderName = event.sender?.name || event.getSender(); + return _t("%(senderName)s has updated the widget layout", {senderName}); +} + function textForMjolnirEvent(event) { const senderName = event.getSender(); const {entity: prevEntity} = event.getPrevContent(); @@ -583,6 +589,7 @@ const stateHandlers = { // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) 'im.vector.modular.widgets': textForWidgetEvent, + [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, }; // Add all the Mjolnir stuff to the renderer diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 11277daa57..4df74f77ce 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -37,6 +37,7 @@ import {E2E_STATE} from "./E2EIcon"; import {toRem} from "../../../utils/units"; import {WidgetType} from "../../../widgets/WidgetType"; import RoomAvatar from "../avatars/RoomAvatar"; +import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStore"; const eventTileTypes = { 'm.room.message': 'messages.MessageEvent', @@ -65,6 +66,7 @@ const stateEventTileTypes = { 'm.room.server_acl': 'messages.TextualEvent', // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) 'im.vector.modular.widgets': 'messages.TextualEvent', + [WIDGET_LAYOUT_EVENT_TYPE]: 'messages.TextualEvent', 'm.room.tombstone': 'messages.TextualEvent', 'm.room.join_rules': 'messages.TextualEvent', 'm.room.guest_access': 'messages.TextualEvent', diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 274bd247e2..2dbb6610f5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -548,6 +548,7 @@ "%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s", "%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s", "%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s", + "%(senderName)s has updated the widget layout": "%(senderName)s has updated the widget layout", "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s removed the rule banning users matching %(glob)s", "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s removed the rule banning rooms matching %(glob)s", "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s removed the rule banning servers matching %(glob)s", From 1768d6ea5f9025c382be0ac929eef0cece878022 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 20:26:47 -0700 Subject: [PATCH 09/21] Move all widget pinning logic to the WidgetLayoutStore --- src/components/structures/RoomView.tsx | 5 +- .../views/context_menus/WidgetContextMenu.tsx | 11 +- .../views/messages/MJitsiWidgetEvent.tsx | 7 +- .../views/right_panel/RoomSummaryCard.tsx | 12 +- .../views/right_panel/WidgetCard.tsx | 20 +- src/stores/WidgetStore.ts | 180 +----------------- src/stores/widgets/WidgetLayoutStore.ts | 83 +++++++- 7 files changed, 116 insertions(+), 202 deletions(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 0ee847fbc9..027c6b3cc3 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -78,6 +78,7 @@ import {UPDATE_EVENT} from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; +import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -280,8 +281,8 @@ export default class RoomView extends React.Component { private checkWidgets = (room) => { this.setState({ - hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0, - }) + hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0, + }); }; private onReadReceiptsChange = () => { diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 4662c74d78..c1af86eae6 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -20,7 +20,7 @@ import {MatrixCapabilities} from "matrix-widget-api"; import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu"; import {ChevronFace} from "../../structures/ContextMenu"; import {_t} from "../../../languageHandler"; -import WidgetStore, {IApp} from "../../../stores/WidgetStore"; +import {IApp} from "../../../stores/WidgetStore"; import WidgetUtils from "../../../utils/WidgetUtils"; import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore"; import RoomContext from "../../../contexts/RoomContext"; @@ -30,6 +30,7 @@ import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import {WidgetType} from "../../../widgets/WidgetType"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; interface IProps extends React.ComponentProps { app: IApp; @@ -56,7 +57,7 @@ const WidgetContextMenu: React.FC = ({ let unpinButton; if (showUnpin) { const onUnpinClick = () => { - WidgetStore.instance.unpinWidget(room.roomId, app.id); + WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); onFinished(); }; @@ -137,13 +138,13 @@ const WidgetContextMenu: React.FC = ({ revokeButton = ; } - const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId); + const pinnedWidgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id); let moveLeftButton; if (showUnpin && widgetIndex > 0) { const onClick = () => { - WidgetStore.instance.movePinnedWidget(roomId, app.id, -1); + WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1); onFinished(); }; @@ -153,7 +154,7 @@ const WidgetContextMenu: React.FC = ({ let moveRightButton; if (showUnpin && widgetIndex < pinnedWidgets.length - 1) { const onClick = () => { - WidgetStore.instance.movePinnedWidget(roomId, app.id, 1); + WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1); onFinished(); }; diff --git a/src/components/views/messages/MJitsiWidgetEvent.tsx b/src/components/views/messages/MJitsiWidgetEvent.tsx index b87efd472a..3ff11f7b6f 100644 --- a/src/components/views/messages/MJitsiWidgetEvent.tsx +++ b/src/components/views/messages/MJitsiWidgetEvent.tsx @@ -19,6 +19,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from "../../../languageHandler"; import WidgetStore from "../../../stores/WidgetStore"; import EventTileBubble from "./EventTileBubble"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; interface IProps { mxEvent: MatrixEvent; @@ -33,9 +35,12 @@ export default class MJitsiWidgetEvent extends React.PureComponent { const url = this.props.mxEvent.getContent()['url']; const prevUrl = this.props.mxEvent.getPrevContent()['url']; const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender(); + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const widgetId = this.props.mxEvent.getStateKey(); + const widget = WidgetStore.instance.getRoom(room.roomId).widgets.find(w => w.id === widgetId); let joinCopy = _t('Join the conference at the top of this room'); - if (!WidgetStore.instance.isPinned(this.props.mxEvent.getRoomId(), this.props.mxEvent.getStateKey())) { + if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Right)) { joinCopy = _t('Join the conference from the room information card on the right'); } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index ebc07e76b8..f405cde11d 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -37,13 +37,14 @@ import SettingsStore from "../../../settings/SettingsStore"; import TextWithTooltip from "../elements/TextWithTooltip"; import WidgetAvatar from "../avatars/WidgetAvatar"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import WidgetStore, {IApp, MAX_PINNED} from "../../../stores/WidgetStore"; +import WidgetStore, {IApp} from "../../../stores/WidgetStore"; import { E2EStatus } from "../../../utils/ShieldUtils"; import RoomContext from "../../../contexts/RoomContext"; import {UIFeature} from "../../../settings/UIFeature"; import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu"; import WidgetContextMenu from "../context_menus/WidgetContextMenu"; import {useRoomMemberCount} from "../../../hooks/useRoomMembers"; +import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; interface IProps { room: Room; @@ -78,6 +79,7 @@ export const useWidgets = (room: Room) => { useEffect(updateApps, [room]); useEventEmitter(WidgetStore.instance, room.roomId, updateApps); + useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateApps); return apps; }; @@ -102,10 +104,10 @@ const AppRow: React.FC = ({ app, room }) => { }); }; - const isPinned = WidgetStore.instance.isPinned(room.roomId, app.id); + const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top); const togglePin = isPinned - ? () => { WidgetStore.instance.unpinWidget(room.roomId, app.id); } - : () => { WidgetStore.instance.pinWidget(room.roomId, app.id); }; + ? () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); } + : () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); }; const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); let contextMenu; @@ -120,7 +122,7 @@ const AppRow: React.FC = ({ app, room }) => { />; } - const cannotPin = !isPinned && !WidgetStore.instance.canPin(room.roomId, app.id); + const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top); let pinTitle: string; if (cannotPin) { diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 593bd0dde7..56e522e206 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -14,22 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useContext, useEffect} from "react"; -import {Room} from "matrix-js-sdk/src/models/room"; +import React, { useContext, useEffect } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import BaseCard from "./BaseCard"; import WidgetUtils from "../../../utils/WidgetUtils"; import AppTile from "../elements/AppTile"; -import {_t} from "../../../languageHandler"; -import {useWidgets} from "./RoomSummaryCard"; -import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; +import { _t } from "../../../languageHandler"; +import { useWidgets } from "./RoomSummaryCard"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; import defaultDispatcher from "../../../dispatcher/dispatcher"; -import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; -import {Action} from "../../../dispatcher/actions"; -import WidgetStore from "../../../stores/WidgetStore"; -import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu"; +import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { Action } from "../../../dispatcher/actions"; +import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import WidgetContextMenu from "../context_menus/WidgetContextMenu"; +import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; interface IProps { room: Room; @@ -42,7 +42,7 @@ const WidgetCard: React.FC = ({ room, widgetId, onClose }) => { const apps = useWidgets(room); const app = apps.find(a => a.id === widgetId); - const isPinned = app && WidgetStore.instance.isPinned(room.roomId, app.id); + const isPinned = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Top); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index c9cf0a1c70..2f06dfcb80 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -21,16 +21,12 @@ import { IWidget } from "matrix-widget-api"; import { ActionPayload } from "../dispatcher/payloads"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; -import SettingsStore from "../settings/SettingsStore"; import WidgetEchoStore from "../stores/WidgetEchoStore"; -import RoomViewStore from "../stores/RoomViewStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import WidgetUtils from "../utils/WidgetUtils"; -import {SettingLevel} from "../settings/SettingLevel"; import {WidgetType} from "../widgets/WidgetType"; import {UPDATE_EVENT} from "./AsyncStore"; import { MatrixClientPeg } from "../MatrixClientPeg"; -import { arrayDiff, arrayHasDiff, arrayUnion } from "../utils/arrays"; interface IState {} @@ -41,15 +37,10 @@ export interface IApp extends IWidget { avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765 } -type PinnedWidgets = Record; - interface IRoomWidgets { widgets: IApp[]; - pinned: PinnedWidgets; } -export const MAX_PINNED = 3; - function widgetUid(app: IApp): string { return `${app.roomId ?? MatrixClientPeg.get().getUserId()}::${app.id}`; } @@ -65,7 +56,6 @@ export default class WidgetStore extends AsyncStoreWithClient { private constructor() { super(defaultDispatcher, {}); - SettingsStore.watchSetting("Widgets.pinned", null, this.onPinnedWidgetsChange); WidgetEchoStore.on("update", this.onWidgetEchoStoreUpdate); } @@ -76,7 +66,6 @@ export default class WidgetStore extends AsyncStoreWithClient { private initRoom(roomId: string) { if (!this.roomMap.has(roomId)) { this.roomMap.set(roomId, { - pinned: {}, // ordered widgets: [], }); } @@ -85,16 +74,6 @@ export default class WidgetStore extends AsyncStoreWithClient { protected async onReady(): Promise { this.matrixClient.on("RoomState.events", this.onRoomStateEvents); this.matrixClient.getRooms().forEach((room: Room) => { - const pinned = SettingsStore.getValue("Widgets.pinned", room.roomId); - - if (pinned || WidgetUtils.getRoomWidgets(room).length) { - this.initRoom(room.roomId); - } - - if (pinned) { - this.getRoom(room.roomId).pinned = pinned; - } - this.loadRoomWidgets(room); }); this.emit(UPDATE_EVENT); @@ -128,7 +107,7 @@ export default class WidgetStore extends AsyncStoreWithClient { private loadRoomWidgets(room: Room) { if (!room) return; - const roomInfo = this.roomMap.get(room.roomId); + const roomInfo = this.roomMap.get(room.roomId) || {}; roomInfo.widgets = []; // first clean out old widgets from the map which originate from this room @@ -138,6 +117,7 @@ export default class WidgetStore extends AsyncStoreWithClient { this.widgetMap.delete(widgetUid(app)); }); + let edited = false; this.generateApps(room).forEach(app => { // Sanity check for https://github.com/vector-im/element-web/issues/15705 const existingApp = this.widgetMap.get(widgetUid(app)); @@ -150,12 +130,16 @@ export default class WidgetStore extends AsyncStoreWithClient { this.widgetMap.set(widgetUid(app), app); roomInfo.widgets.push(app); + edited = true; }); + if (edited && !this.roomMap.has(room.roomId)) { + this.roomMap.set(room.roomId, roomInfo); + } this.emit(room.roomId); } private onRoomStateEvents = (ev: MatrixEvent) => { - if (ev.getType() !== "im.vector.modular.widgets") return; + if (ev.getType() !== "im.vector.modular.widgets") return; // TODO: Support m.widget too const roomId = ev.getRoomId(); this.initRoom(roomId); this.loadRoomWidgets(this.matrixClient.getRoom(roomId)); @@ -166,156 +150,6 @@ export default class WidgetStore extends AsyncStoreWithClient { return this.roomMap.get(roomId); }; - private onPinnedWidgetsChange = (settingName: string, roomId: string) => { - this.initRoom(roomId); - - const pinned: PinnedWidgets = SettingsStore.getValue(settingName, roomId); - - // Sanity check for https://github.com/vector-im/element-web/issues/15705 - const roomInfo = this.getRoom(roomId); - const remappedPinned: PinnedWidgets = {}; - for (const widgetId of Object.keys(pinned)) { - const isPinned = pinned[widgetId]; - if (!roomInfo.widgets?.some(w => w.id === widgetId)) { - console.warn(`Skipping pinned widget update for ${widgetId} in ${roomId} -- wrong room`); - } else { - remappedPinned[widgetId] = isPinned; - } - } - roomInfo.pinned = remappedPinned; - - this.emit(roomId); - this.emit(UPDATE_EVENT); - }; - - public isPinned(roomId: string, widgetId: string) { - return !!this.getPinnedApps(roomId).find(w => w.id === widgetId); - } - - // dev note: we don't need the widgetId on this function, but the contract makes more sense - // when we require it. - public canPin(roomId: string, widgetId: string) { - return this.getPinnedApps(roomId).length < MAX_PINNED; - } - - public pinWidget(roomId: string, widgetId: string) { - const roomInfo = this.getRoom(roomId); - if (!roomInfo) return; - - // When pinning, first confirm all the widgets (Jitsi) which were autopinned so that the order is correct - const autoPinned = this.getPinnedApps(roomId).filter(app => !roomInfo.pinned[app.id]); - autoPinned.forEach(app => { - this.setPinned(roomId, app.id, true); - }); - - this.setPinned(roomId, widgetId, true); - - // Show the apps drawer upon the user pinning a widget - if (RoomViewStore.getRoomId() === roomId) { - defaultDispatcher.dispatch({ - action: "appsDrawer", - show: true, - }); - } - } - - public unpinWidget(roomId: string, widgetId: string) { - this.setPinned(roomId, widgetId, false); - } - - private setPinned(roomId: string, widgetId: string, value: boolean) { - const roomInfo = this.getRoom(roomId); - if (!roomInfo) return; - if (roomInfo.pinned[widgetId] === false && value) { - // delete this before write to maintain the correct object insertion order - delete roomInfo.pinned[widgetId]; - } - roomInfo.pinned[widgetId] = value; - - // Clean up the pinned record - Object.keys(roomInfo).forEach(wId => { - if (!roomInfo.widgets.some(w => w.id === wId) || !roomInfo.pinned[wId]) { - delete roomInfo.pinned[wId]; - } - }); - - SettingsStore.setValue("Widgets.pinned", roomId, SettingLevel.ROOM_ACCOUNT, roomInfo.pinned); - this.emit(roomId); - this.emit(UPDATE_EVENT); - } - - public movePinnedWidget(roomId: string, widgetId: string, delta: 1 | -1) { - // TODO simplify this by changing the storage medium of pinned to an array once the Jitsi default-on goes away - const roomInfo = this.getRoom(roomId); - if (!roomInfo || roomInfo.pinned[widgetId] === false) return; - - const pinnedApps = this.getPinnedApps(roomId).map(app => app.id); - const i = pinnedApps.findIndex(id => id === widgetId); - - if (delta > 0) { - pinnedApps.splice(i, 2, pinnedApps[i + 1], pinnedApps[i]); - } else { - pinnedApps.splice(i - 1, 2, pinnedApps[i], pinnedApps[i - 1]); - } - - const reorderedPinned: IRoomWidgets["pinned"] = {}; - pinnedApps.forEach(id => { - reorderedPinned[id] = true; - }); - Object.keys(roomInfo.pinned).forEach(id => { - if (reorderedPinned[id] === undefined) { - reorderedPinned[id] = roomInfo.pinned[id]; - } - }); - roomInfo.pinned = reorderedPinned; - - SettingsStore.setValue("Widgets.pinned", roomId, SettingLevel.ROOM_ACCOUNT, roomInfo.pinned); - this.emit(roomId); - this.emit(UPDATE_EVENT); - } - - public getPinnedApps(roomId: string): IApp[] { - // returns the apps in the order they were pinned with, up to the maximum - const roomInfo = this.getRoom(roomId); - if (!roomInfo) return []; - - // Show Jitsi widgets even if the user already had the maximum pinned, instead of their latest pinned, - // except if the user already explicitly unpinned the Jitsi widget - const priorityWidget = roomInfo.widgets.find(widget => { - return roomInfo.pinned[widget.id] === undefined && WidgetType.JITSI.matches(widget.type); - }); - - const order = Object.keys(roomInfo.pinned).filter(k => roomInfo.pinned[k]); - const apps = order - .map(wId => Array.from(this.widgetMap.values()) - .find(w2 => w2.roomId === roomId && w2.id === wId)) - .filter(Boolean) - .slice(0, priorityWidget ? MAX_PINNED - 1 : MAX_PINNED); - if (priorityWidget) { - apps.push(priorityWidget); - } - - // Sanity check for https://github.com/vector-im/element-web/issues/15705 - // We union the app IDs the above generated with the roomInfo's known widgets to - // get a list of IDs which both exist. We then diff that against the generated app - // IDs above to ensure that all of the app IDs are captured by the union with the - // room - if we grabbed a widget that wasn't part of the roomInfo's list, it wouldn't - // be in the union and thus result in a diff. - const appIds = apps.map(a => widgetUid(a)); - const roomAppIds = roomInfo.widgets.map(a => widgetUid(a)); - const roomAppIdsUnion = arrayUnion(appIds, roomAppIds); - const missingSomeApps = arrayHasDiff(roomAppIdsUnion, appIds); - if (missingSomeApps) { - const diff = arrayDiff(roomAppIdsUnion, appIds); - console.warn( - `${roomId} appears to have a conflict for which widgets belong to it. ` + - `Widget UIDs are: `, [...diff.added, ...diff.removed], - ); - } - - return apps; - } - public getApps(roomId: string): IApp[] { const roomInfo = this.getRoom(roomId); return roomInfo?.widgets || []; diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 12051d35bc..4065dd2469 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -23,6 +23,8 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import { ReadyWatchingStore } from "../ReadyWatchingStore"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { SettingLevel } from "../../settings/SettingLevel"; +import { arrayFastClone } from "../../utils/arrays"; +import { UPDATE_EVENT } from "../AsyncStore"; export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout"; @@ -79,7 +81,7 @@ interface ILayoutSettings extends ILayoutStateEvent { } // Dev note: "Pinned" widgets are ones in the top container. -const MAX_PINNED = 3; +export const MAX_PINNED = 3; // These two are whole percentages and don't really mean anything. Later values will decide // minimum, but these help determine proportions during our calculations here. In fact, these @@ -126,19 +128,19 @@ export class WidgetLayoutStore extends ReadyWatchingStore { this.matrixClient.on("RoomState.events", this.updateRoomFromState); SettingsStore.watchSetting("Widgets.pinned", null, this.updateFromSettings); SettingsStore.watchSetting("Widgets.layout", null, this.updateFromSettings); - // TODO: Register WidgetStore listener + WidgetStore.instance.on(UPDATE_EVENT, this.updateAllRooms); } protected async onNotReady(): Promise { this.byRoom = {}; } - private updateAllRooms() { + private updateAllRooms = () => { this.byRoom = {}; for (const room of this.matrixClient.getVisibleRooms()) { this.recalculateRoom(room); } - } + }; private updateRoomFromState = (ev: MatrixEvent) => { if (ev.getType() !== WIDGET_LAYOUT_EVENT_TYPE) return; @@ -222,8 +224,8 @@ export class WidgetLayoutStore extends ReadyWatchingStore { const defaultA = WidgetType.JITSI.matches(a.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER; const defaultB = WidgetType.JITSI.matches(b.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER; - const orderA = defaultNumber(userLayoutA, defaultNumber(layoutA?.index, defaultA)); - const orderB = defaultNumber(userLayoutB, defaultNumber(layoutB?.index, defaultB)); + const orderA = defaultNumber(userLayoutA?.index, defaultNumber(layoutA?.index, defaultA)); + const orderB = defaultNumber(userLayoutB?.index, defaultNumber(layoutB?.index, defaultB)); if (orderA === orderB) { // We just need a tiebreak @@ -308,6 +310,14 @@ export class WidgetLayoutStore extends ReadyWatchingStore { return this.byRoom[room.roomId]?.[container]?.ordered || []; } + public isInContainer(room: Room, widget: IApp, container: Container): boolean { + return this.getContainerWidgets(room, container).some(w => w.id === widget.id); + } + + public canAddToContainer(room: Room, container: Container): boolean { + return this.getContainerWidgets(room, container).length < MAX_PINNED; + } + public getResizerDistributions(room: Room, container: Container): string[] { // yes, string. let distributions = this.byRoom[room.roomId]?.[container]?.distributions; if (!distributions || distributions.length < 2) return []; @@ -335,6 +345,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { const localLayout = {}; widgets.forEach((w, i) => { localLayout[w.id] = { + container: container, width: numbers[i], index: i, height: this.byRoom[room.roomId]?.[container]?.height || MIN_WIDGET_HEIGHT_PCT, @@ -353,6 +364,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { const localLayout = {}; widgets.forEach((w, i) => { localLayout[w.id] = { + container: container, width: widths[i], index: i, height: height, @@ -361,7 +373,66 @@ export class WidgetLayoutStore extends ReadyWatchingStore { this.updateUserLayout(room, localLayout); } + public moveWithinContainer(room: Room, container: Container, widget: IApp, delta: number) { + const widgets = arrayFastClone(this.getContainerWidgets(room, container)); + const currentIdx = widgets.findIndex(w => w.id === widget.id); + if (currentIdx < 0) return; // no change needed + + widgets.splice(currentIdx, 1); // remove existing widget + const newIdx = clamp(currentIdx + delta, 0, widgets.length); + widgets.splice(newIdx, 0, widget); + + const widths = this.byRoom[room.roomId]?.[container]?.distributions; + const height = this.byRoom[room.roomId]?.[container]?.height; + const localLayout = {}; + widgets.forEach((w, i) => { + localLayout[w.id] = { + container: container, + width: widths[i], + index: i, + height: height, + }; + }); + this.updateUserLayout(room, localLayout); + } + + public moveToContainer(room: Room, widget: IApp, toContainer: Container) { + const allWidgets = this.getAllWidgets(room); + if (!allWidgets.some(([w])=> w.id === widget.id)) return; // invalid + this.updateUserLayout(room, {[widget.id]:{container: toContainer}}); + } + + private getAllWidgets(room: Room): [IApp, Container][] { + const containers = this.byRoom[room.roomId]; + if (!containers) return []; + + const ret = []; + for (const container of Object.keys(containers)) { + const widgets = containers[container].ordered; + for (const widget of widgets) { + ret.push([widget, container]); + } + } + return ret; + } + private updateUserLayout(room: Room, newLayout: IWidgetLayouts) { + // Polyfill any missing widgets + const allWidgets = this.getAllWidgets(room); + for (const [widget, container] of allWidgets) { + const containerWidgets = this.getContainerWidgets(room, container); + const idx = containerWidgets.findIndex(w => w.id === widget.id); + const widths = this.byRoom[room.roomId]?.[container]?.distributions; + if (!newLayout[widget.id]) { + newLayout[widget.id] = { + container: container, + index: idx, + height: this.byRoom[room.roomId]?.[container]?.height, + width: widths?.[idx], + }; + } + } + const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, ""); SettingsStore.setValue("Widgets.layout", room.roomId, SettingLevel.ROOM_ACCOUNT, { overrides: layoutEv?.getId(), From 7ff76b250010b6c71a456895452a0ad1fd31d568 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 20:28:09 -0700 Subject: [PATCH 10/21] Don't forget to run cleanup --- src/stores/widgets/WidgetLayoutStore.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 4065dd2469..94d77df78b 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -103,6 +103,9 @@ export class WidgetLayoutStore extends ReadyWatchingStore { }; } = {}; + private pinnedRef: string; + private layoutRef: string; + private constructor() { super(defaultDispatcher); } @@ -126,13 +129,17 @@ export class WidgetLayoutStore extends ReadyWatchingStore { this.updateAllRooms(); this.matrixClient.on("RoomState.events", this.updateRoomFromState); - SettingsStore.watchSetting("Widgets.pinned", null, this.updateFromSettings); - SettingsStore.watchSetting("Widgets.layout", null, this.updateFromSettings); + this.pinnedRef = SettingsStore.watchSetting("Widgets.pinned", null, this.updateFromSettings); + this.layoutRef = SettingsStore.watchSetting("Widgets.layout", null, this.updateFromSettings); WidgetStore.instance.on(UPDATE_EVENT, this.updateAllRooms); } protected async onNotReady(): Promise { this.byRoom = {}; + + SettingsStore.unwatchSetting(this.pinnedRef); + SettingsStore.unwatchSetting(this.layoutRef); + WidgetStore.instance.off(UPDATE_EVENT, this.updateAllRooms); } private updateAllRooms = () => { From 5c393cc2d0a3f40886132784f92264902d22715c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 20:29:51 -0700 Subject: [PATCH 11/21] Add in some local echo support to make changes appear faster --- src/stores/widgets/WidgetLayoutStore.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 94d77df78b..7d695578cd 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -444,7 +444,8 @@ export class WidgetLayoutStore extends ReadyWatchingStore { SettingsStore.setValue("Widgets.layout", room.roomId, SettingLevel.ROOM_ACCOUNT, { overrides: layoutEv?.getId(), widgets: newLayout, - }); + }).catch(() => this.recalculateRoom(room)); + this.recalculateRoom(room); // call to try local echo on changes (the catch above undoes any errors) } } From 04d1f5dd28948be4bcca1b14e30bb5b6a5d04baa Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 20:42:21 -0700 Subject: [PATCH 12/21] Implement a "Copy my layout to the room" button --- .../views/right_panel/RoomSummaryCard.tsx | 11 +++++++- src/i18n/strings/en_EN.json | 1 + src/stores/widgets/WidgetLayoutStore.ts | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index f405cde11d..00ee1945d8 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -186,9 +186,18 @@ const AppsSection: React.FC = ({ room }) => { } }; + let copyLayoutBtn = null; + if (apps.length > 0 && WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) { + copyLayoutBtn = ( + WidgetLayoutStore.instance.copyLayoutToRoom(room)}> + { _t("Set my room layout for everyone") } + + ); + } + return { apps.map(app => ) } - + { copyLayoutBtn } { apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2dbb6610f5..356de3730a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1636,6 +1636,7 @@ "Unpin": "Unpin", "Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel", "Options": "Options", + "Set my room layout for everyone": "Set my room layout for everyone", "Widgets": "Widgets", "Edit widgets, bridges & bots": "Edit widgets, bridges & bots", "Add widgets, bridges & bots": "Add widgets, bridges & bots", diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 7d695578cd..b9350328f6 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -409,6 +409,32 @@ export class WidgetLayoutStore extends ReadyWatchingStore { this.updateUserLayout(room, {[widget.id]:{container: toContainer}}); } + public canCopyLayoutToRoom(room: Room): boolean { + if (!this.matrixClient) return false; // not ready yet + return room.currentState.maySendStateEvent(WIDGET_LAYOUT_EVENT_TYPE, this.matrixClient.getUserId()); + } + + public copyLayoutToRoom(room: Room) { + const allWidgets = this.getAllWidgets(room); + const evContent: ILayoutStateEvent = {widgets: {}}; + for (const [widget, container] of allWidgets) { + evContent.widgets[widget.id] = {container}; + if (container === Container.Top) { + const containerWidgets = this.getContainerWidgets(room, container); + const idx = containerWidgets.findIndex(w => w.id === widget.id); + const widths = this.byRoom[room.roomId]?.[container]?.distributions; + const height = this.byRoom[room.roomId]?.[container]?.height; + evContent.widgets[widget.id] = { + ...evContent.widgets[widget.id], + height: height ? Math.round(height) : null, + width: widths[idx] ? Math.round(widths[idx]) : null, + index: idx, + }; + } + } + this.matrixClient.sendStateEvent(room.roomId, WIDGET_LAYOUT_EVENT_TYPE, evContent, ""); + } + private getAllWidgets(room: Room): [IApp, Container][] { const containers = this.byRoom[room.roomId]; if (!containers) return []; From 6227d3c183d3c589bfc9381a8e6a6a06ba2cffc2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 20:53:15 -0700 Subject: [PATCH 13/21] Appease the linters --- src/components/views/rooms/AppsDrawer.js | 2 +- src/hooks/useSettings.ts | 6 +++--- src/stores/AsyncStoreWithClient.ts | 4 ++-- src/stores/room-list/RoomListStore.ts | 2 +- src/stores/widgets/WidgetLayoutStore.ts | 10 ++++------ 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 5efcefc4bc..4aa9efa3fd 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -287,7 +287,7 @@ const PersistentVResizer = ({ const [height, setHeight] = useStateCallback(defaultHeight, newHeight => { newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100; - WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight) + WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight); }); return { - const [value, setValue] = useState(SettingsStore.getValue(settingName, roomId, excludeDefault)); +export const useSettingValue = (settingName: string, roomId: string = null, excludeDefault = false) => { + const [value, setValue] = useState(SettingsStore.getValue(settingName, roomId, excludeDefault)); useEffect(() => { const ref = SettingsStore.watchSetting(settingName, roomId, () => { - setValue(SettingsStore.getValue(settingName, roomId, excludeDefault)); + setValue(SettingsStore.getValue(settingName, roomId, excludeDefault)); }); // clean-up return () => { diff --git a/src/stores/AsyncStoreWithClient.ts b/src/stores/AsyncStoreWithClient.ts index 3050555ad4..38e709d8c2 100644 --- a/src/stores/AsyncStoreWithClient.ts +++ b/src/stores/AsyncStoreWithClient.ts @@ -21,13 +21,13 @@ import { Dispatcher } from "flux"; import { ReadyWatchingStore } from "./ReadyWatchingStore"; export abstract class AsyncStoreWithClient extends AsyncStore { - private readyStore: ReadyWatchingStore; + protected readyStore: ReadyWatchingStore; protected constructor(dispatcher: Dispatcher, initialState: T = {}) { super(dispatcher, initialState); // Create an anonymous class to avoid code duplication - const asyncStore = this; + const asyncStore = this; // eslint-disable-line @typescript-eslint/no-this-alias this.readyStore = new (class extends ReadyWatchingStore { public get mxClient(): MatrixClient { return this.matrixClient; diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index b2fe630760..cc9e00d29b 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -114,7 +114,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { // Public for test usage. Do not call this. public async makeReady(forcedClient?: MatrixClient) { if (forcedClient) { - super.matrixClient = forcedClient; + super.readyStore.matrixClient = forcedClient; } this.checkLoggingEnabled(); diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index b9350328f6..9742712fcf 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -155,7 +155,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { if (room) this.recalculateRoom(room); }; - private updateFromSettings = (settingName: string, roomId: string, /* and other stuff */) => { + private updateFromSettings = (settingName: string, roomId: string /* and other stuff */) => { if (roomId) { const room = this.matrixClient.getRoom(roomId); if (room) this.recalculateRoom(room); @@ -266,10 +266,6 @@ export class WidgetLayoutStore extends ReadyWatchingStore { maxHeight = Math.max(maxHeight, clamp(h, MIN_WIDGET_HEIGHT_PCT, 100)); } } - let remainingWidth = 100; - for (const width of widths) { - remainingWidth -= width; - } if (doAutobalance) { for (let i = 0; i < widths.length; i++) { widths[i] = 100 / widths.length; @@ -406,7 +402,9 @@ export class WidgetLayoutStore extends ReadyWatchingStore { public moveToContainer(room: Room, widget: IApp, toContainer: Container) { const allWidgets = this.getAllWidgets(room); if (!allWidgets.some(([w])=> w.id === widget.id)) return; // invalid - this.updateUserLayout(room, {[widget.id]:{container: toContainer}}); + this.updateUserLayout(room, { + [widget.id]: {container: toContainer}, + }); } public canCopyLayoutToRoom(room: Room): boolean { From 6d770cb4d126e13c63f8d994c0a0ae3826fbabaf Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 21:00:47 -0700 Subject: [PATCH 14/21] Appease the linter 2 --- src/stores/ReadyWatchingStore.ts | 4 ++++ src/stores/room-list/RoomListStore.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/stores/ReadyWatchingStore.ts b/src/stores/ReadyWatchingStore.ts index 9a28fe75fa..4bb577c784 100644 --- a/src/stores/ReadyWatchingStore.ts +++ b/src/stores/ReadyWatchingStore.ts @@ -42,6 +42,10 @@ export abstract class ReadyWatchingStore extends EventEmitter implements IDestro return this.matrixClient; // for external readonly access } + public useUnitTestClient(cli: MatrixClient) { + this.matrixClient = cli; + } + public destroy() { this.dispatcher.unregister(this.dispatcherRef); } diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index cc9e00d29b..febd9aad9b 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -114,7 +114,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { // Public for test usage. Do not call this. public async makeReady(forcedClient?: MatrixClient) { if (forcedClient) { - super.readyStore.matrixClient = forcedClient; + super.readyStore.useUnitTestClient(forcedClient); } this.checkLoggingEnabled(); From ab51404421f9bb060962f200b0af28a0afb34e54 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jan 2021 21:07:44 -0700 Subject: [PATCH 15/21] Appease the tests? --- src/stores/room-list/RoomListStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index febd9aad9b..8c17da06fe 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -114,7 +114,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { // Public for test usage. Do not call this. public async makeReady(forcedClient?: MatrixClient) { if (forcedClient) { - super.readyStore.useUnitTestClient(forcedClient); + this.readyStore.useUnitTestClient(forcedClient); } this.checkLoggingEnabled(); From f06aa00240d59d4d15b9cf4d2736f2058d5133bc Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 19 Jan 2021 00:22:02 -0700 Subject: [PATCH 16/21] Fix percentage calculation --- src/utils/numbers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index c8b9e7248f..6ba19d0bef 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -38,5 +38,5 @@ export function percentageWithin(pct: number, min: number, max: number): number } export function percentageOf(val: number, min: number, max: number): number { - return (val - min) / max; + return (val - min) / (max - min); } From 0359a977befde8850100b5ff4968b21453f56046 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 19 Jan 2021 07:19:39 -0700 Subject: [PATCH 17/21] Update docs/widget-layouts.md Co-authored-by: David Baker --- docs/widget-layouts.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/widget-layouts.md b/docs/widget-layouts.md index e49f2a2e60..215b026b0f 100644 --- a/docs/widget-layouts.md +++ b/docs/widget-layouts.md @@ -27,7 +27,7 @@ and interpret the other options differently. This is the "App Drawer" or any pinned widgets in a room. This is by far the most versatile container though does introduce potential usability issues upon members of the room (widgets take up space and -therefore less messages can be shown). +therefore fewer messages can be shown). The `index` for a widget determines which order the widgets show up in from left to right. Widgets without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined @@ -58,4 +58,3 @@ container will be put in this container instead. Putting a widget in the right c automatically show it - it only mentions that widgets should not be in another container. The behaviour of this container may change in the future. - From 6985e8f41fac7394fb62f42297a1e7d19c713834 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 19 Jan 2021 10:00:45 -0700 Subject: [PATCH 18/21] Update documentation words --- docs/widget-layouts.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/widget-layouts.md b/docs/widget-layouts.md index 215b026b0f..e7f72e2001 100644 --- a/docs/widget-layouts.md +++ b/docs/widget-layouts.md @@ -32,13 +32,13 @@ therefore fewer messages can be shown). The `index` for a widget determines which order the widgets show up in from left to right. Widgets without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined without an `index`) are resolved by comparing widget IDs. A maximum of 3 widgets can be in the top -container - any which exceed this will be ignored. Smaller numbers represent leftmost widgets. +container - any which exceed this will be ignored (placed into the `right` container). Smaller numbers +represent leftmost widgets. The `width` is relative width within the container in percentage points. This will be clamped to a -range of 0-100 (inclusive). The rightmost widget will have its percentage adjusted to fill the -container appropriately, shrinking and growing if required. For example, if three widgets are in the -top container at 40% width each then the 3rd widget will be shrunk to 20% because 120% > 100%. -Similarly, if all three widgets were set to 10% width each then the 3rd widget would grow to be 80%. +range of 0-100 (inclusive). The widgets will attempt to scale to relative proportions when more than +100% space is allocated. For example, if 3 widgets are defined at 40% width each then the client will +attempt to show them at 33% width each. Note that the client may impose minimum widths on the widgets, such as a 10% minimum to avoid pinning hidden widgets. In general, widgets defined in the 30-70% range each will be free of these restrictions. From f8fe454c595d8e8e10b0fbabbdfa2cee08b03080 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 20 Jan 2021 14:42:24 -0700 Subject: [PATCH 19/21] Replace looping resizer with actual math --- src/stores/widgets/WidgetLayoutStore.ts | 43 +++++++++++++++++-------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 9742712fcf..d54cb36a6c 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -270,20 +270,37 @@ export class WidgetLayoutStore extends ReadyWatchingStore { for (let i = 0; i < widths.length; i++) { widths[i] = 100 / widths.length; } - } + } else { + // If we're not autobalancing then it means that we're trying to make + // sure that widgets make up exactly 100% of space (not over, not under) + const difference = sum(...widths) - 100; // positive = over, negative = under + if (difference < 0) { + // For a deficit we just fill everything in equally + for (let i = 0; i < widths.length; i++) { + widths[i] += Math.abs(difference) / widths.length; + } + } else if (difference > 0) { + // When we're over, we try to scale all the widgets within range first. + // We clamp values to try and keep ourselves sane and within range. + for (let i = 0; i < widths.length; i++) { + widths[i] = clamp(widths[i] - (difference / widths.length), MIN_WIDGET_WIDTH_PCT, 100); + } - // TODO: There is probably a more efficient way to do this. - // All we're doing is making sure that our widths sum up to 100 and take - // any excess width off all widgets equally to keep the proportions. - let toReclaim = sum(...widths) - 100; - while (toReclaim > 0 && topWidgets.length > 0) { - for (let i = 0; i < widths.length; i++) { - if (toReclaim <= 0) break; - const w = widths[i]; - const adjusted = clamp(w - 1, MIN_WIDGET_WIDTH_PCT, 100); - if (adjusted !== w) { - toReclaim -= 1; - widths[i] = adjusted; + // If we're still over, find the widgets which have more width than the minimum + // and balance them out until we're at 100%. This should keep us as close as possible + // to the intended distributions. + // + // Note: if we ever decide to set a minimum which is larger than 100%/MAX_WIDGETS then + // we probably have other issues - this code assumes we don't do that. + const toReclaim = sum(...widths) - 100; + if (toReclaim > 0) { + const largeIndices = widths + .map((v, i) => ([i, v])) + .filter(p => p[1] > MIN_WIDGET_WIDTH_PCT) + .map(p => p[0]); + for (const idx of largeIndices) { + widths[idx] -= toReclaim / largeIndices.length; + } } } } From 29780d902cc62a863083ccc8eef6f1d162796721 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 20 Jan 2021 14:44:30 -0700 Subject: [PATCH 20/21] Try to reduce WidgetStore causing downstream problems --- src/stores/WidgetStore.ts | 6 +++--- src/stores/widgets/WidgetLayoutStore.ts | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 2f06dfcb80..c53c85dfd5 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -76,7 +76,7 @@ export default class WidgetStore extends AsyncStoreWithClient { this.matrixClient.getRooms().forEach((room: Room) => { this.loadRoomWidgets(room); }); - this.emit(UPDATE_EVENT); + this.emit(UPDATE_EVENT, null); // emit for all rooms } protected async onNotReady(): Promise { @@ -94,7 +94,7 @@ export default class WidgetStore extends AsyncStoreWithClient { private onWidgetEchoStoreUpdate = (roomId: string, widgetId: string) => { this.initRoom(roomId); this.loadRoomWidgets(this.matrixClient.getRoom(roomId)); - this.emit(UPDATE_EVENT); + this.emit(UPDATE_EVENT, roomId); }; private generateApps(room: Room): IApp[] { @@ -143,7 +143,7 @@ export default class WidgetStore extends AsyncStoreWithClient { const roomId = ev.getRoomId(); this.initRoom(roomId); this.loadRoomWidgets(this.matrixClient.getRoom(roomId)); - this.emit(UPDATE_EVENT); + this.emit(UPDATE_EVENT, roomId); }; public getRoom = (roomId: string) => { diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index d54cb36a6c..bce684241d 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -131,7 +131,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { this.matrixClient.on("RoomState.events", this.updateRoomFromState); this.pinnedRef = SettingsStore.watchSetting("Widgets.pinned", null, this.updateFromSettings); this.layoutRef = SettingsStore.watchSetting("Widgets.layout", null, this.updateFromSettings); - WidgetStore.instance.on(UPDATE_EVENT, this.updateAllRooms); + WidgetStore.instance.on(UPDATE_EVENT, this.updateFromWidgetStore); } protected async onNotReady(): Promise { @@ -139,7 +139,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { SettingsStore.unwatchSetting(this.pinnedRef); SettingsStore.unwatchSetting(this.layoutRef); - WidgetStore.instance.off(UPDATE_EVENT, this.updateAllRooms); + WidgetStore.instance.off(UPDATE_EVENT, this.updateFromWidgetStore); } private updateAllRooms = () => { @@ -149,6 +149,15 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } }; + private updateFromWidgetStore = (roomId?:string) => { + if (roomId) { + const room = this.matrixClient.getRoom(roomId); + if (room) this.recalculateRoom(room); + } else { + this.updateAllRooms(); + } + }; + private updateRoomFromState = (ev: MatrixEvent) => { if (ev.getType() !== WIDGET_LAYOUT_EVENT_TYPE) return; const room = this.matrixClient.getRoom(ev.getRoomId()); From f023fc50e28e1c581e5770198df7b21c5f17f519 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 21 Jan 2021 13:22:44 -0700 Subject: [PATCH 21/21] Update src/stores/widgets/WidgetLayoutStore.ts Co-authored-by: J. Ryan Stinnett --- src/stores/widgets/WidgetLayoutStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index bce684241d..b6320defb9 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -149,7 +149,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } }; - private updateFromWidgetStore = (roomId?:string) => { + private updateFromWidgetStore = (roomId?: string) => { if (roomId) { const room = this.matrixClient.getRoom(roomId); if (room) this.recalculateRoom(room);