Merge pull request #5247 from matrix-org/t3chguy/feat/room-list-widgets

Left Panel Widget support
This commit is contained in:
Michael Telatynski 2020-10-21 12:59:41 +01:00 committed by GitHub
commit 1bbd273b01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 385 additions and 49 deletions

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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