diff --git a/docs/widget-layouts.md b/docs/widget-layouts.md new file mode 100644 index 0000000000..e7f72e2001 --- /dev/null +++ b/docs/widget-layouts.md @@ -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. 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/TextForEvent.js b/src/TextForEvent.js index 56e9abc0f2..3afe41d216 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -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 diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 0ee847fbc9..027c6b3cc3 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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 { private checkWidgets = (room) => { this.setState({ - hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0, - }) + hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0, + }); }; private onReadReceiptsChange = () => { diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 4662c74d78..c1af86eae6 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -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 { app: IApp; @@ -56,7 +57,7 @@ const WidgetContextMenu: React.FC = ({ 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 = ({ revokeButton = ; } - 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 = ({ 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(); }; diff --git a/src/components/views/messages/MJitsiWidgetEvent.tsx b/src/components/views/messages/MJitsiWidgetEvent.tsx index b87efd472a..3ff11f7b6f 100644 --- a/src/components/views/messages/MJitsiWidgetEvent.tsx +++ b/src/components/views/messages/MJitsiWidgetEvent.tsx @@ -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 { 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'); } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index ebc07e76b8..00ee1945d8 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -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 = ({ 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(); let contextMenu; @@ -120,7 +122,7 @@ const AppRow: React.FC = ({ 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 = ({ room }) => { } }; + let copyLayoutBtn = null; + if (apps.length > 0 && WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) { + copyLayoutBtn = ( + WidgetLayoutStore.instance.copyLayoutToRoom(room)}> + { _t("Set my room layout for everyone") } + + ); + } + return { apps.map(app => ) } - + { copyLayoutBtn } { apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") } diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 593bd0dde7..56e522e206 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -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 = ({ 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(); diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 24a7d1417a..ef30e4a8f5 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -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 (
{ - 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 { - const [value, setValue] = useState(SettingsStore.getValue(settingName, roomId, excludeDefault)); +export const useSettingValue = (settingName: string, roomId: string = null, excludeDefault = false) => { + const [value, setValue] = useState(SettingsStore.getValue(settingName, roomId, excludeDefault)); useEffect(() => { const ref = SettingsStore.watchSetting(settingName, roomId, () => { - setValue(SettingsStore.getValue(settingName, roomId, excludeDefault)); + setValue(SettingsStore.getValue(settingName, roomId, excludeDefault)); }); // clean-up return () => { diff --git a/src/hooks/useStateCallback.ts b/src/hooks/useStateCallback.ts new file mode 100644 index 0000000000..f3993c1d32 --- /dev/null +++ b/src/hooks/useStateCallback.ts @@ -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 = (initialValue: T, callback: (v: T) => void): [T, Dispatch>] => { + const [value, setValue] = useState(initialValue); + const interceptSetValue = (newVal: T) => { + setValue(newVal); + callback(newVal); + }; + return [value, interceptSetValue]; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f8adb620f4..8d047ea3f1 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", 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/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 1b718a72b3..6dc2a76ae8 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -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(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."); diff --git a/src/stores/AsyncStoreWithClient.ts b/src/stores/AsyncStoreWithClient.ts index 1ed7c6a547..38e709d8c2 100644 --- a/src/stores/AsyncStoreWithClient.ts +++ b/src/stores/AsyncStoreWithClient.ts @@ -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 extends AsyncStore { - protected matrixClient: MatrixClient; - - protected abstract async onAction(payload: ActionPayload); + protected readyStore: ReadyWatchingStore; protected constructor(dispatcher: Dispatcher, initialState: 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 { + return asyncStore.onReady(); + } + + protected async onNotReady(): Promise { + return asyncStore.onNotReady(); + } + })(dispatcher); + } + + protected get matrixClient(): MatrixClient { + return this.readyStore.mxClient; } protected async onReady() { @@ -44,30 +55,9 @@ export abstract class AsyncStoreWithClient 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; - } - } } } diff --git a/src/stores/ReadyWatchingStore.ts b/src/stores/ReadyWatchingStore.ts new file mode 100644 index 0000000000..4bb577c784 --- /dev/null +++ b/src/stores/ReadyWatchingStore.ts @@ -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) { + 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; + } + } + }; +} diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index c9cf0a1c70..c53c85dfd5 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -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; - 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 { 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 { 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 { protected async onReady(): Promise { 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 { @@ -115,7 +94,7 @@ export default class WidgetStore extends AsyncStoreWithClient { 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 { private loadRoomWidgets(room: Room) { if (!room) return; - const roomInfo = this.roomMap.get(room.roomId); + const roomInfo = this.roomMap.get(room.roomId) || {}; 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 { 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 { 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 || []; diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index b2fe630760..8c17da06fe 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -114,7 +114,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { // Public for test usage. Do not call this. public async makeReady(forcedClient?: MatrixClient) { if (forcedClient) { - super.matrixClient = forcedClient; + this.readyStore.useUnitTestClient(forcedClient); } this.checkLoggingEnabled(); diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts new file mode 100644 index 0000000000..b6320defb9 --- /dev/null +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -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 { + 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) { + 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; diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 986c68342c..815900d97a 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -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 diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts new file mode 100644 index 0000000000..6ba19d0bef --- /dev/null +++ b/src/utils/numbers.ts @@ -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); +}