element-web/src/stores/widgets/WidgetLayoutStore.ts

275 lines
10 KiB
TypeScript
Raw Normal View History

/*
* 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<any> {
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<any> {
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<ILayoutSettings>("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;