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