-
+ const loadingElement = (
+
+
+
+ );
+ if (!this.state.hasPermissionToLoad) {
+ const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
+ appTileBody = (
+
);
- if (!this.state.hasPermissionToLoad) {
- const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
+ } else if (this.state.initialising) {
+ appTileBody = (
+
+ { loadingElement }
+
+ );
+ } else {
+ if (this.isMixedContent()) {
appTileBody = (
- );
- } else if (this.state.initialising) {
- appTileBody = (
-
);
} else {
- if (this.isMixedContent()) {
- appTileBody = (
-
- );
- } else {
- appTileBody = (
-
- { this.state.loading && loadingElement }
-
-
- );
- // if the widget would be allowed to remain on screen, we must put it in
- // a PersistedElement from the get-go, otherwise the iframe will be
- // re-mounted later when we do.
- if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
- const PersistedElement = sdk.getComponent("elements.PersistedElement");
- // Also wrap the PersistedElement in a div to fix the height, otherwise
- // AppTile's border is in the wrong place
- appTileBody =
;
- }
+ appTileBody = (
+
+ { this.state.loading && loadingElement }
+
+
+ );
+ // if the widget would be allowed to remain on screen, we must put it in
+ // a PersistedElement from the get-go, otherwise the iframe will be
+ // re-mounted later when we do.
+ if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
+ const PersistedElement = sdk.getComponent("elements.PersistedElement");
+ // Also wrap the PersistedElement in a div to fix the height, otherwise
+ // AppTile's border is in the wrong place
+ appTileBody =
;
}
}
}
- const showMinimiseButton = this.props.showMinimise && this.props.show;
- const showMaximiseButton = this.props.showMinimise && !this.props.show;
-
let appTileClasses;
if (this.props.miniMode) {
appTileClasses = {mx_AppTile_mini: true};
@@ -560,73 +397,37 @@ export default class AppTile extends React.Component {
} else {
appTileClasses = {mx_AppTile: true};
}
- appTileClasses.mx_AppTile_minimised = !this.props.show;
appTileClasses = classNames(appTileClasses);
- const menuBarClasses = classNames({
- mx_AppTileMenuBar: true,
- mx_AppTileMenuBar_expanded: this.props.show,
- });
-
let contextMenu;
if (this.state.menuDisplayed) {
- const elementRect = this._contextMenuButton.current.getBoundingClientRect();
-
- const canUserModify = this._canUserModify();
- const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify);
- const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
- const showPictureSnapshotButton = this.props.show && this._sgWidget.widgetApi &&
- this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots);
-
- const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
contextMenu = (
-
-
-
+
);
}
return
{ this.props.showMenubar &&
-
+
- { /* Minimise widget */ }
- { showMinimiseButton && }
- { /* Maximise widget */ }
- { showMaximiseButton && }
- { /* Title */ }
{ this.props.showTitle && this._getTileTitle() }
- { /* Popout widget */ }
{ this.props.showPopout && }
- { /* Context menu */ }
{ ;
}
}
+
+export const getPersistKey = (appId: string) => 'widget_' + appId;
diff --git a/src/components/views/elements/PersistentApp.js b/src/components/views/elements/PersistentApp.js
index 8b2aa5c87c..64825dfc96 100644
--- a/src/components/views/elements/PersistentApp.js
+++ b/src/components/views/elements/PersistentApp.js
@@ -79,13 +79,10 @@ export default class PersistentApp extends React.Component {
fullWidth={true}
room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId}
- show={true}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
- showDelete={false}
- showMinimise={false}
miniMode={true}
showMenubar={false}
/>;
diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx
index 6b71a1dbf5..03b9eb08d0 100644
--- a/src/components/views/elements/Tooltip.tsx
+++ b/src/components/views/elements/Tooltip.tsx
@@ -36,6 +36,7 @@ interface IProps {
// the react element to put into the tooltip
label: React.ReactNode;
forceOnRight?: boolean;
+ yOffset?: number;
}
export default class Tooltip extends React.Component {
@@ -46,6 +47,7 @@ export default class Tooltip extends React.Component {
public static readonly defaultProps = {
visible: true,
+ yOffset: 0,
};
// Create a wrapper for the tooltip outside the parent and attach it to the body element
@@ -82,9 +84,9 @@ export default class Tooltip extends React.Component {
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
}
- style.top = (parentBox.top - 2) + window.pageYOffset + offset;
+ style.top = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset + offset;
if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) {
- style.right = window.innerWidth - parentBox.right - window.pageXOffset - 8;
+ style.right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
} else {
style.left = parentBox.right + window.pageXOffset + 6;
}
diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx
index 95b159deed..77e76d1c25 100644
--- a/src/components/views/right_panel/RoomSummaryCard.tsx
+++ b/src/components/views/right_panel/RoomSummaryCard.tsx
@@ -17,7 +17,6 @@ limitations under the License.
import React, {useCallback, useState, useEffect, useContext} from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
-import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useIsEncrypted } from '../../../hooks/useIsEncrypted';
@@ -37,12 +36,14 @@ import WidgetUtils from "../../../utils/WidgetUtils";
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import TextWithTooltip from "../elements/TextWithTooltip";
-import BaseAvatar from "../avatars/BaseAvatar";
+import WidgetAvatar from "../avatars/WidgetAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
-import WidgetStore, {IApp} from "../../../stores/WidgetStore";
+import WidgetStore, {IApp, MAX_PINNED} 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";
interface IProps {
room: Room;
@@ -68,11 +69,11 @@ const Button: React.FC = ({ children, className, onClick }) => {
};
export const useWidgets = (room: Room) => {
- const [apps, setApps] = useState(WidgetStore.instance.getApps(room));
+ const [apps, setApps] = useState(WidgetStore.instance.getApps(room.roomId));
const updateApps = useCallback(() => {
// Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings
- setApps([...WidgetStore.instance.getApps(room)]);
+ setApps([...WidgetStore.instance.getApps(room.roomId)]);
}, [room]);
useEffect(updateApps, [room]);
@@ -82,8 +83,92 @@ export const useWidgets = (room: Room) => {
return apps;
};
+interface IAppRowProps {
+ app: IApp;
+}
+
+const AppRow: React.FC = ({ app }) => {
+ const name = WidgetUtils.getWidgetName(app);
+ const dataTitle = WidgetUtils.getWidgetDataTitle(app);
+ const subtitle = dataTitle && " - " + dataTitle;
+
+ const onOpenWidgetClick = () => {
+ defaultDispatcher.dispatch({
+ action: Action.SetRightPanelPhase,
+ phase: RightPanelPhases.Widget,
+ refireParams: {
+ widgetId: app.id,
+ },
+ });
+ };
+
+ const isPinned = WidgetStore.instance.isPinned(app.id);
+ const togglePin = isPinned
+ ? () => { WidgetStore.instance.unpinWidget(app.id); }
+ : () => { WidgetStore.instance.pinWidget(app.id); };
+
+ const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
+ let contextMenu;
+ if (menuDisplayed) {
+ const rect = handle.current.getBoundingClientRect();
+ contextMenu = ;
+ }
+
+ const cannotPin = !isPinned && !WidgetStore.instance.canPin(app.id);
+
+ let pinTitle: string;
+ if (cannotPin) {
+ pinTitle = _t("You can only pin up to %(count)s widgets", { count: MAX_PINNED });
+ } else {
+ pinTitle = isPinned ? _t("Unpin") : _t("Pin");
+ }
+
+ const classes = classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", {
+ mx_RoomSummaryCard_Button_pinned: isPinned,
+ });
+
+ return
+
+
+ {name}
+ { subtitle }
+
+
+
+
+
+
+ { contextMenu }
+
;
+};
+
const AppsSection: React.FC = ({ room }) => {
- const cli = useContext(MatrixClientContext);
const apps = useWidgets(room);
const onManageIntegrations = () => {
@@ -100,65 +185,7 @@ const AppsSection: React.FC = ({ room }) => {
};
return
- { apps.map(app => {
- const name = WidgetUtils.getWidgetName(app);
- const dataTitle = WidgetUtils.getWidgetDataTitle(app);
- const subtitle = dataTitle && " - " + dataTitle;
-
- let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")];
- // heuristics for some better icons until Widgets support their own icons
- if (app.type.includes("meeting") || app.type.includes("calendar")) {
- iconUrls = [require("../../../../res/img/element-icons/room/default_cal.svg")];
- } else if (app.type.includes("pad") || app.type.includes("doc") || app.type.includes("calc")) {
- iconUrls = [require("../../../../res/img/element-icons/room/default_doc.svg")];
- } else if (app.type.includes("clock")) {
- iconUrls = [require("../../../../res/img/element-icons/room/default_clock.svg")];
- }
-
- if (app.avatar_url) { // MSC2765
- iconUrls.unshift(getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop"));
- }
-
- const isPinned = WidgetStore.instance.isPinned(app.id);
- const classes = classNames("mx_RoomSummaryCard_icon_app", {
- mx_RoomSummaryCard_icon_app_pinned: isPinned,
- });
-
- if (isPinned) {
- const onClick = () => {
- WidgetStore.instance.unpinWidget(app.id);
- };
-
- return
-
- {name}
- { subtitle }
-
- }
-
- const onOpenWidgetClick = () => {
- defaultDispatcher.dispatch({
- action: Action.SetRightPanelPhase,
- phase: RightPanelPhases.Widget,
- refireParams: {
- widgetId: app.id,
- },
- });
- };
-
- return (
-
- );
- }) }
+ { apps.map(app => ) }
{ apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") }
diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx
index 30900b9a4d..7dbb77df18 100644
--- a/src/components/views/right_panel/WidgetCard.tsx
+++ b/src/components/views/right_panel/WidgetCard.tsx
@@ -20,7 +20,6 @@ import {Room} from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseCard from "./BaseCard";
import WidgetUtils from "../../../utils/WidgetUtils";
-import AccessibleButton from "../elements/AccessibleButton";
import AppTile from "../elements/AppTile";
import {_t} from "../../../languageHandler";
import {useWidgets} from "./RoomSummaryCard";
@@ -30,16 +29,7 @@ import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPa
import {Action} from "../../../dispatcher/actions";
import WidgetStore from "../../../stores/WidgetStore";
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
-import IconizedContextMenu, {
- IconizedContextMenuOption,
- IconizedContextMenuOptionList,
-} from "../context_menus/IconizedContextMenu";
-import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload";
-import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
-import classNames from "classnames";
-import dis from "../../../dispatcher/dispatcher";
-import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
-import { MatrixCapabilities } from "matrix-widget-api";
+import WidgetContextMenu from "../context_menus/WidgetContextMenu";
interface IProps {
room: Room;
@@ -69,111 +59,22 @@ const WidgetCard: React.FC = ({ room, widgetId, onClose }) => {
// Don't render anything as we are about to transition
if (!app || isPinned) return null;
- const header =
- { WidgetUtils.getWidgetName(app) }
- ;
-
- const canModify = WidgetUtils.canUserModifyWidgets(room.roomId);
-
let contextMenu;
if (menuDisplayed) {
- let snapshotButton;
- const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
- if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
- const onSnapshotClick = () => {
- widgetMessaging.takeScreenshot().then(data => {
- dis.dispatch({
- action: 'picture_snapshot',
- file: data.screenshot,
- });
- }).catch(err => {
- console.error("Failed to take screenshot: ", err);
- });
- closeMenu();
- };
-
- snapshotButton = ;
- }
-
- let deleteButton;
- if (canModify) {
- const onDeleteClick = () => {
- defaultDispatcher.dispatch({
- action: Action.AppTileDelete,
- widgetId: app.id,
- });
- closeMenu();
- };
-
- deleteButton = ;
- }
-
- const onRevokeClick = () => {
- defaultDispatcher.dispatch({
- action: Action.AppTileRevoke,
- widgetId: app.id,
- });
- closeMenu();
- };
-
const rect = handle.current.getBoundingClientRect();
contextMenu = (
-
-
- { snapshotButton }
- { deleteButton }
-
-
-
+ app={app}
+ />
);
}
- const onPinClick = () => {
- WidgetStore.instance.pinWidget(app.id);
- };
-
- const onEditClick = () => {
- WidgetUtils.editWidget(room, app);
- };
-
- let editButton;
- if (canModify) {
- editButton =
- { _t("Edit") }
- ;
- }
-
- const pinButtonClasses = canModify ? "" : "mx_WidgetCard_widePinButton";
-
- let pinButton;
- if (WidgetStore.instance.canPin(app.id)) {
- pinButton =
- { _t("Pin to room") }
- ;
- } else {
- pinButton =
- { _t("Pin to room") }
- ;
- }
-
- const footer =
- { editButton }
- { pinButton }
+ const header =
+ { WidgetUtils.getWidgetName(app) }
= ({ room, widgetId, onClose }) => {
isExpanded={menuDisplayed}
label={_t("Options")}
/>
-
{ contextMenu }
;
return {
+ this.setState({ resizing });
+ if (!resizing) {
+ this._relaxResizer();
+ }
+ };
+
+ _createResizer() {
+ const classNames = {
+ handle: "mx_ResizeHandle",
+ vertical: "mx_ResizeHandle_vertical",
+ reverse: "mx_ResizeHandle_reverse",
+ };
+ const collapseConfig = {
+ onResizeStart: () => {
+ this._resizeContainer.classList.add("mx_AppsDrawer_resizing");
+ },
+ onResizeStop: () => {
+ this._resizeContainer.classList.remove("mx_AppsDrawer_resizing");
+ // persist to localStorage
+ localStorage.setItem(this._getStorageKey(), JSON.stringify([
+ this.state.apps.map(app => app.id),
+ ...this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
+ ]));
+ },
+ };
+ // pass a truthy container for now, we won't call attach until we update it
+ const resizer = new Resizer({}, PercentageDistributor, collapseConfig);
+ resizer.setClassNames(classNames);
+ return resizer;
+ }
+
+ _collectResizer = (ref) => {
+ if (this._resizeContainer) {
+ this.resizer.detach();
+ }
+
+ if (ref) {
+ this.resizer.container = ref;
+ this.resizer.attach();
+ }
+ this._resizeContainer = ref;
+ this._loadResizerPreferences();
+ };
+
+ _getStorageKey = () => `mx_apps_drawer-${this.props.room.roomId}`;
+
+ _getAppsHash = (apps) => apps.map(app => app.id).join("~");
+
+ componentDidUpdate(prevProps, prevState) {
+ if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) {
+ this._loadResizerPreferences();
+ }
+ }
+
+ _relaxResizer = () => {
+ const distributors = this.resizer.getDistributors();
+
+ // relax all items if they had any overconstrained flexboxes
+ distributors.forEach(d => d.start());
+ distributors.forEach(d => d.finish());
+ };
+
+ _loadResizerPreferences = () => {
+ try {
+ const [[...lastIds], ...sizes] = JSON.parse(localStorage.getItem(this._getStorageKey()));
+ // Every app was included in the last split, reuse the last sizes
+ if (this.state.apps.length <= lastIds.length && this.state.apps.every((app, i) => lastIds[i] === app.id)) {
+ sizes.forEach((size, i) => {
+ const distributor = this.resizer.forHandleAt(i);
+ if (distributor) {
+ distributor.size = size;
+ distributor.finish();
+ }
+ });
+ return;
+ }
+ } catch (e) {
+ // this is expected
+ }
+
+ if (this.state.apps) {
+ const distributors = this.resizer.getDistributors();
+ distributors.forEach(d => d.item.clearSize());
+ distributors.forEach(d => d.start());
+ distributors.forEach(d => d.finish());
+ }
+ };
+
onAction = (action) => {
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) {
@@ -91,7 +190,7 @@ export default class AppsDrawer extends React.Component {
}
};
- _getApps = () => WidgetStore.instance.getApps(this.props.room, true);
+ _getApps = () => WidgetStore.instance.getPinnedApps(this.props.room.roomId);
_updateApps = () => {
this.setState({
@@ -99,15 +198,6 @@ export default class AppsDrawer extends React.Component {
});
};
- _canUserModify() {
- try {
- return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
- } catch (err) {
- console.error(err);
- return false;
- }
- }
-
_launchManageIntegrations() {
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll();
@@ -116,12 +206,9 @@ export default class AppsDrawer extends React.Component {
}
}
- onClickAddWidget = (e) => {
- e.preventDefault();
- this._launchManageIntegrations();
- };
-
render() {
+ if (!this.props.showApps) return ;
+
const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
@@ -131,7 +218,6 @@ export default class AppsDrawer extends React.Component {
fullWidth={arr.length < 2}
room={this.props.room}
userId={this.props.userId}
- show={this.props.showApps}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
@@ -143,21 +229,6 @@ export default class AppsDrawer extends React.Component {
return ;
}
- let addWidget;
- if (this.props.showApps &&
- this._canUserModify()
- ) {
- addWidget =
- [+] { _t('Add a widget') }
- ;
- }
-
let spinner;
if (
apps.length === 0 && WidgetEchoStore.roomHasPendingWidgets(
@@ -170,9 +241,11 @@ export default class AppsDrawer extends React.Component {
}
const classes = classNames({
- "mx_AppsDrawer": true,
- "mx_AppsDrawer_fullWidth": apps.length < 2,
- "mx_AppsDrawer_minimised": !this.props.showApps,
+ mx_AppsDrawer: true,
+ mx_AppsDrawer_fullWidth: apps.length < 2,
+ mx_AppsDrawer_resizing: this.state.resizing,
+ mx_AppsDrawer_2apps: apps.length === 2,
+ mx_AppsDrawer_3apps: apps.length === 3,
});
return (
@@ -182,13 +255,20 @@ export default class AppsDrawer extends React.Component {
minHeight={100}
maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined}
handleClass="mx_AppsContainer_resizerHandle"
- className="mx_AppsContainer"
+ className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier}
>
- { apps }
- { spinner }
+
+ { apps.map((app, i) => {
+ if (i < 1) return app;
+ return
+ apps.length / 2} />
+ { app }
+ ;
+ }) }
+
- { this._canUserModify() && addWidget }
+ { spinner }
);
}
@@ -205,14 +285,12 @@ const PersistentVResizer = ({
children,
}) => {
const [height, setHeight] = useLocalStorageState("pvr_" + id, 280); // old fixed height was 273px
- const [resizing, setResizing] = useState(false);
return
{
- if (!resizing) setResizing(true);
resizeNotifier.startResizing();
}}
onResize={() => {
@@ -220,14 +298,11 @@ const PersistentVResizer = ({
}}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
- if (resizing) setResizing(false);
resizeNotifier.stopResizing();
}}
handleWrapperClass={handleWrapperClass}
handleClasses={{bottom: handleClass}}
- className={classNames(className, {
- mx_AppsDrawer_resizing: resizing,
- })}
+ className={className}
enable={{bottom: true}}
>
{ children }
diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js
index 1a116838ac..8eb8276630 100644
--- a/src/components/views/rooms/RoomHeader.js
+++ b/src/components/views/rooms/RoomHeader.js
@@ -42,6 +42,8 @@ export default class RoomHeader extends React.Component {
onLeaveClick: PropTypes.func,
onCancelClick: PropTypes.func,
e2eStatus: PropTypes.string,
+ onAppsClick: PropTypes.func,
+ appsShown: PropTypes.bool,
};
static defaultProps = {
@@ -230,6 +232,17 @@ export default class RoomHeader extends React.Component {
title={_t("Forget room")} />;
}
+ let appsButton;
+ if (this.props.onAppsClick) {
+ appsButton =
+ ;
+ }
+
let searchButton;
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
@@ -243,6 +256,7 @@ export default class RoomHeader extends React.Component {
{ pinnedEventsButton }
{ forgetButton }
+ { appsButton }
{ searchButton }
;
diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js
index 2faa0fea27..ae7ed48898 100644
--- a/src/components/views/rooms/Stickerpicker.js
+++ b/src/components/views/rooms/Stickerpicker.js
@@ -272,13 +272,10 @@ export default class Stickerpicker extends React.Component {
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
- show={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
- showMinimise={true}
- showDelete={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 26d585b76e..6fb71df30d 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -94,14 +94,4 @@ export enum Action {
* Trigged after the phase of the right panel is set. Should be used with AfterRightPanelPhaseChangePayload.
*/
AfterRightPanelPhaseChange = "after_right_panel_phase_change",
-
- /**
- * Requests that the AppTile deletes the widget. Should be used with the AppTileActionPayload.
- */
- AppTileDelete = "appTile_delete",
-
- /**
- * Requests that the AppTile revokes the widget. Should be used with the AppTileActionPayload.
- */
- AppTileRevoke = "appTile_revoke",
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index e79a098920..4982526a2c 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1031,7 +1031,6 @@
"Remove %(phone)s?": "Remove %(phone)s?",
"A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.",
"Phone Number": "Phone Number",
- "Add a widget": "Add a widget",
"Drop File Here": "Drop File Here",
"Drop file here to upload": "Drop file here to upload",
"This user has not verified all of their sessions.": "This user has not verified all of their sessions.",
@@ -1113,6 +1112,8 @@
"(~%(count)s results)|one": "(~%(count)s result)",
"Join Room": "Join Room",
"Forget room": "Forget room",
+ "Hide Widgets": "Hide Widgets",
+ "Show Widgets": "Show Widgets",
"Search": "Search",
"Invites": "Invites",
"Favourites": "Favourites",
@@ -1278,8 +1279,11 @@
"Yours, or the other users’ session": "Yours, or the other users’ session",
"Members": "Members",
"Room Info": "Room Info",
+ "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
+ "Unpin": "Unpin",
+ "Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
+ "Options": "Options",
"Widgets": "Widgets",
- "Unpin app": "Unpin app",
"Edit widgets, bridges & bots": "Edit widgets, bridges & bots",
"Add widgets, bridges & bots": "Add widgets, bridges & bots",
"Not encrypted": "Not encrypted",
@@ -1302,7 +1306,6 @@
"Invite": "Invite",
"Share Link to User": "Share Link to User",
"Direct message": "Direct message",
- "Options": "Options",
"Demote yourself?": "Demote yourself?",
"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.",
"Demote": "Demote",
@@ -1366,12 +1369,6 @@
"You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled",
"Compare emoji": "Compare emoji",
- "Take a picture": "Take a picture",
- "Remove for everyone": "Remove for everyone",
- "Remove for me": "Remove for me",
- "Edit": "Edit",
- "Pin to room": "Pin to room",
- "You can only pin 2 widgets at a time": "You can only pin 2 widgets at a time",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@@ -1390,6 +1387,7 @@
"Error decrypting audio": "Error decrypting audio",
"React": "React",
"Reply": "Reply",
+ "Edit": "Edit",
"Message Actions": "Message Actions",
"Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment",
@@ -1482,15 +1480,7 @@
"Widgets do not use message encryption.": "Widgets do not use message encryption.",
"Widget added by": "Widget added by",
"This widget may use cookies.": "This widget may use cookies.",
- "Delete Widget": "Delete Widget",
- "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?",
- "Delete widget": "Delete widget",
- "Failed to remove widget": "Failed to remove widget",
- "An error ocurred whilst trying to remove the widget from the room": "An error ocurred whilst trying to remove the widget from the room",
- "Minimize widget": "Minimize widget",
- "Maximize widget": "Maximize widget",
"Popout widget": "Popout widget",
- "More options": "More options",
"Use the Desktop app to see all encrypted files": "Use the Desktop app to see all encrypted files",
"Use the Desktop app to search encrypted messages": "Use the Desktop app to search encrypted messages",
"This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files",
@@ -1923,9 +1913,14 @@
"Set status": "Set status",
"Set a new status...": "Set a new status...",
"View Community": "View Community",
- "Unpin": "Unpin",
- "Reload": "Reload",
- "Take picture": "Take picture",
+ "Take a picture": "Take a picture",
+ "Delete Widget": "Delete Widget",
+ "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?",
+ "Delete widget": "Delete widget",
+ "Remove for everyone": "Remove for everyone",
+ "Revoke permissions": "Revoke permissions",
+ "Move left": "Move left",
+ "Move right": "Move right",
"This room is public": "This room is public",
"Away": "Away",
"User Status": "User Status",
diff --git a/src/resizer/distributors/fixed.ts b/src/resizer/distributors/fixed.ts
index 59ed845467..64f1c5015b 100644
--- a/src/resizer/distributors/fixed.ts
+++ b/src/resizer/distributors/fixed.ts
@@ -43,6 +43,14 @@ export default class FixedDistributor {
+ static createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean) {
+ return new PercentageSizer(containerElement, vertical, reverse);
+ }
+}
diff --git a/src/resizer/index.ts b/src/resizer/index.ts
index e0e72ff159..9199fc2657 100644
--- a/src/resizer/index.ts
+++ b/src/resizer/index.ts
@@ -15,5 +15,6 @@ limitations under the License.
*/
export {default as FixedDistributor} from "./distributors/fixed";
+export {default as PercentageDistributor} from "./distributors/percentage";
export {default as CollapseDistributor} from "./distributors/collapse";
export {default as Resizer} from "./resizer";
diff --git a/src/resizer/item.ts b/src/resizer/item.ts
index 1b42c19a4a..3be290f15e 100644
--- a/src/resizer/item.ts
+++ b/src/resizer/item.ts
@@ -39,9 +39,7 @@ export default class ResizeItem {
private advance(forwards: boolean) {
// opposite direction from fromResizeHandle to get back to handle
- let handle = this.reverse ?
- this.domNode.previousElementSibling :
- this.domNode.nextElementSibling;
+ let handle = this.reverse ? this.domNode.previousElementSibling : this.domNode.nextElementSibling;
const moveNext = forwards !== this.reverse; // xor
// iterate at least once to avoid infinite loop
do {
@@ -75,8 +73,24 @@ export default class ResizeItem {
return this.sizer.getItemOffset(this.domNode);
}
- public setSize(size: number) {
+ public start() {
+ this.sizer.start(this.domNode);
+ }
+
+ public finish() {
+ this.sizer.finish(this.domNode);
+ }
+
+ public getSize() {
+ return this.sizer.getDesiredItemSize(this.domNode);
+ }
+
+ public setRawSize(size: string) {
this.sizer.setItemSize(this.domNode, size);
+ }
+
+ public setSize(size: number) {
+ this.setRawSize(`${Math.round(size)}px`);
const callback = this.resizer.config.onResized;
if (callback) {
callback(size, this.id, this.domNode);
diff --git a/src/resizer/resizer.ts b/src/resizer/resizer.ts
index b8a6777da0..c7c7edcd11 100644
--- a/src/resizer/resizer.ts
+++ b/src/resizer/resizer.ts
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import {throttle} from "lodash";
+
import FixedDistributor from "./distributors/fixed";
import ResizeItem from "./item";
import Sizer from "./sizer";
@@ -43,7 +45,7 @@ export default class Resizer {
constructor(
public container: HTMLElement,
private readonly distributorCtor: {
- new(item: ResizeItem): FixedDistributor;
+ new(item: ResizeItem): FixedDistributor;
createItem(resizeHandle: HTMLDivElement, resizer: Resizer, sizer: Sizer): ResizeItem;
createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean): Sizer;
},
@@ -67,10 +69,12 @@ export default class Resizer {
public attach() {
this.container.addEventListener("mousedown", this.onMouseDown, false);
+ window.addEventListener("resize", this.onResize);
}
public detach() {
this.container.removeEventListener("mousedown", this.onMouseDown, false);
+ window.removeEventListener("resize", this.onResize);
}
/**
@@ -97,11 +101,11 @@ export default class Resizer {
}
}
- public isReverseResizeHandle(el: HTMLElement) {
+ public isReverseResizeHandle(el: HTMLElement): boolean {
return el && el.classList.contains(this.classNames.reverse);
}
- public isResizeHandle(el: HTMLElement) {
+ public isResizeHandle(el: HTMLElement): boolean {
return el && el.classList.contains(this.classNames.handle);
}
@@ -136,10 +140,10 @@ export default class Resizer {
if (this.classNames.resizing) {
this.container.classList.remove(this.classNames.resizing);
}
+ distributor.finish();
if (this.config.onResizeStop) {
this.config.onResizeStop();
}
- distributor.finish();
body.removeEventListener("mouseup", finishResize, false);
document.removeEventListener("mouseleave", finishResize, false);
body.removeEventListener("mousemove", onMouseMove, false);
@@ -149,7 +153,24 @@ export default class Resizer {
body.addEventListener("mousemove", onMouseMove, false);
};
- private createSizerAndDistributor(resizeHandle: HTMLDivElement) {
+ private onResize = throttle(() => {
+ const distributors = this.getDistributors();
+
+ // relax all items if they had any overconstrained flexboxes
+ distributors.forEach(d => d.start());
+ distributors.forEach(d => d.finish());
+ }, 100, {trailing: true, leading: true});
+
+ public getDistributors = () => {
+ return this.getResizeHandles().map(handle => {
+ const {distributor} = this.createSizerAndDistributor(handle);
+ return distributor;
+ });
+ };
+
+ private createSizerAndDistributor(
+ resizeHandle: HTMLDivElement,
+ ): {sizer: Sizer, distributor: FixedDistributor} {
const vertical = resizeHandle.classList.contains(this.classNames.vertical);
const reverse = this.isReverseResizeHandle(resizeHandle);
const Distributor = this.distributorCtor;
@@ -159,9 +180,10 @@ export default class Resizer {
return {sizer, distributor};
}
- private getResizeHandles(): HTMLDivElement[] {
+ private getResizeHandles() {
+ if (!this.container.children) return [];
return Array.from(this.container.children).filter(el => {
return this.isResizeHandle(el);
- }) as HTMLDivElement[];
+ }) as HTMLElement[];
}
}
diff --git a/src/resizer/sizer.ts b/src/resizer/sizer.ts
index 4630ffd6bf..4de8bb9221 100644
--- a/src/resizer/sizer.ts
+++ b/src/resizer/sizer.ts
@@ -68,11 +68,19 @@ export default class Sizer {
return offset;
}
- public setItemSize(item: HTMLElement, size: number) {
+ public getDesiredItemSize(item: HTMLElement) {
if (this.vertical) {
- item.style.height = `${Math.round(size)}px`;
+ return item.style.height;
} else {
- item.style.width = `${Math.round(size)}px`;
+ return item.style.width;
+ }
+ }
+
+ public setItemSize(item: HTMLElement, size: string) {
+ if (this.vertical) {
+ item.style.height = size;
+ } else {
+ item.style.width = size;
}
}
@@ -84,6 +92,10 @@ export default class Sizer {
}
}
+ public start(item: HTMLElement) {}
+
+ public finish(item: HTMLElement) {}
+
/**
@param {MouseEvent} event the mouse event
@return {number} the distance between the cursor and the edge of the container,
diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts
index d9cbdec76d..952cd49606 100644
--- a/src/stores/WidgetStore.ts
+++ b/src/stores/WidgetStore.ts
@@ -22,6 +22,7 @@ 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";
@@ -46,6 +47,8 @@ interface IRoomWidgets {
pinned: Record;
}
+export const MAX_PINNED = 3;
+
// TODO consolidate WidgetEchoStore into this
// TODO consolidate ActiveWidgetStore into this
export default class WidgetStore extends AsyncStoreWithClient {
@@ -68,7 +71,7 @@ export default class WidgetStore extends AsyncStoreWithClient {
private initRoom(roomId: string) {
if (!this.roomMap.has(roomId)) {
this.roomMap.set(roomId, {
- pinned: {},
+ pinned: {}, // ordered
widgets: [],
});
}
@@ -156,27 +159,34 @@ export default class WidgetStore extends AsyncStoreWithClient {
public isPinned(widgetId: string) {
const roomId = this.getRoomId(widgetId);
- const roomInfo = this.getRoom(roomId);
-
- let pinned = roomInfo && roomInfo.pinned[widgetId];
- // Jitsi widgets should be pinned by default
- const widget = this.widgetMap.get(widgetId);
- if (pinned === undefined && WidgetType.JITSI.matches(widget?.type)) pinned = true;
- return pinned;
+ return !!this.getPinnedApps(roomId).find(w => w.id === widgetId);
}
public canPin(widgetId: string) {
- // only allow pinning up to a max of two as we do not yet have grid splits
- // the only case it will go to three is if you have two and then a Jitsi gets added
const roomId = this.getRoomId(widgetId);
- const roomInfo = this.getRoom(roomId);
- return roomInfo && Object.keys(roomInfo.pinned).filter(k => {
- return roomInfo.pinned[k] && roomInfo.widgets.some(app => app.id === k);
- }).length < 2;
+ return this.getPinnedApps(roomId).length < MAX_PINNED;
}
public pinWidget(widgetId: string) {
+ const roomId = this.getRoomId(widgetId);
+ 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(app.id, true);
+ });
+
this.setPinned(widgetId, true);
+
+ // Show the apps drawer upon the user pinning a widget
+ if (RoomViewStore.getRoomId() === this.getRoomId(widgetId)) {
+ defaultDispatcher.dispatch({
+ action: "appsDrawer",
+ show: true,
+ })
+ }
}
public unpinWidget(widgetId: string) {
@@ -187,6 +197,10 @@ export default class WidgetStore extends AsyncStoreWithClient {
const roomId = this.getRoomId(widgetId);
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
@@ -201,13 +215,61 @@ export default class WidgetStore extends AsyncStoreWithClient {
this.emit(UPDATE_EVENT);
}
- public getApps(room: Room, pinned?: boolean): IApp[] {
- const roomInfo = this.getRoom(room.roomId);
- if (!roomInfo) return [];
- if (pinned) {
- return roomInfo.widgets.filter(app => this.isPinned(app.id));
+ public movePinnedWidget(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 roomId = this.getRoomId(widgetId);
+ 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]);
}
- return roomInfo.widgets;
+
+ 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]);
+ let apps = order.map(wId => this.widgetMap.get(wId)).filter(Boolean);
+ apps = apps.slice(0, priorityWidget ? MAX_PINNED - 1 : MAX_PINNED);
+ if (priorityWidget) {
+ apps.push(priorityWidget);
+ }
+
+ return apps;
+ }
+
+ public getApps(roomId: string): IApp[] {
+ const roomInfo = this.getRoom(roomId);
+ return roomInfo?.widgets || [];
}
public doesRoomHaveConference(room: Room): boolean {
diff --git a/src/utils/ResizeNotifier.js b/src/utils/ResizeNotifier.js
index 512946828b..fd12a454f6 100644
--- a/src/utils/ResizeNotifier.js
+++ b/src/utils/ResizeNotifier.js
@@ -40,10 +40,12 @@ export default class ResizeNotifier extends EventEmitter {
startResizing() {
this._isResizing = true;
+ this.emit("isResizing", true);
}
stopResizing() {
this._isResizing = false;
+ this.emit("isResizing", false);
}
_noisyMiddlePanel() {
diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js
index 6cc95efb25..93d132efaf 100644
--- a/src/utils/WidgetUtils.js
+++ b/src/utils/WidgetUtils.js
@@ -459,6 +459,7 @@ export default class WidgetUtils {
'avatarUrl=$matrix_avatar_url',
'userId=$matrix_user_id',
'roomId=$matrix_room_id',
+ 'theme=$theme',
];
if (opts.auth) {
queryStringParts.push(`auth=${opts.auth}`);
@@ -494,4 +495,16 @@ export default class WidgetUtils {
IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id);
}
}
+
+ static isManagedByManager(app) {
+ if (WidgetUtils.isScalarUrl(app.url)) {
+ const managers = IntegrationManagers.sharedInstance();
+ if (managers.hasManager()) {
+ // TODO: Pick the right manager for the widget
+ const defaultManager = managers.getPrimaryManager();
+ return WidgetUtils.isScalarUrl(defaultManager.apiUrl);
+ }
+ }
+ return false;
+ }
}