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<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 = () => { 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<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(); }; 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<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'); } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index ebc07e76b8..f405cde11d 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<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) { 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<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(); diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index c9cf0a1c70..2f06dfcb80 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<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,16 +74,6 @@ 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); @@ -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,12 +130,16 @@ 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)); @@ -166,156 +150,6 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> { 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/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 12051d35bc..4065dd2469 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -23,6 +23,8 @@ 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"; @@ -79,7 +81,7 @@ interface ILayoutSettings extends ILayoutStateEvent { } // Dev note: "Pinned" widgets are ones in the top container. -const MAX_PINNED = 3; +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 @@ -126,19 +128,19 @@ export class WidgetLayoutStore extends ReadyWatchingStore { this.matrixClient.on("RoomState.events", this.updateRoomFromState); SettingsStore.watchSetting("Widgets.pinned", null, this.updateFromSettings); SettingsStore.watchSetting("Widgets.layout", null, this.updateFromSettings); - // TODO: Register WidgetStore listener + WidgetStore.instance.on(UPDATE_EVENT, this.updateAllRooms); } protected async onNotReady(): Promise<any> { this.byRoom = {}; } - private updateAllRooms() { + private updateAllRooms = () => { this.byRoom = {}; for (const room of this.matrixClient.getVisibleRooms()) { this.recalculateRoom(room); } - } + }; private updateRoomFromState = (ev: MatrixEvent) => { if (ev.getType() !== WIDGET_LAYOUT_EVENT_TYPE) return; @@ -222,8 +224,8 @@ export class WidgetLayoutStore extends ReadyWatchingStore { const defaultA = WidgetType.JITSI.matches(a.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER; const defaultB = WidgetType.JITSI.matches(b.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER; - const orderA = defaultNumber(userLayoutA, defaultNumber(layoutA?.index, defaultA)); - const orderB = defaultNumber(userLayoutB, defaultNumber(layoutB?.index, defaultB)); + 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 @@ -308,6 +310,14 @@ export class WidgetLayoutStore extends ReadyWatchingStore { 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 []; @@ -335,6 +345,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { 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, @@ -353,6 +364,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { const localLayout = {}; widgets.forEach((w, i) => { localLayout[w.id] = { + container: container, width: widths[i], index: i, height: height, @@ -361,7 +373,66 @@ export class WidgetLayoutStore extends ReadyWatchingStore { 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}}); + } + + 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(),