Merge pull request #5247 from matrix-org/t3chguy/feat/room-list-widgets
Left Panel Widget support
This commit is contained in:
commit
1bbd273b01
13 changed files with 385 additions and 49 deletions
|
@ -13,6 +13,7 @@
|
||||||
@import "./structures/_HeaderButtons.scss";
|
@import "./structures/_HeaderButtons.scss";
|
||||||
@import "./structures/_HomePage.scss";
|
@import "./structures/_HomePage.scss";
|
||||||
@import "./structures/_LeftPanel.scss";
|
@import "./structures/_LeftPanel.scss";
|
||||||
|
@import "./structures/_LeftPanelWidget.scss";
|
||||||
@import "./structures/_MainSplit.scss";
|
@import "./structures/_MainSplit.scss";
|
||||||
@import "./structures/_MatrixChat.scss";
|
@import "./structures/_MatrixChat.scss";
|
||||||
@import "./structures/_MyGroups.scss";
|
@import "./structures/_MyGroups.scss";
|
||||||
|
|
145
res/css/structures/_LeftPanelWidget.scss
Normal file
145
res/css/structures/_LeftPanelWidget.scss
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget {
|
||||||
|
// largely based on RoomSublist
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget_headerContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
height: 24px;
|
||||||
|
color: $roomlist-header-color;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget_stickable {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget_headerText {
|
||||||
|
flex: 1;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
line-height: $font-16px;
|
||||||
|
font-size: $font-13px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
// Ellipsize any text overflow
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget_collapseBtn {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-right: 6px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
position: absolute;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
background-color: $roomlist-header-color;
|
||||||
|
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_LeftPanelWidget_collapseBtn_collapsed::before {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget_resizeBox {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: visible; // let the resize handle out
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AppTileFullWidth {
|
||||||
|
flex: 1 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
// need this to be flex otherwise the overflow hidden from above
|
||||||
|
// sometimes vertically centers the clipped list ... no idea why it would do this
|
||||||
|
// as the box model should be top aligned. Happens in both FF and Chromium
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
mask-image: linear-gradient(0deg, transparent, black 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget_resizerHandle {
|
||||||
|
cursor: ns-resize;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
// Override styles from library
|
||||||
|
width: unset !important;
|
||||||
|
height: 4px !important;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: -24px !important; // override from library - puts it in the margin-top of the headerContainer
|
||||||
|
|
||||||
|
// Together, these make the bar 64px wide
|
||||||
|
// These are also overridden from the library
|
||||||
|
left: calc(50% - 32px) !important;
|
||||||
|
right: calc(50% - 32px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .mx_LeftPanelWidget_resizerHandle {
|
||||||
|
opacity: 0.8;
|
||||||
|
background-color: $primary-fg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget_maximizeButton {
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 7px;
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 32px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-image: url('$(res)/img/feather-customised/widget/maximise.svg');
|
||||||
|
background: $muted-fg-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanelWidget_maximizeButtonTooltip {
|
||||||
|
margin-top: -3px;
|
||||||
|
}
|
|
@ -59,10 +59,6 @@ limitations under the License.
|
||||||
width: calc(100% - 22px);
|
width: calc(100% - 22px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mx_RoomSublist_headerContainer_stickyBottom {
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We don't have a top style because the top is dependent on the room list header's
|
// We don't have a top style because the top is dependent on the room list header's
|
||||||
// height, and is therefore calculated in JS.
|
// height, and is therefore calculated in JS.
|
||||||
// The class, mx_RoomSublist_headerContainer_stickyTop, is applied though.
|
// The class, mx_RoomSublist_headerContainer_stickyTop, is applied though.
|
||||||
|
|
|
@ -205,7 +205,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
|
||||||
// onFocus should be called when the index gained focus in any manner
|
// onFocus should be called when the index gained focus in any manner
|
||||||
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
|
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
|
||||||
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
||||||
export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => {
|
export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
|
||||||
const context = useContext(RovingTabIndexContext);
|
const context = useContext(RovingTabIndexContext);
|
||||||
let ref = useRef<HTMLElement>(null);
|
let ref = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,7 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||||
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
||||||
|
import LeftPanelWidget from "./LeftPanelWidget";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
isMinimized: boolean;
|
isMinimized: boolean;
|
||||||
|
@ -142,7 +143,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
const bottomEdge = list.offsetHeight + list.scrollTop;
|
const bottomEdge = list.offsetHeight + list.scrollTop;
|
||||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
|
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
|
||||||
|
|
||||||
const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
|
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
|
||||||
const headerStickyWidth = list.clientWidth - headerRightMargin;
|
const headerStickyWidth = list.clientWidth - headerRightMargin;
|
||||||
|
|
||||||
// We track which styles we want on a target before making the changes to avoid
|
// We track which styles we want on a target before making the changes to avoid
|
||||||
|
@ -213,10 +214,19 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||||
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
|
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
|
||||||
|
const newBottom = `${offset}px`;
|
||||||
|
if (header.style.bottom !== newBottom) {
|
||||||
|
header.style.bottom = newBottom;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
|
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
|
||||||
}
|
}
|
||||||
|
if (header.style.bottom) {
|
||||||
|
header.style.removeProperty('bottom');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (style.stickyTop || style.stickyBottom) {
|
if (style.stickyTop || style.stickyBottom) {
|
||||||
|
@ -425,6 +435,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
{roomList}
|
{roomList}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
149
src/components/structures/LeftPanelWidget.tsx
Normal file
149
src/components/structures/LeftPanelWidget.tsx
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 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 React, {useContext, useEffect, useMemo} from "react";
|
||||||
|
import {Resizable} from "re-resizable";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||||
|
import {useRovingTabIndex} from "../../accessibility/RovingTabIndex";
|
||||||
|
import {Key} from "../../Keyboard";
|
||||||
|
import {useLocalStorageState} from "../../hooks/useLocalStorageState";
|
||||||
|
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||||
|
import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
|
||||||
|
import {useAccountData} from "../../hooks/useAccountData";
|
||||||
|
import AppTile from "../views/elements/AppTile";
|
||||||
|
import {useSettingValue} from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
onResize(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 100;
|
||||||
|
const MAX_HEIGHT = 500; // or 50% of the window height
|
||||||
|
const INITIAL_HEIGHT = 280;
|
||||||
|
|
||||||
|
const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
|
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
|
||||||
|
const leftPanelWidgetId = useSettingValue("Widgets.leftPanel");
|
||||||
|
const app = useMemo(() => {
|
||||||
|
if (!mWidgetsEvent || !leftPanelWidgetId) return null;
|
||||||
|
const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId);
|
||||||
|
if (!widgetConfig) return null;
|
||||||
|
|
||||||
|
return WidgetUtils.makeAppConfig(
|
||||||
|
widgetConfig.state_key,
|
||||||
|
widgetConfig.content,
|
||||||
|
widgetConfig.sender,
|
||||||
|
null,
|
||||||
|
widgetConfig.id);
|
||||||
|
}, [mWidgetsEvent, leftPanelWidgetId]);
|
||||||
|
|
||||||
|
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
|
||||||
|
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
|
||||||
|
useEffect(onResize, [expanded]);
|
||||||
|
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||||
|
const tabIndex = isActive ? 0 : -1;
|
||||||
|
|
||||||
|
if (!app) return null;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
if (expanded) {
|
||||||
|
content = <Resizable
|
||||||
|
size={{height} as any}
|
||||||
|
minHeight={MIN_HEIGHT}
|
||||||
|
maxHeight={Math.min(window.innerHeight / 2, MAX_HEIGHT)}
|
||||||
|
onResize={onResize}
|
||||||
|
onResizeStop={(e, dir, ref, d) => {
|
||||||
|
setHeight(height + d.height);
|
||||||
|
}}
|
||||||
|
handleWrapperClass="mx_LeftPanelWidget_resizerHandles"
|
||||||
|
handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}}
|
||||||
|
className="mx_LeftPanelWidget_resizeBox"
|
||||||
|
enable={{ top: true }}
|
||||||
|
>
|
||||||
|
<AppTile
|
||||||
|
app={app}
|
||||||
|
fullWidth
|
||||||
|
show
|
||||||
|
showMenubar={false}
|
||||||
|
userWidget
|
||||||
|
userId={cli.getUserId()}
|
||||||
|
creatorUserId={app.creatorUserId}
|
||||||
|
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||||
|
waitForIframeLoad={app.waitForIframeLoad}
|
||||||
|
/>
|
||||||
|
</Resizable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="mx_LeftPanelWidget">
|
||||||
|
<div
|
||||||
|
onFocus={onFocus}
|
||||||
|
className="mx_LeftPanelWidget_headerContainer"
|
||||||
|
onKeyDown={(ev: React.KeyboardEvent) => {
|
||||||
|
switch (ev.key) {
|
||||||
|
case Key.ARROW_LEFT:
|
||||||
|
ev.stopPropagation();
|
||||||
|
setExpanded(false);
|
||||||
|
break;
|
||||||
|
case Key.ARROW_RIGHT: {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setExpanded(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mx_LeftPanelWidget_stickable">
|
||||||
|
<AccessibleButton
|
||||||
|
onFocus={onFocus}
|
||||||
|
inputRef={ref}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className="mx_LeftPanelWidget_headerText"
|
||||||
|
role="treeitem"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-level={1}
|
||||||
|
onClick={() => {
|
||||||
|
setExpanded(e => !e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={classNames({
|
||||||
|
"mx_LeftPanelWidget_collapseBtn": true,
|
||||||
|
"mx_LeftPanelWidget_collapseBtn_collapsed": !expanded,
|
||||||
|
})} />
|
||||||
|
<span>{ WidgetUtils.getWidgetName(app) }</span>
|
||||||
|
</AccessibleButton>
|
||||||
|
|
||||||
|
{/* Code for the maximise button for once we have full screen widgets */}
|
||||||
|
{/*<AccessibleTooltipButton
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
onClick={() => {
|
||||||
|
}}
|
||||||
|
className="mx_LeftPanelWidget_maximizeButton"
|
||||||
|
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
|
||||||
|
title={_t("Maximize")}
|
||||||
|
/>*/}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ content }
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LeftPanelWidget;
|
|
@ -61,6 +61,7 @@ export default class AppTile extends React.Component {
|
||||||
// This is a function to make the impact of calling SettingsStore slightly less
|
// This is a function to make the impact of calling SettingsStore slightly less
|
||||||
hasPermissionToLoad = (props) => {
|
hasPermissionToLoad = (props) => {
|
||||||
if (this._usingLocalWidget()) return true;
|
if (this._usingLocalWidget()) return true;
|
||||||
|
if (!props.room) return true; // user widgets always have permissions
|
||||||
|
|
||||||
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
|
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
|
||||||
if (currentlyAllowedWidgets[props.app.eventId] === undefined) {
|
if (currentlyAllowedWidgets[props.app.eventId] === undefined) {
|
||||||
|
@ -335,6 +336,7 @@ export default class AppTile extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!this.state.hasPermissionToLoad) {
|
if (!this.state.hasPermissionToLoad) {
|
||||||
|
// only possible for room widgets, can assert this.props.room here
|
||||||
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
|
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
|
||||||
appTileBody = (
|
appTileBody = (
|
||||||
<div className={appTileBodyClass}>
|
<div className={appTileBodyClass}>
|
||||||
|
@ -446,7 +448,9 @@ AppTile.displayName = 'AppTile';
|
||||||
|
|
||||||
AppTile.propTypes = {
|
AppTile.propTypes = {
|
||||||
app: PropTypes.object.isRequired,
|
app: PropTypes.object.isRequired,
|
||||||
room: PropTypes.object.isRequired,
|
// If room is not specified then it is an account level widget
|
||||||
|
// which bypasses permission prompts as it was added explicitly by that user
|
||||||
|
room: PropTypes.object,
|
||||||
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
|
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
|
||||||
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
|
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
|
||||||
fullWidth: PropTypes.bool,
|
fullWidth: PropTypes.bool,
|
||||||
|
|
|
@ -26,7 +26,7 @@ const getValue = <T>(key: string, initialValue: T): T => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hook behaving like useState but persisting the value to localStorage. Returns same as useState
|
// Hook behaving like useState but persisting the value to localStorage. Returns same as useState
|
||||||
export const useLocalStorageState = <T>(key: string, initialValue: T) => {
|
export const useLocalStorageState = <T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] => {
|
||||||
const lsKey = "mx_" + key;
|
const lsKey = "mx_" + key;
|
||||||
|
|
||||||
const [value, setValue] = useState<T>(getValue(lsKey, initialValue));
|
const [value, setValue] = useState<T>(getValue(lsKey, initialValue));
|
||||||
|
|
|
@ -120,7 +120,7 @@ export class IntegrationManagers {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
const uiUrl = w.content['url'];
|
const uiUrl = w.content['url'];
|
||||||
const apiUrl = data['api_url'];
|
const apiUrl = data['api_url'] as string;
|
||||||
if (!apiUrl || !uiUrl) return;
|
if (!apiUrl || !uiUrl) return;
|
||||||
|
|
||||||
const manager = new IntegrationManagerInstance(
|
const manager = new IntegrationManagerInstance(
|
||||||
|
|
|
@ -626,6 +626,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
||||||
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
|
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
|
||||||
default: {},
|
default: {},
|
||||||
},
|
},
|
||||||
|
"Widgets.leftPanel": {
|
||||||
|
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
[UIFeature.AdvancedEncryption]: {
|
[UIFeature.AdvancedEncryption]: {
|
||||||
supportedLevels: LEVELS_UI_FEATURE,
|
supportedLevels: LEVELS_UI_FEATURE,
|
||||||
default: true,
|
default: true,
|
||||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
import { IWidget } from "matrix-widget-api";
|
||||||
|
|
||||||
import { ActionPayload } from "../dispatcher/payloads";
|
import { ActionPayload } from "../dispatcher/payloads";
|
||||||
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
||||||
|
@ -31,13 +32,9 @@ import {UPDATE_EVENT} from "./AsyncStore";
|
||||||
|
|
||||||
interface IState {}
|
interface IState {}
|
||||||
|
|
||||||
export interface IApp {
|
export interface IApp extends IWidget {
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
roomId: string;
|
roomId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
creatorUserId: string;
|
|
||||||
waitForIframeLoad?: boolean;
|
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
|
avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,7 +74,7 @@ class ElementWidget extends Widget {
|
||||||
if (WidgetType.JITSI.matches(this.type)) {
|
if (WidgetType.JITSI.matches(this.type)) {
|
||||||
return WidgetUtils.getLocalJitsiWrapperUrl({
|
return WidgetUtils.getLocalJitsiWrapperUrl({
|
||||||
forLocalRender: true,
|
forLocalRender: true,
|
||||||
auth: super.rawData?.auth, // this.rawData can call templateUrl, do this to prevent looping
|
auth: super.rawData?.auth as string, // this.rawData can call templateUrl, do this to prevent looping
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return super.templateUrl;
|
return super.templateUrl;
|
||||||
|
@ -84,7 +84,7 @@ class ElementWidget extends Widget {
|
||||||
if (WidgetType.JITSI.matches(this.type)) {
|
if (WidgetType.JITSI.matches(this.type)) {
|
||||||
return WidgetUtils.getLocalJitsiWrapperUrl({
|
return WidgetUtils.getLocalJitsiWrapperUrl({
|
||||||
forLocalRender: false, // The only important difference between this and templateUrl()
|
forLocalRender: false, // The only important difference between this and templateUrl()
|
||||||
auth: super.rawData?.auth,
|
auth: super.rawData?.auth as string,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this.templateUrl; // use this instead of super to ensure we get appropriate templating
|
return this.templateUrl; // use this instead of super to ensure we get appropriate templating
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2017 Vector Creations Ltd
|
|
||||||
Copyright 2018 New Vector Ltd
|
|
||||||
Copyright 2019 Travis Ralston
|
Copyright 2019 Travis Ralston
|
||||||
|
Copyright 2017 - 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -16,15 +15,12 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as url from "url";
|
||||||
|
|
||||||
import {MatrixClientPeg} from '../MatrixClientPeg';
|
import {MatrixClientPeg} from '../MatrixClientPeg';
|
||||||
import SdkConfig from "../SdkConfig";
|
import SdkConfig from "../SdkConfig";
|
||||||
import dis from '../dispatcher/dispatcher';
|
import dis from '../dispatcher/dispatcher';
|
||||||
import * as url from "url";
|
|
||||||
import WidgetEchoStore from '../stores/WidgetEchoStore';
|
import WidgetEchoStore from '../stores/WidgetEchoStore';
|
||||||
|
|
||||||
// How long we wait for the state event echo to come back from the server
|
|
||||||
// before waitFor[Room/User]Widget rejects its promise
|
|
||||||
const WIDGET_WAIT_TIME = 20000;
|
|
||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
|
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
|
||||||
import {IntegrationManagers} from "../integrations/IntegrationManagers";
|
import {IntegrationManagers} from "../integrations/IntegrationManagers";
|
||||||
|
@ -32,7 +28,21 @@ import {Room} from "matrix-js-sdk/src/models/room";
|
||||||
import {WidgetType} from "../widgets/WidgetType";
|
import {WidgetType} from "../widgets/WidgetType";
|
||||||
import {objectClone} from "./objects";
|
import {objectClone} from "./objects";
|
||||||
import {_t} from "../languageHandler";
|
import {_t} from "../languageHandler";
|
||||||
import {MatrixCapabilities} from "matrix-widget-api";
|
import {Capability, IWidgetData, MatrixCapabilities} from "matrix-widget-api";
|
||||||
|
import {IApp} from "../stores/WidgetStore"; // TODO @@
|
||||||
|
|
||||||
|
// How long we wait for the state event echo to come back from the server
|
||||||
|
// before waitFor[Room/User]Widget rejects its promise
|
||||||
|
const WIDGET_WAIT_TIME = 20000;
|
||||||
|
|
||||||
|
export interface IWidgetEvent {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
sender: string;
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
state_key: string;
|
||||||
|
content: Partial<IApp>;
|
||||||
|
}
|
||||||
|
|
||||||
export default class WidgetUtils {
|
export default class WidgetUtils {
|
||||||
/* Returns true if user is able to send state events to modify widgets in this room
|
/* Returns true if user is able to send state events to modify widgets in this room
|
||||||
|
@ -41,7 +51,7 @@ export default class WidgetUtils {
|
||||||
* @return Boolean -- true if the user can modify widgets in this room
|
* @return Boolean -- true if the user can modify widgets in this room
|
||||||
* @throws Error -- specifies the error reason
|
* @throws Error -- specifies the error reason
|
||||||
*/
|
*/
|
||||||
static canUserModifyWidgets(roomId) {
|
static canUserModifyWidgets(roomId: string): boolean {
|
||||||
if (!roomId) {
|
if (!roomId) {
|
||||||
console.warn('No room ID specified');
|
console.warn('No room ID specified');
|
||||||
return false;
|
return false;
|
||||||
|
@ -80,7 +90,7 @@ export default class WidgetUtils {
|
||||||
* @param {[type]} testUrlString URL to check
|
* @param {[type]} testUrlString URL to check
|
||||||
* @return {Boolean} True if specified URL is a scalar URL
|
* @return {Boolean} True if specified URL is a scalar URL
|
||||||
*/
|
*/
|
||||||
static isScalarUrl(testUrlString) {
|
static isScalarUrl(testUrlString: string): boolean {
|
||||||
if (!testUrlString) {
|
if (!testUrlString) {
|
||||||
console.error('Scalar URL check failed. No URL specified');
|
console.error('Scalar URL check failed. No URL specified');
|
||||||
return false;
|
return false;
|
||||||
|
@ -123,7 +133,7 @@ export default class WidgetUtils {
|
||||||
* @returns {Promise} that resolves when the widget is in the
|
* @returns {Promise} that resolves when the widget is in the
|
||||||
* requested state according to the `add` param
|
* requested state according to the `add` param
|
||||||
*/
|
*/
|
||||||
static waitForUserWidget(widgetId, add) {
|
static waitForUserWidget(widgetId: string, add: boolean): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Tests an account data event, returning true if it's in the state
|
// Tests an account data event, returning true if it's in the state
|
||||||
// we're waiting for it to be in
|
// we're waiting for it to be in
|
||||||
|
@ -170,7 +180,7 @@ export default class WidgetUtils {
|
||||||
* @returns {Promise} that resolves when the widget is in the
|
* @returns {Promise} that resolves when the widget is in the
|
||||||
* requested state according to the `add` param
|
* requested state according to the `add` param
|
||||||
*/
|
*/
|
||||||
static waitForRoomWidget(widgetId, roomId, add) {
|
static waitForRoomWidget(widgetId: string, roomId: string, add: boolean): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Tests a list of state events, returning true if it's in the state
|
// Tests a list of state events, returning true if it's in the state
|
||||||
// we're waiting for it to be in
|
// we're waiting for it to be in
|
||||||
|
@ -213,7 +223,13 @@ export default class WidgetUtils {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static setUserWidget(widgetId, widgetType: WidgetType, widgetUrl, widgetName, widgetData) {
|
static setUserWidget(
|
||||||
|
widgetId: string,
|
||||||
|
widgetType: WidgetType,
|
||||||
|
widgetUrl: string,
|
||||||
|
widgetName: string,
|
||||||
|
widgetData: IWidgetData,
|
||||||
|
) {
|
||||||
const content = {
|
const content = {
|
||||||
type: widgetType.preferred,
|
type: widgetType.preferred,
|
||||||
url: widgetUrl,
|
url: widgetUrl,
|
||||||
|
@ -257,7 +273,14 @@ export default class WidgetUtils {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static setRoomWidget(roomId, widgetId, widgetType: WidgetType, widgetUrl, widgetName, widgetData) {
|
static setRoomWidget(
|
||||||
|
roomId: string,
|
||||||
|
widgetId: string,
|
||||||
|
widgetType?: WidgetType,
|
||||||
|
widgetUrl?: string,
|
||||||
|
widgetName?: string,
|
||||||
|
widgetData?: object,
|
||||||
|
) {
|
||||||
let content;
|
let content;
|
||||||
|
|
||||||
const addingWidget = Boolean(widgetUrl);
|
const addingWidget = Boolean(widgetUrl);
|
||||||
|
@ -307,7 +330,7 @@ export default class WidgetUtils {
|
||||||
* Get user specific widgets (not linked to a specific room)
|
* Get user specific widgets (not linked to a specific room)
|
||||||
* @return {object} Event content object containing current / active user widgets
|
* @return {object} Event content object containing current / active user widgets
|
||||||
*/
|
*/
|
||||||
static getUserWidgets() {
|
static getUserWidgets(): Record<string, IWidgetEvent> {
|
||||||
const client = MatrixClientPeg.get();
|
const client = MatrixClientPeg.get();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error('User not logged in');
|
throw new Error('User not logged in');
|
||||||
|
@ -323,7 +346,7 @@ export default class WidgetUtils {
|
||||||
* Get user specific widgets (not linked to a specific room) as an array
|
* Get user specific widgets (not linked to a specific room) as an array
|
||||||
* @return {[object]} Array containing current / active user widgets
|
* @return {[object]} Array containing current / active user widgets
|
||||||
*/
|
*/
|
||||||
static getUserWidgetsArray() {
|
static getUserWidgetsArray(): IWidgetEvent[] {
|
||||||
return Object.values(WidgetUtils.getUserWidgets());
|
return Object.values(WidgetUtils.getUserWidgets());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -331,7 +354,7 @@ export default class WidgetUtils {
|
||||||
* Get active stickerpicker widgets (stickerpickers are user widgets by nature)
|
* Get active stickerpicker widgets (stickerpickers are user widgets by nature)
|
||||||
* @return {[object]} Array containing current / active stickerpicker widgets
|
* @return {[object]} Array containing current / active stickerpicker widgets
|
||||||
*/
|
*/
|
||||||
static getStickerpickerWidgets() {
|
static getStickerpickerWidgets(): IWidgetEvent[] {
|
||||||
const widgets = WidgetUtils.getUserWidgetsArray();
|
const widgets = WidgetUtils.getUserWidgetsArray();
|
||||||
return widgets.filter((widget) => widget.content && widget.content.type === "m.stickerpicker");
|
return widgets.filter((widget) => widget.content && widget.content.type === "m.stickerpicker");
|
||||||
}
|
}
|
||||||
|
@ -340,12 +363,12 @@ export default class WidgetUtils {
|
||||||
* Get all integration manager widgets for this user.
|
* Get all integration manager widgets for this user.
|
||||||
* @returns {Object[]} An array of integration manager user widgets.
|
* @returns {Object[]} An array of integration manager user widgets.
|
||||||
*/
|
*/
|
||||||
static getIntegrationManagerWidgets() {
|
static getIntegrationManagerWidgets(): IWidgetEvent[] {
|
||||||
const widgets = WidgetUtils.getUserWidgetsArray();
|
const widgets = WidgetUtils.getUserWidgetsArray();
|
||||||
return widgets.filter(w => w.content && w.content.type === "m.integration_manager");
|
return widgets.filter(w => w.content && w.content.type === "m.integration_manager");
|
||||||
}
|
}
|
||||||
|
|
||||||
static getRoomWidgetsOfType(room: Room, type: WidgetType) {
|
static getRoomWidgetsOfType(room: Room, type: WidgetType): IWidgetEvent[] {
|
||||||
const widgets = WidgetUtils.getRoomWidgets(room);
|
const widgets = WidgetUtils.getRoomWidgets(room);
|
||||||
return (widgets || []).filter(w => {
|
return (widgets || []).filter(w => {
|
||||||
const content = w.getContent();
|
const content = w.getContent();
|
||||||
|
@ -353,14 +376,14 @@ export default class WidgetUtils {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeIntegrationManagerWidgets() {
|
static removeIntegrationManagerWidgets(): Promise<void> {
|
||||||
const client = MatrixClientPeg.get();
|
const client = MatrixClientPeg.get();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error('User not logged in');
|
throw new Error('User not logged in');
|
||||||
}
|
}
|
||||||
const widgets = client.getAccountData('m.widgets');
|
const widgets = client.getAccountData('m.widgets');
|
||||||
if (!widgets) return;
|
if (!widgets) return;
|
||||||
const userWidgets = widgets.getContent() || {};
|
const userWidgets: IWidgetEvent[] = widgets.getContent() || {};
|
||||||
Object.entries(userWidgets).forEach(([key, widget]) => {
|
Object.entries(userWidgets).forEach(([key, widget]) => {
|
||||||
if (widget.content && widget.content.type === "m.integration_manager") {
|
if (widget.content && widget.content.type === "m.integration_manager") {
|
||||||
delete userWidgets[key];
|
delete userWidgets[key];
|
||||||
|
@ -369,7 +392,7 @@ export default class WidgetUtils {
|
||||||
return client.setAccountData('m.widgets', userWidgets);
|
return client.setAccountData('m.widgets', userWidgets);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addIntegrationManagerWidget(name: string, uiUrl: string, apiUrl: string) {
|
static addIntegrationManagerWidget(name: string, uiUrl: string, apiUrl: string): Promise<void> {
|
||||||
return WidgetUtils.setUserWidget(
|
return WidgetUtils.setUserWidget(
|
||||||
"integration_manager_" + (new Date().getTime()),
|
"integration_manager_" + (new Date().getTime()),
|
||||||
WidgetType.INTEGRATION_MANAGER,
|
WidgetType.INTEGRATION_MANAGER,
|
||||||
|
@ -383,14 +406,14 @@ export default class WidgetUtils {
|
||||||
* Remove all stickerpicker widgets (stickerpickers are user widgets by nature)
|
* Remove all stickerpicker widgets (stickerpickers are user widgets by nature)
|
||||||
* @return {Promise} Resolves on account data updated
|
* @return {Promise} Resolves on account data updated
|
||||||
*/
|
*/
|
||||||
static removeStickerpickerWidgets() {
|
static removeStickerpickerWidgets(): Promise<void> {
|
||||||
const client = MatrixClientPeg.get();
|
const client = MatrixClientPeg.get();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error('User not logged in');
|
throw new Error('User not logged in');
|
||||||
}
|
}
|
||||||
const widgets = client.getAccountData('m.widgets');
|
const widgets = client.getAccountData('m.widgets');
|
||||||
if (!widgets) return;
|
if (!widgets) return;
|
||||||
const userWidgets = widgets.getContent() || {};
|
const userWidgets: Record<string, IWidgetEvent> = widgets.getContent() || {};
|
||||||
Object.entries(userWidgets).forEach(([key, widget]) => {
|
Object.entries(userWidgets).forEach(([key, widget]) => {
|
||||||
if (widget.content && widget.content.type === 'm.stickerpicker') {
|
if (widget.content && widget.content.type === 'm.stickerpicker') {
|
||||||
delete userWidgets[key];
|
delete userWidgets[key];
|
||||||
|
@ -399,7 +422,13 @@ export default class WidgetUtils {
|
||||||
return client.setAccountData('m.widgets', userWidgets);
|
return client.setAccountData('m.widgets', userWidgets);
|
||||||
}
|
}
|
||||||
|
|
||||||
static makeAppConfig(appId, app, senderUserId, roomId, eventId) {
|
static makeAppConfig(
|
||||||
|
appId: string,
|
||||||
|
app: Partial<IApp>,
|
||||||
|
senderUserId: string,
|
||||||
|
roomId: string | null,
|
||||||
|
eventId: string,
|
||||||
|
): IApp {
|
||||||
if (!senderUserId) {
|
if (!senderUserId) {
|
||||||
throw new Error("Widgets must be created by someone - provide a senderUserId");
|
throw new Error("Widgets must be created by someone - provide a senderUserId");
|
||||||
}
|
}
|
||||||
|
@ -410,10 +439,10 @@ export default class WidgetUtils {
|
||||||
app.eventId = eventId;
|
app.eventId = eventId;
|
||||||
app.name = app.name || app.type;
|
app.name = app.name || app.type;
|
||||||
|
|
||||||
return app;
|
return app as IApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getCapWhitelistForAppTypeInRoomId(appType, roomId) {
|
static getCapWhitelistForAppTypeInRoomId(appType: string, roomId: string): Capability[] {
|
||||||
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId);
|
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId);
|
||||||
|
|
||||||
const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : [];
|
const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : [];
|
||||||
|
@ -428,7 +457,7 @@ export default class WidgetUtils {
|
||||||
return capWhitelist;
|
return capWhitelist;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getWidgetSecurityKey(widgetId, widgetUrl, isUserWidget) {
|
static getWidgetSecurityKey(widgetId: string, widgetUrl: string, isUserWidget: boolean): string {
|
||||||
let widgetLocation = ActiveWidgetStore.getRoomId(widgetId);
|
let widgetLocation = ActiveWidgetStore.getRoomId(widgetId);
|
||||||
|
|
||||||
if (isUserWidget) {
|
if (isUserWidget) {
|
||||||
|
@ -449,7 +478,7 @@ export default class WidgetUtils {
|
||||||
return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
|
return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string}={}) {
|
static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string} = {}) {
|
||||||
// NB. we can't just encodeURIComponent all of these because the $ signs need to be there
|
// NB. we can't just encodeURIComponent all of these because the $ signs need to be there
|
||||||
const queryStringParts = [
|
const queryStringParts = [
|
||||||
'conferenceDomain=$domain',
|
'conferenceDomain=$domain',
|
||||||
|
@ -466,7 +495,7 @@ export default class WidgetUtils {
|
||||||
}
|
}
|
||||||
const queryString = queryStringParts.join('&');
|
const queryString = queryStringParts.join('&');
|
||||||
|
|
||||||
let baseUrl = window.location;
|
let baseUrl = window.location.href;
|
||||||
if (window.location.protocol !== "https:" && !opts.forLocalRender) {
|
if (window.location.protocol !== "https:" && !opts.forLocalRender) {
|
||||||
// Use an external wrapper if we're not locally rendering the widget. This is usually
|
// Use an external wrapper if we're not locally rendering the widget. This is usually
|
||||||
// the URL that will end up in the widget event, so we want to make sure it's relatively
|
// the URL that will end up in the widget event, so we want to make sure it's relatively
|
||||||
|
@ -479,15 +508,15 @@ export default class WidgetUtils {
|
||||||
return url.href;
|
return url.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getWidgetName(app) {
|
static getWidgetName(app?: IApp): string {
|
||||||
return app?.name?.trim() || _t("Unknown App");
|
return app?.name?.trim() || _t("Unknown App");
|
||||||
}
|
}
|
||||||
|
|
||||||
static getWidgetDataTitle(app) {
|
static getWidgetDataTitle(app?: IApp): string {
|
||||||
return app?.data?.title?.trim() || "";
|
return app?.data?.title?.trim() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
static editWidget(room, app) {
|
static editWidget(room: Room, app: IApp): void {
|
||||||
// TODO: Open the right manager for the widget
|
// TODO: Open the right manager for the widget
|
||||||
if (SettingsStore.getValue("feature_many_integration_managers")) {
|
if (SettingsStore.getValue("feature_many_integration_managers")) {
|
||||||
IntegrationManagers.sharedInstance().openAll(room, 'type_' + app.type, app.id);
|
IntegrationManagers.sharedInstance().openAll(room, 'type_' + app.type, app.id);
|
Loading…
Add table
Add a link
Reference in a new issue