Add maximise widget functionality (#7098)

Co-authored-by: J. Ryan Stinnett <jryans@gmail.com>
This commit is contained in:
Timo 2021-11-16 15:43:18 +01:00 committed by GitHub
parent 2f4f3f2a8c
commit 556cfc7ed8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 418 additions and 233 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.mx_AppTileMenuBar_iconButton_menu {
mask-image: url('$(res)/img/element-icons/room/ellipsis.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_menu {
mask-image: url('$(res)/img/element-icons/room/ellipsis.svg');
}
}
.mx_AppTileBody {

View File

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

View File

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 4.34778V0.777778C14 0.35 13.65 0 13.2222 0H9.65222C8.96 0 8.61 0.84 9.1 1.33L10.3367 2.56667L2.55889 10.3444L1.32222 9.10778C0.84 8.61778 0 8.96 0 9.65222V13.2222C0 13.65 0.35 14 0.777778 14H4.34778C5.04 14 5.39 13.16 4.9 12.67L3.66333 11.4333L11.4411 3.65556L12.6778 4.89222C13.16 5.38222 14 5.04 14 4.34778Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.7855 1.25024L10.4345 4.60126L11.5953 5.76207C12.0553 6.22202 11.7267 7.01049 11.077 7.01049H7.73326C7.33172 7.01049 7.00319 6.68196 7.00319 6.28042V2.9221C7.00319 2.27234 7.79166 1.94381 8.25161 2.40375L9.41242 3.56456L12.7634 0.213545C13.0482 -0.0711818 13.5081 -0.0711818 13.7928 0.213545C14.0703 0.505573 14.0703 0.965517 13.7855 1.25024ZM1.25024 13.7855L4.60126 10.4345L5.76207 11.5953C6.22202 12.0553 7.01049 11.7267 7.01049 11.077V7.73326C7.01049 7.33172 6.68196 7.00319 6.28042 7.00319H2.9221C2.27234 7.00319 1.94381 7.79166 2.40375 8.25161L3.56456 9.41242L0.213545 12.7634C-0.0711818 13.0482 -0.0711818 13.5081 0.213545 13.7928C0.505573 14.0703 0.965517 14.0703 1.25024 13.7855Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 821 B

View File

@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="11"
height="11"
viewBox="0 0 11 11"
version="1.1"
id="svg6"
sodipodi:docname="maximise.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="750"
inkscape:window-height="480"
id="namedview8"
showgrid="false"
inkscape:zoom="21.454545"
inkscape:cx="5.5"
inkscape:cy="5.5"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="0"
inkscape:current-layer="svg6" />
<g
fill="none"
fill-rule="evenodd"
stroke="#7AC9A1"
stroke-linecap="round"
stroke-linejoin="round"
id="g4"
style="stroke:#000000;stroke-opacity:1">
<path
d="M7 1h3v3M4 10H1V7M10 1L6.5 4.5M1 10l3.5-3.5"
id="path2"
style="stroke:#000000;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="10.2101"
height="10.2101"
viewBox="0 0 10.2101 10.210099"
version="1.1"
id="svg6"
sodipodi:docname="minimise.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1016"
id="namedview8"
showgrid="false"
fit-margin-top="0.1"
fit-margin-left="0.1"
fit-margin-right="0.1"
fit-margin-bottom="0.1"
inkscape:zoom="26.222222"
inkscape:cx="-1.5686788"
inkscape:cy="5.0287789"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<g
id="g4"
style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
transform="translate(-0.39494988,0.60505012)">
<path
d="m 1.5,5.5 h 3 v 3 m 5,-5 h -3 v -3 m 0,3 L 10,0 M 1,9 4.5,5.5"
id="path2"
inkscape:connector-curvature="0"
style="stroke:#000000;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -208,8 +208,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

View File

@ -296,8 +296,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

View File

@ -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<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
}
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<IRoomProps, IRoomState> {
}
}
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<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
const showChatEffects = SettingsStore.getValue('showChatEffects');
// Decide what to show in the main split
let mainSplitBody = <React.Fragment>
{ auxPanel }
<div className={timelineClasses}>
{ fileDropTarget }
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
</React.Fragment>;
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 = <AppsDrawer
room={this.state.room}
userId={this.context.credentials.userId}
resizeNotifier={this.props.resizeNotifier}
showApps={true}
/>;
break;
// TODO-video MainSplitContentType.Video:
// break;
}
return (
<RoomContext.Provider value={this.state}>
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
@ -2115,17 +2161,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
/>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className="mx_RoomView_body">
{ auxPanel }
<div className={timelineClasses}>
{ fileDropTarget }
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
{ mainSplitBody }
</div>
</MainSplit>
</ErrorBoundary>

View File

@ -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<IProps, IState> {
{ 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<IProps, IState> {
/>
);
}
let maxMinButton;
if (SettingsStore.getValue("feature_maximised_widgets")) {
const widgetIsMaximised = WidgetLayoutStore.instance.
isInContainer(this.props.room, this.props.app, Container.Center);
maxMinButton = <AccessibleButton
className={
"mx_AppTileMenuBar_iconButton"
+ (widgetIsMaximised
? " mx_AppTileMenuBar_iconButton_minWidget"
: " mx_AppTileMenuBar_iconButton_maxWidget")
}
title={
widgetIsMaximised ? _t('Close'): _t('Maximise widget')
}
onClick={this.onMaxMinWidgetClick}
/>;
}
return <React.Fragment>
<div className={appTileClasses} id={this.props.app.id}>
@ -531,6 +556,7 @@ export default class AppTile extends React.Component<IProps, IState> {
{ this.props.showTitle && this.getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ maxMinButton }
{ (this.props.showPopout && !this.state.requiresClient) && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}

View File

@ -138,14 +138,28 @@ const AppRow: React.FC<IAppRowProps> = ({ 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 <div className={classes} ref={handle}>
<AccessibleTooltipButton
className="mx_RoomSummaryCard_icon_app"
onClick={onOpenWidgetClick}
// only show a tooltip if the widget is pinned
title={isPinned ? _t("Unpin a widget to view it in this panel") : ""}
forceHide={!isPinned}
disabled={isPinned}
title={openTitle}
forceHide={!(isPinned || isMaximised)}
disabled={isPinned || isMaximised}
yOffset={-48}
>
<WidgetAvatar app={app} />
@ -154,7 +168,10 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
</AccessibleTooltipButton>
<ContextMenuTooltipButton
className="mx_RoomSummaryCard_app_options"
className={classNames({
"mx_RoomSummaryCard_app_options": true,
"mx_RoomSummaryCard_maximised_widget": SettingsStore.getValue("feature_maximised_widgets"),
})}
isExpanded={menuDisplayed}
onClick={openMenu}
title={_t("Options")}
@ -168,6 +185,13 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
disabled={cannotPin}
yOffset={-24}
/>
{ SettingsStore.getValue("feature_maximised_widgets") &&
<AccessibleTooltipButton
className={isMaximised ? "mx_RoomSummaryCard_app_minimise" : "mx_RoomSummaryCard_app_maximise"}
onClick={toggleMaximised}
title={maximiseTitle}
yOffset={-24}
/> }
{ contextMenu }
</div>;

View File

@ -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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
public render(): JSX.Element {
if (!this.props.showApps) return <div />;
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 (<AppTile
key={app.id}
app={app}
@ -242,33 +252,42 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
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 =
<div className="mx_AppsContainer" ref={this.collectResizer}>
{ apps.map((app, i) => {
if (i < 1) return app;
return <React.Fragment key={app.key}>
<ResizeHandle reverse={i > apps.length / 2} />
{ app }
</React.Fragment>;
}) }
</div>;
let drawer;
if (widgetIsMaxmised) {
drawer = appConatiners;
} else {
drawer = <PersistentVResizer
room={this.props.room}
minHeight={100}
maxHeight={(this.props.maxHeight || !widgetIsMaxmised) ? this.props.maxHeight - 50 : undefined}
handleClass="mx_AppsContainer_resizerHandle"
handleWrapperClass="mx_AppsContainer_resizerHandleContainer"
className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier}>
{ appConatiners }
</PersistentVResizer>;
}
return (
<div className={classes}>
<PersistentVResizer
room={this.props.room}
minHeight={100}
maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined}
handleClass="mx_AppsContainer_resizerHandle"
handleWrapperClass="mx_AppsContainer_resizerHandleContainer"
className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier}
>
<div className="mx_AppsContainer" ref={this.collectResizer}>
{ apps.map((app, i) => {
if (i < 1) return app;
return <React.Fragment key={app.key}>
<ResizeHandle reverse={i > apps.length / 2} />
{ app }
</React.Fragment>;
}) }
</div>
</PersistentVResizer>
{ drawer }
{ spinner }
</div>
);

View File

@ -1873,7 +1873,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",

View File

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

View File

@ -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 = <Room>{
roomId: roomId,
currentState: {
getStateEvents: (_l, _x) => {
return {
getId: ()=>"$layoutEventId",
getContent: () => null,
};
},
} };
const mockApps = [
<IApp> { roomId: roomId, id: "1" },
<IApp> { roomId: roomId, id: "2" },
<IApp> { roomId: roomId, id: "3" },
<IApp> { roomId: roomId, id: "4" },
];
// fake the WidgetStore.instance to just return an object with `getApps`
jest.spyOn(WidgetStore, 'instance', 'get').mockReturnValue(<WidgetStore>{ 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]]));
});
});