Support initial ordering and calculation for widgets by layout

This commit is contained in:
Travis Ralston 2021-01-18 17:41:42 -07:00
parent 4ee29d4e63
commit 0001e1e684
4 changed files with 311 additions and 1 deletions

View file

@ -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;

View file

@ -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: {},
},

View file

@ -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<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;

30
src/utils/numbers.ts Normal file
View file

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