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); +}