diff --git a/res/css/_common.scss b/res/css/_common.scss index d6d139d2bd4..3d8b6659b38 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -30,6 +30,8 @@ $MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $selected-mess $slider-dot-size: 1em; $slider-selection-dot-size: 2.4em; +$container-border-width: 8px; + :root { font-size: 10px; diff --git a/res/css/structures/_LeftPanelWidget.scss b/res/css/structures/_LeftPanelWidget.scss index bb04b856245..0a01a19b90a 100644 --- a/res/css/structures/_LeftPanelWidget.scss +++ b/res/css/structures/_LeftPanelWidget.scss @@ -134,7 +134,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - mask-image: url('$(res)/img/feather-customised/maximise.svg'); + mask-image: url("$(res)/img/element-icons/maximise-expand.svg"); background: $muted-fg-color; } } diff --git a/res/css/structures/_MainSplit.scss b/res/css/structures/_MainSplit.scss index 407a1c270cf..8c30abe97da 100644 --- a/res/css/structures/_MainSplit.scss +++ b/res/css/structures/_MainSplit.scss @@ -23,9 +23,8 @@ limitations under the License. } .mx_MainSplit > .mx_RightPanel_ResizeWrapper { - padding: 5px; - // margin left to not allow the handle to not encroach on the space for the scrollbar - margin-left: 8px; + // no padding on the left. The spacing is taken care of by the main split content. + padding: 5px 5px 5px 0px; height: calc(100vh - 51px); // height of .mx_RoomHeader.light-panel &:hover .mx_RightPanel_ResizeHandle { diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 38a1fe9099a..1809626ed95 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -22,7 +22,7 @@ limitations under the License. display: flex; flex-direction: column; border-radius: 8px; - padding: 8px 0; + padding: $container-border-width 0; box-sizing: border-box; height: 100%; contain: strict; diff --git a/res/css/views/dialogs/_HostSignupDialog.scss b/res/css/views/dialogs/_HostSignupDialog.scss index d8a6652a397..56d71034045 100644 --- a/res/css/views/dialogs/_HostSignupDialog.scss +++ b/res/css/views/dialogs/_HostSignupDialog.scss @@ -78,7 +78,7 @@ limitations under the License. } .mx_HostSignup_maximize_button { - mask: url('$(res)/img/feather-customised/maximise.svg'); + mask: url("$(res)/img/element-icons/maximise-expand.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: cover; @@ -92,7 +92,7 @@ limitations under the License. } .mx_HostSignup_minimize_button { - mask: url('$(res)/img/feather-customised/minimise.svg'); + mask: url("$(res)/img/element-icons/minimise-collapse.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: cover; diff --git a/res/css/views/elements/_ResizeHandle.scss b/res/css/views/elements/_ResizeHandle.scss index 63c5d978611..2af2880654e 100644 --- a/res/css/views/elements/_ResizeHandle.scss +++ b/res/css/views/elements/_ResizeHandle.scss @@ -32,11 +32,6 @@ limitations under the License. cursor: row-resize; } -.mx_MatrixChat > .mx_ResizeHandle.mx_ResizeHandle_horizontal { - margin: 0 -10px 0 0; - padding: 0 8px 0 0; -} - .mx_ResizeHandle.mx_ResizeHandle_horizontal > div { width: 1px; height: 100%; diff --git a/res/css/views/messages/_ViewSourceEvent.scss b/res/css/views/messages/_ViewSourceEvent.scss index bdb036fe173..f076c774737 100644 --- a/res/css/views/messages/_ViewSourceEvent.scss +++ b/res/css/views/messages/_ViewSourceEvent.scss @@ -29,19 +29,19 @@ limitations under the License. } .mx_ViewSourceEvent_toggle { - width: 12px; mask-repeat: no-repeat; mask-position: 0 center; mask-size: auto 12px; + width: 12px; visibility: hidden; background-color: $accent; - mask-image: url('$(res)/img/feather-customised/maximise.svg'); + mask-image: url("$(res)/img/element-icons/maximise-expand.svg"); } &.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle { mask-position: 0 bottom; - margin-bottom: 7px; - mask-image: url('$(res)/img/feather-customised/minimise.svg'); + margin-bottom: 5px; + mask-image: url("$(res)/img/element-icons/minimise-collapse.svg"); } } diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 539793eef68..bb3638c475c 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -133,6 +133,8 @@ limitations under the License. } .mx_RoomSummaryCard_app_pinToggle, + .mx_RoomSummaryCard_app_maximise, + .mx_RoomSummaryCard_app_minimise, .mx_RoomSummaryCard_app_options { position: absolute; top: 0; @@ -174,6 +176,21 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); } } + .mx_RoomSummaryCard_app_maximise { + right: 48px; + &::before { + mask-size: 14px; + mask-image: url("$(res)/img/element-icons/maximise-expand.svg"); + } + } + .mx_RoomSummaryCard_app_minimise { + right: 48px; + &::before { + mask-size: 14px; + mask-image: url("$(res)/img/element-icons/minimise-collapse.svg"); + background-color: $accent; + } + } .mx_RoomSummaryCard_app_options { right: 48px; @@ -182,6 +199,9 @@ limitations under the License. &::before { mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); } + &.mx_RoomSummaryCard_maximised_widget { + right: 72px; + } } &.mx_RoomSummaryCard_Button_pinned { diff --git a/res/css/views/right_panel/_WidgetCard.scss b/res/css/views/right_panel/_WidgetCard.scss index edf9ba7dd72..812de3cfc83 100644 --- a/res/css/views/right_panel/_WidgetCard.scss +++ b/res/css/views/right_panel/_WidgetCard.scss @@ -20,6 +20,8 @@ limitations under the License. .mx_AppTileFullWidth { max-width: unset; + width: auto !important; + margin: 0px $container-border-width 0px $container-border-width; height: 100%; border: 0; } diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 30f969262fd..3a62d728f63 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -18,11 +18,12 @@ limitations under the License. $MiniAppTileHeight: 200px; .mx_AppsDrawer { - margin: 5px 5px 5px 18px; + margin: 5px; position: relative; display: flex; flex-direction: column; overflow: hidden; + flex-grow: 1; .mx_AppsContainer_resizerHandleContainer { width: 100%; @@ -100,11 +101,11 @@ $MiniAppTileHeight: 200px; min-height: 0; .mx_AppTile:first-of-type { - border-left-width: 8px; + border-left-width: $container-border-width; border-radius: 10px 0 0 10px; } .mx_AppTile:last-of-type { - border-right-width: 8px; + border-right-width: $container-border-width; border-radius: 0 10px 10px 0; } @@ -142,7 +143,7 @@ $MinWidth: 240px; .mx_AppTile { width: 50%; min-width: $MinWidth; - border: 8px solid $widget-menu-bar-bg-color; + border: $container-border-width solid $widget-menu-bar-bg-color; border-left-width: 5px; border-right-width: 5px; display: flex; @@ -155,7 +156,7 @@ $MinWidth: 240px; width: 100% !important; // to override the inline style set by the resizer margin: 0; padding: 0; - border: 5px solid $widget-menu-bar-bg-color; + border: $container-border-width solid $widget-menu-bar-bg-color; border-radius: 8px; display: flex; flex-direction: column; @@ -224,15 +225,28 @@ $MinWidth: 240px; mask-position: 0 center; mask-size: auto 12px; background-color: $topleftmenu-color; - margin: 0 3px; -} + margin: 0 5px; -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout { - mask-image: url('$(res)/img/feather-customised/widget/external-link.svg'); -} + &.mx_AppTileMenuBar_iconButton_minWidget { + width: 10px; + height: 12px; + mask-size: auto 10px; + mask-image: url("$(res)/img/element-icons/minimise-collapse.svg"); + } + + &.mx_AppTileMenuBar_iconButton_maxWidget { + width: 11px; + height: 11px; + mask-image: url("$(res)/img/element-icons/maximise-expand.svg"); + } + + &.mx_AppTileMenuBar_iconButton_popout { + mask-image: url('$(res)/img/feather-customised/widget/external-link.svg'); + } -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_menu { - mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + &.mx_AppTileMenuBar_iconButton_menu { + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } } .mx_AppTileBody { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index f7b55709563..5a3e1a90a64 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -546,13 +546,13 @@ $left-gutter: 64px; mask-size: 75%; mask-position: center; mask-repeat: no-repeat; - mask-image: url($collapse-button-url); + mask-image: url("$(res)/img/element-icons/minimise-collapse.svg"); } .mx_EventTile_expandButton { mask-size: 75%; mask-position: center; mask-repeat: no-repeat; - mask-image: url($expand-button-url); + mask-image: url("$(res)/img/element-icons/maximise-expand.svg"); } .mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_copyButton, diff --git a/res/img/element-icons/maximise-expand.svg b/res/img/element-icons/maximise-expand.svg new file mode 100644 index 00000000000..06c44e2acdd --- /dev/null +++ b/res/img/element-icons/maximise-expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/minimise-collapse.svg b/res/img/element-icons/minimise-collapse.svg new file mode 100644 index 00000000000..e941d41276f --- /dev/null +++ b/res/img/element-icons/minimise-collapse.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/maximise.svg b/res/img/feather-customised/maximise.svg deleted file mode 100644 index 96185da1359..00000000000 --- a/res/img/feather-customised/maximise.svg +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - diff --git a/res/img/feather-customised/minimise.svg b/res/img/feather-customised/minimise.svg deleted file mode 100644 index f05e9399600..00000000000 --- a/res/img/feather-customised/minimise.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 703cc9a917a..45555f49fc3 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -213,8 +213,6 @@ $event-highlight-bg-color: $yellow-background; $event-timestamp-color: #acacac; $copy-button-url: "$(res)/img/feather-customised/clipboard.svg"; -$collapse-button-url: "$(res)/img/feather-customised/minimise.svg"; -$expand-button-url: "$(res)/img/feather-customised/maximise.svg"; // e2e $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 8a884273357..54a8b692f89 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -302,8 +302,6 @@ $focus-brightness: 105%; // Icon URLs // ******************** $copy-button-url: "$(res)/img/feather-customised/clipboard.svg"; -$collapse-button-url: "$(res)/img/feather-customised/minimise.svg"; -$expand-button-url: "$(res)/img/feather-customised/maximise.svg"; // ******************** // Mixins diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 3385af8ac47..7f20d0c824e 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -95,6 +95,7 @@ import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { dispatchShowThreadEvent } from '../../dispatcher/dispatch-actions/threads'; import { fetchInitialEvent } from "../../utils/EventUtils"; import { ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; +import AppsDrawer from '../views/rooms/AppsDrawer'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -119,6 +120,13 @@ interface IRoomProps extends MatrixClientProps { onRegistered?(credentials: IMatrixClientCreds): void; } +// This defines the content of the mainSplit. +// If the mainSplit does not contain the Timeline, the chat is shown in the right panel. +enum MainSplitContentType { + Timeline, + MaximisedWidget, + // Video +} export interface IRoomState { room?: Room; roomId?: string; @@ -188,6 +196,7 @@ export interface IRoomState { rejecting?: boolean; rejectError?: Error; hasPinnedWidgets?: boolean; + mainSplitContentType?: MainSplitContentType; dragCounter: number; // whether or not a spaces context switch brought us here, // if it did we don't want the room to be marked as read as soon as it is loaded. @@ -254,6 +263,7 @@ export class RoomView extends React.Component { showAvatarChanges: true, showDisplaynameChanges: true, matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), + mainSplitContentType: MainSplitContentType.Timeline, dragCounter: 0, timelineRenderingType: TimelineRenderingType.Room, liveTimeline: undefined, @@ -306,18 +316,35 @@ export class RoomView extends React.Component { } private onWidgetStoreUpdate = () => { - if (this.state.room) { - this.checkWidgets(this.state.room); - } + if (!this.state.room) return; + this.checkWidgets(this.state.room); + }; + + private onWidgetEchoStoreUpdate = () => { + if (!this.state.room) return; + this.checkWidgets(this.state.room); + }; + + private onWidgetLayoutChange = () => { + if (!this.state.room) return; + this.checkWidgets(this.state.room); }; private checkWidgets = (room) => { this.setState({ - hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0, - showApps: this.shouldShowApps(room), + hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(this.state.room), + mainSplitContentType: this.getMainSplitContentType(), + showApps: this.shouldShowApps(this.state.room), }); }; + private getMainSplitContentType = () => { + // TODO-video check if video should be displayed in main panel + return (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) + ? MainSplitContentType.MaximisedWidget + : MainSplitContentType.Timeline; + }; + private onReadReceiptsChange = () => { this.setState({ showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), @@ -504,18 +531,6 @@ export class RoomView extends React.Component { } } - private onWidgetEchoStoreUpdate = () => { - if (!this.state.room) return; - this.setState({ - hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(this.state.room, Container.Top).length > 0, - showApps: this.shouldShowApps(this.state.room), - }); - }; - - private onWidgetLayoutChange = () => { - this.onWidgetEchoStoreUpdate(); // we cheat here by calling the thing that matters - }; - private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) @@ -972,7 +987,6 @@ export class RoomView extends React.Component { if (this.unmounted) return; // Attach a widget store listener only when we get a room WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); - this.onWidgetLayoutChange(); // provoke an update this.calculatePeekRules(room); this.updatePreviewUrlVisibility(room); @@ -2094,6 +2108,38 @@ export class RoomView extends React.Component { const showChatEffects = SettingsStore.getValue('showChatEffects'); + // Decide what to show in the main split + let mainSplitBody = + { auxPanel } +
+ { fileDropTarget } + { topUnreadMessagesBar } + { jumpToBottom } + { messagePanel } + { searchResultsPanel } +
+ { statusBarArea } + { previewBar } + { messageComposer } +
; + + switch (this.state.mainSplitContentType) { + case MainSplitContentType.Timeline: + // keep the timeline in as the mainSplitBody + break; + case MainSplitContentType.MaximisedWidget: + if (!SettingsStore.getValue("feature_maximised_widgets")) break; + mainSplitBody = ; + break; + // TODO-video MainSplitContentType.Video: + // break; + } + return (
@@ -2115,17 +2161,7 @@ export class RoomView extends React.Component { />
- { auxPanel } -
- { fileDropTarget } - { topUnreadMessagesBar } - { jumpToBottom } - { messagePanel } - { searchResultsPanel } -
- { statusBarArea } - { previewBar } - { messageComposer } + { mainSplitBody }
diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index dcb6e62649b..a1b2933125c 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -40,7 +40,7 @@ import WidgetAvatar from "../avatars/WidgetAvatar"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { Room } from "matrix-js-sdk/src/models/room"; import { IApp } from "../../../stores/WidgetStore"; - +import { WidgetLayoutStore, Container } from "../../../stores/widgets/WidgetLayoutStore"; interface IProps { app: IApp; // If room is not specified then it is an account level widget @@ -400,6 +400,14 @@ export default class AppTile extends React.Component { { target: '_blank', href: this.sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click(); }; + private onMaxMinWidgetClick = (): void => { + const targetContainer = + WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center) + ? Container.Right + : Container.Center; + WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer); + }; + private onContextMenuClick = (): void => { this.setState({ menuDisplayed: true }); }; @@ -522,6 +530,23 @@ export default class AppTile extends React.Component { /> ); } + let maxMinButton; + if (SettingsStore.getValue("feature_maximised_widgets")) { + const widgetIsMaximised = WidgetLayoutStore.instance. + isInContainer(this.props.room, this.props.app, Container.Center); + maxMinButton = ; + } return
@@ -531,6 +556,7 @@ export default class AppTile extends React.Component { { this.props.showTitle && this.getTileTitle() } + { maxMinButton } { (this.props.showPopout && !this.state.requiresClient) && = ({ app, room }) => { mx_RoomSummaryCard_Button_pinned: isPinned, }); + const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, Container.Center); + const toggleMaximised = isMaximised + ? () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); } + : () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Center); }; + + const maximiseTitle = isMaximised ? _t("Close") : _t("Maximise widget"); + + let openTitle = ""; + if (isPinned) { + openTitle = _t("Unpin this widget to view it in this panel"); + } else if (isMaximised) { + openTitle =_t("Close this widget to view it in this panel"); + } + return
@@ -154,7 +168,10 @@ const AppRow: React.FC = ({ app, room }) => { = ({ app, room }) => { disabled={cannotPin} yOffset={-24} /> + { SettingsStore.getValue("feature_maximised_widgets") && + } { contextMenu }
; diff --git a/src/components/views/rooms/AppsDrawer.tsx b/src/components/views/rooms/AppsDrawer.tsx index e4bbd3a2886..0e48e1420af 100644 --- a/src/components/views/rooms/AppsDrawer.tsx +++ b/src/components/views/rooms/AppsDrawer.tsx @@ -47,7 +47,8 @@ interface IProps { } interface IState { - apps: IApp[]; + // @ts-ignore - TS wants a string key, but we know better + apps: {[id: Container]: IApp[]}; resizingVertical: boolean; // true when changing the height of the apps drawer resizingHorizontal: boolean; // true when chagning the distribution of the width between widgets resizing: boolean; @@ -118,7 +119,7 @@ export default class AppsDrawer extends React.Component { this.resizeContainer.classList.remove("mx_AppsDrawer_resizing"); WidgetLayoutStore.instance.setResizerDistributions( this.props.room, Container.Top, - this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size), + this.topApps().slice(1).map((_, i) => this.resizer.forHandleAt(i).size), ); this.setState({ resizingHorizontal: false }); }, @@ -148,7 +149,7 @@ export default class AppsDrawer extends React.Component { if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) { // Room has changed, update apps this.updateApps(); - } else if (this.getAppsHash(this.state.apps) !== this.getAppsHash(prevState.apps)) { + } else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps[Container.Top])) { this.loadResizerPreferences(); } } @@ -163,7 +164,7 @@ export default class AppsDrawer extends React.Component { private loadResizerPreferences = (): void => { const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top); - if (this.state.apps && (this.state.apps.length - 1) === distributions.length) { + if (this.state.apps && (this.topApps().length - 1) === distributions.length) { distributions.forEach((size, i) => { const distributor = this.resizer.forHandleAt(i); if (distributor) { @@ -200,8 +201,16 @@ export default class AppsDrawer extends React.Component { break; } }; - - private getApps = (): IApp[] => WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top); + // @ts-ignore - TS wants a string key, but we know better + private getApps = (): { [id: Container]: IApp[] } => { + // @ts-ignore + const appsDict: { [id: Container]: IApp[] } = {}; + appsDict[Container.Top] = WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top); + appsDict[Container.Center] = WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Center); + return appsDict; + }; + private topApps = (): IApp[] => this.state.apps[Container.Top]; + private centerApps = (): IApp[] => this.state.apps[Container.Center]; private updateApps = (): void => { this.setState({ @@ -211,8 +220,9 @@ export default class AppsDrawer extends React.Component { public render(): JSX.Element { if (!this.props.showApps) return
; - - const apps = this.state.apps.map((app, index, arr) => { + const widgetIsMaxmised: boolean = this.centerApps().length > 0; + const appsToDisplay = widgetIsMaxmised ? this.centerApps() : this.topApps(); + const apps = appsToDisplay.map((app, index, arr) => { return ( { const classes = classNames({ mx_AppsDrawer: true, + mx_AppsDrawer_maximise: widgetIsMaxmised, mx_AppsDrawer_fullWidth: apps.length < 2, mx_AppsDrawer_resizing: this.state.resizing, mx_AppsDrawer_2apps: apps.length === 2, mx_AppsDrawer_3apps: apps.length === 3, }); + const appConatiners = +
+ { apps.map((app, i) => { + if (i < 1) return app; + return + apps.length / 2} /> + { app } + ; + }) } +
; + + let drawer; + if (widgetIsMaxmised) { + drawer = appConatiners; + } else { + drawer = + { appConatiners } + ; + } return (
- -
- { apps.map((app, i) => { - if (i < 1) return app; - return - apps.length / 2} /> - { app } - ; - }) } -
-
+ { drawer } { spinner }
); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ea7a144e53d..7223e9c7ab5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1871,7 +1871,9 @@ "Threads": "Threads", "Room Info": "Room Info", "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets", - "Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel", + "Maximise widget": "Maximise widget", + "Unpin this widget to view it in this panel": "Unpin this widget to view it in this panel", + "Close this widget to view it in this panel": "Close this widget to view it in this panel", "Set my room layout for everyone": "Set my room layout for everyone", "Widgets": "Widgets", "Edit widgets, bridges & bots": "Edit widgets, bridges & bots", diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index 7efc5fb195b..823dd450d22 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -38,8 +38,7 @@ export enum Container { // 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. + Center = "center" } export interface IStoredLayout { @@ -174,7 +173,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } }; - private recalculateRoom(room: Room) { + public recalculateRoom(room: Room) { const widgets = WidgetStore.instance.getApps(room.roomId); if (!widgets?.length) { this.byRoom[room.roomId] = {}; @@ -195,18 +194,26 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } 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. + // We filter for the center container first. + // (An error is raised, if there are multiple widgets marked for the center container) + // For the right and top container multiple widgets are allowed. const topWidgets: IApp[] = []; const rightWidgets: IApp[] = []; + const centerWidgets: IApp[] = []; for (const widget of widgets) { 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) ? manualContainer === Container.Center : stateContainer === Container.Center) { + if (centerWidgets.length) { + console.error("Tried to push a second widget into the center container"); + } else { + centerWidgets.push(widget); + } + // The widget won't need to be put in any other container. + continue; + } let targetContainer = defaultContainer; if (!!manualContainer || !!stateContainer) { targetContainer = (manualContainer) ? manualContainer : stateContainer; @@ -323,6 +330,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore { ordered: rightWidgets, }; } + if (centerWidgets.length) { + this.byRoom[room.roomId][Container.Center] = { + ordered: centerWidgets, + }; + } const afterChanges = JSON.stringify(this.byRoom[room.roomId]); if (afterChanges !== beforeChanges) { @@ -339,7 +351,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } public canAddToContainer(room: Room, container: Container): boolean { - return this.getContainerWidgets(room, container).length < MAX_PINNED; + switch (container) { + case Container.Top: return this.getContainerWidgets(room, container).length < MAX_PINNED; + case Container.Right: return this.getContainerWidgets(room, container).length < MAX_PINNED; + case Container.Center: return this.getContainerWidgets(room, container).length < 1; + } } public getResizerDistributions(room: Room, container: Container): string[] { // yes, string. @@ -423,11 +439,42 @@ export class WidgetLayoutStore extends ReadyWatchingStore { public moveToContainer(room: Room, widget: IApp, toContainer: Container) { const allWidgets = this.getAllWidgets(room); if (!allWidgets.some(([w]) => w.id === widget.id)) return; // invalid + // Prepare other containers (potentially move widgets to obay the following rules) + switch (toContainer) { + case Container.Right: + // new "right" widget + break; + case Container.Center: + // new "center" widget => all other widgets go into "right" + for (const w of this.getContainerWidgets(room, Container.Top)) { + this.moveToContainer(room, w, Container.Right); + } + for (const w of this.getContainerWidgets(room, Container.Center)) { + this.moveToContainer(room, w, Container.Right); + } + break; + case Container.Top: + // new "top" widget => the center widget moves into "right" + if (this.hasMaximisedWidget(room)) { + this.moveToContainer(room, this.getContainerWidgets(room, Container.Center)[0], Container.Right); + } + break; + } + + // move widgets into requested container. this.updateUserLayout(room, { [widget.id]: { container: toContainer }, }); } + public hasMaximisedWidget(room: Room) { + return this.getContainerWidgets(room, Container.Center).length > 0; + } + + public hasPinnedWidgets(room: Room) { + return this.getContainerWidgets(room, Container.Top).length > 0; + } + public canCopyLayoutToRoom(room: Room): boolean { if (!this.matrixClient) return false; // not ready yet return room.currentState.maySendStateEvent(WIDGET_LAYOUT_EVENT_TYPE, this.matrixClient.getUserId()); diff --git a/test/stores/WidgetLayoutStore-test.ts b/test/stores/WidgetLayoutStore-test.ts new file mode 100644 index 00000000000..61399862ab4 --- /dev/null +++ b/test/stores/WidgetLayoutStore-test.ts @@ -0,0 +1,125 @@ +/* +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 "../skinned-sdk"; // Must be first for skinning to work +import WidgetStore, { IApp } from "../../src/stores/WidgetStore"; +import { Container, WidgetLayoutStore } from "../../src/stores/widgets/WidgetLayoutStore"; +import { Room } from "matrix-js-sdk"; +import { stubClient } from "../test-utils"; + +// setup test env values +const roomId = "!room:server"; +const mockRoom = { + roomId: roomId, + currentState: { + getStateEvents: (_l, _x) => { + return { + getId: ()=>"$layoutEventId", + getContent: () => null, + }; + }, + } }; + +const mockApps = [ + { roomId: roomId, id: "1" }, + { roomId: roomId, id: "2" }, + { roomId: roomId, id: "3" }, + { roomId: roomId, id: "4" }, +]; + +// fake the WidgetStore.instance to just return an object with `getApps` +jest.spyOn(WidgetStore, 'instance', 'get').mockReturnValue({ getApps: (_room) => mockApps }); + +describe("WidgetLayoutStore", () => { + // we need to init a client so it does not error, when asking for DeviceStorage handlers (SettingsStore.setValue("Widgets.layout")) + stubClient(); + + const store = WidgetLayoutStore.instance; + + it("all widgets should be in the right container by default", async () => { + store.recalculateRoom(mockRoom); + expect(store.getContainerWidgets(mockRoom, Container.Right).length).toStrictEqual(mockApps.length); + }); + it("add widget to top container", async () => { + store.recalculateRoom(mockRoom); + store.moveToContainer(mockRoom, mockApps[0], Container.Top); + expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([mockApps[0]]); + }); + it("add three widgets to top container", async () => { + store.recalculateRoom(mockRoom); + store.moveToContainer(mockRoom, mockApps[0], Container.Top); + store.moveToContainer(mockRoom, mockApps[1], Container.Top); + store.moveToContainer(mockRoom, mockApps[2], Container.Top); + expect(new Set(store.getContainerWidgets(mockRoom, Container.Top))) + .toEqual(new Set([mockApps[0], mockApps[1], mockApps[2]])); + }); + it("cannot add more than three widgets to top container", async () => { + store.recalculateRoom(mockRoom); + store.moveToContainer(mockRoom, mockApps[0], Container.Top); + store.moveToContainer(mockRoom, mockApps[1], Container.Top); + store.moveToContainer(mockRoom, mockApps[2], Container.Top); + expect(store.canAddToContainer(mockRoom, Container.Top)) + .toEqual(false); + }); + it("remove pins when maximising (other widget)", async () => { + store.recalculateRoom(mockRoom); + store.moveToContainer(mockRoom, mockApps[0], Container.Top); + store.moveToContainer(mockRoom, mockApps[1], Container.Top); + store.moveToContainer(mockRoom, mockApps[2], Container.Top); + store.moveToContainer(mockRoom, mockApps[3], Container.Center); + expect(store.getContainerWidgets(mockRoom, Container.Top)) + .toEqual([]); + expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))) + .toEqual(new Set([mockApps[0], mockApps[1], mockApps[2]])); + expect(store.getContainerWidgets(mockRoom, Container.Center)) + .toEqual([mockApps[3]]); + }); + it("remove pins when maximising (one of the pinned widgets)", async () => { + store.recalculateRoom(mockRoom); + store.moveToContainer(mockRoom, mockApps[0], Container.Top); + store.moveToContainer(mockRoom, mockApps[1], Container.Top); + store.moveToContainer(mockRoom, mockApps[2], Container.Top); + store.moveToContainer(mockRoom, mockApps[0], Container.Center); + expect(store.getContainerWidgets(mockRoom, Container.Top)) + .toEqual([]); + expect(store.getContainerWidgets(mockRoom, Container.Center)) + .toEqual([mockApps[0]]); + expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))) + .toEqual(new Set([mockApps[1], mockApps[2], mockApps[3]])); + }); + it("remove maximised when pinning (other widget)", async () => { + store.recalculateRoom(mockRoom); + store.moveToContainer(mockRoom, mockApps[0], Container.Center); + store.moveToContainer(mockRoom, mockApps[1], Container.Top); + expect(store.getContainerWidgets(mockRoom, Container.Top)) + .toEqual([mockApps[1]]); + expect(store.getContainerWidgets(mockRoom, Container.Center)) + .toEqual([]); + expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))) + .toEqual(new Set([mockApps[2], mockApps[3], mockApps[0]])); + }); + it("remove maximised when pinning (same widget)", async () => { + store.recalculateRoom(mockRoom); + store.moveToContainer(mockRoom, mockApps[0], Container.Center); + store.moveToContainer(mockRoom, mockApps[0], Container.Top); + expect(store.getContainerWidgets(mockRoom, Container.Top)) + .toEqual([mockApps[0]]); + expect(store.getContainerWidgets(mockRoom, Container.Center)) + .toEqual([]); + expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))) + .toEqual(new Set([mockApps[2], mockApps[3], mockApps[1]])); + }); +});