Merge pull request #5553 from matrix-org/travis/widget-layout

Support room-defined widget layouts
pull/21833/head
Travis Ralston 2021-01-21 13:22:52 -07:00 committed by GitHub
commit a779951512
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 855 additions and 272 deletions

60
docs/widget-layouts.md Normal file
View File

@ -0,0 +1,60 @@
# Widget layout support
Rooms can have a default widget layout to auto-pin certain widgets, make the container different
sizes, etc. These are defined through the `io.element.widgets.layout` state event (empty state key).
Full example content:
```json5
{
"widgets": {
"first-widget-id": {
"container": "top",
"index": 0,
"width": 60,
"height": 40
},
"second-widget-id": {
"container": "right"
}
}
}
```
As shown, there are two containers possible for widgets. These containers have different behaviour
and interpret the other options differently.
## `top` container
This is the "App Drawer" or any pinned widgets in a room. This is by far the most versatile container
though does introduce potential usability issues upon members of the room (widgets take up space and
therefore fewer messages can be shown).
The `index` for a widget determines which order the widgets show up in from left to right. Widgets
without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined
without an `index`) are resolved by comparing widget IDs. A maximum of 3 widgets can be in the top
container - any which exceed this will be ignored (placed into the `right` container). Smaller numbers
represent leftmost widgets.
The `width` is relative width within the container in percentage points. This will be clamped to a
range of 0-100 (inclusive). The widgets will attempt to scale to relative proportions when more than
100% space is allocated. For example, if 3 widgets are defined at 40% width each then the client will
attempt to show them at 33% width each.
Note that the client may impose minimum widths on the widgets, such as a 10% minimum to avoid pinning
hidden widgets. In general, widgets defined in the 30-70% range each will be free of these restrictions.
The `height` is not in fact applied per-widget but is recorded per-widget for potential future
capabilities in future containers. The top container will take the tallest `height` and use that for
the height of the whole container, and thus all widgets in that container. The `height` is relative
to the container, like with `width`, meaning that 100% will consume as much space as the client is
willing to sacrifice to the widget container. Like with `width`, the client may impose minimums to avoid
the container being uselessly small. Heights in the 30-100% range are generally acceptable. The height
is also clamped to be within 0-100, inclusive.
## `right` container
This is the default container and has no special configuration. Widgets which overflow from the top
container will be put in this container instead. Putting a widget in the right container does not
automatically show it - it only mentions that widgets should not be in another container.
The behaviour of this container may change in the future.

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

@ -19,6 +19,7 @@ import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore";
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore";
function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
@ -477,6 +478,11 @@ function textForWidgetEvent(event) {
}
}
function textForWidgetLayoutEvent(event) {
const senderName = event.sender?.name || event.getSender();
return _t("%(senderName)s has updated the widget layout", {senderName});
}
function textForMjolnirEvent(event) {
const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent();
@ -583,6 +589,7 @@ const stateHandlers = {
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
'im.vector.modular.widgets': textForWidgetEvent,
[WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent,
};
// Add all the Mjolnir stuff to the renderer

View File

@ -78,6 +78,7 @@ import {UPDATE_EVENT} from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -280,8 +281,8 @@ export default class RoomView extends React.Component<IProps, IState> {
private checkWidgets = (room) => {
this.setState({
hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0,
})
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0,
});
};
private onReadReceiptsChange = () => {

View File

@ -20,7 +20,7 @@ import {MatrixCapabilities} from "matrix-widget-api";
import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu";
import {ChevronFace} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
import {IApp} from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
import RoomContext from "../../../contexts/RoomContext";
@ -30,6 +30,7 @@ import Modal from "../../../Modal";
import QuestionDialog from "../dialogs/QuestionDialog";
import {WidgetType} from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
app: IApp;
@ -56,7 +57,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let unpinButton;
if (showUnpin) {
const onUnpinClick = () => {
WidgetStore.instance.unpinWidget(room.roomId, app.id);
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
onFinished();
};
@ -137,13 +138,13 @@ const WidgetContextMenu: React.FC<IProps> = ({
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
}
const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId);
const pinnedWidgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id);
let moveLeftButton;
if (showUnpin && widgetIndex > 0) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(roomId, app.id, -1);
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
onFinished();
};
@ -153,7 +154,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let moveRightButton;
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(roomId, app.id, 1);
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1);
onFinished();
};

