/* * 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, 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"; import { arrayFastClone } from "../../utils/arrays"; import { UPDATE_EVENT } from "../AsyncStore"; 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 IWidgetLayouts { [widgetId: string]: IStoredLayout; } interface ILayoutStateEvent { // TODO: [Deferred] Forced layout (fixed with no changes) // The widget layouts. widgets: IWidgetLayouts; } interface ILayoutSettings extends ILayoutStateEvent { overrides?: string; // event ID for layout state event, if present } // Dev note: "Pinned" widgets are ones in the top container. 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 // 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; private byRoom: { [roomId: string]: { // @ts-ignore - TS wants a string key, but we know better [container: Container]: { ordered: IApp[]; height?: number; distributions?: number[]; }; }; } = {}; private pinnedRef: string; private layoutRef: string; 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.updateAllRooms(); 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.updateFromWidgetStore); } protected async onNotReady(): Promise { this.byRoom = {}; SettingsStore.unwatchSetting(this.pinnedRef); SettingsStore.unwatchSetting(this.layoutRef); WidgetStore.instance.off(UPDATE_EVENT, this.updateFromWidgetStore); } private updateAllRooms = () => { this.byRoom = {}; for (const room of this.matrixClient.getVisibleRooms()) { this.recalculateRoom(room); } }; 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()); 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) { const widgets = WidgetStore.instance.getApps(room.roomId); if (!widgets?.length) { this.byRoom[room.roomId] = {}; this.emitFor(room); 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); 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) { 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?.index, defaultNumber(layoutA?.index, defaultA)); const orderB = defaultNumber(userLayoutB?.index, 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 = null; // null == default 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 } 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)); } } if (doAutobalance) { 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); } // 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; } } } } // Finally, fill in our cache and update this.byRoom[room.roomId] = {}; if (topWidgets.length) { this.byRoom[room.roomId][Container.Top] = { ordered: topWidgets, distributions: widths, height: maxHeight, }; } if (rightWidgets.length) { this.byRoom[room.roomId][Container.Right] = { ordered: rightWidgets, }; } 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 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 []; // 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] = { container: container, 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] = { container: container, width: widths[i], index: i, height: height, }; }); 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}, }); } 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 []; 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(), widgets: newLayout, }).catch(() => this.recalculateRoom(room)); this.recalculateRoom(room); // call to try local echo on changes (the catch above undoes any errors) } } window.mxWidgetLayoutStore = WidgetLayoutStore.instance;