View File

@ -19,6 +19,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import EventTileBubble from "./EventTileBubble";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
mxEvent: MatrixEvent;
@ -33,9 +35,12 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
const url = this.props.mxEvent.getContent()['url'];
const prevUrl = this.props.mxEvent.getPrevContent()['url'];
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const widgetId = this.props.mxEvent.getStateKey();
const widget = WidgetStore.instance.getRoom(room.roomId).widgets.find(w => w.id === widgetId);
let joinCopy = _t('Join the conference at the top of this room');
if (!WidgetStore.instance.isPinned(this.props.mxEvent.getRoomId(), this.props.mxEvent.getStateKey())) {
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Right)) {
joinCopy = _t('Join the conference from the room information card on the right');
}

View File

@ -37,13 +37,14 @@ import SettingsStore from "../../../settings/SettingsStore";
import TextWithTooltip from "../elements/TextWithTooltip";
import WidgetAvatar from "../avatars/WidgetAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import WidgetStore, {IApp, MAX_PINNED} from "../../../stores/WidgetStore";
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
import { E2EStatus } from "../../../utils/ShieldUtils";
import RoomContext from "../../../contexts/RoomContext";
import {UIFeature} from "../../../settings/UIFeature";
import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import {useRoomMemberCount} from "../../../hooks/useRoomMembers";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
room: Room;
@ -78,6 +79,7 @@ export const useWidgets = (room: Room) => {
useEffect(updateApps, [room]);
useEventEmitter(WidgetStore.instance, room.roomId, updateApps);
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateApps);
return apps;
};
@ -102,10 +104,10 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
});
};
const isPinned = WidgetStore.instance.isPinned(room.roomId, app.id);
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
const togglePin = isPinned
? () => { WidgetStore.instance.unpinWidget(room.roomId, app.id); }
: () => { WidgetStore.instance.pinWidget(room.roomId, app.id); };
? () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); }
: () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); };
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
let contextMenu;
@ -120,7 +122,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
/>;
}
const cannotPin = !isPinned && !WidgetStore.instance.canPin(room.roomId, app.id);
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
let pinTitle: string;
if (cannotPin) {
@ -184,9 +186,18 @@ const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
}
};
let copyLayoutBtn = null;
if (apps.length > 0 && WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) {
copyLayoutBtn = (
<AccessibleButton kind="link" onClick={() => WidgetLayoutStore.instance.copyLayoutToRoom(room)}>
{ _t("Set my room layout for everyone") }
</AccessibleButton>
);
}
return <Group className="mx_RoomSummaryCard_appsGroup" title={_t("Widgets")}>
{ apps.map(app => <AppRow key={app.id} app={app} room={room} />) }
{ copyLayoutBtn }
<AccessibleButton kind="link" onClick={onManageIntegrations}>
{ apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") }
</AccessibleButton>

View File

@ -14,22 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useEffect} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import React, { useContext, useEffect } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseCard from "./BaseCard";
import WidgetUtils from "../../../utils/WidgetUtils";
import AppTile from "../elements/AppTile";
import {_t} from "../../../languageHandler";
import {useWidgets} from "./RoomSummaryCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import { _t } from "../../../languageHandler";
import { useWidgets } from "./RoomSummaryCard";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {Action} from "../../../dispatcher/actions";
import WidgetStore from "../../../stores/WidgetStore";
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
room: Room;
@ -42,7 +42,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
const apps = useWidgets(room);
const app = apps.find(a => a.id === widgetId);
const isPinned = app && WidgetStore.instance.isPinned(room.roomId, app.id);
const isPinned = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();

View File

@ -28,12 +28,13 @@ import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import {useLocalStorageState} from "../../../hooks/useLocalStorageState";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import WidgetStore from "../../../stores/WidgetStore";
import ResizeHandle from "../elements/ResizeHandle";
import Resizer from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
import {Container, WidgetLayoutStore} from "../../../stores/widgets/WidgetLayoutStore";
import {clamp, percentageOf, percentageWithin} from "../../../utils/numbers";
import {useStateCallback} from "../../../hooks/useStateCallback";
export default class AppsDrawer extends React.Component {
static propTypes = {
@ -62,13 +63,13 @@ export default class AppsDrawer extends React.Component {
componentDidMount() {
ScalarMessaging.startListening();
WidgetStore.instance.on(this.props.room.roomId, this._updateApps);
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps);
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
ScalarMessaging.stopListening();
WidgetStore.instance.off(this.props.room.roomId, this._updateApps);
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(this.props.room), this._updateApps);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
if (this._resizeContainer) {
this.resizer.detach();
@ -102,11 +103,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 +128,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 +145,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());
@ -190,7 +180,7 @@ export default class AppsDrawer extends React.Component {
}
};
_getApps = () => WidgetStore.instance.getPinnedApps(this.props.room.roomId);
_getApps = () => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top);
_updateApps = () => {
this.setState({
@ -248,7 +238,7 @@ export default class AppsDrawer extends React.Component {
return (
<div className={classes}>
<PersistentVResizer
id={"apps-drawer_" + this.props.room.roomId}
room={this.props.room}
minHeight={100}
maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined}
handleClass="mx_AppsContainer_resizerHandle"
@ -273,7 +263,7 @@ export default class AppsDrawer extends React.Component {
}
const PersistentVResizer = ({
id,
room,
minHeight,
maxHeight,
className,
@ -282,7 +272,24 @@ const PersistentVResizer = ({
resizeNotifier,
children,
}) => {
const [height, setHeight] = useLocalStorageState("pvr_" + id, 280); // old fixed height was 273px
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, Container.Top);
// Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window.
if (!minHeight) minHeight = 100;
if (!maxHeight) maxHeight = (window.innerHeight / 4) * 3;
// Convert from percentage to height. Note that the default height is 280px.
if (defaultHeight) {
defaultHeight = clamp(defaultHeight, 0, 100);
defaultHeight = percentageWithin(defaultHeight / 100, minHeight, maxHeight);
} else {
defaultHeight = 280;
}
const [height, setHeight] = useStateCallback(defaultHeight, newHeight => {
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight);
});
return <Resizable
size={{height: Math.min(height, maxHeight)}}

View File

@ -37,6 +37,7 @@ import {E2E_STATE} from "./E2EIcon";
import {toRem} from "../../../utils/units";
import {WidgetType} from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar";
import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStore";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
@ -65,6 +66,7 @@ const stateEventTileTypes = {
'm.room.server_acl': 'messages.TextualEvent',
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
'im.vector.modular.widgets': 'messages.TextualEvent',
[WIDGET_LAYOUT_EVENT_TYPE]: 'messages.TextualEvent',
'm.room.tombstone': 'messages.TextualEvent',
'm.room.join_rules': 'messages.TextualEvent',
'm.room.guest_access': 'messages.TextualEvent',

View File

@ -18,12 +18,12 @@ import {useEffect, useState} from "react";
import SettingsStore from '../settings/SettingsStore';
// Hook to fetch the value of a setting and dynamically update when it changes
export const useSettingValue = (settingName: string, roomId: string = null, excludeDefault = false) => {
const [value, setValue] = useState(SettingsStore.getValue(settingName, roomId, excludeDefault));
export const useSettingValue = <T>(settingName: string, roomId: string = null, excludeDefault = false) => {
const [value, setValue] = useState(SettingsStore.getValue<T>(settingName, roomId, excludeDefault));
useEffect(() => {
const ref = SettingsStore.watchSetting(settingName, roomId, () => {
setValue(SettingsStore.getValue(settingName, roomId, excludeDefault));
setValue(SettingsStore.getValue<T>(settingName, roomId, excludeDefault));
});
// clean-up
return () => {

View File

@ -0,0 +1,28 @@
/*
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 {Dispatch, SetStateAction, useState} from "react";
// Hook to simplify interactions with a store-backed state values
// Returns value and method to change the state value
export const useStateCallback = <T>(initialValue: T, callback: (v: T) => void): [T, Dispatch<SetStateAction<T>>] => {
const [value, setValue] = useState(initialValue);
const interceptSetValue = (newVal: T) => {
setValue(newVal);
callback(newVal);
};
return [value, interceptSetValue];
};

View File

@ -548,6 +548,7 @@
"%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s",
"%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s",
"%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s",
"%(senderName)s has updated the widget layout": "%(senderName)s has updated the widget layout",
"%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s removed the rule banning users matching %(glob)s",
"%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s removed the rule banning rooms matching %(glob)s",
"%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s removed the rule banning servers matching %(glob)s",
@ -1635,6 +1636,7 @@
"Unpin": "Unpin",
"Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
"Options": "Options",
"Set my room layout for everyone": "Set my room layout for everyone",
"Widgets": "Widgets",
"Edit widgets, bridges & bots": "Edit widgets, bridges & bots",
"Add widgets, bridges & bots": "Add widgets, bridges & bots",

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

@ -276,7 +276,7 @@ export default class SettingsStore {
* @param {boolean} excludeDefault True to disable using the default value.
* @return {*} The value, or null if not found
*/
public static getValue(settingName: string, roomId: string = null, excludeDefault = false): any {
public static getValue<T = any>(settingName: string, roomId: string = null, excludeDefault = false): T {
// Verify that the setting is actually a setting
if (!SETTINGS[settingName]) {
throw new Error("Setting '" + settingName + "' does not appear to be a setting.");

View File

@ -18,22 +18,33 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import { AsyncStore } from "./AsyncStore";
import { ActionPayload } from "../dispatcher/payloads";
import { Dispatcher } from "flux";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { ReadyWatchingStore } from "./ReadyWatchingStore";
export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<T> {
protected matrixClient: MatrixClient;
protected abstract async onAction(payload: ActionPayload);
protected readyStore: ReadyWatchingStore;
protected constructor(dispatcher: Dispatcher<ActionPayload>, initialState: T = <T>{}) {
super(dispatcher, initialState);
if (MatrixClientPeg.get()) {
this.matrixClient = MatrixClientPeg.get();
// Create an anonymous class to avoid code duplication
const asyncStore = this; // eslint-disable-line @typescript-eslint/no-this-alias
this.readyStore = new (class extends ReadyWatchingStore {
public get mxClient(): MatrixClient {
return this.matrixClient;
}
// noinspection JSIgnoredPromiseFromCall
this.onReady();
}
protected async onReady(): Promise<any> {
return asyncStore.onReady();
}
protected async onNotReady(): Promise<any> {
return asyncStore.onNotReady();
}
})(dispatcher);
}
protected get matrixClient(): MatrixClient {
return this.readyStore.mxClient;
}
protected async onReady() {
@ -44,30 +55,9 @@ export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<
// Default implementation is to do nothing.
}
protected abstract async onAction(payload: ActionPayload);
protected async onDispatch(payload: ActionPayload) {
await this.onAction(payload);
if (payload.action === 'MatrixActions.sync') {
// Only set the client on the transition into the PREPARED state.
// Everything after this is unnecessary (we only need to know once we have a client)
// and we intentionally don't set the client before this point to avoid stores
// updating for every event emitted during the cached sync.
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
return;
}
if (this.matrixClient !== payload.matrixClient) {
if (this.matrixClient) {
await this.onNotReady();
}
this.matrixClient = payload.matrixClient;
await this.onReady();
}
} else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
if (this.matrixClient) {
await this.onNotReady();
this.matrixClient = null;
}
}
}
}

View File

@ -0,0 +1,85 @@
/*
* 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 { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { ActionPayload } from "../dispatcher/payloads";
import { Dispatcher } from "flux";
import { IDestroyable } from "../utils/IDestroyable";
import { EventEmitter } from "events";
export abstract class ReadyWatchingStore extends EventEmitter implements IDestroyable {
protected matrixClient: MatrixClient;
private readonly dispatcherRef: string;
constructor(protected readonly dispatcher: Dispatcher<ActionPayload>) {
super();
this.dispatcherRef = this.dispatcher.register(this.onAction);
if (MatrixClientPeg.get()) {
this.matrixClient = MatrixClientPeg.get();
// noinspection JSIgnoredPromiseFromCall
this.onReady();
}
}
public get mxClient(): MatrixClient {
return this.matrixClient; // for external readonly access
}
public useUnitTestClient(cli: MatrixClient) {
this.matrixClient = cli;
}
public destroy() {
this.dispatcher.unregister(this.dispatcherRef);
}
protected async onReady() {
// Default implementation is to do nothing.
}
protected async onNotReady() {
// Default implementation is to do nothing.
}
private onAction = async (payload: ActionPayload) => {
if (payload.action === 'MatrixActions.sync') {
// Only set the client on the transition into the PREPARED state.
// Everything after this is unnecessary (we only need to know once we have a client)
// and we intentionally don't set the client before this point to avoid stores
// updating for every event emitted during the cached sync.
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
return;
}
if (this.matrixClient !== payload.matrixClient) {
if (this.matrixClient) {
await this.onNotReady();
}
this.matrixClient = payload.matrixClient;
await this.onReady();
}
} else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
if (this.matrixClient) {
await this.onNotReady();
this.matrixClient = null;
}
}
};
}

View File

@ -21,16 +21,12 @@ import { IWidget } from "matrix-widget-api";
import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import SettingsStore from "../settings/SettingsStore";
import WidgetEchoStore from "../stores/WidgetEchoStore";
import RoomViewStore from "../stores/RoomViewStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import WidgetUtils from "../utils/WidgetUtils";
import {SettingLevel} from "../settings/SettingLevel";
import {WidgetType} from "../widgets/WidgetType";
import {UPDATE_EVENT} from "./AsyncStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { arrayDiff, arrayHasDiff, arrayUnion } from "../utils/arrays";
interface IState {}
@ -41,15 +37,10 @@ export interface IApp extends IWidget {
avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
}
type PinnedWidgets = Record<string, boolean>;
interface IRoomWidgets {
widgets: IApp[];
pinned: PinnedWidgets;
}
export const MAX_PINNED = 3;
function widgetUid(app: IApp): string {
return `${app.roomId ?? MatrixClientPeg.get().getUserId()}::${app.id}`;
}
@ -65,7 +56,6 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
private constructor() {
super(defaultDispatcher, {});
SettingsStore.watchSetting("Widgets.pinned", null, this.onPinnedWidgetsChange);
WidgetEchoStore.on("update", this.onWidgetEchoStoreUpdate);
}
@ -76,7 +66,6 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
private initRoom(roomId: string) {
if (!this.roomMap.has(roomId)) {
this.roomMap.set(roomId, {
pinned: {}, // ordered
widgets: [],
});
}
@ -85,19 +74,9 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
protected async onReady(): Promise<any> {
this.matrixClient.on("RoomState.events", this.onRoomStateEvents);
this.matrixClient.getRooms().forEach((room: Room) => {
const pinned = SettingsStore.getValue("Widgets.pinned", room.roomId);
if (pinned || WidgetUtils.getRoomWidgets(room).length) {
this.initRoom(room.roomId);
}
if (pinned) {
this.getRoom(room.roomId).pinned = pinned;
}
this.loadRoomWidgets(room);
});
this.emit(UPDATE_EVENT);
this.emit(UPDATE_EVENT, null); // emit for all rooms
}
protected async onNotReady(): Promise<any> {
@ -115,7 +94,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
private onWidgetEchoStoreUpdate = (roomId: string, widgetId: string) => {
this.initRoom(roomId);
this.loadRoomWidgets(this.matrixClient.getRoom(roomId));
this.emit(UPDATE_EVENT);
this.emit(UPDATE_EVENT, roomId);
};
private generateApps(room: Room): IApp[] {
@ -128,7 +107,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
private loadRoomWidgets(room: Room) {
if (!room) return;
const roomInfo = this.roomMap.get(room.roomId);
const roomInfo = this.roomMap.get(room.roomId) || <IRoomWidgets>{};
roomInfo.widgets = [];
// first clean out old widgets from the map which originate from this room
@ -138,6 +117,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
this.widgetMap.delete(widgetUid(app));
});
let edited = false;
this.generateApps(room).forEach(app => {
// Sanity check for https://github.com/vector-im/element-web/issues/15705
const existingApp = this.widgetMap.get(widgetUid(app));
@ -150,172 +130,26 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
this.widgetMap.set(widgetUid(app), app);
roomInfo.widgets.push(app);
edited = true;
});
if (edited && !this.roomMap.has(room.roomId)) {
this.roomMap.set(room.roomId, roomInfo);
}
this.emit(room.roomId);
}
private onRoomStateEvents = (ev: MatrixEvent) => {
if (ev.getType() !== "im.vector.modular.widgets") return;
if (ev.getType() !== "im.vector.modular.widgets") return; // TODO: Support m.widget too
const roomId = ev.getRoomId();
this.initRoom(roomId);
this.loadRoomWidgets(this.matrixClient.getRoom(roomId));
this.emit(UPDATE_EVENT);
this.emit(UPDATE_EVENT, roomId);
};
public getRoom = (roomId: string) => {
return this.roomMap.get(roomId);
};
private onPinnedWidgetsChange = (settingName: string, roomId: string) => {
this.initRoom(roomId);
const pinned: PinnedWidgets = SettingsStore.getValue(settingName, roomId);
// Sanity check for https://github.com/vector-im/element-web/issues/15705
const roomInfo = this.getRoom(roomId);
const remappedPinned: PinnedWidgets = {};
for (const widgetId of Object.keys(pinned)) {
const isPinned = pinned[widgetId];
if (!roomInfo.widgets?.some(w => w.id === widgetId)) {
console.warn(`Skipping pinned widget update for ${widgetId} in ${roomId} -- wrong room`);
} else {
remappedPinned[widgetId] = isPinned;
}
}
roomInfo.pinned = remappedPinned;
this.emit(roomId);
this.emit(UPDATE_EVENT);
};
public isPinned(roomId: string, widgetId: string) {
return !!this.getPinnedApps(roomId).find(w => w.id === widgetId);
}
// dev note: we don't need the widgetId on this function, but the contract makes more sense
// when we require it.
public canPin(roomId: string, widgetId: string) {
return this.getPinnedApps(roomId).length < MAX_PINNED;
}
public pinWidget(roomId: string, widgetId: string) {
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return;
// When pinning, first confirm all the widgets (Jitsi) which were autopinned so that the order is correct
const autoPinned = this.getPinnedApps(roomId).filter(app => !roomInfo.pinned[app.id]);
autoPinned.forEach(app => {
this.setPinned(roomId, app.id, true);
});
this.setPinned(roomId, widgetId, true);
// Show the apps drawer upon the user pinning a widget
if (RoomViewStore.getRoomId() === roomId) {
defaultDispatcher.dispatch({
action: "appsDrawer",
show: true,
});
}
}
public unpinWidget(roomId: string, widgetId: string) {
this.setPinned(roomId, widgetId, false);
}
private setPinned(roomId: string, widgetId: string, value: boolean) {
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return;
if (roomInfo.pinned[widgetId] === false && value) {
// delete this before write to maintain the correct object insertion order
delete roomInfo.pinned[widgetId];
}
roomInfo.pinned[widgetId] = value;
// Clean up the pinned record
Object.keys(roomInfo).forEach(wId => {
if (!roomInfo.widgets.some(w => w.id === wId) || !roomInfo.pinned[wId]) {
delete roomInfo.pinned[wId];
}
});
SettingsStore.setValue("Widgets.pinned", roomId, SettingLevel.ROOM_ACCOUNT, roomInfo.pinned);
this.emit(roomId);
this.emit(UPDATE_EVENT);
}
public movePinnedWidget(roomId: string, widgetId: string, delta: 1 | -1) {
// TODO simplify this by changing the storage medium of pinned to an array once the Jitsi default-on goes away
const roomInfo = this.getRoom(roomId);
if (!roomInfo || roomInfo.pinned[widgetId] === false) return;
const pinnedApps = this.getPinnedApps(roomId).map(app => app.id);
const i = pinnedApps.findIndex(id => id === widgetId);
if (delta > 0) {
pinnedApps.splice(i, 2, pinnedApps[i + 1], pinnedApps[i]);
} else {
pinnedApps.splice(i - 1, 2, pinnedApps[i], pinnedApps[i - 1]);
}
const reorderedPinned: IRoomWidgets["pinned"] = {};
pinnedApps.forEach(id => {
reorderedPinned[id] = true;
});
Object.keys(roomInfo.pinned).forEach(id => {
if (reorderedPinned[id] === undefined) {
reorderedPinned[id] = roomInfo.pinned[id];
}
});
roomInfo.pinned = reorderedPinned;
SettingsStore.setValue("Widgets.pinned", roomId, SettingLevel.ROOM_ACCOUNT, roomInfo.pinned);
this.emit(roomId);
this.emit(UPDATE_EVENT);
}
public getPinnedApps(roomId: string): IApp[] {
// returns the apps in the order they were pinned with, up to the maximum
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return [];
// Show Jitsi widgets even if the user already had the maximum pinned, instead of their latest pinned,
// except if the user already explicitly unpinned the Jitsi widget
const priorityWidget = roomInfo.widgets.find(widget => {
return roomInfo.pinned[widget.id] === undefined && WidgetType.JITSI.matches(widget.type);
});
const order = Object.keys(roomInfo.pinned).filter(k => roomInfo.pinned[k]);
const apps = order
.map(wId => Array.from(this.widgetMap.values())
.find(w2 => w2.roomId === roomId && w2.id === wId))
.filter(Boolean)
.slice(0, priorityWidget ? MAX_PINNED - 1 : MAX_PINNED);
if (priorityWidget) {
apps.push(priorityWidget);
}
// Sanity check for https://github.com/vector-im/element-web/issues/15705
// We union the app IDs the above generated with the roomInfo's known widgets to
// get a list of IDs which both exist. We then diff that against the generated app
// IDs above to ensure that all of the app IDs are captured by the union with the
// room - if we grabbed a widget that wasn't part of the roomInfo's list, it wouldn't
// be in the union and thus result in a diff.
const appIds = apps.map(a => widgetUid(a));
const roomAppIds = roomInfo.widgets.map(a => widgetUid(a));
const roomAppIdsUnion = arrayUnion(appIds, roomAppIds);
const missingSomeApps = arrayHasDiff(roomAppIdsUnion, appIds);
if (missingSomeApps) {
const diff = arrayDiff(roomAppIdsUnion, appIds);
console.warn(
`${roomId} appears to have a conflict for which widgets belong to it. ` +
`Widget UIDs are: `, [...diff.added, ...diff.removed],
);
}
return apps;
}
public getApps(roomId: string): IApp[] {
const roomInfo = this.getRoom(roomId);
return roomInfo?.widgets || [];

View File

@ -114,7 +114,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
// Public for test usage. Do not call this.
public async makeReady(forcedClient?: MatrixClient) {
if (forcedClient) {
super.matrixClient = forcedClient;
this.readyStore.useUnitTestClient(forcedClient);
}
this.checkLoggingEnabled();

View File

@ -0,0 +1,502 @@
/*
* 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<any> {
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<any> {
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<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?.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;

View File

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

42
src/utils/numbers.ts Normal file
View File

@ -0,0 +1,42 @@
/*
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);
}
export function sum(...i: number[]): number {
return [...i].reduce((p, c) => c + p, 0);
}
export function percentageWithin(pct: number, min: number, max: number): number {
return (pct * (max - min)) + min;
}
export function percentageOf(val: number, min: number, max: number): number {
return (val - min) / (max - min);
}