Merge branch 'develop' into warn-on-access-token-reveal
This commit is contained in:
commit
ff788ca3da
541 changed files with 47991 additions and 13927 deletions
|
@ -76,6 +76,7 @@ export interface IProps extends IPosition {
|
|||
hasBackground?: boolean;
|
||||
// whether this context menu should be focus managed. If false it must handle itself
|
||||
managed?: boolean;
|
||||
wrapperClassName?: string;
|
||||
|
||||
// Function to be called on menu close
|
||||
onFinished();
|
||||
|
@ -299,7 +300,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
// such that it does not leave the (padded) window.
|
||||
if (contextMenuRect) {
|
||||
const padding = 10;
|
||||
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height + padding);
|
||||
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
|
||||
}
|
||||
|
||||
position.top = adjusted;
|
||||
|
@ -365,7 +366,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="mx_ContextualMenu_wrapper"
|
||||
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
|
||||
style={{...position, ...wrapperStyle}}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
|
@ -390,15 +391,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
||||
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
|
||||
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => {
|
||||
const left = elementRect.right + window.pageXOffset + 3;
|
||||
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
|
||||
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
||||
return {left, top, chevronOffset};
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
|
||||
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
|
@ -408,16 +410,52 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
|
|||
menuOptions.right = window.innerWidth - buttonRight;
|
||||
// Align the menu vertically on whichever side of the button has more space available.
|
||||
if (buttonBottom < window.innerHeight / 2) {
|
||||
menuOptions.top = buttonBottom;
|
||||
menuOptions.top = buttonBottom + vPadding;
|
||||
} else {
|
||||
menuOptions.bottom = window.innerHeight - buttonTop;
|
||||
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
|
||||
}
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
export const useContextMenu = (): [boolean, RefObject<HTMLElement>, () => void, () => void, (val: boolean) => void] => {
|
||||
const button = useRef<HTMLElement>(null);
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
|
||||
// and always above elementRect
|
||||
export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
const buttonBottom = elementRect.bottom + window.pageYOffset;
|
||||
const buttonTop = elementRect.top + window.pageYOffset;
|
||||
// Align the right edge of the menu to the right edge of the button
|
||||
menuOptions.right = window.innerWidth - buttonRight;
|
||||
// Align the menu vertically on whichever side of the button has more space available.
|
||||
if (buttonBottom < window.innerHeight / 2) {
|
||||
menuOptions.top = buttonBottom + vPadding;
|
||||
} else {
|
||||
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
|
||||
}
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect
|
||||
// and always above elementRect
|
||||
export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonLeft = elementRect.left + window.pageXOffset;
|
||||
const buttonTop = elementRect.top + window.pageYOffset;
|
||||
// Align the left edge of the menu to the left edge of the button
|
||||
menuOptions.left = buttonLeft;
|
||||
// Align the menu vertically above the menu
|
||||
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void];
|
||||
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
|
||||
const button = useRef<T>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const open = () => {
|
||||
setIsOpen(true);
|
||||
|
|
|
@ -45,7 +45,7 @@ class FilePanel extends React.Component {
|
|||
};
|
||||
|
||||
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
|
||||
if (room.roomId !== this.props.roomId) return;
|
||||
if (room?.roomId !== this.props?.roomId) return;
|
||||
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
|
||||
|
||||
if (ev.isBeingDecrypted()) {
|
||||
|
|
|
@ -47,7 +47,7 @@ const LONG_DESC_PLACEHOLDER = _td(
|
|||
some important <a href="foo">links</a>
|
||||
</p>
|
||||
<p>
|
||||
You can even use 'img' tags
|
||||
You can even add images with Matrix URLs <img src="mxc://url" />
|
||||
</p>
|
||||
`);
|
||||
|
||||
|
|
|
@ -15,20 +15,83 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {useContext, useState} from "react";
|
||||
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import { getHomePageUrl } from "../../utils/pages";
|
||||
import { _t } from "../../languageHandler";
|
||||
import {getHomePageUrl} from "../../utils/pages";
|
||||
import {_t} from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import {OwnProfileStore} from "../../stores/OwnProfileStore";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import {UPDATE_EVENT} from "../../stores/AsyncStore";
|
||||
import {useEventEmitter} from "../../hooks/useEventEmitter";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader";
|
||||
import Analytics from "../../Analytics";
|
||||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
|
||||
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
|
||||
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
|
||||
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
|
||||
const onClickSendDm = () => {
|
||||
Analytics.trackEvent('home_page', 'button', 'dm');
|
||||
CountlyAnalytics.instance.track("home_page_button", { button: "dm" });
|
||||
dis.dispatch({action: 'view_create_chat'});
|
||||
};
|
||||
|
||||
const HomePage = () => {
|
||||
const onClickExplore = () => {
|
||||
Analytics.trackEvent('home_page', 'button', 'room_directory');
|
||||
CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" });
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
const onClickNewRoom = () => {
|
||||
Analytics.trackEvent('home_page', 'button', 'create_room');
|
||||
CountlyAnalytics.instance.track("home_page_button", { button: "create_room" });
|
||||
dis.dispatch({action: 'view_create_room'});
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
justRegistered?: boolean;
|
||||
}
|
||||
|
||||
const getOwnProfile = (userId: string) => ({
|
||||
displayName: OwnProfileStore.instance.displayName || userId,
|
||||
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE),
|
||||
});
|
||||
|
||||
const UserWelcomeTop = () => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const userId = cli.getUserId();
|
||||
const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId));
|
||||
useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => {
|
||||
setOwnProfile(getOwnProfile(userId));
|
||||
});
|
||||
|
||||
return <div>
|
||||
<MiniAvatarUploader
|
||||
hasAvatar={!!ownProfile.avatarUrl}
|
||||
hasAvatarLabel={_t("Great, that'll help people know it's you")}
|
||||
noAvatarLabel={_t("Add a photo so people know it's you.")}
|
||||
setAvatarUrl={url => cli.setAvatarUrl(url)}
|
||||
>
|
||||
<BaseAvatar
|
||||
idName={userId}
|
||||
name={ownProfile.displayName}
|
||||
url={ownProfile.avatarUrl}
|
||||
width={AVATAR_SIZE}
|
||||
height={AVATAR_SIZE}
|
||||
resizeMethod="crop"
|
||||
/>
|
||||
</MiniAvatarUploader>
|
||||
|
||||
<h1>{ _t("Welcome %(name)s", { name: ownProfile.displayName }) }</h1>
|
||||
<h4>{ _t("Now, let's help you get started") }</h4>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
|
||||
const config = SdkConfig.get();
|
||||
const pageUrl = getHomePageUrl(config);
|
||||
|
||||
|
@ -37,18 +100,27 @@ const HomePage = () => {
|
|||
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
|
||||
}
|
||||
|
||||
const brandingConfig = config.branding;
|
||||
let logoUrl = "themes/element/img/logos/element-logo.svg";
|
||||
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
|
||||
logoUrl = brandingConfig.authHeaderLogoUrl;
|
||||
let introSection;
|
||||
if (justRegistered) {
|
||||
introSection = <UserWelcomeTop />;
|
||||
} else {
|
||||
const brandingConfig = config.branding;
|
||||
let logoUrl = "themes/element/img/logos/element-logo.svg";
|
||||
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
|
||||
logoUrl = brandingConfig.authHeaderLogoUrl;
|
||||
}
|
||||
|
||||
introSection = <React.Fragment>
|
||||
<img src={logoUrl} alt={config.brand} />
|
||||
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand }) }</h1>
|
||||
<h4>{ _t("Liberate your communication") }</h4>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default">
|
||||
<div className="mx_HomePage_default_wrapper">
|
||||
<img src={logoUrl} alt={config.brand || "Element"} />
|
||||
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand || "Element" }) }</h1>
|
||||
<h4>{ _t("Liberate your communication") }</h4>
|
||||
{ introSection }
|
||||
<div className="mx_HomePage_default_buttons">
|
||||
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
|
||||
{ _t("Send a Direct Message") }
|
||||
|
|
56
src/components/structures/HostSignupAction.tsx
Normal file
56
src/components/structures/HostSignupAction.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { HostSignupStore } from "../../stores/HostSignupStore";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {}
|
||||
|
||||
export default class HostSignupAction extends React.PureComponent<IProps, IState> {
|
||||
private openDialog = async () => {
|
||||
await HostSignupStore.instance.setHostSignupActive(true);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const hostSignupConfig = SdkConfig.get().hostSignup;
|
||||
if (!hostSignupConfig?.brand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconizedContextMenuOptionList>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconHosting"
|
||||
label={_t(
|
||||
"Upgrade to %(hostSignupBrand)s",
|
||||
{
|
||||
hostSignupBrand: hostSignupConfig.brand,
|
||||
},
|
||||
)}
|
||||
onClick={this.openDialog}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -177,7 +177,14 @@ export default class InteractiveAuthComponent extends React.Component {
|
|||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
}, () => {
|
||||
if (oldStage != stageType) this._setFocus();
|
||||
if (oldStage !== stageType) {
|
||||
this._setFocus();
|
||||
} else if (
|
||||
!stageState.error && this._stageComponent.current &&
|
||||
this._stageComponent.current.attemptFailed
|
||||
) {
|
||||
this._stageComponent.current.attemptFailed();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -38,6 +38,8 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
|||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
||||
import LeftPanelWidget from "./LeftPanelWidget";
|
||||
import SpacePanel from "../views/spaces/SpacePanel";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
|
@ -142,7 +144,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
const bottomEdge = list.offsetHeight + list.scrollTop;
|
||||
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;
|
||||
|
||||
// We track which styles we want on a target before making the changes to avoid
|
||||
|
@ -213,10 +215,19 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
if (!header.classList.contains("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 {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
if (header.style.bottom) {
|
||||
header.style.removeProperty('bottom');
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyTop || style.stickyBottom) {
|
||||
|
@ -378,17 +389,23 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const groupFilterPanel = !this.state.showGroupFilterPanel ? null : (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
</div>
|
||||
);
|
||||
let leftLeftPanel;
|
||||
// Currently TagPanel.enableTagPanel is disabled when Legacy Communities are disabled so for now
|
||||
// ignore it and force the rendering of SpacePanel if that Labs flag is enabled.
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
leftLeftPanel = <SpacePanel />;
|
||||
} else if (this.state.showGroupFilterPanel) {
|
||||
leftLeftPanel = (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this.onKeyDown}
|
||||
resizeNotifier={null}
|
||||
collapsed={false}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
isMinimized={this.props.isMinimized}
|
||||
|
@ -397,7 +414,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
const containerClasses = classNames({
|
||||
"mx_LeftPanel": true,
|
||||
"mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel,
|
||||
"mx_LeftPanel_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
|
@ -408,7 +424,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{groupFilterPanel}
|
||||
{leftLeftPanel}
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
|
@ -426,6 +442,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
{roomList}
|
||||
</div>
|
||||
</div>
|
||||
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
|
||||
</aside>
|
||||
</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, onResize]);
|
||||
|
||||
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;
|
|
@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types';
|
|||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
|
||||
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import CallMediaHandler from '../../CallMediaHandler';
|
||||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
|
@ -52,6 +52,10 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
|
|||
import NonUrgentToastContainer from "./NonUrgentToastContainer";
|
||||
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
|
||||
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
import Modal from "../../Modal";
|
||||
import { ICollapseConfig } from "../../resizer/distributors/collapse";
|
||||
import HostSignupContainer from '../views/host_signup/HostSignupContainer';
|
||||
import { IOpts } from "../../createRoom";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
@ -87,6 +91,8 @@ interface IProps {
|
|||
currentUserId?: string;
|
||||
currentGroupId?: string;
|
||||
currentGroupIsNew?: boolean;
|
||||
justRegistered?: boolean;
|
||||
roomJustCreatedOpts?: IOpts;
|
||||
}
|
||||
|
||||
interface IUsageLimit {
|
||||
|
@ -103,7 +109,9 @@ interface IState {
|
|||
errcode: string;
|
||||
};
|
||||
};
|
||||
usageLimitDismissed: boolean;
|
||||
usageLimitEventContent?: IUsageLimit;
|
||||
usageLimitEventTs?: number;
|
||||
useCompactLayout: boolean;
|
||||
}
|
||||
|
||||
|
@ -137,7 +145,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
protected readonly _matrixClient: MatrixClient;
|
||||
protected readonly _roomView: React.RefObject<any>;
|
||||
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
|
||||
protected readonly _compactLayoutWatcherRef: string;
|
||||
protected compactLayoutWatcherRef: string;
|
||||
protected resizer: Resizer;
|
||||
|
||||
constructor(props, context) {
|
||||
|
@ -147,6 +155,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
syncErrorData: undefined,
|
||||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
usageLimitDismissed: false,
|
||||
};
|
||||
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
|
@ -154,18 +163,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
|
||||
CallMediaHandler.loadDevices();
|
||||
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
|
||||
this._updateServerNoticeEvents();
|
||||
|
||||
this._matrixClient.on("accountData", this.onAccountData);
|
||||
this._matrixClient.on("sync", this.onSync);
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
|
||||
fixupColorFonts();
|
||||
|
||||
this._roomView = React.createRef();
|
||||
|
@ -173,6 +170,24 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
|
||||
this._updateServerNoticeEvents();
|
||||
|
||||
this._matrixClient.on("accountData", this.onAccountData);
|
||||
this._matrixClient.on("sync", this.onSync);
|
||||
// Call `onSync` with the current state as well
|
||||
this.onSync(
|
||||
this._matrixClient.getSyncState(),
|
||||
null,
|
||||
this._matrixClient.getSyncStateData(),
|
||||
);
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
|
||||
this.resizer = this._createResizer();
|
||||
this.resizer.attach();
|
||||
this._loadResizerPreferences();
|
||||
|
@ -183,7 +198,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
|
||||
this.resizer.detach();
|
||||
}
|
||||
|
||||
|
@ -205,16 +220,20 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
_createResizer() {
|
||||
const classNames = {
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
};
|
||||
let size;
|
||||
const collapseConfig = {
|
||||
toggleSize: 260 - 50,
|
||||
onCollapsed: (collapsed) => {
|
||||
if (collapsed) {
|
||||
let collapsed;
|
||||
const collapseConfig: ICollapseConfig = {
|
||||
// TODO: the space panel currently does not have a fixed width,
|
||||
// just the headers at each level have a max-width of 150px
|
||||
// Taking 222px for the space panel for now,
|
||||
// so this will look slightly off for now,
|
||||
// depending on the depth of your space tree.
|
||||
// To fix this, we'll need to turn toggleSize
|
||||
// into a callback so it can be measured when starting the resize operation
|
||||
toggleSize: 222 + 68,
|
||||
onCollapsed: (_collapsed) => {
|
||||
collapsed = _collapsed;
|
||||
if (_collapsed) {
|
||||
dis.dispatch({action: "hide_left_panel"}, true);
|
||||
window.localStorage.setItem("mx_lhs_size", '0');
|
||||
} else {
|
||||
|
@ -229,12 +248,19 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this.props.resizeNotifier.startResizing();
|
||||
},
|
||||
onResizeStop: () => {
|
||||
window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
},
|
||||
isItemCollapsed: domNode => {
|
||||
return domNode.classList.contains("mx_LeftPanel_minimized");
|
||||
},
|
||||
};
|
||||
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
|
||||
resizer.setClassNames(classNames);
|
||||
resizer.setClassNames({
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle_vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
});
|
||||
return resizer;
|
||||
}
|
||||
|
||||
|
@ -291,14 +317,27 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onUsageLimitDismissed = () => {
|
||||
this.setState({
|
||||
usageLimitDismissed: true,
|
||||
});
|
||||
}
|
||||
|
||||
_calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
|
||||
const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
|
||||
if (error) {
|
||||
usageLimitEventContent = syncError.error.data;
|
||||
}
|
||||
|
||||
if (usageLimitEventContent) {
|
||||
showServerLimitToast(usageLimitEventContent.limit_type, usageLimitEventContent.admin_contact, error);
|
||||
// usageLimitDismissed is true when the user has explicitly hidden the toast
|
||||
// and it will be reset to false if a *new* usage alert comes in.
|
||||
if (usageLimitEventContent && this.state.usageLimitDismissed) {
|
||||
showServerLimitToast(
|
||||
usageLimitEventContent.limit_type,
|
||||
this.onUsageLimitDismissed,
|
||||
usageLimitEventContent.admin_contact,
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
hideServerLimitToast();
|
||||
}
|
||||
|
@ -309,10 +348,12 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
if (!serverNoticeList) return [];
|
||||
|
||||
const events = [];
|
||||
let pinnedEventTs = 0;
|
||||
for (const room of serverNoticeList) {
|
||||
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
||||
|
||||
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
||||
pinnedEventTs = pinStateEvent.getTs();
|
||||
|
||||
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
|
||||
for (const eventId of pinnedEventIds) {
|
||||
|
@ -322,6 +363,11 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
if (pinnedEventTs && this.state.usageLimitEventTs > pinnedEventTs) {
|
||||
// We've processed a newer event than this one, so ignore it.
|
||||
return;
|
||||
}
|
||||
|
||||
const usageLimitEvent = events.find((e) => {
|
||||
return (
|
||||
e && e.getType() === 'm.room.message' &&
|
||||
|
@ -330,7 +376,12 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
});
|
||||
const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent();
|
||||
this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
|
||||
this.setState({ usageLimitEventContent });
|
||||
this.setState({
|
||||
usageLimitEventContent,
|
||||
usageLimitEventTs: pinnedEventTs,
|
||||
// This is a fresh toast, we can show toasts again
|
||||
usageLimitDismissed: false,
|
||||
});
|
||||
};
|
||||
|
||||
_onPaste = (ev) => {
|
||||
|
@ -391,6 +442,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
|
||||
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
|
||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
const modKey = isMac ? ev.metaKey : ev.ctrlKey;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.PAGE_UP:
|
||||
|
@ -416,6 +468,14 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
handled = true;
|
||||
}
|
||||
break;
|
||||
case Key.F:
|
||||
if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) {
|
||||
dis.dispatch({
|
||||
action: 'focus_search',
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
case Key.BACKTICK:
|
||||
// Ideally this would be CTRL+P for "Profile", but that's
|
||||
// taken by the print dialog. CTRL+I for "Information"
|
||||
|
@ -435,6 +495,16 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
break;
|
||||
|
||||
case Key.H:
|
||||
if (ev.altKey && modKey) {
|
||||
dis.dispatch({
|
||||
action: 'view_home_page',
|
||||
});
|
||||
Modal.closeCurrentModal("homeKeyboardShortcut");
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN:
|
||||
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
|
@ -561,6 +631,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
viaServers={this.props.viaServers}
|
||||
key={this.props.currentRoomId || 'roomview'}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
justCreatedOpts={this.props.roomJustCreatedOpts}
|
||||
/>;
|
||||
break;
|
||||
|
||||
|
@ -573,7 +644,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
break;
|
||||
|
||||
case PageTypes.HomePage:
|
||||
pageElement = <HomePage />;
|
||||
pageElement = <HomePage justRegistered={this.props.justRegistered} />;
|
||||
break;
|
||||
|
||||
case PageTypes.UserView:
|
||||
|
@ -619,6 +690,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
</div>
|
||||
<CallContainer />
|
||||
<NonUrgentToastContainer />
|
||||
<HostSignupContainer />
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -29,11 +29,11 @@ import 'focus-visible';
|
|||
import 'what-input';
|
||||
|
||||
import Analytics from "../../Analytics";
|
||||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
|
||||
import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import * as RoomListSorter from "../../RoomListSorter";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import Notifier from '../../Notifier';
|
||||
|
||||
|
@ -47,9 +47,8 @@ import * as Lifecycle from '../../Lifecycle';
|
|||
// LifecycleStore is not used but does listen to and dispatch actions
|
||||
import '../../stores/LifecycleStore';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import { getHomePageUrl } from '../../utils/pages';
|
||||
|
||||
import createRoom from "../../createRoom";
|
||||
import createRoom, {IOpts} from "../../createRoom";
|
||||
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import ThemeController from "../../settings/controllers/ThemeController";
|
||||
|
@ -61,7 +60,7 @@ import DMRoomMap from '../../utils/DMRoomMap';
|
|||
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
||||
import { FontWatcher } from '../../settings/watchers/FontWatcher';
|
||||
import { storeRoomAliasInCache } from '../../RoomAliasCache';
|
||||
import { defer, IDeferred } from "../../utils/promise";
|
||||
import { defer, IDeferred, sleep } from "../../utils/promise";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
import * as StorageManager from "../../utils/StorageManager";
|
||||
import type LoggedInViewType from "./LoggedInView";
|
||||
|
@ -81,42 +80,46 @@ import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityProt
|
|||
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
|
||||
import {UIFeature} from "../../settings/UIFeature";
|
||||
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
|
||||
import DialPadModal from "../views/voip/DialPadModal";
|
||||
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import SpaceRoomDirectory from "./SpaceRoomDirectory";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
// a special initial state which is only used at startup, while we are
|
||||
// trying to re-animate a matrix client or register as a guest.
|
||||
LOADING = 0,
|
||||
LOADING,
|
||||
|
||||
// we are showing the welcome view
|
||||
WELCOME = 1,
|
||||
WELCOME,
|
||||
|
||||
// we are showing the login view
|
||||
LOGIN = 2,
|
||||
LOGIN,
|
||||
|
||||
// we are showing the registration view
|
||||
REGISTER = 3,
|
||||
|
||||
// completing the registration flow
|
||||
POST_REGISTRATION = 4,
|
||||
REGISTER,
|
||||
|
||||
// showing the 'forgot password' view
|
||||
FORGOT_PASSWORD = 5,
|
||||
FORGOT_PASSWORD,
|
||||
|
||||
// showing flow to trust this new device with cross-signing
|
||||
COMPLETE_SECURITY = 6,
|
||||
COMPLETE_SECURITY,
|
||||
|
||||
// flow to setup SSSS / cross-signing on this account
|
||||
E2E_SETUP = 7,
|
||||
E2E_SETUP,
|
||||
|
||||
// we are logged in with an active matrix client.
|
||||
LOGGED_IN = 8,
|
||||
// we are logged in with an active matrix client. The logged_in state also
|
||||
// includes guests users as they too are logged in at the client level.
|
||||
LOGGED_IN,
|
||||
|
||||
// We are logged out (invalid token) but have our local state again. The user
|
||||
// should log back in to rehydrate the client.
|
||||
SOFT_LOGOUT = 9,
|
||||
SOFT_LOGOUT,
|
||||
}
|
||||
|
||||
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
|
||||
|
||||
// Actions that are redirected through the onboarding process prior to being
|
||||
// re-dispatched. NOTE: some actions are non-trivial and would require
|
||||
// re-factoring to be included in this list in future.
|
||||
|
@ -143,6 +146,8 @@ interface IRoomInfo {
|
|||
oob_data?: object;
|
||||
via_servers?: string[];
|
||||
threepid_invite?: IThreepidInvite;
|
||||
|
||||
justCreatedOpts?: IOpts;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
|
@ -199,6 +204,8 @@ interface IState {
|
|||
roomOobData?: object;
|
||||
viaServers?: string[];
|
||||
pendingInitialSync?: boolean;
|
||||
justRegistered?: boolean;
|
||||
roomJustCreatedOpts?: IOpts;
|
||||
}
|
||||
|
||||
export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
|
@ -217,6 +224,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
private screenAfterLogin?: IScreen;
|
||||
private windowWidth: number;
|
||||
private pageChanging: boolean;
|
||||
private tokenLogin?: boolean;
|
||||
private accountPassword?: string;
|
||||
private accountPasswordTimer?: NodeJS.Timeout;
|
||||
private focusComposer: boolean;
|
||||
|
@ -322,13 +330,21 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
Lifecycle.attemptTokenLogin(
|
||||
this.props.realQueryParams,
|
||||
this.props.defaultDeviceDisplayName,
|
||||
).then((loggedIn) => {
|
||||
if (loggedIn) {
|
||||
this.getFragmentAfterLogin(),
|
||||
).then(async (loggedIn) => {
|
||||
if (this.props.realQueryParams?.loginToken) {
|
||||
// remove the loginToken from the URL regardless
|
||||
this.props.onTokenLoginCompleted();
|
||||
}
|
||||
|
||||
// don't do anything else until the page reloads - just stay in
|
||||
// the 'loading' state.
|
||||
return;
|
||||
if (loggedIn) {
|
||||
this.tokenLogin = true;
|
||||
|
||||
// Create and start the client
|
||||
await Lifecycle.restoreFromLocalStorage({
|
||||
ignoreGuest: true,
|
||||
});
|
||||
return this.postLoginSetup();
|
||||
}
|
||||
|
||||
// if the user has followed a login or register link, don't reanimate
|
||||
|
@ -349,6 +365,43 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
Analytics.enable();
|
||||
}
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
}
|
||||
|
||||
private async postLoginSetup() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const cryptoEnabled = cli.isCryptoEnabled();
|
||||
if (!cryptoEnabled) {
|
||||
this.onLoggedIn();
|
||||
}
|
||||
|
||||
const promisesList = [this.firstSyncPromise.promise];
|
||||
if (cryptoEnabled) {
|
||||
// wait for the client to finish downloading cross-signing keys for us so we
|
||||
// know whether or not we have keys set up on this account
|
||||
promisesList.push(cli.downloadKeys([cli.getUserId()]));
|
||||
}
|
||||
|
||||
// Now update the state to say we're waiting for the first sync to complete rather
|
||||
// than for the login to finish.
|
||||
this.setState({ pendingInitialSync: true });
|
||||
|
||||
await Promise.all(promisesList);
|
||||
|
||||
if (!cryptoEnabled) {
|
||||
this.setState({ pendingInitialSync: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
|
||||
if (crossSigningIsSetUp) {
|
||||
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
|
||||
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
|
||||
this.setStateForNewView({ view: Views.E2E_SETUP });
|
||||
} else {
|
||||
this.onLoggedIn();
|
||||
}
|
||||
this.setState({ pendingInitialSync: false });
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
|
||||
|
@ -363,6 +416,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (this.shouldTrackPageChange(prevState, this.state)) {
|
||||
const durationMs = this.stopPageChangeTimer();
|
||||
Analytics.trackPageChange(durationMs);
|
||||
CountlyAnalytics.instance.trackPageChange(durationMs);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.fire(Action.FocusComposer);
|
||||
|
@ -415,6 +469,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
} else {
|
||||
dis.dispatch({action: "view_welcome_page"});
|
||||
}
|
||||
} else if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ false);
|
||||
}
|
||||
});
|
||||
// Note we don't catch errors from this: we catch everything within
|
||||
|
@ -473,6 +529,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
const newState = {
|
||||
currentUserId: null,
|
||||
justRegistered: false,
|
||||
};
|
||||
Object.assign(newState, state);
|
||||
this.setState(newState);
|
||||
|
@ -554,11 +611,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
ThemeController.isLogin = true;
|
||||
this.themeWatcher.recheck();
|
||||
break;
|
||||
case 'start_post_registration':
|
||||
this.setState({
|
||||
view: Views.POST_REGISTRATION,
|
||||
});
|
||||
break;
|
||||
case 'start_password_recovery':
|
||||
this.setStateForNewView({
|
||||
view: Views.FORGOT_PASSWORD,
|
||||
|
@ -589,7 +641,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
MatrixClientPeg.get().leave(payload.room_id).then(() => {
|
||||
modal.close();
|
||||
if (this.state.currentRoomId === payload.room_id) {
|
||||
dis.dispatch({action: 'view_next_room'});
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
}
|
||||
}, (err) => {
|
||||
modal.close();
|
||||
|
@ -618,9 +670,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case 'view_next_room':
|
||||
this.viewNextRoom(1);
|
||||
break;
|
||||
case Action.ViewUserSettings: {
|
||||
const tabPayload = payload as OpenToTabPayload;
|
||||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||
|
@ -644,9 +693,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
break;
|
||||
}
|
||||
case Action.ViewRoomDirectory: {
|
||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
|
||||
'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
if (SpaceStore.instance.activeSpace) {
|
||||
Modal.createTrackedDialog("Space room directory", "", SpaceRoomDirectory, {
|
||||
space: SpaceStore.instance.activeSpace,
|
||||
initialText: payload.initialText,
|
||||
}, "mx_SpaceRoomDirectory_dialogWrapper", false, true);
|
||||
} else {
|
||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
|
||||
initialText: payload.initialText,
|
||||
}, 'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
}
|
||||
|
||||
// View the welcome or home page if we need something to look at
|
||||
this.viewSomethingBehindModal();
|
||||
|
@ -663,13 +720,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.viewWelcome();
|
||||
break;
|
||||
case 'view_home_page':
|
||||
this.viewHome();
|
||||
this.viewHome(payload.justRegistered);
|
||||
break;
|
||||
case 'view_start_chat_or_reuse':
|
||||
this.chatCreateOrReuse(payload.user_id);
|
||||
break;
|
||||
case 'view_create_chat':
|
||||
showStartChatInviteDialog();
|
||||
showStartChatInviteDialog(payload.initialText || "");
|
||||
break;
|
||||
case 'view_invite':
|
||||
showRoomInviteDialog(payload.roomId);
|
||||
|
@ -705,8 +762,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.state.resizeNotifier.notifyLeftHandleResized();
|
||||
});
|
||||
break;
|
||||
case Action.OpenDialPad:
|
||||
Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper");
|
||||
break;
|
||||
case 'on_logged_in':
|
||||
if (
|
||||
// Skip this handling for token login as that always calls onLoggedIn itself
|
||||
!this.tokenLogin &&
|
||||
!Lifecycle.isSoftLogout() &&
|
||||
this.state.view !== Views.LOGIN &&
|
||||
this.state.view !== Views.REGISTER &&
|
||||
|
@ -750,7 +812,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
|
||||
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
|
||||
hideAnalyticsToast();
|
||||
Analytics.enable();
|
||||
if (Analytics.canEnable()) {
|
||||
Analytics.enable();
|
||||
}
|
||||
if (CountlyAnalytics.instance.canEnable()) {
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ false);
|
||||
}
|
||||
break;
|
||||
case 'reject_cookies':
|
||||
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
|
||||
|
@ -794,35 +861,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.notifyNewScreen('register');
|
||||
}
|
||||
|
||||
// TODO: Move to RoomViewStore
|
||||
private viewNextRoom(roomIndexDelta: number) {
|
||||
const allRooms = RoomListSorter.mostRecentActivityFirst(
|
||||
MatrixClientPeg.get().getRooms(),
|
||||
);
|
||||
// If there are 0 rooms or 1 room, view the home page because otherwise
|
||||
// if there are 0, we end up trying to index into an empty array, and
|
||||
// if there is 1, we end up viewing the same room.
|
||||
if (allRooms.length < 2) {
|
||||
dis.dispatch({
|
||||
action: 'view_home_page',
|
||||
});
|
||||
return;
|
||||
}
|
||||
let roomIndex = -1;
|
||||
for (let i = 0; i < allRooms.length; ++i) {
|
||||
if (allRooms[i].roomId === this.state.currentRoomId) {
|
||||
roomIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
|
||||
if (roomIndex < 0) roomIndex = allRooms.length - 1;
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: allRooms[roomIndex].roomId,
|
||||
});
|
||||
}
|
||||
|
||||
// switch view to the given room
|
||||
//
|
||||
// @param {Object} roomInfo Object containing data about the room to be joined
|
||||
|
@ -896,6 +934,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
roomOobData: roomInfo.oob_data,
|
||||
viaServers: roomInfo.via_servers,
|
||||
ready: true,
|
||||
roomJustCreatedOpts: roomInfo.justCreatedOpts,
|
||||
}, () => {
|
||||
this.notifyNewScreen('room/' + presentedId, replaceLast);
|
||||
});
|
||||
|
@ -942,10 +981,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.themeWatcher.recheck();
|
||||
}
|
||||
|
||||
private viewHome() {
|
||||
private viewHome(justRegistered = false) {
|
||||
// The home page requires the "logged in" view, so we'll set that.
|
||||
this.setStateForNewView({
|
||||
view: Views.LOGGED_IN,
|
||||
justRegistered,
|
||||
});
|
||||
this.setPage(PageTypes.HomePage);
|
||||
this.notifyNewScreen('home');
|
||||
|
@ -1041,6 +1081,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
private leaveRoomWarnings(roomId: string) {
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const isSpace = roomToLeave?.isSpaceRoom();
|
||||
// Show a warning if there are additional complications.
|
||||
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
|
||||
const warnings = [];
|
||||
|
@ -1050,7 +1091,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
warnings.push((
|
||||
<span className="warning" key="non_public_warning">
|
||||
{' '/* Whitespace, otherwise the sentences get smashed together */ }
|
||||
{ _t("This room is not public. You will not be able to rejoin without an invite.") }
|
||||
{ isSpace
|
||||
? _t("This space is not public. You will not be able to rejoin without an invite.")
|
||||
: _t("This room is not public. You will not be able to rejoin without an invite.") }
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
@ -1063,11 +1106,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const warnings = this.leaveRoomWarnings(roomId);
|
||||
|
||||
Modal.createTrackedDialog('Leave room', '', QuestionDialog, {
|
||||
title: _t("Leave room"),
|
||||
const isSpace = roomToLeave?.isSpaceRoom();
|
||||
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
|
||||
title: isSpace ? _t("Leave space") : _t("Leave room"),
|
||||
description: (
|
||||
<span>
|
||||
{ _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
|
||||
{ isSpace
|
||||
? _t("Are you sure you want to leave the space '%(spaceName)s'?", {spaceName: roomToLeave.name})
|
||||
: _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
|
||||
{ warnings }
|
||||
</span>
|
||||
),
|
||||
|
@ -1081,6 +1127,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
|
||||
|
||||
d.finally(() => modal.close());
|
||||
dis.dispatch({
|
||||
action: "after_leave_room",
|
||||
room_id: roomId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -1088,9 +1138,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
private forgetRoom(roomId: string) {
|
||||
MatrixClientPeg.get().forget(roomId).then(() => {
|
||||
// Switch to another room view if we're currently viewing the historical room
|
||||
// Switch to home page if we're currently viewing the forgotten room
|
||||
if (this.state.currentRoomId === roomId) {
|
||||
dis.dispatch({ action: "view_next_room" });
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
}
|
||||
}).catch((err) => {
|
||||
const errCode = err.errcode || _td("unknown error code");
|
||||
|
@ -1179,7 +1229,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (welcomeUserRoom === null) {
|
||||
// We didn't redirect to the welcome user room, so show
|
||||
// the homepage.
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
dis.dispatch({action: 'view_home_page', justRegistered: true});
|
||||
}
|
||||
} else if (ThreepidInviteStore.instance.pickBestInvite()) {
|
||||
// The user has a 3pid invite pending - show them that
|
||||
|
@ -1192,7 +1242,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
} else {
|
||||
// The user has just logged in after registering,
|
||||
// so show the homepage.
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
dis.dispatch({action: 'view_home_page', justRegistered: true});
|
||||
}
|
||||
} else {
|
||||
this.showScreenAfterLogin();
|
||||
|
@ -1200,9 +1250,18 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
StorageManager.tryPersistStorage();
|
||||
|
||||
if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) {
|
||||
// defer the following actions by 30 seconds to not throw them at the user immediately
|
||||
await sleep(30);
|
||||
if (SettingsStore.getValue("showCookieBar") &&
|
||||
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
|
||||
) {
|
||||
showAnalyticsToast(this.props.config.piwik?.policyUrl);
|
||||
}
|
||||
if (SdkConfig.get().mobileGuideToast) {
|
||||
// The toast contains further logic to detect mobile platforms,
|
||||
// check if it has been dismissed before, etc.
|
||||
showMobileGuideToast();
|
||||
}
|
||||
}
|
||||
|
||||
private showScreenAfterLogin() {
|
||||
|
@ -1220,12 +1279,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
} else {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
dis.dispatch({action: 'view_welcome_page'});
|
||||
} else if (getHomePageUrl(this.props.config)) {
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
} else {
|
||||
this.firstSyncPromise.promise.then(() => {
|
||||
dis.dispatch({action: 'view_next_room'});
|
||||
});
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1330,8 +1385,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.firstSyncComplete = true;
|
||||
this.firstSyncPromise.resolve();
|
||||
|
||||
if (Notifier.shouldShowPrompt()) {
|
||||
showNotificationsToast();
|
||||
if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) {
|
||||
showNotificationsToast(false);
|
||||
}
|
||||
|
||||
dis.fire(Action.FocusComposer);
|
||||
|
@ -1340,21 +1395,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
});
|
||||
|
||||
if (SettingsStore.getValue(UIFeature.Voip)) {
|
||||
cli.on('Call.incoming', function(call) {
|
||||
// we dispatch this synchronously to make sure that the event
|
||||
// handlers on the call are set up immediately (so that if
|
||||
// we get an immediate hangup, we don't get a stuck call)
|
||||
dis.dispatch({
|
||||
action: 'incoming_call',
|
||||
call: call,
|
||||
}, true);
|
||||
});
|
||||
}
|
||||
|
||||
cli.on('Session.logged_out', function(errObj) {
|
||||
if (Lifecycle.isLoggingOut()) return;
|
||||
|
||||
// A modal might have been open when we were logged out by the server
|
||||
Modal.closeCurrentModal('Session.logged_out');
|
||||
|
||||
if (errObj.httpStatus === 401 && errObj.data && errObj.data['soft_logout']) {
|
||||
console.warn("Soft logout issued by server - avoiding data deletion");
|
||||
Lifecycle.softLogout();
|
||||
|
@ -1365,6 +1411,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
title: _t('Signed Out'),
|
||||
description: _t('For security, this session has been signed out. Please sign in again.'),
|
||||
});
|
||||
|
||||
dis.dispatch({
|
||||
action: 'logout',
|
||||
});
|
||||
|
@ -1394,6 +1441,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
const dft = new DecryptionFailureTracker((total, errorCode) => {
|
||||
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
|
||||
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
|
||||
}, (errorCode) => {
|
||||
// Map JS-SDK error codes to tracker codes for aggregation
|
||||
switch (errorCode) {
|
||||
|
@ -1535,6 +1583,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
showScreen(screen: string, params?: {[key: string]: any}) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const isLoggedOutOrGuest = !cli || cli.isGuest();
|
||||
if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) {
|
||||
// user is logged in and landing on an auth page which will uproot their session, redirect them home instead
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (screen === 'register') {
|
||||
dis.dispatch({
|
||||
action: 'start_registration',
|
||||
|
@ -1551,7 +1607,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
params: params,
|
||||
});
|
||||
} else if (screen === 'soft_logout') {
|
||||
if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) {
|
||||
if (cli.getUserId() && !Lifecycle.isSoftLogout()) {
|
||||
// Logged in - visit a room
|
||||
this.viewLastRoom();
|
||||
} else {
|
||||
|
@ -1581,6 +1637,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
action: 'require_registration',
|
||||
});
|
||||
} else if (screen === 'directory') {
|
||||
if (this.state.view === Views.WELCOME) {
|
||||
CountlyAnalytics.instance.track("onboarding_room_directory");
|
||||
}
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
} else if (screen === "start_sso" || screen === "start_cas") {
|
||||
// TODO if logged in, skip SSO
|
||||
|
@ -1599,14 +1658,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
dis.dispatch({
|
||||
action: 'view_my_groups',
|
||||
});
|
||||
} else if (screen === 'complete_security') {
|
||||
dis.dispatch({
|
||||
action: 'start_complete_security',
|
||||
});
|
||||
} else if (screen === 'post_registration') {
|
||||
dis.dispatch({
|
||||
action: 'start_post_registration',
|
||||
});
|
||||
} else if (screen.indexOf('room/') === 0) {
|
||||
// Rooms can have the following formats:
|
||||
// #room_alias:domain or !opaque_id:domain
|
||||
|
@ -1630,10 +1681,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149
|
||||
|
||||
let threepidInvite: IThreepidInvite;
|
||||
// if we landed here from a 3PID invite, persist it
|
||||
if (params.signurl && params.email) {
|
||||
threepidInvite = ThreepidInviteStore.instance
|
||||
.storeInvite(roomString, params as IThreepidInviteWireFormat);
|
||||
}
|
||||
// otherwise check that this room doesn't already have a known invite
|
||||
if (!threepidInvite) {
|
||||
const invites = ThreepidInviteStore.instance.getInvites();
|
||||
threepidInvite = invites.find(invite => invite.roomId === roomString);
|
||||
}
|
||||
|
||||
// on our URLs there might be a ?via=matrix.org or similar to help
|
||||
// joins to the room succeed. We'll pass these through as an array
|
||||
|
@ -1777,14 +1834,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
return Lifecycle.setLoggedIn(credentials);
|
||||
}
|
||||
|
||||
onFinishPostRegistration = () => {
|
||||
// Don't confuse this with "PageType" which is the middle window to show
|
||||
this.setState({
|
||||
view: Views.LOGGED_IN,
|
||||
});
|
||||
this.showScreen("settings");
|
||||
};
|
||||
|
||||
onSendEvent(roomId: string, event: MatrixEvent) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli) {
|
||||
|
@ -1870,40 +1919,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
// Create and start the client
|
||||
await Lifecycle.setLoggedIn(credentials);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const cryptoEnabled = cli.isCryptoEnabled();
|
||||
if (!cryptoEnabled) {
|
||||
this.onLoggedIn();
|
||||
}
|
||||
|
||||
const promisesList = [this.firstSyncPromise.promise];
|
||||
if (cryptoEnabled) {
|
||||
// wait for the client to finish downloading cross-signing keys for us so we
|
||||
// know whether or not we have keys set up on this account
|
||||
promisesList.push(cli.downloadKeys([cli.getUserId()]));
|
||||
}
|
||||
|
||||
// Now update the state to say we're waiting for the first sync to complete rather
|
||||
// than for the login to finish.
|
||||
this.setState({ pendingInitialSync: true });
|
||||
|
||||
await Promise.all(promisesList);
|
||||
|
||||
if (!cryptoEnabled) {
|
||||
this.setState({ pendingInitialSync: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
|
||||
if (crossSigningIsSetUp) {
|
||||
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
|
||||
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
|
||||
this.setStateForNewView({ view: Views.E2E_SETUP });
|
||||
} else {
|
||||
this.onLoggedIn();
|
||||
}
|
||||
this.setState({ pendingInitialSync: false });
|
||||
await this.postLoginSetup();
|
||||
};
|
||||
|
||||
// complete security / e2e setup has finished
|
||||
|
@ -1947,15 +1963,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
<E2eSetup
|
||||
onFinished={this.onCompleteSecurityE2eSetupFinished}
|
||||
accountPassword={this.accountPassword}
|
||||
tokenLogin={!!this.tokenLogin}
|
||||
/>
|
||||
);
|
||||
} else if (this.state.view === Views.POST_REGISTRATION) {
|
||||
// needs to be before normal PageTypes as you are logged in technically
|
||||
const PostRegistration = sdk.getComponent('structures.auth.PostRegistration');
|
||||
view = (
|
||||
<PostRegistration
|
||||
onComplete={this.onFinishPostRegistration} />
|
||||
);
|
||||
} else if (this.state.view === Views.LOGGED_IN) {
|
||||
// store errors stop the client syncing and require user intervention, so we'll
|
||||
// be showing a dialog. Don't show anything else.
|
||||
|
@ -2019,6 +2029,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
onLoginClick={this.onLoginClick}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
|
||||
fragmentAfterLogin={fragmentAfterLogin}
|
||||
{...this.getServerProperties()}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -23,13 +23,17 @@ import classNames from 'classnames';
|
|||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
import {wantsDateSeparator} from '../../DateUtils';
|
||||
import * as sdk from '../../index';
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {Layout, LayoutPropType} from "../../settings/Layout";
|
||||
import {_t} from "../../languageHandler";
|
||||
import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||
import {textForEvent} from "../../TextForEvent";
|
||||
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
|
||||
import DMRoomMap from "../../utils/DMRoomMap";
|
||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
||||
|
@ -133,14 +137,13 @@ export default class MessagePanel extends React.Component {
|
|||
// whether to show reactions for an event
|
||||
showReactions: PropTypes.bool,
|
||||
|
||||
// whether to use the irc layout
|
||||
useIRCLayout: PropTypes.bool,
|
||||
// which layout to use
|
||||
layout: LayoutPropType,
|
||||
|
||||
// whether or not to show flair at all
|
||||
enableFlair: PropTypes.bool,
|
||||
};
|
||||
|
||||
// Force props to be loaded for useIRCLayout
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -205,11 +208,13 @@ export default class MessagePanel extends React.Component {
|
|||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
|
@ -222,6 +227,14 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
onAction = (payload) => {
|
||||
switch (payload.action) {
|
||||
case "scroll_to_bottom":
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onShowTypingNotificationsChange = () => {
|
||||
this.setState({
|
||||
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
|
||||
|
@ -610,7 +623,7 @@ export default class MessagePanel extends React.Component {
|
|||
isSelectedEvent={highlight}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
showReactions={this.props.showReactions}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
layout={this.props.layout}
|
||||
enableFlair={this.props.enableFlair}
|
||||
/>
|
||||
</TileErrorBoundary>
|
||||
|
@ -808,7 +821,7 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
|
||||
let ircResizer = null;
|
||||
if (this.props.useIRCLayout) {
|
||||
if (this.props.layout == Layout.IRC) {
|
||||
ircResizer = <IRCTimelineProfileResizer
|
||||
minWidth={20}
|
||||
maxWidth={600}
|
||||
|
@ -952,15 +965,25 @@ class CreationGrouper {
|
|||
}).reduce((a, b) => a.concat(b), []);
|
||||
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
|
||||
const ev = this.events[this.events.length - 1];
|
||||
|
||||
let summaryText;
|
||||
const roomId = ev.getRoomId();
|
||||
const creator = ev.sender ? ev.sender.name : ev.getSender();
|
||||
if (DMRoomMap.shared().getUserIdForRoomId(roomId)) {
|
||||
summaryText = _t("%(creator)s created this DM.", { creator });
|
||||
} else {
|
||||
summaryText = _t("%(creator)s created and configured the room.", { creator });
|
||||
}
|
||||
|
||||
ret.push(<NewRoomIntro key="newroomintro" />);
|
||||
|
||||
ret.push(
|
||||
<EventListSummary
|
||||
key="roomcreationsummary"
|
||||
events={this.events}
|
||||
onToggle={panel._onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={_t("%(creator)s created and configured the room.", {
|
||||
creator: ev.sender ? ev.sender.name : ev.getSender(),
|
||||
})}
|
||||
summaryText={summaryText}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
|
|
|
@ -39,7 +39,7 @@ class NotificationPanel extends React.Component {
|
|||
|
||||
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
|
||||
<h2>{_t('You’re all caught up')}</h2>
|
||||
<p>{_t('You have no visible notifications in this room.')}</p>
|
||||
<p>{_t('You have no visible notifications.')}</p>
|
||||
</div>);
|
||||
|
||||
let content;
|
||||
|
|
|
@ -24,13 +24,16 @@ import dis from '../../dispatcher/dispatcher';
|
|||
import RateLimitedFunc from '../../ratelimitedfunc';
|
||||
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import GroupStore from '../../stores/GroupStore';
|
||||
import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases";
|
||||
import {
|
||||
RightPanelPhases,
|
||||
RIGHT_PANEL_PHASES_NO_ARGS,
|
||||
RIGHT_PANEL_SPACE_PHASES,
|
||||
} from "../../stores/RightPanelStorePhases";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
|
||||
import WidgetCard from "../views/right_panel/WidgetCard";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
|
||||
export default class RightPanel extends React.Component {
|
||||
static get propTypes() {
|
||||
|
@ -80,6 +83,8 @@ export default class RightPanel extends React.Component {
|
|||
return RightPanelPhases.GroupMemberList;
|
||||
}
|
||||
return rps.groupPanelPhase;
|
||||
} else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) {
|
||||
return RightPanelPhases.SpaceMemberList;
|
||||
} else if (userForPanel) {
|
||||
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state
|
||||
// from its props and some from a store, except if the contents of the store changes
|
||||
|
@ -100,9 +105,8 @@ export default class RightPanel extends React.Component {
|
|||
return rps.roomPanelPhase;
|
||||
}
|
||||
return RightPanelPhases.RoomMemberInfo;
|
||||
} else {
|
||||
return rps.roomPanelPhase;
|
||||
}
|
||||
return rps.roomPanelPhase;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -182,11 +186,12 @@ export default class RightPanel extends React.Component {
|
|||
verificationRequest: payload.verificationRequest,
|
||||
verificationRequestPromise: payload.verificationRequestPromise,
|
||||
widgetId: payload.widgetId,
|
||||
space: payload.space,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onCloseUserInfo = () => {
|
||||
onClose = () => {
|
||||
// XXX: There are three different ways of 'closing' this panel depending on what state
|
||||
// things are in... this knows far more than it should do about the state of the rest
|
||||
// of the app and is generally a bit silly.
|
||||
|
@ -198,31 +203,21 @@ export default class RightPanel extends React.Component {
|
|||
dis.dispatch({
|
||||
action: "view_home_page",
|
||||
});
|
||||
} else if (this.state.phase === RightPanelPhases.EncryptionPanel &&
|
||||
} else if (
|
||||
this.state.phase === RightPanelPhases.EncryptionPanel &&
|
||||
this.state.verificationRequest && this.state.verificationRequest.pending
|
||||
) {
|
||||
// When the user clicks close on the encryption panel cancel the pending request first if any
|
||||
this.state.verificationRequest.cancel();
|
||||
} else {
|
||||
// Otherwise we have got our user from RoomViewStore which means we're being shown
|
||||
// within a room/group, so go back to the member panel if we were in the encryption panel,
|
||||
// or the member list if we were in the member panel... phew.
|
||||
const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
|
||||
// the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
|
||||
dis.dispatch({
|
||||
action: Action.ViewUser,
|
||||
member: isEncryptionPhase ? this.state.member : null,
|
||||
action: Action.ToggleRightPanel,
|
||||
type: this.props.groupId ? "group" : "room",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
// the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ToggleRightPanel,
|
||||
type: this.props.groupId ? "group" : "room",
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const MemberList = sdk.getComponent('rooms.MemberList');
|
||||
const UserInfo = sdk.getComponent('right_panel.UserInfo');
|
||||
|
@ -243,6 +238,13 @@ export default class RightPanel extends React.Component {
|
|||
panel = <MemberList roomId={roomId} key={roomId} onClose={this.onClose} />;
|
||||
}
|
||||
break;
|
||||
case RightPanelPhases.SpaceMemberList:
|
||||
panel = <MemberList
|
||||
roomId={this.state.space ? this.state.space.roomId : roomId}
|
||||
key={this.state.space ? this.state.space.roomId : roomId}
|
||||
onClose={this.onClose}
|
||||
/>;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupMemberList:
|
||||
if (this.props.groupId) {
|
||||
|
@ -255,12 +257,13 @@ export default class RightPanel extends React.Component {
|
|||
break;
|
||||
|
||||
case RightPanelPhases.RoomMemberInfo:
|
||||
case RightPanelPhases.SpaceMemberInfo:
|
||||
case RightPanelPhases.EncryptionPanel:
|
||||
panel = <UserInfo
|
||||
user={this.state.member}
|
||||
room={this.props.room}
|
||||
room={this.state.phase === RightPanelPhases.SpaceMemberInfo ? this.state.space : this.props.room}
|
||||
key={roomId || this.state.member.userId}
|
||||
onClose={this.onCloseUserInfo}
|
||||
onClose={this.onClose}
|
||||
phase={this.state.phase}
|
||||
verificationRequest={this.state.verificationRequest}
|
||||
verificationRequestPromise={this.state.verificationRequestPromise}
|
||||
|
@ -268,6 +271,7 @@ export default class RightPanel extends React.Component {
|
|||
break;
|
||||
|
||||
case RightPanelPhases.Room3pidMemberInfo:
|
||||
case RightPanelPhases.Space3pidMemberInfo:
|
||||
panel = <ThirdPartyMemberInfo event={this.state.event} key={roomId} />;
|
||||
break;
|
||||
|
||||
|
@ -276,7 +280,7 @@ export default class RightPanel extends React.Component {
|
|||
user={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.userId}
|
||||
onClose={this.onCloseUserInfo} />;
|
||||
onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.GroupRoomInfo:
|
||||
|
|
|
@ -33,6 +33,7 @@ import SettingsStore from "../../settings/SettingsStore";
|
|||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import GroupStore from "../../stores/GroupStore";
|
||||
import FlairStore from "../../stores/FlairStore";
|
||||
import CountlyAnalytics from "../../CountlyAnalytics";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
@ -43,12 +44,16 @@ function track(action) {
|
|||
|
||||
export default class RoomDirectory extends React.Component {
|
||||
static propTypes = {
|
||||
initialText: PropTypes.string,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
CountlyAnalytics.instance.trackRoomDirectoryBegin();
|
||||
this.startTime = CountlyAnalytics.getTimestamp();
|
||||
|
||||
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
|
||||
this.state = {
|
||||
publicRooms: [],
|
||||
|
@ -57,7 +62,7 @@ export default class RoomDirectory extends React.Component {
|
|||
error: null,
|
||||
instanceId: undefined,
|
||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
||||
filterString: null,
|
||||
filterString: this.props.initialText || "",
|
||||
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
|
||||
? selectedCommunityId
|
||||
: null,
|
||||
|
@ -198,6 +203,11 @@ export default class RoomDirectory extends React.Component {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.state.filterString) {
|
||||
const count = data.total_room_count_estimate || data.chunk.length;
|
||||
CountlyAnalytics.instance.trackRoomDirectorySearch(count, this.state.filterString);
|
||||
}
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
this.setState((s) => {
|
||||
s.publicRooms.push(...(data.chunk || []));
|
||||
|
@ -407,7 +417,7 @@ export default class RoomDirectory extends React.Component {
|
|||
};
|
||||
|
||||
onCreateRoomClick = room => {
|
||||
this.props.onFinished();
|
||||
this.onFinished();
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
|
@ -419,11 +429,12 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
|
||||
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
|
||||
this.props.onFinished();
|
||||
this.onFinished();
|
||||
const payload = {
|
||||
action: 'view_room',
|
||||
auto_join: autoJoin,
|
||||
should_peek: shouldPeek,
|
||||
_type: "room_directory", // instrumentation
|
||||
};
|
||||
if (room) {
|
||||
// Don't let the user view a room they won't be able to either
|
||||
|
@ -466,7 +477,7 @@ export default class RoomDirectory extends React.Component {
|
|||
dis.dispatch(payload);
|
||||
}
|
||||
|
||||
getRow(room) {
|
||||
createRoomCells(room) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const clientRoom = client.getRoom(room.room_id);
|
||||
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
|
||||
|
@ -476,7 +487,11 @@ export default class RoomDirectory extends React.Component {
|
|||
let previewButton;
|
||||
let joinOrViewButton;
|
||||
|
||||
if (room.world_readable && !hasJoinedRoom) {
|
||||
// Element Web currently does not allow guests to join rooms, so we
|
||||
// instead show them preview buttons for all rooms. If the room is not
|
||||
// world readable, a modal will appear asking you to register first. If
|
||||
// it is readable, the preview appears as normal.
|
||||
if (!hasJoinedRoom && (room.world_readable || isGuest)) {
|
||||
previewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
|
||||
);
|
||||
|
@ -485,7 +500,7 @@ export default class RoomDirectory extends React.Component {
|
|||
joinOrViewButton = (
|
||||
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
|
||||
);
|
||||
} else if (!isGuest || room.guest_can_join) {
|
||||
} else if (!isGuest) {
|
||||
joinOrViewButton = (
|
||||
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
|
||||
);
|
||||
|
@ -508,31 +523,56 @@ export default class RoomDirectory extends React.Component {
|
|||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
room.avatar_url, 32, 32, "crop",
|
||||
);
|
||||
return (
|
||||
<tr key={ room.room_id }
|
||||
return [
|
||||
<div key={ `${room.room_id}_avatar` }
|
||||
onClick={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
className="mx_RoomDirectory_roomAvatar"
|
||||
>
|
||||
<td className="mx_RoomDirectory_roomAvatar">
|
||||
<BaseAvatar width={32} height={32} resizeMethod='crop'
|
||||
name={ name } idName={ name }
|
||||
url={ avatarUrl } />
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomDescription">
|
||||
<div className="mx_RoomDirectory_name">{ name }</div>
|
||||
<div className="mx_RoomDirectory_topic"
|
||||
onClick={ (ev) => { ev.stopPropagation(); } }
|
||||
dangerouslySetInnerHTML={{ __html: topic }} />
|
||||
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomMemberCount">
|
||||
{ room.num_joined_members }
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_preview">{previewButton}</td>
|
||||
<td className="mx_RoomDirectory_join">{joinOrViewButton}</td>
|
||||
</tr>
|
||||
);
|
||||
<BaseAvatar width={32} height={32} resizeMethod='crop'
|
||||
name={ name } idName={ name }
|
||||
url={ avatarUrl }
|
||||
/>
|
||||
</div>,
|
||||
<div key={ `${room.room_id}_description` }
|
||||
onClick={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
className="mx_RoomDirectory_roomDescription"
|
||||
>
|
||||
<div className="mx_RoomDirectory_name">{ name }</div>
|
||||
<div className="mx_RoomDirectory_topic"
|
||||
onClick={ (ev) => { ev.stopPropagation(); } }
|
||||
dangerouslySetInnerHTML={{ __html: topic }}
|
||||
/>
|
||||
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
|
||||
</div>,
|
||||
<div key={ `${room.room_id}_memberCount` }
|
||||
onClick={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
className="mx_RoomDirectory_roomMemberCount"
|
||||
>
|
||||
{ room.num_joined_members }
|
||||
</div>,
|
||||
<div key={ `${room.room_id}_preview` }
|
||||
onClick={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
className="mx_RoomDirectory_preview"
|
||||
>
|
||||
{previewButton}
|
||||
</div>,
|
||||
<div key={ `${room.room_id}_join` }
|
||||
onClick={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
className="mx_RoomDirectory_join"
|
||||
>
|
||||
{joinOrViewButton}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
collectScrollPanel = (element) => {
|
||||
|
@ -575,6 +615,11 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onFinished = () => {
|
||||
CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
@ -586,7 +631,8 @@ export default class RoomDirectory extends React.Component {
|
|||
} else if (this.state.protocolsLoading) {
|
||||
content = <Loader />;
|
||||
} else {
|
||||
const rows = (this.state.publicRooms || []).map(room => this.getRow(room));
|
||||
const cells = (this.state.publicRooms || [])
|
||||
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],);
|
||||
// we still show the scrollpanel, at least for now, because
|
||||
// otherwise we don't fetch more because we don't get a fill
|
||||
// request from the scrollpanel because there isn't one
|
||||
|
@ -597,14 +643,12 @@ export default class RoomDirectory extends React.Component {
|
|||
}
|
||||
|
||||
let scrollpanel_content;
|
||||
if (rows.length === 0 && !this.state.loading) {
|
||||
if (cells.length === 0 && !this.state.loading) {
|
||||
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
|
||||
} else {
|
||||
scrollpanel_content = <table className="mx_RoomDirectory_table">
|
||||
<tbody>
|
||||
{ rows }
|
||||
</tbody>
|
||||
</table>;
|
||||
scrollpanel_content = <div className="mx_RoomDirectory_table">
|
||||
{ cells }
|
||||
</div>;
|
||||
}
|
||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
content = <ScrollPanel ref={this.collectScrollPanel}
|
||||
|
@ -671,6 +715,7 @@ export default class RoomDirectory extends React.Component {
|
|||
onJoinClick={this.onJoinFromSearchClick}
|
||||
placeholder={placeholder}
|
||||
showJoinButton={showJoinButton}
|
||||
initialText={this.props.initialText}
|
||||
/>
|
||||
{dropdown}
|
||||
</div>;
|
||||
|
@ -693,7 +738,7 @@ export default class RoomDirectory extends React.Component {
|
|||
<BaseDialog
|
||||
className={'mx_RoomDirectory_dialog'}
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
onFinished={this.onFinished}
|
||||
title={title}
|
||||
>
|
||||
<div className="mx_RoomDirectory">
|
||||
|
|
|
@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
onBlur={this.onBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
placeholder={_t("Search")}
|
||||
placeholder={_t("Filter")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
|
@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
if (this.props.isMinimized) {
|
||||
icon = (
|
||||
<AccessibleButton
|
||||
title={_t("Search rooms")}
|
||||
title={_t("Filter rooms and people")}
|
||||
className="mx_RoomSearch_icon mx_RoomSearch_minimizedHandle"
|
||||
onClick={this.openSearch}
|
||||
/>
|
||||
|
|
|
@ -18,13 +18,11 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import { _t, _td } from '../../languageHandler';
|
||||
import * as sdk from '../../index';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import Resend from '../../Resend';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
|
@ -41,16 +39,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
static propTypes = {
|
||||
// the room this statusbar is representing.
|
||||
room: PropTypes.object.isRequired,
|
||||
// This is true when the user is alone in the room, but has also sent a message.
|
||||
// Used to suggest to the user to invite someone
|
||||
sentMessageAndIsAlone: PropTypes.bool,
|
||||
|
||||
// The active call in the room, if any (means we show the call bar
|
||||
// along with the status of the call)
|
||||
callState: PropTypes.string,
|
||||
|
||||
// The type of the call in progress, or null if no call is in progress
|
||||
callType: PropTypes.string,
|
||||
|
||||
// true if the room is being peeked at. This affects components that shouldn't
|
||||
// logically be shown when peeking, such as a prompt to invite people to a room.
|
||||
|
@ -68,10 +56,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
// 'you are alone' bar
|
||||
onInviteClick: PropTypes.func,
|
||||
|
||||
// callback for when the user clicks on the 'stop warning me' button in the
|
||||
// 'you are alone' bar
|
||||
onStopWarningClick: PropTypes.func,
|
||||
|
||||
// callback for when we do something that changes the size of the
|
||||
// status bar. This is used to trigger a re-layout in the parent
|
||||
// component.
|
||||
|
@ -122,12 +106,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
_showCallBar() {
|
||||
return (this.props.callState &&
|
||||
(this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing)
|
||||
);
|
||||
}
|
||||
|
||||
_onResendAllClick = () => {
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
dis.fire(Action.FocusComposer);
|
||||
|
@ -159,10 +137,7 @@ export default class RoomStatusBar extends React.Component {
|
|||
// changed - so we use '0' to indicate normal size, and other values to
|
||||
// indicate other sizes.
|
||||
_getSize() {
|
||||
if (this._shouldShowConnectionError() ||
|
||||
this._showCallBar() ||
|
||||
this.props.sentMessageAndIsAlone
|
||||
) {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return STATUS_BAR_EXPANDED;
|
||||
} else if (this.state.unsentMessages.length > 0) {
|
||||
return STATUS_BAR_EXPANDED_LARGE;
|
||||
|
@ -170,22 +145,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
return STATUS_BAR_HIDDEN;
|
||||
}
|
||||
|
||||
// return suitable content for the image on the left of the status bar.
|
||||
_getIndicator() {
|
||||
if (this._showCallBar()) {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
return (
|
||||
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
|
||||
);
|
||||
}
|
||||
|
||||
if (this._shouldShowConnectionError()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_shouldShowConnectionError() {
|
||||
// no conn bar trumps the "some not sent" msg since you can't resend without
|
||||
// a connection!
|
||||
|
@ -276,25 +235,6 @@ export default class RoomStatusBar extends React.Component {
|
|||
</div>;
|
||||
}
|
||||
|
||||
_getCallStatusText() {
|
||||
switch (this.props.callState) {
|
||||
case CallState.CreateOffer:
|
||||
case CallState.InviteSent:
|
||||
return _t('Calling...');
|
||||
case CallState.Connecting:
|
||||
case CallState.CreateAnswer:
|
||||
return _t('Call connecting...');
|
||||
case CallState.Connected:
|
||||
return _t('Active call');
|
||||
case CallState.WaitLocalMedia:
|
||||
if (this.props.callType === CallType.Video) {
|
||||
return _t('Starting camera...');
|
||||
} else {
|
||||
return _t('Starting microphone...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return suitable content for the main (text) part of the status bar.
|
||||
_getContent() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
|
@ -317,44 +257,14 @@ export default class RoomStatusBar extends React.Component {
|
|||
return this._getUnsentMessageContent();
|
||||
}
|
||||
|
||||
if (this._showCallBar()) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_callBar">
|
||||
<b>{ this._getCallStatusText() }</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If you're alone in the room, and have sent a message, suggest to invite someone
|
||||
if (this.props.sentMessageAndIsAlone && !this.props.isPeeking) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar_isAlone">
|
||||
{ _t("There's no one else here! Would you like to <inviteText>invite others</inviteText> " +
|
||||
"or <nowarnText>stop warning about the empty room</nowarnText>?",
|
||||
{},
|
||||
{
|
||||
'inviteText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
|
||||
'nowarnText': (sub) =>
|
||||
<a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
|
||||
},
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this._getContent();
|
||||
const indicator = this._getIndicator();
|
||||
|
||||
return (
|
||||
<div className="mx_RoomStatusBar">
|
||||
<div className="mx_RoomStatusBar_indicator">
|
||||
{ indicator }
|
||||
</div>
|
||||
<div role="alert">
|
||||
{ content }
|
||||
</div>
|
||||
|
|
|
@ -21,43 +21,42 @@ limitations under the License.
|
|||
// - Search results component
|
||||
// - Drag and drop
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import {EventSubscription} from "fbemitter";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { EventSubscription } from "fbemitter";
|
||||
|
||||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
import {_t} from '../../languageHandler';
|
||||
import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks';
|
||||
import { _t } from '../../languageHandler';
|
||||
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
|
||||
import ResizeNotifier from '../../utils/ResizeNotifier';
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
import Modal from '../../Modal';
|
||||
import * as sdk from '../../index';
|
||||
import CallHandler from '../../CallHandler';
|
||||
import CallHandler, { PlaceCallType } from '../../CallHandler';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import Tinter from '../../Tinter';
|
||||
import rateLimitedFunc from '../../ratelimitedfunc';
|
||||
import * as ObjectUtils from '../../ObjectUtils';
|
||||
import * as Rooms from '../../Rooms';
|
||||
import eventSearch, {searchPagination} from '../../Searching';
|
||||
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
|
||||
import eventSearch, { searchPagination } from '../../Searching';
|
||||
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key } from '../../Keyboard';
|
||||
import MainSplit from './MainSplit';
|
||||
import RightPanel from './RightPanel';
|
||||
import RoomViewStore from '../../stores/RoomViewStore';
|
||||
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
|
||||
import WidgetEchoStore from '../../stores/WidgetEchoStore';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import {Layout} from "../../settings/Layout";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||
import { haveTileForEvent } from "../views/rooms/EventTile";
|
||||
import RoomContext from "../../contexts/RoomContext";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import {SettingLevel} from "../../settings/SettingLevel";
|
||||
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
|
||||
import {IMatrixClientCreds} from "../../MatrixClientPeg";
|
||||
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import { IMatrixClientCreds } from "../../MatrixClientPeg";
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
|
@ -68,10 +67,21 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
|
|||
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
|
||||
import AuxPanel from "../views/rooms/AuxPanel";
|
||||
import RoomHeader from "../views/rooms/RoomHeader";
|
||||
import TintableSvg from "../views/elements/TintableSvg";
|
||||
import {XOR} from "../../@types/common";
|
||||
import { XOR } from "../../@types/common";
|
||||
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call";
|
||||
import EffectsOverlay from "../views/elements/EffectsOverlay";
|
||||
import { containsEmoji } from '../../effects/utils';
|
||||
import { CHAT_EFFECTS } from '../../effects';
|
||||
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import WidgetStore from "../../stores/WidgetStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import Notifier from "../../Notifier";
|
||||
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
|
||||
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
|
||||
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import { objectHasDiff } from "../../utils/objects";
|
||||
import SpaceRoomView from "./SpaceRoomView";
|
||||
import { IOpts } from "../../createRoom";
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function(msg: string) {};
|
||||
|
@ -106,6 +116,7 @@ interface IProps {
|
|||
|
||||
autoJoin?: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
justCreatedOpts?: IOpts;
|
||||
|
||||
// Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU)
|
||||
onRegistered?(credentials: IMatrixClientCreds): void;
|
||||
|
@ -127,6 +138,7 @@ export interface IState {
|
|||
initialEventPixelOffset?: number;
|
||||
// Whether to highlight the event scrolled to
|
||||
isInitialEventHighlighted?: boolean;
|
||||
replyToEvent?: MatrixEvent;
|
||||
forwardingEvent?: MatrixEvent;
|
||||
numUnreadMessages: number;
|
||||
draggingFile: boolean;
|
||||
|
@ -145,7 +157,6 @@ export interface IState {
|
|||
guestsCanJoin: boolean;
|
||||
canPeek: boolean;
|
||||
showApps: boolean;
|
||||
isAlone: boolean;
|
||||
isPeeking: boolean;
|
||||
showingPinned: boolean;
|
||||
showReadReceipts: boolean;
|
||||
|
@ -174,12 +185,13 @@ export interface IState {
|
|||
};
|
||||
canReact: boolean;
|
||||
canReply: boolean;
|
||||
useIRCLayout: boolean;
|
||||
layout: Layout;
|
||||
matrixClientIsReady: boolean;
|
||||
showUrlPreview?: boolean;
|
||||
e2eStatus?: E2EStatus;
|
||||
rejecting?: boolean;
|
||||
rejectError?: Error;
|
||||
hasPinnedWidgets?: boolean;
|
||||
}
|
||||
|
||||
export default class RoomView extends React.Component<IProps, IState> {
|
||||
|
@ -217,7 +229,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
guestsCanJoin: false,
|
||||
canPeek: false,
|
||||
showApps: false,
|
||||
isAlone: false,
|
||||
isPeeking: false,
|
||||
showingPinned: false,
|
||||
showReadReceipts: true,
|
||||
|
@ -229,7 +240,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
statusBarVisible: false,
|
||||
canReact: false,
|
||||
canReply: false,
|
||||
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
|
||||
layout: SettingsStore.getValue("layout"),
|
||||
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
|
||||
};
|
||||
|
||||
|
@ -246,22 +257,33 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this.context.on("Event.decrypted", this.onEventDecrypted);
|
||||
this.context.on("event", this.onEvent);
|
||||
// Start listening for RoomViewStore updates
|
||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
|
||||
|
||||
WidgetEchoStore.on('update', this.onWidgetEchoStoreUpdate);
|
||||
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
|
||||
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||
|
||||
this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
|
||||
this.onReadReceiptsChange);
|
||||
this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange);
|
||||
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Move into constructor
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillMount() {
|
||||
this.onRoomViewStoreUpdate(true);
|
||||
private onWidgetStoreUpdate = () => {
|
||||
if (this.state.room) {
|
||||
this.checkWidgets(this.state.room);
|
||||
}
|
||||
}
|
||||
|
||||
private checkWidgets = (room) => {
|
||||
this.setState({
|
||||
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0,
|
||||
showApps: this.shouldShowApps(room),
|
||||
});
|
||||
};
|
||||
|
||||
private onReadReceiptsChange = () => {
|
||||
this.setState({
|
||||
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
|
||||
|
@ -298,6 +320,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
joining: RoomViewStore.isJoining(),
|
||||
initialEventId: RoomViewStore.getInitialEventId(),
|
||||
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
|
||||
replyToEvent: RoomViewStore.getQuotingEvent(),
|
||||
forwardingEvent: RoomViewStore.getForwardingEvent(),
|
||||
// we should only peek once we have a ready client
|
||||
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
|
||||
|
@ -395,11 +418,17 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private onWidgetEchoStoreUpdate = () => {
|
||||
if (!this.state.room) return;
|
||||
this.setState({
|
||||
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(this.state.room, Container.Top).length > 0,
|
||||
showApps: this.shouldShowApps(this.state.room),
|
||||
});
|
||||
};
|
||||
|
||||
private onWidgetLayoutChange = () => {
|
||||
this.onWidgetEchoStoreUpdate(); // we cheat here by calling the thing that matters
|
||||
};
|
||||
|
||||
private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) {
|
||||
// if this is an unknown room then we're in one of three states:
|
||||
// - This is a room we can peek into (search engine) (we can /peek)
|
||||
|
@ -465,7 +494,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private shouldShowApps(room: Room) {
|
||||
if (!BROWSER_SUPPORTS_SANDBOX) return false;
|
||||
if (!BROWSER_SUPPORTS_SANDBOX || !room) return false;
|
||||
|
||||
// Check if user has previously chosen to hide the app drawer for this
|
||||
// room. If so, do not show apps
|
||||
|
@ -474,10 +503,15 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
// This is confusing, but it means to say that we default to the tray being
|
||||
// hidden unless the user clicked to open it.
|
||||
return hideWidgetDrawer === "false";
|
||||
const isManuallyShown = hideWidgetDrawer === "false";
|
||||
|
||||
const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
|
||||
return widgets.length > 0 || isManuallyShown;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.onRoomViewStoreUpdate(true);
|
||||
|
||||
const call = this.getCallForRoom();
|
||||
const callState = call ? call.state : null;
|
||||
this.setState({
|
||||
|
@ -489,13 +523,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
|
||||
}
|
||||
this.onResize();
|
||||
|
||||
document.addEventListener("keydown", this.onNativeKeyDown);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
|
||||
!ObjectUtils.shallowEqual(this.state, nextState));
|
||||
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState));
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
|
@ -566,6 +597,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this.context.removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
this.context.removeListener("event", this.onEvent);
|
||||
}
|
||||
|
||||
window.removeEventListener('beforeunload', this.onPageUnload);
|
||||
|
@ -573,8 +606,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
|
||||
}
|
||||
|
||||
document.removeEventListener("keydown", this.onNativeKeyDown);
|
||||
|
||||
// Remove RoomStore listener
|
||||
if (this.roomStoreToken) {
|
||||
this.roomStoreToken.remove();
|
||||
|
@ -584,7 +615,15 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.rightPanelStoreToken.remove();
|
||||
}
|
||||
|
||||
WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate);
|
||||
WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
|
||||
WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||
|
||||
if (this.state.room) {
|
||||
WidgetLayoutStore.instance.off(
|
||||
WidgetLayoutStore.emissionForRoom(this.state.room),
|
||||
this.onWidgetLayoutChange,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.showReadReceiptsWatchRef) {
|
||||
SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef);
|
||||
|
@ -602,7 +641,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
private onLayoutChange = () => {
|
||||
this.setState({
|
||||
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
|
||||
layout: SettingsStore.getValue("layout"),
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -622,33 +661,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
|
||||
private onNativeKeyDown = ev => {
|
||||
let handled = false;
|
||||
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.D:
|
||||
if (ctrlCmdOnly) {
|
||||
this.onMuteAudioClick();
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.E:
|
||||
if (ctrlCmdOnly) {
|
||||
this.onMuteVideoClick();
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private onReactKeyDown = ev => {
|
||||
let handled = false;
|
||||
|
||||
|
@ -683,9 +695,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
private onAction = payload => {
|
||||
switch (payload.action) {
|
||||
case 'message_send_failed':
|
||||
case 'message_sent':
|
||||
this.checkIfAlone(this.state.room);
|
||||
this.checkDesktopNotifications();
|
||||
break;
|
||||
case 'post_sticker_message':
|
||||
this.injectSticker(
|
||||
|
@ -754,6 +765,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
break;
|
||||
case 'focus_search':
|
||||
this.onSearchClick();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -795,6 +809,30 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onEventDecrypted = (ev) => {
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
private onEvent = (ev) => {
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
private handleEffects = (ev) => {
|
||||
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
|
||||
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
|
||||
|
||||
const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room);
|
||||
if (!notifState.isUnread) return;
|
||||
|
||||
CHAT_EFFECTS.forEach(effect => {
|
||||
if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) {
|
||||
dis.dispatch({action: `effects.${effect.command}`});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private onRoomName = (room: Room) => {
|
||||
if (this.state.room && room.roomId == this.state.room.roomId) {
|
||||
this.forceUpdate();
|
||||
|
@ -817,12 +855,17 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
// called when state.room is first initialised (either at initial load,
|
||||
// after a successful peek, or after we join the room).
|
||||
private onRoomLoaded = (room: Room) => {
|
||||
// Attach a widget store listener only when we get a room
|
||||
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
|
||||
this.onWidgetLayoutChange(); // provoke an update
|
||||
|
||||
this.calculatePeekRules(room);
|
||||
this.updatePreviewUrlVisibility(room);
|
||||
this.loadMembersIfJoined(room);
|
||||
this.calculateRecommendedVersion(room);
|
||||
this.updateE2EStatus(room);
|
||||
this.updatePermissions(room);
|
||||
this.checkWidgets(room);
|
||||
};
|
||||
|
||||
private async calculateRecommendedVersion(room: Room) {
|
||||
|
@ -878,6 +921,15 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
if (!room || room.roomId !== this.state.roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detach the listener if the room is changing for some reason
|
||||
if (this.state.room) {
|
||||
WidgetLayoutStore.instance.off(
|
||||
WidgetLayoutStore.emissionForRoom(this.state.room),
|
||||
this.onWidgetLayoutChange,
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
room: room,
|
||||
}, () => {
|
||||
|
@ -1002,33 +1054,17 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// rate limited because a power level change will emit an event for every member in the room.
|
||||
private updateRoomMembers = rateLimitedFunc((dueToMember) => {
|
||||
private updateRoomMembers = rateLimitedFunc(() => {
|
||||
this.updateDMState();
|
||||
|
||||
let memberCountInfluence = 0;
|
||||
if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) {
|
||||
// A member got invited, but the room hasn't detected that change yet. Influence the member
|
||||
// count by 1 to counteract this.
|
||||
memberCountInfluence = 1;
|
||||
}
|
||||
this.checkIfAlone(this.state.room, memberCountInfluence);
|
||||
|
||||
this.updateE2EStatus(this.state.room);
|
||||
}, 500);
|
||||
|
||||
private checkIfAlone(room: Room, countInfluence?: number) {
|
||||
let warnedAboutLonelyRoom = false;
|
||||
if (localStorage) {
|
||||
warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId));
|
||||
private checkDesktopNotifications() {
|
||||
const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount();
|
||||
// if they are not alone prompt the user about notifications so they don't miss replies
|
||||
if (memberCount > 1 && Notifier.shouldShowPrompt()) {
|
||||
showNotificationsToast(true);
|
||||
}
|
||||
if (warnedAboutLonelyRoom) {
|
||||
if (this.state.isAlone) this.setState({isAlone: false});
|
||||
return;
|
||||
}
|
||||
|
||||
let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
|
||||
if (countInfluence) joinedOrInvitedMemberCount += countInfluence;
|
||||
this.setState({isAlone: joinedOrInvitedMemberCount === 1});
|
||||
}
|
||||
|
||||
private updateDMState() {
|
||||
|
@ -1063,14 +1099,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
action: 'view_invite',
|
||||
roomId: this.state.room.roomId,
|
||||
});
|
||||
this.setState({isAlone: false}); // there's a good chance they'll invite someone
|
||||
};
|
||||
|
||||
private onStopAloneWarningClick = () => {
|
||||
if (localStorage) {
|
||||
localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true));
|
||||
}
|
||||
this.setState({isAlone: false});
|
||||
};
|
||||
|
||||
private onJoinButtonClicked = () => {
|
||||
|
@ -1092,6 +1120,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
dis.dispatch({
|
||||
action: 'join_room',
|
||||
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
|
||||
_type: "unknown", // TODO: instrumentation
|
||||
});
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
@ -1118,16 +1147,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
|
||||
const items = [...ev.dataTransfer.items];
|
||||
if (items.length >= 1) {
|
||||
const isDraggingFiles = items.every(function(item) {
|
||||
return item.kind == 'file';
|
||||
});
|
||||
|
||||
if (isDraggingFiles) {
|
||||
this.setState({ draggingFile: true });
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
|
||||
this.setState({ draggingFile: true });
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1258,7 +1280,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
if (!this.state.searchResults.next_batch) {
|
||||
if (this.state.searchResults.results.length == 0) {
|
||||
if (!this.state.searchResults?.results?.length) {
|
||||
ret.push(<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
|
||||
</li>,
|
||||
|
@ -1282,7 +1304,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
let lastRoomId;
|
||||
|
||||
for (let i = this.state.searchResults.results.length - 1; i >= 0; i--) {
|
||||
for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) {
|
||||
const result = this.state.searchResults.results[i];
|
||||
|
||||
const mxEv = result.context.getEvent();
|
||||
|
@ -1333,13 +1355,18 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned);
|
||||
};
|
||||
|
||||
private onSettingsClick = () => {
|
||||
private onCallPlaced = (type: PlaceCallType) => {
|
||||
dis.dispatch({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.RoomSummary,
|
||||
action: 'place_call',
|
||||
type: type,
|
||||
room_id: this.state.room.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
private onSettingsClick = () => {
|
||||
dis.dispatch({ action: "open_room_settings" });
|
||||
};
|
||||
|
||||
private onCancelClick = () => {
|
||||
console.log("updateTint from onCancelClick");
|
||||
this.updateTint();
|
||||
|
@ -1352,6 +1379,13 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
dis.fire(Action.FocusComposer);
|
||||
};
|
||||
|
||||
private onAppsClick = () => {
|
||||
dis.dispatch({
|
||||
action: "appsDrawer",
|
||||
show: !this.state.showApps,
|
||||
});
|
||||
};
|
||||
|
||||
private onLeaveClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'leave_room',
|
||||
|
@ -1366,12 +1400,12 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onRejectButtonClicked = ev => {
|
||||
private onRejectButtonClicked = () => {
|
||||
this.setState({
|
||||
rejecting: true,
|
||||
});
|
||||
this.context.leave(this.state.roomId).then(() => {
|
||||
dis.dispatch({ action: 'view_next_room' });
|
||||
dis.dispatch({ action: 'view_home_page' });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
|
@ -1405,7 +1439,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
await this.context.setIgnoredUsers(ignoredUsers);
|
||||
|
||||
await this.context.leave(this.state.roomId);
|
||||
dis.dispatch({ action: 'view_next_room' });
|
||||
dis.dispatch({ action: 'view_home_page' });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
|
@ -1426,7 +1460,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onRejectThreepidInviteButtonClicked = ev => {
|
||||
private onRejectThreepidInviteButtonClicked = () => {
|
||||
// We can reject 3pid invites in the same way that we accept them,
|
||||
// using /leave rather than /join. In the short term though, we
|
||||
// just ignore them.
|
||||
|
@ -1689,7 +1723,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const myMembership = this.state.room.getMyMembership();
|
||||
if (myMembership == 'invite') {
|
||||
if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself
|
||||
if (this.state.joining || this.state.rejecting) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
@ -1761,12 +1795,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
isStatusAreaExpanded = this.state.statusBarVisible;
|
||||
statusBar = <RoomStatusBar
|
||||
room={this.state.room}
|
||||
sentMessageAndIsAlone={this.state.isAlone}
|
||||
callState={this.state.callState}
|
||||
callType={activeCall ? activeCall.type : null}
|
||||
isPeeking={myMembership !== "join"}
|
||||
onInviteClick={this.onInviteButtonClick}
|
||||
onStopWarningClick={this.onStopAloneWarningClick}
|
||||
onVisible={this.onStatusBarVisible}
|
||||
onHidden={this.onStatusBarHidden}
|
||||
/>;
|
||||
|
@ -1822,7 +1852,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
room={this.state.room}
|
||||
/>
|
||||
);
|
||||
if (!this.state.canPeek) {
|
||||
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
{ previewBar }
|
||||
|
@ -1844,6 +1874,18 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
if (this.state.room?.isSpaceRoom()) {
|
||||
return <SpaceRoomView
|
||||
space={this.state.room}
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onJoinButtonClicked={this.onJoinButtonClicked}
|
||||
onRejectButtonClicked={this.props.threepidInvite
|
||||
? this.onRejectThreepidInviteButtonClicked
|
||||
: this.onRejectButtonClicked}
|
||||
/>;
|
||||
}
|
||||
|
||||
const auxPanel = (
|
||||
<AuxPanel
|
||||
room={this.state.room}
|
||||
|
@ -1873,6 +1915,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
showApps={this.state.showApps}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
|
||||
/>;
|
||||
}
|
||||
|
@ -1887,56 +1930,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
};
|
||||
}
|
||||
|
||||
if (activeCall) {
|
||||
let zoomButton; let videoMuteButton;
|
||||
|
||||
if (activeCall.type === CallType.Video) {
|
||||
zoomButton = (
|
||||
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
|
||||
<TintableSvg
|
||||
src={require("../../../res/img/element-icons/call/fullscreen.svg")}
|
||||
width="29"
|
||||
height="22"
|
||||
style={{ marginTop: 1, marginRight: 4 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
videoMuteButton =
|
||||
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
|
||||
<TintableSvg
|
||||
src={activeCall.isLocalVideoMuted() ?
|
||||
require("../../../res/img/element-icons/call/video-muted.svg") :
|
||||
require("../../../res/img/element-icons/call/video-call.svg")}
|
||||
alt={activeCall.isLocalVideoMuted() ? _t("Click to unmute video") :
|
||||
_t("Click to mute video")}
|
||||
width=""
|
||||
height="27"
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
const voiceMuteButton =
|
||||
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
|
||||
<TintableSvg
|
||||
src={activeCall.isMicrophoneMuted() ?
|
||||
require("../../../res/img/element-icons/call/voice-muted.svg") :
|
||||
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
|
||||
alt={activeCall.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
|
||||
width="21"
|
||||
height="26"
|
||||
/>
|
||||
</div>;
|
||||
|
||||
// wrap the existing status bar into a 'callStatusBar' which adds more knobs.
|
||||
statusBar =
|
||||
<div className="mx_RoomView_callStatusBar">
|
||||
{ voiceMuteButton }
|
||||
{ videoMuteButton }
|
||||
{ zoomButton }
|
||||
{ statusBar }
|
||||
</div>;
|
||||
}
|
||||
|
||||
// if we have search results, we keep the messagepanel (so that it preserves its
|
||||
// scroll state), but hide it.
|
||||
let searchResultsPanel;
|
||||
|
@ -1944,7 +1937,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
if (this.state.searchResults) {
|
||||
// show searching spinner
|
||||
if (this.state.searchResults.results === undefined) {
|
||||
if (this.state.searchResults.count === undefined) {
|
||||
searchResultsPanel = (
|
||||
<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />
|
||||
);
|
||||
|
@ -1975,8 +1968,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
const messagePanelClassNames = classNames(
|
||||
"mx_RoomView_messagePanel",
|
||||
{
|
||||
"mx_IRCLayout": this.state.useIRCLayout,
|
||||
"mx_GroupLayout": !this.state.useIRCLayout,
|
||||
"mx_IRCLayout": this.state.layout == Layout.IRC,
|
||||
"mx_GroupLayout": this.state.layout == Layout.Group,
|
||||
});
|
||||
|
||||
// console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
|
||||
|
@ -1999,7 +1992,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
showReactions={true}
|
||||
useIRCLayout={this.state.useIRCLayout}
|
||||
layout={this.state.layout}
|
||||
/>);
|
||||
|
||||
let topUnreadMessagesBar = null;
|
||||
|
@ -2038,9 +2031,14 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
mx_RoomView_inCall: Boolean(activeCall),
|
||||
});
|
||||
|
||||
const showChatEffects = SettingsStore.getValue('showChatEffects');
|
||||
|
||||
return (
|
||||
<RoomContext.Provider value={this.state}>
|
||||
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
|
||||
{showChatEffects && this.roomView.current &&
|
||||
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
|
||||
}
|
||||
<ErrorBoundary>
|
||||
<RoomHeader
|
||||
room={this.state.room}
|
||||
|
@ -2054,6 +2052,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
|
||||
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null}
|
||||
appsShown={this.state.showApps}
|
||||
onCallPlaced={this.onCallPlaced}
|
||||
/>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<div className="mx_RoomView_body">
|
||||
|
|
|
@ -704,7 +704,7 @@ export default class ScrollPanel extends React.Component {
|
|||
if (itemlist.style.height !== newHeight) {
|
||||
itemlist.style.height = newHeight;
|
||||
}
|
||||
if (sn.scrollTop !== sn.scrollHeight){
|
||||
if (sn.scrollTop !== sn.scrollHeight) {
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
}
|
||||
debuglog("updateHeight to", newHeight);
|
||||
|
|
576
src/components/structures/SpaceRoomDirectory.tsx
Normal file
576
src/components/structures/SpaceRoomDirectory.tsx
Normal file
|
@ -0,0 +1,576 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useMemo, useRef, useState} from "react";
|
||||
import Room from "matrix-js-sdk/src/models/room";
|
||||
import MatrixEvent from "matrix-js-sdk/src/models/event";
|
||||
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import {_t} from "../../languageHandler";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import FormButton from "../views/elements/FormButton";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
|
||||
import {shouldShowSpaceSettings} from "../../utils/space";
|
||||
import {EnhancedMap} from "../../utils/maps";
|
||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface ISpaceSummaryRoom {
|
||||
canonical_alias?: string;
|
||||
aliases: string[];
|
||||
avatar_url?: string;
|
||||
guest_can_join: boolean;
|
||||
name?: string;
|
||||
num_joined_members: number
|
||||
room_id: string;
|
||||
topic?: string;
|
||||
world_readable: boolean;
|
||||
num_refs: number;
|
||||
room_type: string;
|
||||
}
|
||||
|
||||
export interface ISpaceSummaryEvent {
|
||||
room_id: string;
|
||||
event_id: string;
|
||||
origin_server_ts: number;
|
||||
type: string;
|
||||
state_key: string;
|
||||
content: {
|
||||
order?: string;
|
||||
auto_join?: boolean;
|
||||
via?: string;
|
||||
};
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
interface ISubspaceProps {
|
||||
space: ISpaceSummaryRoom;
|
||||
event?: MatrixEvent;
|
||||
editing?: boolean;
|
||||
onPreviewClick?(): void;
|
||||
queueAction?(action: IAction): void;
|
||||
onJoinClick?(): void;
|
||||
}
|
||||
|
||||
const SubSpace: React.FC<ISubspaceProps> = ({
|
||||
space,
|
||||
editing,
|
||||
event,
|
||||
queueAction,
|
||||
onJoinClick,
|
||||
onPreviewClick,
|
||||
children,
|
||||
}) => {
|
||||
const name = space.name || space.canonical_alias || space.aliases?.[0] || _t("Unnamed Space");
|
||||
|
||||
const evContent = event?.getContent();
|
||||
const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join);
|
||||
const [removed, _setRemoved] = useState(!evContent?.via);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const cliRoom = cli.getRoom(space.room_id);
|
||||
const myMembership = cliRoom?.getMyMembership();
|
||||
|
||||
// TODO DRY code
|
||||
let actions;
|
||||
if (editing && queueAction) {
|
||||
if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
|
||||
const setAutoJoin = () => {
|
||||
_setAutoJoin(v => {
|
||||
queueAction({
|
||||
event,
|
||||
removed,
|
||||
autoJoin: !v,
|
||||
});
|
||||
return !v;
|
||||
});
|
||||
};
|
||||
|
||||
const setRemoved = () => {
|
||||
_setRemoved(v => {
|
||||
queueAction({
|
||||
event,
|
||||
removed: !v,
|
||||
autoJoin,
|
||||
});
|
||||
return !v;
|
||||
});
|
||||
};
|
||||
|
||||
if (removed) {
|
||||
actions = <React.Fragment>
|
||||
<FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
|
||||
</React.Fragment>;
|
||||
} else {
|
||||
actions = <React.Fragment>
|
||||
<FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
|
||||
<StyledCheckbox checked={autoJoin} onChange={setAutoJoin} />
|
||||
</React.Fragment>;
|
||||
}
|
||||
} else {
|
||||
actions = <span className="mx_SpaceRoomDirectory_actionsText">
|
||||
{ _t("No permissions")}
|
||||
</span>;
|
||||
}
|
||||
// TODO confirm remove from space click behaviour here
|
||||
} else {
|
||||
if (myMembership === "join") {
|
||||
actions = <span className="mx_SpaceRoomDirectory_actionsText">
|
||||
{ _t("You're in this space")}
|
||||
</span>;
|
||||
} else if (onJoinClick) {
|
||||
actions = <React.Fragment>
|
||||
<AccessibleButton onClick={onPreviewClick} kind="link">
|
||||
{ _t("Preview") }
|
||||
</AccessibleButton>
|
||||
<FormButton onClick={onJoinClick} label={_t("Join")} />
|
||||
</React.Fragment>
|
||||
}
|
||||
}
|
||||
|
||||
let url: string;
|
||||
if (space.avatar_url) {
|
||||
url = MatrixClientPeg.get().mxcUrlToHttp(
|
||||
space.avatar_url,
|
||||
Math.floor(24 * window.devicePixelRatio),
|
||||
Math.floor(24 * window.devicePixelRatio),
|
||||
"crop",
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceRoomDirectory_subspace">
|
||||
<div className="mx_SpaceRoomDirectory_subspace_info">
|
||||
<BaseAvatar name={name} idName={space.room_id} url={url} width={24} height={24} />
|
||||
{ name }
|
||||
|
||||
<div className="mx_SpaceRoomDirectory_actions">
|
||||
{ actions }
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_SpaceRoomDirectory_subspace_children">
|
||||
{ children }
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
interface IAction {
|
||||
event: MatrixEvent;
|
||||
removed: boolean;
|
||||
autoJoin: boolean;
|
||||
}
|
||||
|
||||
interface IRoomTileProps {
|
||||
room: ISpaceSummaryRoom;
|
||||
event?: MatrixEvent;
|
||||
editing?: boolean;
|
||||
onPreviewClick(): void;
|
||||
queueAction?(action: IAction): void;
|
||||
onJoinClick?(): void;
|
||||
}
|
||||
|
||||
const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinClick }: IRoomTileProps) => {
|
||||
const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Unnamed Room");
|
||||
|
||||
const evContent = event?.getContent();
|
||||
const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join);
|
||||
const [removed, _setRemoved] = useState(!evContent?.via);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const cliRoom = cli.getRoom(room.room_id);
|
||||
const myMembership = cliRoom?.getMyMembership();
|
||||
|
||||
let actions;
|
||||
if (editing && queueAction) {
|
||||
if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
|
||||
const setAutoJoin = () => {
|
||||
_setAutoJoin(v => {
|
||||
queueAction({
|
||||
event,
|
||||
removed,
|
||||
autoJoin: !v,
|
||||
});
|
||||
return !v;
|
||||
});
|
||||
};
|
||||
|
||||
const setRemoved = () => {
|
||||
_setRemoved(v => {
|
||||
queueAction({
|
||||
event,
|
||||
removed: !v,
|
||||
autoJoin,
|
||||
});
|
||||
return !v;
|
||||
});
|
||||
};
|
||||
|
||||
if (removed) {
|
||||
actions = <React.Fragment>
|
||||
<FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
|
||||
</React.Fragment>;
|
||||
} else {
|
||||
actions = <React.Fragment>
|
||||
<FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
|
||||
<StyledCheckbox checked={autoJoin} onChange={setAutoJoin} />
|
||||
</React.Fragment>;
|
||||
}
|
||||
} else {
|
||||
actions = <span className="mx_SpaceRoomDirectory_actionsText">
|
||||
{ _t("No permissions")}
|
||||
</span>;
|
||||
}
|
||||
// TODO confirm remove from space click behaviour here
|
||||
} else {
|
||||
if (myMembership === "join") {
|
||||
actions = <span className="mx_SpaceRoomDirectory_actionsText">
|
||||
{ _t("You're in this room")}
|
||||
</span>;
|
||||
} else if (onJoinClick) {
|
||||
actions = <React.Fragment>
|
||||
<AccessibleButton onClick={onPreviewClick} kind="link">
|
||||
{ _t("Preview") }
|
||||
</AccessibleButton>
|
||||
<FormButton onClick={onJoinClick} label={_t("Join")} />
|
||||
</React.Fragment>
|
||||
}
|
||||
}
|
||||
|
||||
let url: string;
|
||||
if (room.avatar_url) {
|
||||
url = cli.mxcUrlToHttp(
|
||||
room.avatar_url,
|
||||
Math.floor(32 * window.devicePixelRatio),
|
||||
Math.floor(32 * window.devicePixelRatio),
|
||||
"crop",
|
||||
);
|
||||
}
|
||||
|
||||
const content = <React.Fragment>
|
||||
<BaseAvatar name={name} idName={room.room_id} url={url} width={32} height={32} />
|
||||
|
||||
<div className="mx_SpaceRoomDirectory_roomTile_info">
|
||||
<div className="mx_SpaceRoomDirectory_roomTile_name">
|
||||
{ name }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomDirectory_roomTile_topic">
|
||||
{ room.topic }
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_SpaceRoomDirectory_roomTile_memberCount">
|
||||
{ room.num_joined_members }
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomDirectory_actions">
|
||||
{ actions }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
if (editing) {
|
||||
return <div className="mx_SpaceRoomDirectory_roomTile">
|
||||
{ content }
|
||||
</div>
|
||||
}
|
||||
|
||||
return <AccessibleButton className="mx_SpaceRoomDirectory_roomTile" onClick={onPreviewClick}>
|
||||
{ content }
|
||||
</AccessibleButton>;
|
||||
};
|
||||
|
||||
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
||||
// Don't let the user view a room they won't be able to either peek or join:
|
||||
// fail earlier so they don't have to click back to the directory.
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
if (!room.world_readable && !room.guest_can_join) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const roomAlias = getDisplayAliasForRoom(room) || undefined;
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
auto_join: autoJoin,
|
||||
should_peek: true,
|
||||
_type: "room_directory", // instrumentation
|
||||
room_alias: roomAlias,
|
||||
room_id: room.room_id,
|
||||
via_servers: viaServers,
|
||||
oob_data: {
|
||||
avatarUrl: room.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
|
||||
name: room.name || roomAlias || _t("Unnamed room"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
interface IHierarchyLevelProps {
|
||||
spaceId: string;
|
||||
rooms: Map<string, ISpaceSummaryRoom>;
|
||||
editing?: boolean;
|
||||
relations: EnhancedMap<string, string[]>;
|
||||
parents: Set<string>;
|
||||
queueAction?(action: IAction): void;
|
||||
onPreviewClick(roomId: string): void;
|
||||
onRemoveFromSpaceClick?(roomId: string): void;
|
||||
onJoinClick?(roomId: string): void;
|
||||
}
|
||||
|
||||
export const HierarchyLevel = ({
|
||||
spaceId,
|
||||
rooms,
|
||||
editing,
|
||||
relations,
|
||||
parents,
|
||||
onPreviewClick,
|
||||
onJoinClick,
|
||||
queueAction,
|
||||
}: IHierarchyLevelProps) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const space = cli.getRoom(spaceId);
|
||||
// TODO respect order
|
||||
const [subspaces, childRooms] = relations.get(spaceId)?.reduce((result, roomId: string) => {
|
||||
if (!rooms.has(roomId)) return result; // TODO wat
|
||||
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
|
||||
return result;
|
||||
}, [[], []]) || [[], []];
|
||||
|
||||
// Don't render this subspace if it has no rooms we can show
|
||||
// TODO this is broken - as a space may have subspaces we still need to show
|
||||
// if (!childRooms.length) return null;
|
||||
|
||||
const userId = cli.getUserId();
|
||||
|
||||
const newParents = new Set(parents).add(spaceId);
|
||||
return <React.Fragment>
|
||||
{
|
||||
childRooms.map(roomId => (
|
||||
<RoomTile
|
||||
key={roomId}
|
||||
room={rooms.get(roomId)}
|
||||
event={space?.currentState.maySendStateEvent(EventType.SpaceChild, userId)
|
||||
? space?.currentState.getStateEvents(EventType.SpaceChild, roomId)
|
||||
: undefined}
|
||||
editing={editing}
|
||||
queueAction={queueAction}
|
||||
onPreviewClick={() => {
|
||||
onPreviewClick(roomId);
|
||||
}}
|
||||
onJoinClick={onJoinClick ? () => {
|
||||
onJoinClick(roomId);
|
||||
} : undefined}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
|
||||
<SubSpace
|
||||
key={roomId}
|
||||
space={rooms.get(roomId)}
|
||||
event={space?.currentState.getStateEvents(EventType.SpaceChild, roomId)}
|
||||
editing={editing}
|
||||
queueAction={queueAction}
|
||||
onPreviewClick={() => {
|
||||
onPreviewClick(roomId);
|
||||
}}
|
||||
onJoinClick={() => {
|
||||
onJoinClick(roomId);
|
||||
}}
|
||||
>
|
||||
<HierarchyLevel
|
||||
spaceId={roomId}
|
||||
rooms={rooms}
|
||||
editing={editing}
|
||||
relations={relations}
|
||||
parents={newParents}
|
||||
onPreviewClick={onPreviewClick}
|
||||
onJoinClick={onJoinClick}
|
||||
queueAction={queueAction}
|
||||
/>
|
||||
</SubSpace>
|
||||
))
|
||||
}
|
||||
</React.Fragment>
|
||||
};
|
||||
|
||||
const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinished }) => {
|
||||
// TODO pagination
|
||||
const cli = MatrixClientPeg.get();
|
||||
const [query, setQuery] = useState(initialText);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const onCreateRoomClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'view_create_room',
|
||||
public: true,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
// stored within a ref as we don't need to re-render when it changes
|
||||
const pendingActions = useRef(new Map<string, IAction>());
|
||||
|
||||
let adminButton;
|
||||
if (shouldShowSpaceSettings(cli, space)) { // TODO this is an imperfect test
|
||||
const onManageButtonClicked = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const onSaveButtonClicked = () => {
|
||||
// TODO setBusy
|
||||
pendingActions.current.forEach(({event, autoJoin, removed}) => {
|
||||
const content = {
|
||||
...event.getContent(),
|
||||
auto_join: autoJoin,
|
||||
};
|
||||
|
||||
if (removed) {
|
||||
delete content["via"];
|
||||
}
|
||||
|
||||
cli.sendStateEvent(event.getRoomId(), event.getType(), content, event.getStateKey());
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
adminButton = <React.Fragment>
|
||||
<FormButton label={_t("Save changes")} onClick={onSaveButtonClicked} />
|
||||
<span>{ _t("All users join by default") }</span>
|
||||
</React.Fragment>;
|
||||
} else {
|
||||
adminButton = <FormButton label={_t("Manage rooms")} onClick={onManageButtonClicked} />;
|
||||
}
|
||||
}
|
||||
|
||||
const [rooms, relations, viaMap] = useAsyncMemo(async () => {
|
||||
try {
|
||||
const data = await cli.getSpaceSummary(space.roomId);
|
||||
|
||||
const parentChildRelations = new EnhancedMap<string, string[]>();
|
||||
const viaMap = new EnhancedMap<string, Set<string>>();
|
||||
data.events.map((ev: ISpaceSummaryEvent) => {
|
||||
if (ev.type === EventType.SpaceChild) {
|
||||
parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
|
||||
}
|
||||
if (Array.isArray(ev.content["via"])) {
|
||||
const set = viaMap.getOrCreate(ev.state_key, new Set());
|
||||
ev.content["via"].forEach(via => set.add(via));
|
||||
}
|
||||
});
|
||||
|
||||
return [data.rooms, parentChildRelations, viaMap];
|
||||
} catch (e) {
|
||||
console.error(e); // TODO
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [space], []);
|
||||
|
||||
const roomsMap = useMemo(() => {
|
||||
if (!rooms) return null;
|
||||
const lcQuery = query.toLowerCase();
|
||||
|
||||
const filteredRooms = rooms.filter(r => {
|
||||
return r.room_type === RoomType.Space // always include spaces to allow filtering of sub-space rooms
|
||||
|| r.name?.toLowerCase().includes(lcQuery)
|
||||
|| r.topic?.toLowerCase().includes(lcQuery);
|
||||
});
|
||||
|
||||
return new Map<string, ISpaceSummaryRoom>(filteredRooms.map(r => [r.room_id, r]));
|
||||
// const root = rooms.get(space.roomId);
|
||||
}, [rooms, query]);
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={space} height={40} width={40} />
|
||||
<div>
|
||||
<h1>{ _t("Explore rooms") }</h1>
|
||||
<div><RoomName room={space} /></div>
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
const explanation =
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
|
||||
{a: sub => {
|
||||
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{sub}</AccessibleButton>;
|
||||
}},
|
||||
);
|
||||
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
content = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
|
||||
<HierarchyLevel
|
||||
spaceId={space.roomId}
|
||||
rooms={roomsMap}
|
||||
editing={isEditing}
|
||||
relations={relations}
|
||||
parents={new Set()}
|
||||
queueAction={action => {
|
||||
pendingActions.current.set(action.event.room_id, action);
|
||||
}}
|
||||
onPreviewClick={roomId => {
|
||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), false);
|
||||
onFinished();
|
||||
}}
|
||||
onJoinClick={(roomId) => {
|
||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), true);
|
||||
onFinished();
|
||||
}}
|
||||
/>
|
||||
</AutoHideScrollbar>;
|
||||
}
|
||||
|
||||
// TODO loading state/error state
|
||||
return (
|
||||
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
|
||||
<div className="mx_Dialog_content">
|
||||
{ explanation }
|
||||
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Find a room...") }
|
||||
onSearch={setQuery}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
||||
{ adminButton }
|
||||
</div>
|
||||
{ content }
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpaceRoomDirectory;
|
||||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
|
||||
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
|
||||
}
|
604
src/components/structures/SpaceRoomView.tsx
Normal file
604
src/components/structures/SpaceRoomView.tsx
Normal file
|
@ -0,0 +1,604 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {RefObject, useContext, useRef, useState} from "react";
|
||||
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import {_t} from "../../languageHandler";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
import RoomTopic from "../views/elements/RoomTopic";
|
||||
import InlineSpinner from "../views/elements/InlineSpinner";
|
||||
import FormButton from "../views/elements/FormButton";
|
||||
import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite";
|
||||
import {useRoomMembers} from "../../hooks/useRoomMembers";
|
||||
import createRoom, {IOpts, Preset} from "../../createRoom";
|
||||
import Field from "../views/elements/Field";
|
||||
import {useEventEmitter} from "../../hooks/useEventEmitter";
|
||||
import StyledRadioGroup from "../views/elements/StyledRadioGroup";
|
||||
import withValidation from "../views/elements/Validation";
|
||||
import * as Email from "../../email";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier"
|
||||
import MainSplit from './MainSplit';
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
import {ActionPayload} from "../../dispatcher/payloads";
|
||||
import RightPanel from "./RightPanel";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import {EventSubscription} from "fbemitter";
|
||||
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
|
||||
import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import {useStateArray} from "../../hooks/useStateArray";
|
||||
import SpacePublicShare from "../views/spaces/SpacePublicShare";
|
||||
import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space";
|
||||
import {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom} from "./SpaceRoomDirectory";
|
||||
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
|
||||
import {EnhancedMap} from "../../utils/maps";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import MemberAvatar from "../views/avatars/MemberAvatar";
|
||||
import {useStateToggle} from "../../hooks/useStateToggle";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
justCreatedOpts?: IOpts;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
onJoinButtonClicked(): void;
|
||||
onRejectButtonClicked(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
showRightPanel: boolean;
|
||||
}
|
||||
|
||||
enum Phase {
|
||||
Landing,
|
||||
PublicCreateRooms,
|
||||
PublicShare,
|
||||
PrivateScope,
|
||||
PrivateInvite,
|
||||
PrivateCreateRooms,
|
||||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
const RoomMemberCount = ({ room, children }) => {
|
||||
const members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
||||
if (children) return children(count);
|
||||
return count;
|
||||
};
|
||||
|
||||
const useMyRoomMembership = (room: Room) => {
|
||||
const [membership, setMembership] = useState(room.getMyMembership());
|
||||
useEventEmitter(room, "Room.myMembership", () => {
|
||||
setMembership(room.getMyMembership());
|
||||
});
|
||||
return membership;
|
||||
};
|
||||
|
||||
const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
const joinRule = space.getJoinRule();
|
||||
const userId = cli.getUserId();
|
||||
|
||||
let joinButtons;
|
||||
if (myMembership === "invite") {
|
||||
joinButtons = <div className="mx_SpaceRoomView_landing_joinButtons">
|
||||
<FormButton label={_t("Accept Invite")} onClick={onJoinButtonClicked} />
|
||||
<AccessibleButton kind="link" onClick={onRejectButtonClicked}>
|
||||
{_t("Decline")}
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
} else if (myMembership !== "join" && joinRule === "public") {
|
||||
joinButtons = <div className="mx_SpaceRoomView_landing_joinButtons">
|
||||
<FormButton label={_t("Join")} onClick={onJoinButtonClicked} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
let inviteButton;
|
||||
if (myMembership === "join" && space.canInvite(userId)) {
|
||||
inviteButton = (
|
||||
<AccessibleButton className="mx_SpaceRoomView_landing_inviteButton" onClick={() => {
|
||||
showRoomInviteDialog(space.roomId);
|
||||
}}>
|
||||
{ _t("Invite people") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
const [_, forceUpdate] = useStateToggle(false); // TODO
|
||||
|
||||
let addRoomButtons;
|
||||
if (canAddRooms) {
|
||||
addRoomButtons = <React.Fragment>
|
||||
<AccessibleButton className="mx_SpaceRoomView_landing_addButton" onClick={async () => {
|
||||
const [added] = await showAddExistingRooms(cli, space);
|
||||
if (added) {
|
||||
forceUpdate();
|
||||
}
|
||||
}}>
|
||||
{ _t("Add existing rooms & spaces") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_SpaceRoomView_landing_createButton" onClick={() => {
|
||||
showCreateNewRoom(cli, space);
|
||||
}}>
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
if (shouldShowSpaceSettings(cli, space)) {
|
||||
settingsButton = <AccessibleButton className="mx_SpaceRoomView_landing_settingsButton" onClick={() => {
|
||||
showSpaceSettings(cli, space);
|
||||
}}>
|
||||
{ _t("Settings") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => {
|
||||
try {
|
||||
const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join");
|
||||
|
||||
const parentChildRelations = new EnhancedMap<string, string[]>();
|
||||
data.events.map((ev: ISpaceSummaryEvent) => {
|
||||
if (ev.type === EventType.SpaceChild) {
|
||||
parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
|
||||
}
|
||||
});
|
||||
|
||||
const roomsMap = new Map<string, ISpaceSummaryRoom>(data.rooms.map(r => [r.room_id, r]));
|
||||
const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length;
|
||||
return [false, roomsMap, parentChildRelations, numRooms];
|
||||
} catch (e) {
|
||||
console.error(e); // TODO
|
||||
}
|
||||
|
||||
return [false];
|
||||
}, [space, _], [true]);
|
||||
|
||||
let previewRooms;
|
||||
if (roomsMap) {
|
||||
previewRooms = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
|
||||
<div className="mx_SpaceRoomDirectory_roomCount">
|
||||
<h3>{ myMembership === "join" ? _t("Rooms") : _t("Default Rooms")}</h3>
|
||||
<span>{ numRooms }</span>
|
||||
</div>
|
||||
<HierarchyLevel
|
||||
spaceId={space.roomId}
|
||||
rooms={roomsMap}
|
||||
editing={false}
|
||||
relations={relations}
|
||||
parents={new Set()}
|
||||
onPreviewClick={roomId => {
|
||||
showRoom(roomsMap.get(roomId), [], false); // TODO
|
||||
}}
|
||||
/>
|
||||
</AutoHideScrollbar>;
|
||||
} else if (loading) {
|
||||
previewRooms = <InlineSpinner />;
|
||||
} else {
|
||||
previewRooms = <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_landing">
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<div className="mx_SpaceRoomView_landing_name">
|
||||
<RoomName room={space}>
|
||||
{(name) => {
|
||||
const tags = { name: () => <div className="mx_SpaceRoomView_landing_nameRow">
|
||||
<h1>{ name }</h1>
|
||||
<RoomMemberCount room={space}>
|
||||
{(count) => count > 0 ? (
|
||||
<AccessibleButton
|
||||
className="mx_SpaceRoomView_landing_memberCount"
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.RoomMemberList,
|
||||
refireParams: { space },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("%(count)s members", { count }) }
|
||||
</AccessibleButton>
|
||||
) : null}
|
||||
</RoomMemberCount>
|
||||
</div> };
|
||||
if (myMembership === "invite") {
|
||||
const inviteSender = space.getMember(userId)?.events.member?.getSender();
|
||||
const inviter = inviteSender && space.getMember(inviteSender);
|
||||
|
||||
if (inviteSender) {
|
||||
return _t("<inviter/> invited you to <name/>", {}, {
|
||||
name: tags.name,
|
||||
inviter: () => inviter
|
||||
? <span className="mx_SpaceRoomView_landing_inviter">
|
||||
<MemberAvatar member={inviter} width={26} height={26} viewUserOnClick={true} />
|
||||
{ inviter.name }
|
||||
</span>
|
||||
: <span className="mx_SpaceRoomView_landing_inviter">
|
||||
{ inviteSender }
|
||||
</span>,
|
||||
}) as JSX.Element;
|
||||
} else {
|
||||
return _t("You have been invited to <name/>", {}, tags) as JSX.Element;
|
||||
}
|
||||
} else if (shouldShowSpaceSettings(cli, space)) {
|
||||
if (space.getJoinRule() === "public") {
|
||||
return _t("Your public space <name/>", {}, tags) as JSX.Element;
|
||||
} else {
|
||||
return _t("Your private space <name/>", {}, tags) as JSX.Element;
|
||||
}
|
||||
}
|
||||
return _t("Welcome to <name/>", {}, tags) as JSX.Element;
|
||||
}}
|
||||
</RoomName>
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_landing_topic">
|
||||
<RoomTopic room={space} />
|
||||
</div>
|
||||
{ joinButtons }
|
||||
<div className="mx_SpaceRoomView_landing_adminButtons">
|
||||
{ inviteButton }
|
||||
{ addRoomButtons }
|
||||
{ settingsButton }
|
||||
</div>
|
||||
|
||||
{ previewRooms }
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const numFields = 3;
|
||||
const placeholders = [_t("General"), _t("Random"), _t("Support")];
|
||||
// TODO vary default prefills for "Just Me" spaces
|
||||
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
|
||||
const fields = new Array(numFields).fill(0).map((_, i) => {
|
||||
const name = "roomName" + i;
|
||||
return <Field
|
||||
key={name}
|
||||
name={name}
|
||||
type="text"
|
||||
label={_t("Room name")}
|
||||
placeholder={placeholders[i]}
|
||||
value={roomNames[i]}
|
||||
onChange={ev => setRoomName(i, ev.target.value)}
|
||||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async () => {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => {
|
||||
return createRoom({
|
||||
createOpts: {
|
||||
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
},
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: false,
|
||||
inlineErrors: true,
|
||||
parentSpace: space,
|
||||
});
|
||||
}));
|
||||
onFinished();
|
||||
} catch (e) {
|
||||
console.error("Failed to create initial space rooms", e);
|
||||
setError(_t("Failed to create initial space rooms"));
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = onFinished;
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (roomNames.some(name => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
buttonLabel = busy ? _t("Creating rooms...") : _t("Next")
|
||||
}
|
||||
|
||||
return <div>
|
||||
<h1>{ title }</h1>
|
||||
<div className="mx_SpaceRoomView_description">{ description }</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
{ fields }
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<FormButton
|
||||
label={buttonLabel}
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPublicShare = ({ space, onFinished }) => {
|
||||
return <div className="mx_SpaceRoomView_publicShare">
|
||||
<h1>{ _t("Share your public space") }</h1>
|
||||
<div className="mx_SpacePublicShare_description">{ _t("At the moment only you can see it.") }</div>
|
||||
|
||||
<SpacePublicShare space={space} onFinished={onFinished} />
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<FormButton label={_t("Finish")} onClick={onFinished} />
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPrivateScope = ({ onFinished }) => {
|
||||
const [option, setOption] = useState<string>(null);
|
||||
|
||||
return <div className="mx_SpaceRoomView_privateScope">
|
||||
<h1>{ _t("Who are you working with?") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">{ _t("Ensure the right people have access to the space.") }</div>
|
||||
|
||||
<StyledRadioGroup
|
||||
name="privateSpaceScope"
|
||||
value={option}
|
||||
onChange={setOption}
|
||||
definitions={[
|
||||
{
|
||||
value: "justMe",
|
||||
className: "mx_SpaceRoomView_privateScope_justMeButton",
|
||||
label: <React.Fragment>
|
||||
<h3>{ _t("Just Me") }</h3>
|
||||
<div>{ _t("A private space just for you") }</div>
|
||||
</React.Fragment>,
|
||||
}, {
|
||||
value: "meAndMyTeammates",
|
||||
className: "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton",
|
||||
label: <React.Fragment>
|
||||
<h3>{ _t("Me and my teammates") }</h3>
|
||||
<div>{ _t("A private space for you and your teammates") }</div>
|
||||
</React.Fragment>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<FormButton label={_t("Next")} disabled={!option} onClick={() => onFinished(option !== "justMe")} />
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const validateEmailRules = withValidation({
|
||||
rules: [{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
}],
|
||||
});
|
||||
|
||||
const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const numFields = 3;
|
||||
const fieldRefs: RefObject<Field>[] = [useRef(), useRef(), useRef()];
|
||||
const [emailAddresses, setEmailAddress] = useStateArray(numFields, "");
|
||||
const fields = new Array(numFields).fill(0).map((_, i) => {
|
||||
const name = "emailAddress" + i;
|
||||
return <Field
|
||||
key={name}
|
||||
name={name}
|
||||
type="text"
|
||||
label={_t("Email address")}
|
||||
placeholder={_t("Email")}
|
||||
value={emailAddresses[i]}
|
||||
onChange={ev => setEmailAddress(i, ev.target.value)}
|
||||
ref={fieldRefs[i]}
|
||||
onValidate={validateEmailRules}
|
||||
/>;
|
||||
});
|
||||
|
||||
const onNextClick = async () => {
|
||||
setError("");
|
||||
for (let i = 0; i < fieldRefs.length; i++) {
|
||||
const fieldRef = fieldRefs[i];
|
||||
const valid = await fieldRef.current.validate({ allowEmpty: true });
|
||||
|
||||
if (valid === false) { // true/null are allowed
|
||||
fieldRef.current.focus();
|
||||
fieldRef.current.validate({ allowEmpty: true, focused: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
const targetIds = emailAddresses.map(name => name.trim()).filter(Boolean);
|
||||
try {
|
||||
const result = await inviteMultipleToRoom(space.roomId, targetIds);
|
||||
|
||||
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error");
|
||||
if (failedUsers.length > 0) {
|
||||
console.log("Failed to invite users to space: ", result);
|
||||
setError(_t("Failed to invite the following users to your space: %(csvUsers)s", {
|
||||
csvUsers: failedUsers.join(", "),
|
||||
}));
|
||||
} else {
|
||||
onFinished();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to invite users to space: ", err);
|
||||
setError(_t("We couldn't invite those users. Please check the users you want to invite and try again."));
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return <div className="mx_SpaceRoomView_inviteTeammates">
|
||||
<h1>{ _t("Invite your teammates") }</h1>
|
||||
<div className="mx_SpaceRoomView_description">{ _t("Ensure the right people have access to the space.") }</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
{ fields }
|
||||
|
||||
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
|
||||
<AccessibleButton
|
||||
className="mx_SpaceRoomView_inviteTeammates_inviteDialogButton"
|
||||
onClick={() => showRoomInviteDialog(space.roomId)}
|
||||
>
|
||||
{ _t("Invite by username") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton onClick={onFinished} kind="link">{_t("Skip for now")}</AccessibleButton>
|
||||
<FormButton label={busy ? _t("Inviting...") : _t("Next")} disabled={busy} onClick={onNextClick} />
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
|
||||
private readonly creator: string;
|
||||
private readonly dispatcherRef: string;
|
||||
private readonly rightPanelStoreToken: EventSubscription;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
let phase = Phase.Landing;
|
||||
|
||||
this.creator = this.props.space.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
|
||||
const showSetup = this.props.justCreatedOpts && this.context.getUserId() === this.creator;
|
||||
|
||||
if (showSetup) {
|
||||
phase = this.props.justCreatedOpts.createOpts.preset === Preset.PublicChat
|
||||
? Phase.PublicCreateRooms : Phase.PrivateScope;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
phase,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
|
||||
};
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
this.rightPanelStoreToken.remove();
|
||||
}
|
||||
|
||||
private onRightPanelStoreUpdate = () => {
|
||||
this.setState({
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
|
||||
});
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return;
|
||||
|
||||
if (payload.action === Action.ViewUser && payload.member) {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.SpaceMemberInfo,
|
||||
refireParams: {
|
||||
space: this.props.space,
|
||||
member: payload.member,
|
||||
},
|
||||
});
|
||||
} else if (payload.action === "view_3pid_invite" && payload.event) {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.Space3pidMemberInfo,
|
||||
refireParams: {
|
||||
space: this.props.space,
|
||||
event: payload.event,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.SpaceMemberList,
|
||||
refireParams: { space: this.props.space },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private renderBody() {
|
||||
switch (this.state.phase) {
|
||||
case Phase.Landing:
|
||||
return <SpaceLanding
|
||||
space={this.props.space}
|
||||
onJoinButtonClicked={this.props.onJoinButtonClicked}
|
||||
onRejectButtonClicked={this.props.onRejectButtonClicked}
|
||||
/>;
|
||||
|
||||
case Phase.PublicCreateRooms:
|
||||
return <SpaceSetupFirstRooms
|
||||
space={this.props.space}
|
||||
title={_t("What discussions do you want to have?")}
|
||||
description={_t("We'll create rooms for each topic.")}
|
||||
onFinished={() => this.setState({ phase: Phase.PublicShare })}
|
||||
/>;
|
||||
case Phase.PublicShare:
|
||||
return <SpaceSetupPublicShare
|
||||
space={this.props.space}
|
||||
onFinished={() => this.setState({ phase: Phase.Landing })}
|
||||
/>;
|
||||
|
||||
case Phase.PrivateScope:
|
||||
return <SpaceSetupPrivateScope
|
||||
onFinished={(invite: boolean) => {
|
||||
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms });
|
||||
}}
|
||||
/>;
|
||||
case Phase.PrivateInvite:
|
||||
return <SpaceSetupPrivateInvite
|
||||
space={this.props.space}
|
||||
onFinished={() => this.setState({ phase: Phase.PrivateCreateRooms })}
|
||||
/>;
|
||||
case Phase.PrivateCreateRooms:
|
||||
return <SpaceSetupFirstRooms
|
||||
space={this.props.space}
|
||||
title={_t("What projects are you working on?")}
|
||||
description={_t("We'll create rooms for each of them. You can add existing rooms after setup.")}
|
||||
onFinished={() => this.setState({ phase: Phase.Landing })}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const rightPanel = this.state.showRightPanel && this.state.phase === Phase.Landing
|
||||
? <RightPanel room={this.props.space} resizeNotifier={this.props.resizeNotifier} />
|
||||
: null;
|
||||
|
||||
return <main className="mx_SpaceRoomView">
|
||||
<ErrorBoundary>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
|
||||
{ this.renderBody() }
|
||||
</MainSplit>
|
||||
</ErrorBoundary>
|
||||
</main>;
|
||||
}
|
||||
}
|
|
@ -20,7 +20,6 @@ import * as React from "react";
|
|||
import {_t} from '../../languageHandler';
|
||||
import * as sdk from "../../index";
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Represents a tab for the TabbedView.
|
||||
|
|
|
@ -18,6 +18,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import {LayoutPropType} from "../../settings/Layout";
|
||||
import React, {createRef} from 'react';
|
||||
import ReactDOM from "react-dom";
|
||||
import PropTypes from 'prop-types';
|
||||
|
@ -25,7 +26,6 @@ import {EventTimeline} from "matrix-js-sdk";
|
|||
import * as Matrix from "matrix-js-sdk";
|
||||
import { _t } from '../../languageHandler';
|
||||
import {MatrixClientPeg} from "../../MatrixClientPeg";
|
||||
import * as ObjectUtils from "../../ObjectUtils";
|
||||
import UserActivity from "../../UserActivity";
|
||||
import Modal from "../../Modal";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
|
@ -36,6 +36,7 @@ import shouldHideEvent from '../../shouldHideEvent';
|
|||
import EditorStateTransfer from '../../utils/EditorStateTransfer';
|
||||
import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||
import {UIFeature} from "../../settings/UIFeature";
|
||||
import {objectHasDiff} from "../../utils/objects";
|
||||
|
||||
const PAGINATE_SIZE = 20;
|
||||
const INITIAL_SIZE = 20;
|
||||
|
@ -111,8 +112,8 @@ class TimelinePanel extends React.Component {
|
|||
// whether to show reactions for an event
|
||||
showReactions: PropTypes.bool,
|
||||
|
||||
// whether to use the irc layout
|
||||
useIRCLayout: PropTypes.bool,
|
||||
// which layout to use
|
||||
layout: LayoutPropType,
|
||||
}
|
||||
|
||||
// a map from room id to read marker event timestamp
|
||||
|
@ -260,7 +261,7 @@ class TimelinePanel extends React.Component {
|
|||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
if (!ObjectUtils.shallowEqual(this.props, nextProps)) {
|
||||
if (objectHasDiff(this.props, nextProps)) {
|
||||
if (DEBUG) {
|
||||
console.group("Timeline.shouldComponentUpdate: props change");
|
||||
console.log("props before:", this.props);
|
||||
|
@ -270,7 +271,7 @@ class TimelinePanel extends React.Component {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!ObjectUtils.shallowEqual(this.state, nextState)) {
|
||||
if (objectHasDiff(this.state, nextState)) {
|
||||
if (DEBUG) {
|
||||
console.group("Timeline.shouldComponentUpdate: state change");
|
||||
console.log("state before:", this.state);
|
||||
|
@ -715,26 +716,22 @@ class TimelinePanel extends React.Component {
|
|||
}
|
||||
this.lastRMSentEventId = this.state.readMarkerEventId;
|
||||
|
||||
const roomId = this.props.timelineSet.room.roomId;
|
||||
const hiddenRR = !SettingsStore.getValue("sendReadReceipts", roomId);
|
||||
|
||||
debuglog('TimelinePanel: Sending Read Markers for ',
|
||||
this.props.timelineSet.room.roomId,
|
||||
'rm', this.state.readMarkerEventId,
|
||||
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
|
||||
' hidden:' + hiddenRR,
|
||||
);
|
||||
MatrixClientPeg.get().setRoomReadMarkers(
|
||||
this.props.timelineSet.room.roomId,
|
||||
this.state.readMarkerEventId,
|
||||
lastReadEvent, // Could be null, in which case no RR is sent
|
||||
{hidden: hiddenRR},
|
||||
{},
|
||||
).catch((e) => {
|
||||
// /read_markers API is not implemented on this HS, fallback to just RR
|
||||
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
|
||||
return MatrixClientPeg.get().sendReadReceipt(
|
||||
lastReadEvent,
|
||||
{hidden: hiddenRR},
|
||||
{},
|
||||
).catch((e) => {
|
||||
console.error(e);
|
||||
this.lastRRSentEventId = undefined;
|
||||
|
@ -1446,7 +1443,7 @@ class TimelinePanel extends React.Component {
|
|||
getRelationsForEvent={this.getRelationsForEvent}
|
||||
editState={this.state.editState}
|
||||
showReactions={this.props.showReactions}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
layout={this.props.layout}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -55,11 +55,11 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
|||
let toast;
|
||||
if (totalCount !== 0) {
|
||||
const topToast = this.state.toasts[0];
|
||||
const {title, icon, key, component, props} = topToast;
|
||||
const {title, icon, key, component, className, props} = topToast;
|
||||
const toastClasses = classNames("mx_Toast_toast", {
|
||||
"mx_Toast_hasIcon": icon,
|
||||
[`mx_Toast_icon_${icon}`]: icon,
|
||||
});
|
||||
}, className);
|
||||
|
||||
let countIndicator;
|
||||
if (isStacked || this.state.countSeen > 0) {
|
||||
|
|
|
@ -86,7 +86,9 @@ export default class UploadBar extends React.Component {
|
|||
}
|
||||
|
||||
// MUST use var name 'count' for pluralization to kick in
|
||||
const uploadText = _t("Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)});
|
||||
const uploadText = _t(
|
||||
"Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx_UploadBar">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,27 +15,30 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from "classnames";
|
||||
import * as fbEmitter from "fbemitter";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ContextMenuButton } from "./ContextMenu";
|
||||
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import { USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB } from "../views/dialogs/UserSettingsDialog";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
|
||||
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
|
||||
import Modal from "../../Modal";
|
||||
import LogoutDialog from "../views/dialogs/LogoutDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import {getCustomTheme} from "../../theme";
|
||||
import {getHostingLink} from "../../utils/HostingLink";
|
||||
import {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import {getHomePageUrl} from "../../utils/pages";
|
||||
import { getHomePageUrl } from "../../utils/pages";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||
import classNames from "classnames";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import IconizedContextMenu, {
|
||||
|
@ -43,14 +46,16 @@ import IconizedContextMenu, {
|
|||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
|
||||
import * as fbEmitter from "fbemitter";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import { showCommunityInviteDialog } from "../../RoomInvite";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog";
|
||||
import {UIFeature} from "../../settings/UIFeature";
|
||||
import { UIFeature } from "../../settings/UIFeature";
|
||||
import HostSignupAction from "./HostSignupAction";
|
||||
import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
|
@ -61,6 +66,7 @@ type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
|
|||
interface IState {
|
||||
contextMenuPosition: PartialDOMRect;
|
||||
isDarkTheme: boolean;
|
||||
selectedSpace?: Room;
|
||||
}
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
|
@ -78,6 +84,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private get hasHomePage(): boolean {
|
||||
|
@ -95,6 +104,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
this.tagStoreRef.remove();
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private onTagStoreUpdate = () => {
|
||||
|
@ -102,11 +114,15 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private isUserOnDarkTheme(): boolean {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return getCustomTheme(theme.substring("custom-".length)).is_dark;
|
||||
if (SettingsStore.getValue("use_system_theme")) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
} else {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return getCustomTheme(theme.substring("custom-".length)).is_dark;
|
||||
}
|
||||
return theme === "dark";
|
||||
}
|
||||
return theme === "dark";
|
||||
}
|
||||
|
||||
private onProfileUpdate = async () => {
|
||||
|
@ -115,6 +131,10 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onSelectedSpaceUpdate = async (selectedSpace?: Room) => {
|
||||
this.setState({ selectedSpace });
|
||||
};
|
||||
|
||||
private onThemeChanged = () => {
|
||||
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
|
||||
};
|
||||
|
@ -186,15 +206,32 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onSignOutClick = (ev: ButtonEvent) => {
|
||||
private onSignOutClick = async (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
|
||||
// log out without user prompt if they have no local megolm sessions
|
||||
dis.dispatch({action: 'logout'});
|
||||
} else {
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
}
|
||||
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onSignInClick = () => {
|
||||
dis.dispatch({ action: 'start_login' });
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onRegisterClick = () => {
|
||||
dis.dispatch({ action: 'start_registration' });
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
|
@ -203,6 +240,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch({action: 'view_home_page'});
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onCommunitySettingsClick = (ev: ButtonEvent) => {
|
||||
|
@ -253,26 +291,40 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
|
||||
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
|
||||
let hostingLink;
|
||||
const signupLink = getHostingLink("user-context-menu");
|
||||
if (signupLink) {
|
||||
hostingLink = (
|
||||
<div className="mx_UserMenu_contextMenu_header">
|
||||
{_t(
|
||||
"<a>Upgrade</a> to your own domain", {},
|
||||
{
|
||||
a: sub => (
|
||||
<a
|
||||
href={signupLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
tabIndex={-1}
|
||||
>{sub}</a>
|
||||
),
|
||||
},
|
||||
)}
|
||||
let topSection;
|
||||
const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup;
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
topSection = (
|
||||
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
|
||||
{_t("Got an account? <a>Sign in</a>", {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton kind="link" onClick={this.onSignInClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
{_t("New here? <a>Create an account</a>", {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton kind="link" onClick={this.onRegisterClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
} else if (hostSignupConfig) {
|
||||
if (hostSignupConfig && hostSignupConfig.url) {
|
||||
// If hostSignup.domains is set to a non-empty array, only show
|
||||
// dialog if the user is on the domain or a subdomain.
|
||||
const hostSignupDomains = hostSignupConfig.domains || [];
|
||||
const mxDomain = MatrixClientPeg.get().getDomain();
|
||||
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
|
||||
if (!hostSignupConfig.domains || validDomains.length > 0) {
|
||||
topSection = <div onClick={this.onCloseMenu}>
|
||||
<HostSignupAction />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let homeButton = null;
|
||||
|
@ -414,6 +466,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
</IconizedContextMenuOptionList>
|
||||
</React.Fragment>
|
||||
)
|
||||
} else if (MatrixClientPeg.get().isGuest()) {
|
||||
primaryOptionList = (
|
||||
<React.Fragment>
|
||||
<IconizedContextMenuOptionList>
|
||||
{ homeButton }
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("Settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
{ feedbackButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
|
@ -443,7 +509,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
/>
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{hostingLink}
|
||||
{topSection}
|
||||
{primaryOptionList}
|
||||
{secondarySection}
|
||||
</IconizedContextMenu>;
|
||||
|
@ -452,7 +518,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
public render() {
|
||||
const avatarSize = 32; // should match border-radius of the avatar
|
||||
|
||||
const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId();
|
||||
const userId = MatrixClientPeg.get().getUserId();
|
||||
const displayName = OwnProfileStore.instance.displayName || userId;
|
||||
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
|
||||
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
|
@ -465,7 +532,16 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
{/* masked image in CSS */}
|
||||
</span>
|
||||
);
|
||||
if (prototypeCommunityName) {
|
||||
if (this.state.selectedSpace) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{displayName}</span>
|
||||
<RoomName room={this.state.selectedSpace}>
|
||||
{(roomName) => <span className="mx_UserMenu_subUserName">{roomName}</span>}
|
||||
</RoomName>
|
||||
</div>
|
||||
);
|
||||
} else if (prototypeCommunityName) {
|
||||
name = (
|
||||
<div className="mx_UserMenu_doubleName">
|
||||
<span className="mx_UserMenu_userName">{prototypeCommunityName}</span>
|
||||
|
@ -507,7 +583,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
<div className="mx_UserMenu_row">
|
||||
<span className="mx_UserMenu_userAvatarContainer">
|
||||
<BaseAvatar
|
||||
idName={displayName}
|
||||
idName={userId}
|
||||
name={displayName}
|
||||
url={avatarUrl}
|
||||
width={avatarSize}
|
||||
|
|
|
@ -24,6 +24,7 @@ export default class E2eSetup extends React.Component {
|
|||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
accountPassword: PropTypes.string,
|
||||
tokenLogin: PropTypes.bool,
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -33,6 +34,7 @@ export default class E2eSetup extends React.Component {
|
|||
<CreateCrossSigningDialog
|
||||
onFinished={this.props.onFinished}
|
||||
accountPassword={this.props.accountPassword}
|
||||
tokenLogin={this.props.tokenLogin}
|
||||
/>
|
||||
</CompleteSecurityBody>
|
||||
</AuthPage>
|
||||
|
|
|
@ -21,15 +21,14 @@ import PropTypes from 'prop-types';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import Modal from "../../../Modal";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import PasswordReset from "../../../PasswordReset";
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from 'classnames';
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import ServerPicker from "../../views/elements/ServerPicker";
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
const PHASE_SERVER_DETAILS = 0;
|
||||
// Show the forgot password inputs
|
||||
const PHASE_FORGOT = 1;
|
||||
// Email is in the process of being sent
|
||||
|
@ -61,9 +60,14 @@ export default class ForgotPassword extends React.Component {
|
|||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
serverRequiresIdServer: null,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_forgot_password_begin");
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.reset = null;
|
||||
this._checkServerLiveliness(this.props.serverConfig);
|
||||
|
@ -86,12 +90,8 @@ export default class ForgotPassword extends React.Component {
|
|||
serverConfig.isUrl,
|
||||
);
|
||||
|
||||
const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl);
|
||||
const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam();
|
||||
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
serverRequiresIdServer,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
|
||||
|
@ -170,20 +170,6 @@ export default class ForgotPassword extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
onServerDetailsNextPhaseClick = async () => {
|
||||
this.setState({
|
||||
phase: PHASE_FORGOT,
|
||||
});
|
||||
};
|
||||
|
||||
onEditServerDetailsClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
};
|
||||
|
||||
onLoginClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
@ -198,24 +184,6 @@ export default class ForgotPassword extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
renderServerDetails() {
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={0}
|
||||
showIdentityServerIfRequiredByHomeserver={true}
|
||||
onAfterSubmit={this.onServerDetailsNextPhaseClick}
|
||||
submitText={_t("Next")}
|
||||
submitClass="mx_Login_submit"
|
||||
/>;
|
||||
}
|
||||
|
||||
renderForgot() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
|
@ -239,57 +207,13 @@ export default class ForgotPassword extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
yourMatrixAccountText = _t('Your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If custom URLs are allowed, wire up the server details edit link.
|
||||
let editLink = null;
|
||||
if (!SdkConfig.get()['disable_custom_urls']) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) {
|
||||
return <div>
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
{_t(
|
||||
"No identity server is configured: " +
|
||||
"add one in server settings to reset your password.",
|
||||
)}
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
|
||||
{_t('Sign in instead')}
|
||||
</a>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
{errorText}
|
||||
{serverDeadSection}
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
<ServerPicker
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
/>
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<Field
|
||||
|
@ -299,15 +223,20 @@ export default class ForgotPassword extends React.Component {
|
|||
value={this.state.email}
|
||||
onChange={this.onInputChanged.bind(this, "email")}
|
||||
autoFocus
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<Field
|
||||
name="reset_password"
|
||||
type="password"
|
||||
label={_t('Password')}
|
||||
label={_t('New Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onInputChanged.bind(this, "password")}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Field
|
||||
name="reset_password_confirm"
|
||||
|
@ -315,6 +244,9 @@ export default class ForgotPassword extends React.Component {
|
|||
label={_t('Confirm')}
|
||||
value={this.state.password2}
|
||||
onChange={this.onInputChanged.bind(this, "password2")}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<span>{_t(
|
||||
|
@ -367,9 +299,6 @@ export default class ForgotPassword extends React.Component {
|
|||
|
||||
let resetPasswordJsx;
|
||||
switch (this.state.phase) {
|
||||
case PHASE_SERVER_DETAILS:
|
||||
resetPasswordJsx = this.renderServerDetails();
|
||||
break;
|
||||
case PHASE_FORGOT:
|
||||
resetPasswordJsx = this.renderForgot();
|
||||
break;
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2015, 2016, 2017, 2018, 2019 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.
|
||||
|
@ -16,32 +14,27 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {MatrixError} from "matrix-js-sdk/src/http-api";
|
||||
|
||||
import {_t, _td} from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import Login from '../../../Login';
|
||||
import Login, {ISSOFlow, LoginFlow} from '../../../Login';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import SSOButton from "../../views/elements/SSOButton";
|
||||
import PlatformPeg from '../../../PlatformPeg';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
const PHASE_SERVER_DETAILS = 0;
|
||||
// Show the appropriate login flow(s) for the server
|
||||
const PHASE_LOGIN = 1;
|
||||
|
||||
// Enable phases for login
|
||||
const PHASES_ENABLED = true;
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import {IMatrixClientCreds} from "../../../MatrixClientPeg";
|
||||
import PasswordLogin from "../../views/auth/PasswordLogin";
|
||||
import InlineSpinner from "../../views/elements/InlineSpinner";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ServerPicker from "../../views/elements/ServerPicker";
|
||||
|
||||
// These are used in several places, and come from the js-sdk's autodiscovery
|
||||
// stuff. We define them here so that they'll be picked up by i18n.
|
||||
|
@ -54,64 +47,80 @@ _td("Invalid base_url for m.identity_server");
|
|||
_td("Identity server URL does not appear to be a valid identity server");
|
||||
_td("General failure");
|
||||
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
// If true, the component will consider itself busy.
|
||||
busy?: boolean;
|
||||
isSyncing?: boolean;
|
||||
// Secondary HS which we try to log into if the user is using
|
||||
// the default HS but login fails. Useful for migrating to a
|
||||
// different homeserver without confusing users.
|
||||
fallbackHsUrl?: string;
|
||||
defaultDeviceDisplayName?: string;
|
||||
fragmentAfterLogin?: string;
|
||||
|
||||
// Called when the user has logged in. Params:
|
||||
// - The object returned by the login API
|
||||
// - The user's password, if applicable, (may be cached in memory for a
|
||||
// short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn(data: IMatrixClientCreds, password: string): void;
|
||||
|
||||
// login shouldn't know or care how registration, password recovery, etc is done.
|
||||
onRegisterClick(): void;
|
||||
onForgotPasswordClick?(): void;
|
||||
onServerConfigChange(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
busy: boolean;
|
||||
busyLoggingIn?: boolean;
|
||||
errorText?: ReactNode;
|
||||
loginIncorrect: boolean;
|
||||
// can we attempt to log in or are there validation errors?
|
||||
canTryLogin: boolean;
|
||||
|
||||
flows?: LoginFlow[];
|
||||
|
||||
// used for preserving form values when changing homeserver
|
||||
username: string;
|
||||
phoneCountry?: string;
|
||||
phoneNumber: string;
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* A wire component which glues together login UI components and Login logic
|
||||
*/
|
||||
export default class LoginComponent extends React.Component {
|
||||
static propTypes = {
|
||||
// Called when the user has logged in. Params:
|
||||
// - The object returned by the login API
|
||||
// - The user's password, if applicable, (may be cached in memory for a
|
||||
// short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn: PropTypes.func.isRequired,
|
||||
export default class LoginComponent extends React.PureComponent<IProps, IState> {
|
||||
private unmounted = false;
|
||||
private loginLogic: Login;
|
||||
|
||||
// If true, the component will consider itself busy.
|
||||
busy: PropTypes.bool,
|
||||
|
||||
// Secondary HS which we try to log into if the user is using
|
||||
// the default HS but login fails. Useful for migrating to a
|
||||
// different homeserver without confusing users.
|
||||
fallbackHsUrl: PropTypes.string,
|
||||
|
||||
defaultDeviceDisplayName: PropTypes.string,
|
||||
|
||||
// login shouldn't know or care how registration, password recovery,
|
||||
// etc is done.
|
||||
onRegisterClick: PropTypes.func.isRequired,
|
||||
onForgotPasswordClick: PropTypes.func,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
isSyncing: PropTypes.bool,
|
||||
};
|
||||
private readonly stepRendererMap: Record<string, () => ReactNode>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._unmounted = false;
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
busyLoggingIn: null,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
canTryLogin: true, // can we attempt to log in or are there validation errors?
|
||||
canTryLogin: true,
|
||||
|
||||
flows: null,
|
||||
|
||||
// used for preserving form values when changing homeserver
|
||||
username: "",
|
||||
phoneCountry: null,
|
||||
phoneNumber: "",
|
||||
|
||||
// Phase of the overall login dialog.
|
||||
phase: PHASE_LOGIN,
|
||||
// The current login flow, such as password, SSO, etc.
|
||||
currentFlow: null, // we need to load the flows from the server
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
|
@ -119,23 +128,25 @@ export default class LoginComponent extends React.Component {
|
|||
|
||||
// map from login step type to a function which will render a control
|
||||
// letting you do that login type
|
||||
this._stepRendererMap = {
|
||||
'm.login.password': this._renderPasswordStep,
|
||||
this.stepRendererMap = {
|
||||
'm.login.password': this.renderPasswordStep,
|
||||
|
||||
// CAS and SSO are the same thing, modulo the url we link to
|
||||
'm.login.cas': () => this._renderSsoStep("cas"),
|
||||
'm.login.sso': () => this._renderSsoStep("sso"),
|
||||
'm.login.cas': () => this.renderSsoStep("cas"),
|
||||
'm.login.sso': () => this.renderSsoStep("sso"),
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_login_begin");
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
// eslint-disable-next-line camelcase
|
||||
UNSAFE_componentWillMount() {
|
||||
this._initLoginLogic();
|
||||
this.initLoginLogic(this.props.serverConfig);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
|
@ -145,16 +156,9 @@ export default class LoginComponent extends React.Component {
|
|||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
// Ensure that we end up actually logging in to the right place
|
||||
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
|
||||
this.initLoginLogic(newProps.serverConfig);
|
||||
}
|
||||
|
||||
onPasswordLoginError = errorText => {
|
||||
this.setState({
|
||||
errorText,
|
||||
loginIncorrect: Boolean(errorText),
|
||||
});
|
||||
};
|
||||
|
||||
isBusy = () => this.state.busy || this.props.busy;
|
||||
|
||||
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
|
||||
|
@ -191,13 +195,13 @@ export default class LoginComponent extends React.Component {
|
|||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
this._loginLogic.loginViaPassword(
|
||||
this.loginLogic.loginViaPassword(
|
||||
username, phoneCountry, phoneNumber, password,
|
||||
).then((data) => {
|
||||
this.setState({serverIsAlive: true}); // it must be, we logged in.
|
||||
this.props.onLoggedIn(data, password);
|
||||
}, (error) => {
|
||||
if (this._unmounted) {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
let errorText;
|
||||
|
@ -209,21 +213,23 @@ export default class LoginComponent extends React.Component {
|
|||
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
error.data.limit_type,
|
||||
error.data.admin_contact, {
|
||||
'monthly_active_user': _td(
|
||||
"This homeserver has hit its Monthly Active User limit.",
|
||||
),
|
||||
'': _td(
|
||||
"This homeserver has exceeded one of its resource limits.",
|
||||
),
|
||||
});
|
||||
error.data.admin_contact,
|
||||
{
|
||||
'monthly_active_user': _td(
|
||||
"This homeserver has hit its Monthly Active User limit.",
|
||||
),
|
||||
'': _td(
|
||||
"This homeserver has exceeded one of its resource limits.",
|
||||
),
|
||||
},
|
||||
);
|
||||
const errorDetail = messageForResourceLimitError(
|
||||
error.data.limit_type,
|
||||
error.data.admin_contact, {
|
||||
'': _td(
|
||||
"Please <a>contact your service administrator</a> to continue using this service.",
|
||||
),
|
||||
});
|
||||
error.data.admin_contact,
|
||||
{
|
||||
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
|
||||
},
|
||||
);
|
||||
errorText = (
|
||||
<div>
|
||||
<div>{errorTop}</div>
|
||||
|
@ -250,7 +256,7 @@ export default class LoginComponent extends React.Component {
|
|||
}
|
||||
} else {
|
||||
// other errors, not specific to doing a password login
|
||||
errorText = this._errorTextFromError(error);
|
||||
errorText = this.errorTextFromError(error);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
|
@ -288,7 +294,7 @@ export default class LoginComponent extends React.Component {
|
|||
// the busy state. In the case of a full MXID that resolves to the same
|
||||
// HS as Element's default HS though, there may not be any server change.
|
||||
// To avoid this trap, we clear busy here. For cases where the server
|
||||
// actually has changed, `_initLoginLogic` will be called and manages
|
||||
// actually has changed, `initLoginLogic` will be called and manages
|
||||
// busy state for its own liveness check.
|
||||
this.setState({
|
||||
busy: false,
|
||||
|
@ -301,7 +307,7 @@ export default class LoginComponent extends React.Component {
|
|||
message = e.translatedMessage;
|
||||
}
|
||||
|
||||
let errorText = message;
|
||||
let errorText: ReactNode = message;
|
||||
let discoveryState = {};
|
||||
if (AutoDiscoveryUtils.isLivelinessError(e)) {
|
||||
errorText = this.state.errorText;
|
||||
|
@ -327,21 +333,6 @@ export default class LoginComponent extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
onPhoneNumberBlur = phoneNumber => {
|
||||
// Validate the phone number entered
|
||||
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
|
||||
this.setState({
|
||||
errorText: _t('The phone number entered looks invalid'),
|
||||
canTryLogin: false,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
canTryLogin: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onRegisterClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
@ -349,14 +340,16 @@ export default class LoginComponent extends React.Component {
|
|||
};
|
||||
|
||||
onTryRegisterClick = ev => {
|
||||
const step = this._getCurrentFlowStep();
|
||||
if (step === 'm.login.sso' || step === 'm.login.cas') {
|
||||
// If we're showing SSO it means that registration is also probably disabled,
|
||||
// so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
|
||||
const hasPasswordFlow = this.state.flows?.find(flow => flow.type === "m.login.password");
|
||||
const ssoFlow = this.state.flows?.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas");
|
||||
// If has no password flow but an SSO flow guess that the user wants to register with SSO.
|
||||
// TODO: instead hide the Register button if registration is disabled by checking with the server,
|
||||
// has no specific errCode currently and uses M_FORBIDDEN.
|
||||
if (ssoFlow && !hasPasswordFlow) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
|
||||
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind,
|
||||
const ssoKind = ssoFlow.type === 'm.login.sso' ? 'sso' : 'cas';
|
||||
PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind,
|
||||
this.props.fragmentAfterLogin);
|
||||
} else {
|
||||
// Don't intercept - just go through to the register page
|
||||
|
@ -364,24 +357,7 @@ export default class LoginComponent extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onServerDetailsNextPhaseClick = () => {
|
||||
this.setState({
|
||||
phase: PHASE_LOGIN,
|
||||
});
|
||||
};
|
||||
|
||||
onEditServerDetailsClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
};
|
||||
|
||||
async _initLoginLogic(hsUrl, isUrl) {
|
||||
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
|
||||
isUrl = isUrl || this.props.serverConfig.isUrl;
|
||||
|
||||
private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) {
|
||||
let isDefaultServer = false;
|
||||
if (this.props.serverConfig.isDefault
|
||||
&& hsUrl === this.props.serverConfig.hsUrl
|
||||
|
@ -394,11 +370,10 @@ export default class LoginComponent extends React.Component {
|
|||
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
|
||||
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
|
||||
});
|
||||
this._loginLogic = loginLogic;
|
||||
this.loginLogic = loginLogic;
|
||||
|
||||
this.setState({
|
||||
busy: true,
|
||||
currentFlow: null, // reset flow
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
|
@ -422,42 +397,26 @@ export default class LoginComponent extends React.Component {
|
|||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e),
|
||||
});
|
||||
if (this.state.serverErrorIsFatal) {
|
||||
// Server is dead: show server details prompt instead
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loginLogic.getFlows().then((flows) => {
|
||||
// look for a flow where we understand all of the steps.
|
||||
for (let i = 0; i < flows.length; i++ ) {
|
||||
if (!this._isSupportedFlow(flows[i])) {
|
||||
continue;
|
||||
}
|
||||
const supportedFlows = flows.filter(this.isSupportedFlow);
|
||||
|
||||
// we just pick the first flow where we support all the
|
||||
// steps. (we don't have a UI for multiple logins so let's skip
|
||||
// that for now).
|
||||
loginLogic.chooseFlow(i);
|
||||
if (supportedFlows.length > 0) {
|
||||
this.setState({
|
||||
currentFlow: this._getCurrentFlowStep(),
|
||||
flows: supportedFlows,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// we got to the end of the list without finding a suitable
|
||||
// flow.
|
||||
|
||||
// we got to the end of the list without finding a suitable flow.
|
||||
this.setState({
|
||||
errorText: _t(
|
||||
"This homeserver doesn't offer any login flows which are " +
|
||||
"supported by this client.",
|
||||
),
|
||||
errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."),
|
||||
});
|
||||
}, (err) => {
|
||||
this.setState({
|
||||
errorText: this._errorTextFromError(err),
|
||||
errorText: this.errorTextFromError(err),
|
||||
loginIncorrect: false,
|
||||
canTryLogin: false,
|
||||
});
|
||||
|
@ -468,28 +427,24 @@ export default class LoginComponent extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
_isSupportedFlow(flow) {
|
||||
private isSupportedFlow = (flow: LoginFlow): boolean => {
|
||||
// technically the flow can have multiple steps, but no one does this
|
||||
// for login and loginLogic doesn't support it so we can ignore it.
|
||||
if (!this._stepRendererMap[flow.type]) {
|
||||
if (!this.stepRendererMap[flow.type]) {
|
||||
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
_getCurrentFlowStep() {
|
||||
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
|
||||
}
|
||||
|
||||
_errorTextFromError(err) {
|
||||
private errorTextFromError(err: MatrixError): ReactNode {
|
||||
let errCode = err.errcode;
|
||||
if (!errCode && err.httpStatus) {
|
||||
errCode = "HTTP " + err.httpStatus;
|
||||
}
|
||||
|
||||
let errorText = _t("Error: Problem communicating with the given homeserver.") +
|
||||
(errCode ? " (" + errCode + ")" : "");
|
||||
let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " +
|
||||
"please try again later.") + (errCode ? " (" + errCode + ")" : "");
|
||||
|
||||
if (err.cors === 'rejected') {
|
||||
if (window.location.protocol === 'https:' &&
|
||||
|
@ -499,29 +454,27 @@ export default class LoginComponent extends React.Component {
|
|||
errorText = <span>
|
||||
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
|
||||
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
|
||||
{
|
||||
'a': (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener"
|
||||
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
|
||||
>
|
||||
{ sub }
|
||||
</a>;
|
||||
},
|
||||
{
|
||||
'a': (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener"
|
||||
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
|
||||
>
|
||||
{ sub }
|
||||
</a>;
|
||||
},
|
||||
) }
|
||||
}) }
|
||||
</span>;
|
||||
} else {
|
||||
errorText = <span>
|
||||
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " +
|
||||
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
|
||||
"is not blocking requests.", {},
|
||||
{
|
||||
'a': (sub) =>
|
||||
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
) }
|
||||
{
|
||||
'a': (sub) =>
|
||||
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
|
||||
{ sub }
|
||||
</a>,
|
||||
}) }
|
||||
</span>;
|
||||
}
|
||||
}
|
||||
|
@ -529,121 +482,63 @@ export default class LoginComponent extends React.Component {
|
|||
return errorText;
|
||||
}
|
||||
|
||||
renderServerComponent() {
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
renderLoginComponentForFlows() {
|
||||
if (!this.state.flows) return null;
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
}
|
||||
// this is the ideal order we want to show the flows in
|
||||
const order = [
|
||||
"m.login.password",
|
||||
"m.login.sso",
|
||||
];
|
||||
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverDetailsProps = {};
|
||||
if (PHASES_ENABLED) {
|
||||
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
|
||||
serverDetailsProps.submitText = _t("Next");
|
||||
serverDetailsProps.submitClass = "mx_Login_submit";
|
||||
}
|
||||
|
||||
return <ServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean);
|
||||
return <React.Fragment>
|
||||
{ flows.map(flow => {
|
||||
const stepRenderer = this.stepRendererMap[flow.type];
|
||||
return <React.Fragment key={flow.type}>{ stepRenderer() }</React.Fragment>
|
||||
}) }
|
||||
</React.Fragment>
|
||||
}
|
||||
|
||||
renderLoginComponentForStep() {
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const step = this.state.currentFlow;
|
||||
|
||||
if (!step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stepRenderer = this._stepRendererMap[step];
|
||||
|
||||
if (stepRenderer) {
|
||||
return stepRenderer();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_renderPasswordStep = () => {
|
||||
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
|
||||
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed, wire up the server details edit link.
|
||||
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
|
||||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
|
||||
private renderPasswordStep = () => {
|
||||
return (
|
||||
<PasswordLogin
|
||||
onSubmit={this.onPasswordLogin}
|
||||
onError={this.onPasswordLoginError}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick}
|
||||
initialUsername={this.state.username}
|
||||
initialPhoneCountry={this.state.phoneCountry}
|
||||
initialPhoneNumber={this.state.phoneNumber}
|
||||
onUsernameChanged={this.onUsernameChanged}
|
||||
onUsernameBlur={this.onUsernameBlur}
|
||||
onPhoneCountryChanged={this.onPhoneCountryChanged}
|
||||
onPhoneNumberChanged={this.onPhoneNumberChanged}
|
||||
onPhoneNumberBlur={this.onPhoneNumberBlur}
|
||||
onForgotPasswordClick={this.props.onForgotPasswordClick}
|
||||
loginIncorrect={this.state.loginIncorrect}
|
||||
serverConfig={this.props.serverConfig}
|
||||
disableSubmit={this.isBusy()}
|
||||
busy={this.props.isSyncing || this.state.busyLoggingIn}
|
||||
onSubmit={this.onPasswordLogin}
|
||||
username={this.state.username}
|
||||
phoneCountry={this.state.phoneCountry}
|
||||
phoneNumber={this.state.phoneNumber}
|
||||
onUsernameChanged={this.onUsernameChanged}
|
||||
onUsernameBlur={this.onUsernameBlur}
|
||||
onPhoneCountryChanged={this.onPhoneCountryChanged}
|
||||
onPhoneNumberChanged={this.onPhoneNumberChanged}
|
||||
onForgotPasswordClick={this.props.onForgotPasswordClick}
|
||||
loginIncorrect={this.state.loginIncorrect}
|
||||
serverConfig={this.props.serverConfig}
|
||||
disableSubmit={this.isBusy()}
|
||||
busy={this.props.isSyncing || this.state.busyLoggingIn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderSsoStep = loginType => {
|
||||
const SignInToText = sdk.getComponent('views.auth.SignInToText');
|
||||
private renderSsoStep = loginType => {
|
||||
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
|
||||
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed, wire up the server details edit link.
|
||||
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
|
||||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
}
|
||||
// XXX: This link does *not* have a target="_blank" because single sign-on relies on
|
||||
// redirecting the user back to a URI once they're logged in. On the web, this means
|
||||
// we use the same window and redirect back to Element. On Electron, this actually
|
||||
// opens the SSO page in the Electron app itself due to
|
||||
// https://github.com/electron/electron/issues/8841 and so happens to work.
|
||||
// If this bug gets fixed, it will break SSO since it will open the SSO page in the
|
||||
// user's browser, let them log into their SSO provider, then redirect their browser
|
||||
// to vector://vector which, of course, will not work.
|
||||
return (
|
||||
<div>
|
||||
<SignInToText serverConfig={this.props.serverConfig}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick} />
|
||||
|
||||
<SSOButton
|
||||
className="mx_Login_sso_link mx_Login_submit"
|
||||
matrixClient={this._loginLogic.createTemporaryClient()}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
/>
|
||||
</div>
|
||||
<SSOButtons
|
||||
matrixClient={this.loginLogic.createTemporaryClient()}
|
||||
flow={flow}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const InlineSpinner = sdk.getComponent("elements.InlineSpinner");
|
||||
const AuthHeader = sdk.getComponent("auth.AuthHeader");
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
const loader = this.isBusy() && !this.state.busyLoggingIn ?
|
||||
<div className="mx_Login_loader"><Loader /></div> : null;
|
||||
<div className="mx_Login_loader"><Spinner /></div> : null;
|
||||
|
||||
const errorText = this.state.errorText;
|
||||
|
||||
|
@ -683,9 +578,11 @@ export default class LoginComponent extends React.Component {
|
|||
</div>;
|
||||
} else if (SettingsStore.getValue(UIFeature.Registration)) {
|
||||
footer = (
|
||||
<a className="mx_AuthBody_changeFlow" onClick={this.onTryRegisterClick} href="#">
|
||||
{ _t('Create account') }
|
||||
</a>
|
||||
<span className="mx_AuthBody_changeFlow">
|
||||
{_t("New? <a>Create account</a>", {}, {
|
||||
a: sub => <a onClick={this.onTryRegisterClick} href="#">{ sub }</a>,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -699,8 +596,11 @@ export default class LoginComponent extends React.Component {
|
|||
</h2>
|
||||
{ errorTextSection }
|
||||
{ serverDeadSection }
|
||||
{ this.renderServerComponent() }
|
||||
{ this.renderLoginComponentForStep() }
|
||||
<ServerPicker
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
/>
|
||||
{ this.renderLoginComponentForFlows() }
|
||||
{ footer }
|
||||
</AuthBody>
|
||||
</AuthPage>
|
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
|
||||
export default class PostRegistration extends React.Component {
|
||||
static propTypes = {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
avatarUrl: null,
|
||||
errorString: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// There is some assymetry between ChangeDisplayName and ChangeAvatar,
|
||||
// as ChangeDisplayName will auto-get the name but ChangeAvatar expects
|
||||
// the URL to be passed to you (because it's also used for room avatars).
|
||||
const cli = MatrixClientPeg.get();
|
||||
this.setState({busy: true});
|
||||
const self = this;
|
||||
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
|
||||
self.setState({
|
||||
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
|
||||
busy: false,
|
||||
});
|
||||
}, function(error) {
|
||||
self.setState({
|
||||
errorString: _t("Failed to fetch avatar URL"),
|
||||
busy: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
|
||||
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
|
||||
const AuthHeader = sdk.getComponent('auth.AuthHeader');
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<div className="mx_Login_profile">
|
||||
{ _t('Set a display name:') }
|
||||
<ChangeDisplayName />
|
||||
{ _t('Upload an avatar:') }
|
||||
<ChangeAvatar
|
||||
initialAvatarUrl={this.state.avatarUrl} />
|
||||
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
|
||||
{ this.state.errorString }
|
||||
</div>
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016, 2017, 2018, 2019, 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.
|
||||
|
@ -18,109 +15,128 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import Login from "../../../Login";
|
||||
import Login, {ISSOFlow} from "../../../Login";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ServerPicker from '../../views/elements/ServerPicker';
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
const PHASE_SERVER_DETAILS = 0;
|
||||
// Show the appropriate registration flow(s) for the server
|
||||
const PHASE_REGISTRATION = 1;
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
defaultDeviceDisplayName: string;
|
||||
email?: string;
|
||||
brand?: string;
|
||||
clientSecret?: string;
|
||||
sessionId?: string;
|
||||
idSid?: string;
|
||||
fragmentAfterLogin?: string;
|
||||
|
||||
// Enable phases for registration
|
||||
const PHASES_ENABLED = true;
|
||||
// Called when the user has logged in. Params:
|
||||
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
|
||||
// - The user's password, if available and applicable (may be cached in memory
|
||||
// for a short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn(params: {
|
||||
userId: string;
|
||||
deviceId: string
|
||||
homeserverUrl: string;
|
||||
identityServerUrl?: string;
|
||||
accessToken: string;
|
||||
}, password: string): void;
|
||||
makeRegistrationUrl(params: {
|
||||
/* eslint-disable camelcase */
|
||||
client_secret: string;
|
||||
hs_url: string;
|
||||
is_url?: string;
|
||||
session_id: string;
|
||||
/* eslint-enable camelcase */
|
||||
}): void;
|
||||
// registration shouldn't know or care how login is done.
|
||||
onLoginClick(): void;
|
||||
onServerConfigChange(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
export default class Registration extends React.Component {
|
||||
static propTypes = {
|
||||
// Called when the user has logged in. Params:
|
||||
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
|
||||
// - The user's password, if available and applicable (may be cached in memory
|
||||
// for a short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn: PropTypes.func.isRequired,
|
||||
interface IState {
|
||||
busy: boolean;
|
||||
errorText?: ReactNode;
|
||||
// true if we're waiting for the user to complete
|
||||
// We remember the values entered by the user because
|
||||
// the registration form will be unmounted during the
|
||||
// course of registration, but if there's an error we
|
||||
// want to bring back the registration form with the
|
||||
// values the user entered still in it. We can keep
|
||||
// them in this component's state since this component
|
||||
// persist for the duration of the registration process.
|
||||
formVals: Record<string, string>;
|
||||
// user-interactive auth
|
||||
// If we've been given a session ID, we're resuming
|
||||
// straight back into UI auth
|
||||
doingUIAuth: boolean;
|
||||
// If set, we've registered but are not going to log
|
||||
// the user in to their new account automatically.
|
||||
completedNoSignin: boolean;
|
||||
flows: {
|
||||
stages: string[];
|
||||
}[];
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError: string;
|
||||
|
||||
clientSecret: PropTypes.string,
|
||||
sessionId: PropTypes.string,
|
||||
makeRegistrationUrl: PropTypes.func.isRequired,
|
||||
idSid: PropTypes.string,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
brand: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
// registration shouldn't know or care how login is done.
|
||||
onLoginClick: PropTypes.func.isRequired,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
defaultDeviceDisplayName: PropTypes.string,
|
||||
};
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
matrixClient?: MatrixClient;
|
||||
// The user ID we've just registered
|
||||
registeredUsername?: string;
|
||||
// if a different user ID to the one we just registered is logged in,
|
||||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId?: string;
|
||||
// the SSO flow definition, this is fetched from /login as that's the only
|
||||
// place it is exposed.
|
||||
ssoFlow?: ISSOFlow;
|
||||
}
|
||||
|
||||
export default class Registration extends React.Component<IProps, IState> {
|
||||
loginLogic: Login;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
|
||||
this.state = {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
// We remember the values entered by the user because
|
||||
// the registration form will be unmounted during the
|
||||
// course of registration, but if there's an error we
|
||||
// want to bring back the registration form with the
|
||||
// values the user entered still in it. We can keep
|
||||
// them in this component's state since this component
|
||||
// persist for the duration of the registration process.
|
||||
formVals: {
|
||||
email: this.props.email,
|
||||
},
|
||||
// true if we're waiting for the user to complete
|
||||
// user-interactive auth
|
||||
// If we've been given a session ID, we're resuming
|
||||
// straight back into UI auth
|
||||
doingUIAuth: Boolean(this.props.sessionId),
|
||||
serverType,
|
||||
// Phase of the overall registration dialog.
|
||||
phase: PHASE_REGISTRATION,
|
||||
flows: null,
|
||||
// If set, we've registered but are not going to log
|
||||
// the user in to their new account automatically.
|
||||
completedNoSignin: false,
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
matrixClient: null,
|
||||
|
||||
// whether the HS requires an ID server to register with a threepid
|
||||
serverRequiresIdServer: null,
|
||||
|
||||
// The user ID we've just registered
|
||||
registeredUsername: null,
|
||||
|
||||
// if a different user ID to the one we just registered is logged in,
|
||||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId: null,
|
||||
};
|
||||
|
||||
const {hsUrl, isUrl} = this.props.serverConfig;
|
||||
this.loginLogic = new Login(hsUrl, isUrl, null, {
|
||||
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
this._replaceClient();
|
||||
this.replaceClient(this.props.serverConfig);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
|
@ -129,63 +145,10 @@ export default class Registration extends React.Component {
|
|||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
this._replaceClient(newProps.serverConfig);
|
||||
|
||||
// Handle cases where the user enters "https://matrix.org" for their server
|
||||
// from the advanced option - we should default to FREE at that point.
|
||||
const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig);
|
||||
if (serverType !== this.state.serverType) {
|
||||
// Reset the phase to default phase for the server type.
|
||||
this.setState({
|
||||
serverType,
|
||||
phase: this.getDefaultPhaseForServerType(serverType),
|
||||
});
|
||||
}
|
||||
this.replaceClient(newProps.serverConfig);
|
||||
}
|
||||
|
||||
getDefaultPhaseForServerType(type) {
|
||||
switch (type) {
|
||||
case ServerType.FREE: {
|
||||
// Move directly to the registration phase since the server
|
||||
// details are fixed.
|
||||
return PHASE_REGISTRATION;
|
||||
}
|
||||
case ServerType.PREMIUM:
|
||||
case ServerType.ADVANCED:
|
||||
return PHASE_SERVER_DETAILS;
|
||||
}
|
||||
}
|
||||
|
||||
onServerTypeChange = type => {
|
||||
this.setState({
|
||||
serverType: type,
|
||||
});
|
||||
|
||||
// When changing server types, set the HS / IS URLs to reasonable defaults for the
|
||||
// the new type.
|
||||
switch (type) {
|
||||
case ServerType.FREE: {
|
||||
const { serverConfig } = ServerType.TYPES.FREE;
|
||||
this.props.onServerConfigChange(serverConfig);
|
||||
break;
|
||||
}
|
||||
case ServerType.PREMIUM:
|
||||
// We can accept whatever server config was the default here as this essentially
|
||||
// acts as a slightly different "custom server"/ADVANCED option.
|
||||
break;
|
||||
case ServerType.ADVANCED:
|
||||
// Use the default config from the config
|
||||
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Reset the phase to default phase for the server type.
|
||||
this.setState({
|
||||
phase: this.getDefaultPhaseForServerType(type),
|
||||
});
|
||||
};
|
||||
|
||||
async _replaceClient(serverConfig) {
|
||||
private async replaceClient(serverConfig: ValidatedServerConfig) {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
serverDeadError: null,
|
||||
|
@ -194,7 +157,6 @@ export default class Registration extends React.Component {
|
|||
// the UI auth component while we don't have a matrix client)
|
||||
busy: true,
|
||||
});
|
||||
if (!serverConfig) serverConfig = this.props.serverConfig;
|
||||
|
||||
// Do a liveliness check on the URLs
|
||||
try {
|
||||
|
@ -222,16 +184,20 @@ export default class Registration extends React.Component {
|
|||
idBaseUrl: isUrl,
|
||||
});
|
||||
|
||||
let serverRequiresIdServer = true;
|
||||
this.loginLogic.setHomeserverUrl(hsUrl);
|
||||
this.loginLogic.setIdentityServerUrl(isUrl);
|
||||
|
||||
let ssoFlow: ISSOFlow;
|
||||
try {
|
||||
serverRequiresIdServer = await cli.doesServerRequireIdServerParam();
|
||||
const loginFlows = await this.loginLogic.getFlows();
|
||||
ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow;
|
||||
} catch (e) {
|
||||
console.log("Unable to determine is server needs id_server param", e);
|
||||
console.error("Failed to get login flows to check for SSO support", e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
matrixClient: cli,
|
||||
serverRequiresIdServer,
|
||||
ssoFlow,
|
||||
busy: false,
|
||||
});
|
||||
const showGenericError = (e) => {
|
||||
|
@ -246,7 +212,7 @@ export default class Registration extends React.Component {
|
|||
// do SSO instead. If we've already started the UI Auth process though, we don't
|
||||
// need to.
|
||||
if (!this.state.doingUIAuth) {
|
||||
await this._makeRegisterRequest(null);
|
||||
await this.makeRegisterRequest(null);
|
||||
// This should never succeed since we specified no auth object.
|
||||
console.log("Expecting 401 from register request but got success!");
|
||||
}
|
||||
|
@ -259,26 +225,16 @@ export default class Registration extends React.Component {
|
|||
// At this point registration is pretty much disabled, but before we do that let's
|
||||
// quickly check to see if the server supports SSO instead. If it does, we'll send
|
||||
// the user off to the login page to figure their account out.
|
||||
try {
|
||||
const loginLogic = new Login(hsUrl, isUrl, null, {
|
||||
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
|
||||
if (ssoFlow) {
|
||||
// Redirect to login page - server probably expects SSO only
|
||||
dis.dispatch({action: 'start_login'});
|
||||
} else {
|
||||
this.setState({
|
||||
serverErrorIsFatal: true, // fatal because user cannot continue on this server
|
||||
errorText: _t("Registration has been disabled on this homeserver."),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
const flows = await loginLogic.getFlows();
|
||||
const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas');
|
||||
if (hasSsoFlow) {
|
||||
// Redirect to login page - server probably expects SSO only
|
||||
dis.dispatch({action: 'start_login'});
|
||||
} else {
|
||||
this.setState({
|
||||
serverErrorIsFatal: true, // fatal because user cannot continue on this server
|
||||
errorText: _t("Registration has been disabled on this homeserver."),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get login flows to check for SSO support", e);
|
||||
showGenericError(e);
|
||||
}
|
||||
} else {
|
||||
console.log("Unable to query for supported registration methods.", e);
|
||||
|
@ -287,7 +243,7 @@ export default class Registration extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
onFormSubmit = formVals => {
|
||||
private onFormSubmit = formVals => {
|
||||
this.setState({
|
||||
errorText: "",
|
||||
busy: true,
|
||||
|
@ -296,7 +252,7 @@ export default class Registration extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
_requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
|
||||
private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
|
||||
return this.state.matrixClient.requestRegisterEmailToken(
|
||||
emailAddress,
|
||||
clientSecret,
|
||||
|
@ -310,28 +266,26 @@ export default class Registration extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
_onUIAuthFinished = async (success, response, extra) => {
|
||||
private onUIAuthFinished = async (success, response, extra) => {
|
||||
if (!success) {
|
||||
let msg = response.message || response.toString();
|
||||
// can we give a better error message?
|
||||
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact, {
|
||||
'monthly_active_user': _td(
|
||||
"This homeserver has hit its Monthly Active User limit.",
|
||||
),
|
||||
'': _td(
|
||||
"This homeserver has exceeded one of its resource limits.",
|
||||
),
|
||||
});
|
||||
response.data.admin_contact,
|
||||
{
|
||||
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
|
||||
'': _td("This homeserver has exceeded one of its resource limits."),
|
||||
},
|
||||
);
|
||||
const errorDetail = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact, {
|
||||
'': _td(
|
||||
"Please <a>contact your service administrator</a> to continue using this service.",
|
||||
),
|
||||
});
|
||||
response.data.admin_contact,
|
||||
{
|
||||
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
|
||||
},
|
||||
);
|
||||
msg = <div>
|
||||
<p>{errorTop}</p>
|
||||
<p>{errorDetail}</p>
|
||||
|
@ -339,11 +293,13 @@ export default class Registration extends React.Component {
|
|||
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
|
||||
let msisdnAvailable = false;
|
||||
for (const flow of response.available_flows) {
|
||||
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
|
||||
msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn');
|
||||
}
|
||||
if (!msisdnAvailable) {
|
||||
msg = _t('This server does not support authentication with a phone number.');
|
||||
}
|
||||
} else if (response.errcode === "M_USER_IN_USE") {
|
||||
msg = _t("That username already exists, please try another.");
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
|
@ -358,6 +314,10 @@ export default class Registration extends React.Component {
|
|||
const newState = {
|
||||
doingUIAuth: false,
|
||||
registeredUsername: response.user_id,
|
||||
differentLoggedInUserId: null,
|
||||
completedNoSignin: false,
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
busy: true,
|
||||
};
|
||||
|
||||
// The user came in through an email validation link. To avoid overwriting
|
||||
|
@ -365,15 +325,12 @@ export default class Registration extends React.Component {
|
|||
// isn't a guest user since we'll usually have set a guest user session before
|
||||
// starting the registration process. This isn't perfect since it's possible
|
||||
// the user had a separate guest session they didn't actually mean to replace.
|
||||
const sessionOwner = Lifecycle.getStoredSessionOwner();
|
||||
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
|
||||
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
|
||||
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
|
||||
console.log(
|
||||
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
|
||||
);
|
||||
newState.differentLoggedInUserId = sessionOwner;
|
||||
} else {
|
||||
newState.differentLoggedInUserId = null;
|
||||
}
|
||||
|
||||
if (response.access_token) {
|
||||
|
@ -385,9 +342,7 @@ export default class Registration extends React.Component {
|
|||
accessToken: response.access_token,
|
||||
}, this.state.formVals.password);
|
||||
|
||||
this._setupPushers();
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
newState.busy = true;
|
||||
this.setupPushers();
|
||||
} else {
|
||||
newState.busy = false;
|
||||
newState.completedNoSignin = true;
|
||||
|
@ -396,7 +351,7 @@ export default class Registration extends React.Component {
|
|||
this.setState(newState);
|
||||
};
|
||||
|
||||
_setupPushers() {
|
||||
private setupPushers() {
|
||||
if (!this.props.brand) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
@ -419,38 +374,23 @@ export default class Registration extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
onLoginClick = ev => {
|
||||
private onLoginClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onLoginClick();
|
||||
};
|
||||
|
||||
onGoToFormClicked = ev => {
|
||||
private onGoToFormClicked = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._replaceClient();
|
||||
this.replaceClient(this.props.serverConfig);
|
||||
this.setState({
|
||||
busy: false,
|
||||
doingUIAuth: false,
|
||||
phase: PHASE_REGISTRATION,
|
||||
});
|
||||
};
|
||||
|
||||
onServerDetailsNextPhaseClick = async () => {
|
||||
this.setState({
|
||||
phase: PHASE_REGISTRATION,
|
||||
});
|
||||
};
|
||||
|
||||
onEditServerDetailsClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
phase: PHASE_SERVER_DETAILS,
|
||||
});
|
||||
};
|
||||
|
||||
_makeRegisterRequest = auth => {
|
||||
private makeRegisterRequest = auth => {
|
||||
// We inhibit login if we're trying to register with an email address: this
|
||||
// avoids a lot of complex race conditions that can occur if we try to log
|
||||
// the user in one one or both of the tabs they might end up with after
|
||||
|
@ -466,13 +406,15 @@ export default class Registration extends React.Component {
|
|||
username: this.state.formVals.username,
|
||||
password: this.state.formVals.password,
|
||||
initial_device_display_name: this.props.defaultDeviceDisplayName,
|
||||
auth: undefined,
|
||||
inhibit_login: undefined,
|
||||
};
|
||||
if (auth) registerParams.auth = auth;
|
||||
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
|
||||
return this.state.matrixClient.registerRequest(registerParams);
|
||||
};
|
||||
|
||||
_getUIAuthInputs() {
|
||||
private getUIAuthInputs() {
|
||||
return {
|
||||
emailAddress: this.state.formVals.email,
|
||||
phoneCountry: this.state.formVals.phoneCountry,
|
||||
|
@ -483,7 +425,7 @@ export default class Registration extends React.Component {
|
|||
// Links to the login page shown after registration is completed are routed through this
|
||||
// which checks the user hasn't already logged in somewhere else (perhaps we should do
|
||||
// this more generally?)
|
||||
_onLoginClickWithCheck = async ev => {
|
||||
private onLoginClickWithCheck = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
|
||||
|
@ -493,72 +435,7 @@ export default class Registration extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
renderServerComponent() {
|
||||
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
|
||||
const ServerConfig = sdk.getComponent("auth.ServerConfig");
|
||||
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
|
||||
|
||||
if (SdkConfig.get()['disable_custom_urls']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we're on a different phase, we only show the server type selector,
|
||||
// which is always shown if we allow custom URLs at all.
|
||||
// (if there's a fatal server error, we need to show the full server
|
||||
// config as the user may need to change servers to resolve the error).
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
|
||||
return <div>
|
||||
<ServerTypeSelector
|
||||
selected={this.state.serverType}
|
||||
onChange={this.onServerTypeChange}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const serverDetailsProps = {};
|
||||
if (PHASES_ENABLED) {
|
||||
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
|
||||
serverDetailsProps.submitText = _t("Next");
|
||||
serverDetailsProps.submitClass = "mx_Login_submit";
|
||||
}
|
||||
|
||||
let serverDetails = null;
|
||||
switch (this.state.serverType) {
|
||||
case ServerType.FREE:
|
||||
break;
|
||||
case ServerType.PREMIUM:
|
||||
serverDetails = <ModularServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
break;
|
||||
case ServerType.ADVANCED:
|
||||
serverDetails = <ServerConfig
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
showIdentityServerIfRequiredByHomeserver={true}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
break;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<ServerTypeSelector
|
||||
selected={this.state.serverType}
|
||||
onChange={this.onServerTypeChange}
|
||||
/>
|
||||
{serverDetails}
|
||||
</div>;
|
||||
}
|
||||
|
||||
renderRegisterComponent() {
|
||||
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderRegisterComponent() {
|
||||
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
|
||||
|
@ -566,10 +443,10 @@ export default class Registration extends React.Component {
|
|||
if (this.state.matrixClient && this.state.doingUIAuth) {
|
||||
return <InteractiveAuth
|
||||
matrixClient={this.state.matrixClient}
|
||||
makeRequest={this._makeRegisterRequest}
|
||||
onAuthFinished={this._onUIAuthFinished}
|
||||
inputs={this._getUIAuthInputs()}
|
||||
requestEmailToken={this._requestEmailToken}
|
||||
makeRequest={this.makeRegisterRequest}
|
||||
onAuthFinished={this.onUIAuthFinished}
|
||||
inputs={this.getUIAuthInputs()}
|
||||
requestEmailToken={this.requestEmailToken}
|
||||
sessionId={this.props.sessionId}
|
||||
clientSecret={this.props.clientSecret}
|
||||
emailSid={this.props.idSid}
|
||||
|
@ -582,30 +459,47 @@ export default class Registration extends React.Component {
|
|||
<Spinner />
|
||||
</div>;
|
||||
} else if (this.state.flows.length) {
|
||||
let onEditServerDetailsClick = null;
|
||||
// If custom URLs are allowed and we haven't selected the Free server type, wire
|
||||
// up the server details edit link.
|
||||
if (
|
||||
PHASES_ENABLED &&
|
||||
!SdkConfig.get()['disable_custom_urls'] &&
|
||||
this.state.serverType !== ServerType.FREE
|
||||
) {
|
||||
onEditServerDetailsClick = this.onEditServerDetailsClick;
|
||||
let ssoSection;
|
||||
if (this.state.ssoFlow) {
|
||||
let continueWithSection;
|
||||
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || [];
|
||||
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
|
||||
if (providers.length > 1) {
|
||||
// i18n: ssoButtons is a placeholder to help translators understand context
|
||||
continueWithSection = <h3 className="mx_AuthBody_centered">
|
||||
{ _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() }
|
||||
</h3>;
|
||||
}
|
||||
|
||||
// i18n: ssoButtons & usernamePassword are placeholders to help translators understand context
|
||||
ssoSection = <React.Fragment>
|
||||
{ continueWithSection }
|
||||
<SSOButtons
|
||||
matrixClient={this.loginLogic.createTemporaryClient()}
|
||||
flow={this.state.ssoFlow}
|
||||
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
/>
|
||||
<h3 className="mx_AuthBody_centered">
|
||||
{ _t("%(ssoButtons)s Or %(usernamePassword)s", { ssoButtons: "", usernamePassword: ""}).trim() }
|
||||
</h3>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
return <RegistrationForm
|
||||
defaultUsername={this.state.formVals.username}
|
||||
defaultEmail={this.state.formVals.email}
|
||||
defaultPhoneCountry={this.state.formVals.phoneCountry}
|
||||
defaultPhoneNumber={this.state.formVals.phoneNumber}
|
||||
defaultPassword={this.state.formVals.password}
|
||||
onRegisterClick={this.onFormSubmit}
|
||||
onEditServerDetailsClick={onEditServerDetailsClick}
|
||||
flows={this.state.flows}
|
||||
serverConfig={this.props.serverConfig}
|
||||
canSubmit={!this.state.serverErrorIsFatal}
|
||||
serverRequiresIdServer={this.state.serverRequiresIdServer}
|
||||
/>;
|
||||
return <React.Fragment>
|
||||
{ ssoSection }
|
||||
<RegistrationForm
|
||||
defaultUsername={this.state.formVals.username}
|
||||
defaultEmail={this.state.formVals.email}
|
||||
defaultPhoneCountry={this.state.formVals.phoneCountry}
|
||||
defaultPhoneNumber={this.state.formVals.phoneNumber}
|
||||
defaultPassword={this.state.formVals.password}
|
||||
onRegisterClick={this.onFormSubmit}
|
||||
flows={this.state.flows}
|
||||
serverConfig={this.props.serverConfig}
|
||||
canSubmit={!this.state.serverErrorIsFatal}
|
||||
/>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -634,13 +528,15 @@ export default class Registration extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
|
||||
{ _t('Sign in instead') }
|
||||
</a>;
|
||||
const signIn = <span className="mx_AuthBody_changeFlow">
|
||||
{_t("Already have an account? <a>Sign in here</a>", {}, {
|
||||
a: sub => <a onClick={this.onLoginClick} href="#">{ sub }</a>,
|
||||
})}
|
||||
</span>;
|
||||
|
||||
// Only show the 'go back' button if you're not looking at the form
|
||||
let goBack;
|
||||
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) {
|
||||
if (this.state.doingUIAuth) {
|
||||
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
|
||||
{ _t('Go back') }
|
||||
</a>;
|
||||
|
@ -658,7 +554,7 @@ export default class Registration extends React.Component {
|
|||
loggedInUserId: this.state.differentLoggedInUserId,
|
||||
},
|
||||
)}</p>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}>
|
||||
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
|
||||
{_t("Continue with previous account")}
|
||||
</AccessibleButton></p>
|
||||
</div>;
|
||||
|
@ -667,7 +563,7 @@ export default class Registration extends React.Component {
|
|||
regDoneText = <h3>{_t(
|
||||
"<a>Log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
|
||||
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
} else {
|
||||
|
@ -677,7 +573,7 @@ export default class Registration extends React.Component {
|
|||
regDoneText = <h3>{_t(
|
||||
"You can now close this window or <a>log in</a> to your new account.", {},
|
||||
{
|
||||
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
|
||||
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
|
||||
},
|
||||
)}</h3>;
|
||||
}
|
||||
|
@ -687,10 +583,15 @@ export default class Registration extends React.Component {
|
|||
</div>;
|
||||
} else {
|
||||
body = <div>
|
||||
<h2>{ _t('Create your account') }</h2>
|
||||
<h2>{ _t('Create account') }</h2>
|
||||
{ errorText }
|
||||
{ serverDeadSection }
|
||||
{ this.renderServerComponent() }
|
||||
<ServerPicker
|
||||
title={_t("Host account on")}
|
||||
dialogTitle={_t("Decide where your account is hosted")}
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.state.doingUIAuth ? undefined : this.props.onServerConfigChange}
|
||||
/>
|
||||
{ this.renderRegisterComponent() }
|
||||
{ goBack }
|
||||
{ signIn }
|
|
@ -120,9 +120,9 @@ export default class SetupEncryptionBody extends React.Component {
|
|||
const store = SetupEncryptionStore.sharedInstance();
|
||||
let recoveryKeyPrompt;
|
||||
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
|
||||
recoveryKeyPrompt = _t("Use Recovery Key or Passphrase");
|
||||
recoveryKeyPrompt = _t("Use Security Key or Phrase");
|
||||
} else if (store.keyInfo) {
|
||||
recoveryKeyPrompt = _t("Use Recovery Key");
|
||||
recoveryKeyPrompt = _t("Use Security Key");
|
||||
}
|
||||
|
||||
let useRecoveryKeyButton;
|
||||
|
|
|
@ -24,8 +24,8 @@ import Modal from '../../../Modal';
|
|||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {sendLoginRequest} from "../../../Login";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import SSOButton from "../../views/elements/SSOButton";
|
||||
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
|
||||
const LOGIN_VIEW = {
|
||||
LOADING: 1,
|
||||
|
@ -72,9 +72,12 @@ export default class SoftLogout extends React.Component {
|
|||
|
||||
this._initLogin();
|
||||
|
||||
MatrixClientPeg.get().countSessionsNeedingBackup().then(remaining => {
|
||||
this.setState({keyBackupNeeded: remaining > 0});
|
||||
});
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli.isCryptoEnabled()) {
|
||||
cli.countSessionsNeedingBackup().then(remaining => {
|
||||
this.setState({ keyBackupNeeded: remaining > 0 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClearAll = () => {
|
||||
|
@ -101,10 +104,11 @@ export default class SoftLogout extends React.Component {
|
|||
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
|
||||
// care about login flows here, unless it is the single flow we support.
|
||||
const client = MatrixClientPeg.get();
|
||||
const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]);
|
||||
const flows = (await client.loginFlows()).flows;
|
||||
const loginViews = flows.map(f => FLOWS_TO_VIEWS[f.type]);
|
||||
|
||||
const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED;
|
||||
this.setState({loginView: chosenView});
|
||||
this.setState({ flows, loginView: chosenView });
|
||||
}
|
||||
|
||||
onPasswordChange = (ev) => {
|
||||
|
@ -240,13 +244,18 @@ export default class SoftLogout extends React.Component {
|
|||
introText = _t("Sign in and regain access to your account.");
|
||||
} // else we already have a message and should use it (key backup warning)
|
||||
|
||||
const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso";
|
||||
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{introText}</p>
|
||||
<SSOButton
|
||||
<SSOButtons
|
||||
matrixClient={MatrixClientPeg.get()}
|
||||
loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"}
|
||||
flow={flow}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
const DIV_ID = 'mx_recaptcha';
|
||||
|
||||
|
@ -45,6 +46,8 @@ export default class CaptchaForm extends React.Component {
|
|||
this._captchaWidgetId = null;
|
||||
|
||||
this._recaptchaContainer = createRef();
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -99,10 +102,16 @@ export default class CaptchaForm extends React.Component {
|
|||
console.log("Loaded recaptcha script.");
|
||||
try {
|
||||
this._renderRecaptcha(DIV_ID);
|
||||
// clear error if re-rendered
|
||||
this.setState({
|
||||
errorText: null,
|
||||
});
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded");
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
errorText: e.toString(),
|
||||
});
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_error", { error: e.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -123,7 +123,7 @@ export default class CountryDropdown extends React.Component {
|
|||
const options = displayedCountries.map((country) => {
|
||||
return <div className="mx_CountryDropdown_option" key={country.iso2}>
|
||||
{ this._flagImgForIso2(country.iso2) }
|
||||
{ country.name } (+{ country.prefix })
|
||||
{ _t(country.name) } (+{ country.prefix })
|
||||
</div>;
|
||||
});
|
||||
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019, 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 from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default class CustomServerDialog extends React.Component {
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
return (
|
||||
<div className="mx_ErrorDialog">
|
||||
<div className="mx_Dialog_title">
|
||||
{ _t("Custom Server Options") }
|
||||
</div>
|
||||
<div className="mx_Dialog_content">
|
||||
<p>{_t(
|
||||
"You can use the custom server options to sign into other " +
|
||||
"Matrix servers by specifying a different homeserver URL. This " +
|
||||
"allows you to use %(brand)s with an existing Matrix account on a " +
|
||||
"different homeserver.",
|
||||
{ brand },
|
||||
)}</p>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.props.onFinished} autoFocus={true}>
|
||||
{ _t("Dismiss") }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -18,7 +18,6 @@ limitations under the License.
|
|||
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import url from 'url';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
|
@ -26,6 +25,7 @@ import { _t } from '../../../languageHandler';
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
/* This file contains a collection of components which are used by the
|
||||
* InteractiveAuth to prompt the user to enter the information needed
|
||||
|
@ -189,6 +189,7 @@ export class RecaptchaAuthEntry extends React.Component {
|
|||
}
|
||||
|
||||
_onCaptchaResponse = response => {
|
||||
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
|
||||
this.props.submitAuthDict({
|
||||
type: RecaptchaAuthEntry.LOGIN_TYPE,
|
||||
response: response,
|
||||
|
@ -297,6 +298,8 @@ export class TermsAuthEntry extends React.Component {
|
|||
toggledPolicies: initToggles,
|
||||
policies: pickedPolicies,
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_terms_begin");
|
||||
}
|
||||
|
||||
|
||||
|
@ -326,8 +329,12 @@ export class TermsAuthEntry extends React.Component {
|
|||
allChecked = allChecked && checked;
|
||||
}
|
||||
|
||||
if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
|
||||
else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
|
||||
if (allChecked) {
|
||||
this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
|
||||
CountlyAnalytics.instance.track("onboarding_terms_complete");
|
||||
} else {
|
||||
this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -413,12 +420,12 @@ export class EmailIdentityAuthEntry extends React.Component {
|
|||
return <Spinner />;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("An email has been sent to %(emailAddress)s",
|
||||
{ emailAddress: (sub) => <i>{ this.props.inputs.emailAddress }</i> },
|
||||
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
|
||||
<p>{ _t("A confirmation email has been sent to %(emailAddress)s",
|
||||
{ emailAddress: (sub) => <b>{ this.props.inputs.emailAddress }</b> },
|
||||
) }
|
||||
</p>
|
||||
<p>{ _t("Please check your email to continue registration.") }</p>
|
||||
<p>{ _t("Open the link in the email to continue registration.") }</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -492,17 +499,11 @@ export class MsisdnAuthEntry extends React.Component {
|
|||
});
|
||||
|
||||
try {
|
||||
const requiresIdServerParam =
|
||||
await this.props.matrixClient.doesServerRequireIdServerParam();
|
||||
let result;
|
||||
if (this._submitUrl) {
|
||||
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
|
||||
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
|
||||
);
|
||||
} else if (requiresIdServerParam) {
|
||||
result = await this.props.matrixClient.submitMsisdnToken(
|
||||
this._sid, this.props.clientSecret, this.state.token,
|
||||
);
|
||||
} else {
|
||||
throw new Error("The registration with MSISDN flow is misconfigured");
|
||||
}
|
||||
|
@ -511,12 +512,6 @@ export class MsisdnAuthEntry extends React.Component {
|
|||
sid: this._sid,
|
||||
client_secret: this.props.clientSecret,
|
||||
};
|
||||
if (requiresIdServerParam) {
|
||||
const idServerParsedUrl = url.parse(
|
||||
this.props.matrixClient.getIdentityServerUrl(),
|
||||
);
|
||||
creds.id_server = idServerParsedUrl.host;
|
||||
}
|
||||
this.props.submitAuthDict({
|
||||
type: MsisdnAuthEntry.LOGIN_TYPE,
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
|
@ -614,8 +609,12 @@ export class SSOAuthEntry extends React.Component {
|
|||
this.props.authSessionId,
|
||||
);
|
||||
|
||||
this._popupWindow = null;
|
||||
window.addEventListener("message", this._onReceiveMessage);
|
||||
|
||||
this.state = {
|
||||
phase: SSOAuthEntry.PHASE_PREAUTH,
|
||||
attemptFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -623,12 +622,35 @@ export class SSOAuthEntry extends React.Component {
|
|||
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("message", this._onReceiveMessage);
|
||||
if (this._popupWindow) {
|
||||
this._popupWindow.close();
|
||||
this._popupWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
attemptFailed = () => {
|
||||
this.setState({
|
||||
attemptFailed: true,
|
||||
});
|
||||
};
|
||||
|
||||
_onReceiveMessage = event => {
|
||||
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
|
||||
if (this._popupWindow) {
|
||||
this._popupWindow.close();
|
||||
this._popupWindow = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onStartAuthClick = () => {
|
||||
// Note: We don't use PlatformPeg's startSsoAuth functions because we almost
|
||||
// certainly will need to open the thing in a new tab to avoid losing application
|
||||
// context.
|
||||
|
||||
window.open(this._ssoUrl, '_blank');
|
||||
this._popupWindow = window.open(this._ssoUrl, "_blank");
|
||||
this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
|
||||
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
|
||||
};
|
||||
|
@ -661,10 +683,28 @@ export class SSOAuthEntry extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
return <div className='mx_InteractiveAuthEntryComponents_sso_buttons'>
|
||||
{cancelButton}
|
||||
{continueButton}
|
||||
</div>;
|
||||
let errorSection;
|
||||
if (this.props.errorText) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ this.props.errorText }
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.attemptFailed) {
|
||||
errorSection = (
|
||||
<div className="error" role="alert">
|
||||
{ _t("Something went wrong in confirming your identity. Cancel and try again.") }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
{ errorSection }
|
||||
<div className="mx_InteractiveAuthEntryComponents_sso_buttons">
|
||||
{cancelButton}
|
||||
{continueButton}
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -715,8 +755,7 @@ export class FallbackAuthEntry extends React.Component {
|
|||
this.props.loginType,
|
||||
this.props.authSessionId,
|
||||
);
|
||||
this._popupWindow = window.open(url);
|
||||
this._popupWindow.opener = null;
|
||||
this._popupWindow = window.open(url, "_blank");
|
||||
};
|
||||
|
||||
_onReceiveMessage = event => {
|
||||
|
|
|
@ -1,124 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
|
||||
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 from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import ServerConfig from "./ServerConfig";
|
||||
|
||||
const MODULAR_URL = 'https://element.io/matrix-services' +
|
||||
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
|
||||
|
||||
// TODO: TravisR - Can this extend ServerConfig for most things?
|
||||
|
||||
/*
|
||||
* Configure the Modular server name.
|
||||
*
|
||||
* This is a variant of ServerConfig with only the HS field and different body
|
||||
* text that is specific to the Modular case.
|
||||
*/
|
||||
export default class ModularServerConfig extends ServerConfig {
|
||||
static propTypes = ServerConfig.propTypes;
|
||||
|
||||
async validateAndApplyServer(hsUrl, isUrl) {
|
||||
// Always try and use the defaults first
|
||||
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
|
||||
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
hsUrl,
|
||||
isUrl,
|
||||
busy: true,
|
||||
errorText: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
let message = _t("Unable to validate homeserver/identity server");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: message,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async validateServer() {
|
||||
// TODO: Do we want to support .well-known lookups here?
|
||||
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
|
||||
// find their homeserver without demanding they use "https://matrix.org"
|
||||
return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl);
|
||||
}
|
||||
|
||||
render() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const submitButton = this.props.submitText
|
||||
? <AccessibleButton
|
||||
element="button"
|
||||
type="submit"
|
||||
className={this.props.submitClass}
|
||||
onClick={this.onSubmit}
|
||||
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx_ServerConfig">
|
||||
<h3>{_t("Your server")}</h3>
|
||||
{_t(
|
||||
"Enter the location of your Element Matrix Services homeserver. It may use your own " +
|
||||
"domain name or be a subdomain of <a>element.io</a>.",
|
||||
{}, {
|
||||
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
</a>,
|
||||
},
|
||||
)}
|
||||
<form onSubmit={this.onSubmit} autoComplete="off" action={null}>
|
||||
<div className="mx_ServerConfig_fields">
|
||||
<Field
|
||||
id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Server Name")}
|
||||
placeholder={this.props.serverConfig.hsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
/>
|
||||
</div>
|
||||
{submitButton}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
|
||||
import {_t, _td} from "../../../languageHandler";
|
||||
import Field from "../elements/Field";
|
||||
import Field, {IInputProps} from "../elements/Field";
|
||||
|
||||
interface IProps {
|
||||
interface IProps extends Omit<IInputProps, "onValidate"> {
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
|
|
|
@ -1,354 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 New Vector Ltd.
|
||||
|
||||
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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
|
||||
/**
|
||||
* A pure UI component which displays a username/password form.
|
||||
*/
|
||||
export default class PasswordLogin extends React.Component {
|
||||
static propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired, // fn(username, password)
|
||||
onError: PropTypes.func,
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
onForgotPasswordClick: PropTypes.func, // fn()
|
||||
initialUsername: PropTypes.string,
|
||||
initialPhoneCountry: PropTypes.string,
|
||||
initialPhoneNumber: PropTypes.string,
|
||||
initialPassword: PropTypes.string,
|
||||
onUsernameChanged: PropTypes.func,
|
||||
onPhoneCountryChanged: PropTypes.func,
|
||||
onPhoneNumberChanged: PropTypes.func,
|
||||
onPasswordChanged: PropTypes.func,
|
||||
loginIncorrect: PropTypes.bool,
|
||||
disableSubmit: PropTypes.bool,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
busy: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onError: function() {},
|
||||
onEditServerDetailsClick: null,
|
||||
onUsernameChanged: function() {},
|
||||
onUsernameBlur: function() {},
|
||||
onPasswordChanged: function() {},
|
||||
onPhoneCountryChanged: function() {},
|
||||
onPhoneNumberChanged: function() {},
|
||||
onPhoneNumberBlur: function() {},
|
||||
initialUsername: "",
|
||||
initialPhoneCountry: "",
|
||||
initialPhoneNumber: "",
|
||||
initialPassword: "",
|
||||
loginIncorrect: false,
|
||||
disableSubmit: false,
|
||||
};
|
||||
|
||||
static LOGIN_FIELD_EMAIL = "login_field_email";
|
||||
static LOGIN_FIELD_MXID = "login_field_mxid";
|
||||
static LOGIN_FIELD_PHONE = "login_field_phone";
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
username: this.props.initialUsername,
|
||||
password: this.props.initialPassword,
|
||||
phoneCountry: this.props.initialPhoneCountry,
|
||||
phoneNumber: this.props.initialPhoneNumber,
|
||||
loginType: PasswordLogin.LOGIN_FIELD_MXID,
|
||||
};
|
||||
|
||||
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
|
||||
this.onSubmitForm = this.onSubmitForm.bind(this);
|
||||
this.onUsernameChanged = this.onUsernameChanged.bind(this);
|
||||
this.onUsernameBlur = this.onUsernameBlur.bind(this);
|
||||
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
|
||||
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
|
||||
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
|
||||
this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this);
|
||||
this.onPasswordChanged = this.onPasswordChanged.bind(this);
|
||||
this.isLoginEmpty = this.isLoginEmpty.bind(this);
|
||||
}
|
||||
|
||||
onForgotPasswordClick(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onForgotPasswordClick();
|
||||
}
|
||||
|
||||
onSubmitForm(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
let username = ''; // XXX: Synapse breaks if you send null here:
|
||||
let phoneCountry = null;
|
||||
let phoneNumber = null;
|
||||
let error;
|
||||
|
||||
switch (this.state.loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
username = this.state.username;
|
||||
if (!username) {
|
||||
error = _t('The email field must not be blank.');
|
||||
}
|
||||
break;
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
username = this.state.username;
|
||||
if (!username) {
|
||||
error = _t('The username field must not be blank.');
|
||||
}
|
||||
break;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE:
|
||||
phoneCountry = this.state.phoneCountry;
|
||||
phoneNumber = this.state.phoneNumber;
|
||||
if (!phoneNumber) {
|
||||
error = _t('The phone number field must not be blank.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this.props.onError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.state.password) {
|
||||
this.props.onError(_t('The password field must not be blank.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onSubmit(
|
||||
username,
|
||||
phoneCountry,
|
||||
phoneNumber,
|
||||
this.state.password,
|
||||
);
|
||||
}
|
||||
|
||||
onUsernameChanged(ev) {
|
||||
this.setState({username: ev.target.value});
|
||||
this.props.onUsernameChanged(ev.target.value);
|
||||
}
|
||||
|
||||
onUsernameBlur(ev) {
|
||||
this.props.onUsernameBlur(ev.target.value);
|
||||
}
|
||||
|
||||
onLoginTypeChange(ev) {
|
||||
const loginType = ev.target.value;
|
||||
this.props.onError(null); // send a null error to clear any error messages
|
||||
this.setState({
|
||||
loginType: loginType,
|
||||
username: "", // Reset because email and username use the same state
|
||||
});
|
||||
}
|
||||
|
||||
onPhoneCountryChanged(country) {
|
||||
this.setState({
|
||||
phoneCountry: country.iso2,
|
||||
phonePrefix: country.prefix,
|
||||
});
|
||||
this.props.onPhoneCountryChanged(country.iso2);
|
||||
}
|
||||
|
||||
onPhoneNumberChanged(ev) {
|
||||
this.setState({phoneNumber: ev.target.value});
|
||||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
}
|
||||
|
||||
onPhoneNumberBlur(ev) {
|
||||
this.props.onPhoneNumberBlur(ev.target.value);
|
||||
}
|
||||
|
||||
onPasswordChanged(ev) {
|
||||
this.setState({password: ev.target.value});
|
||||
this.props.onPasswordChanged(ev.target.value);
|
||||
}
|
||||
|
||||
renderLoginField(loginType, autoFocus) {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
const classes = {};
|
||||
|
||||
switch (loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
classes.error = this.props.loginIncorrect && !this.state.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="email_input"
|
||||
type="text"
|
||||
label={_t("Email")}
|
||||
placeholder="joe@example.com"
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
/>;
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
classes.error = this.props.loginIncorrect && !this.state.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={_t("Username")}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
/>;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE: {
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
|
||||
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.state.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChanged}
|
||||
/>;
|
||||
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="phoneNumber"
|
||||
key="phone_input"
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
value={this.state.phoneNumber}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoginEmpty() {
|
||||
switch (this.state.loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
return !this.state.username;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE:
|
||||
return !this.state.phoneCountry || !this.state.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const SignInToText = sdk.getComponent('views.auth.SignInToText');
|
||||
|
||||
let forgotPasswordJsx;
|
||||
|
||||
if (this.props.onForgotPasswordClick) {
|
||||
forgotPasswordJsx = <span>
|
||||
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton
|
||||
className="mx_Login_forgot"
|
||||
disabled={this.props.busy}
|
||||
kind="link"
|
||||
onClick={this.onForgotPasswordClick}
|
||||
>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
</span>;
|
||||
}
|
||||
|
||||
const pwFieldClass = classNames({
|
||||
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
|
||||
});
|
||||
|
||||
// If login is empty, autoFocus login, otherwise autoFocus password.
|
||||
// this is for when auto server discovery remounts us when the user tries to tab from username to password
|
||||
const autoFocusPassword = !this.isLoginEmpty();
|
||||
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
|
||||
|
||||
let loginType;
|
||||
if (!SdkConfig.get().disable_3pid_login) {
|
||||
loginType = (
|
||||
<div className="mx_Login_type_container">
|
||||
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
|
||||
<Field
|
||||
element="select"
|
||||
value={this.state.loginType}
|
||||
onChange={this.onLoginTypeChange}
|
||||
disabled={this.props.disableSubmit}
|
||||
>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_MXID}
|
||||
value={PasswordLogin.LOGIN_FIELD_MXID}
|
||||
>
|
||||
{_t('Username')}
|
||||
</option>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_EMAIL}
|
||||
value={PasswordLogin.LOGIN_FIELD_EMAIL}
|
||||
>
|
||||
{_t('Email address')}
|
||||
</option>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_PHONE}
|
||||
value={PasswordLogin.LOGIN_FIELD_PHONE}
|
||||
>
|
||||
{_t('Phone')}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SignInToText serverConfig={this.props.serverConfig}
|
||||
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
{loginType}
|
||||
{loginField}
|
||||
<Field
|
||||
className={pwFieldClass}
|
||||
type="password"
|
||||
name="password"
|
||||
label={_t('Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChanged}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocusPassword}
|
||||
/>
|
||||
{forgotPasswordJsx}
|
||||
{ !this.props.busy && <input className="mx_Login_submit"
|
||||
type="submit"
|
||||
value={_t('Sign in')}
|
||||
disabled={this.props.disableSubmit}
|
||||
/> }
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
485
src/components/views/auth/PasswordLogin.tsx
Normal file
485
src/components/views/auth/PasswordLogin.tsx
Normal file
|
@ -0,0 +1,485 @@
|
|||
/*
|
||||
Copyright 2015, 2016, 2017, 2019 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 from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import withValidation from "../elements/Validation";
|
||||
import * as Email from "../../../email";
|
||||
import Field from "../elements/Field";
|
||||
import CountryDropdown from "./CountryDropdown";
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
||||
interface IProps {
|
||||
username: string; // also used for email address
|
||||
phoneCountry: string;
|
||||
phoneNumber: string;
|
||||
|
||||
serverConfig: ValidatedServerConfig;
|
||||
loginIncorrect?: boolean;
|
||||
disableSubmit?: boolean;
|
||||
busy?: boolean;
|
||||
|
||||
onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void;
|
||||
onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void;
|
||||
onUsernameChanged?(username: string): void;
|
||||
onUsernameBlur?(username: string): void;
|
||||
onPhoneCountryChanged?(phoneCountry: string): void;
|
||||
onPhoneNumberChanged?(phoneNumber: string): void;
|
||||
onForgotPasswordClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
fieldValid: Partial<Record<LoginField, boolean>>;
|
||||
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone,
|
||||
password: "",
|
||||
}
|
||||
|
||||
enum LoginField {
|
||||
Email = "login_field_email",
|
||||
MatrixId = "login_field_mxid",
|
||||
Phone = "login_field_phone",
|
||||
Password = "login_field_phone",
|
||||
}
|
||||
|
||||
/*
|
||||
* A pure UI component which displays a username/password form.
|
||||
* The email/username/phone fields are fully-controlled, the password field is not.
|
||||
*/
|
||||
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onUsernameChanged: function() {},
|
||||
onUsernameBlur: function() {},
|
||||
onPhoneCountryChanged: function() {},
|
||||
onPhoneNumberChanged: function() {},
|
||||
loginIncorrect: false,
|
||||
disableSubmit: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
// Field error codes by field ID
|
||||
fieldValid: {},
|
||||
loginType: LoginField.MatrixId,
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
private onForgotPasswordClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onForgotPasswordClick();
|
||||
};
|
||||
|
||||
private onSubmitForm = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let username = ''; // XXX: Synapse breaks if you send null here:
|
||||
let phoneCountry = null;
|
||||
let phoneNumber = null;
|
||||
|
||||
switch (this.state.loginType) {
|
||||
case LoginField.Email:
|
||||
case LoginField.MatrixId:
|
||||
username = this.props.username;
|
||||
break;
|
||||
case LoginField.Phone:
|
||||
phoneCountry = this.props.phoneCountry;
|
||||
phoneNumber = this.props.phoneNumber;
|
||||
break;
|
||||
}
|
||||
|
||||
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
|
||||
};
|
||||
|
||||
private onUsernameChanged = ev => {
|
||||
this.props.onUsernameChanged(ev.target.value);
|
||||
};
|
||||
|
||||
private onUsernameFocus = () => {
|
||||
if (this.state.loginType === LoginField.MatrixId) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_focus");
|
||||
}
|
||||
};
|
||||
|
||||
private onUsernameBlur = ev => {
|
||||
if (this.state.loginType === LoginField.MatrixId) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_blur");
|
||||
}
|
||||
this.props.onUsernameBlur(ev.target.value);
|
||||
};
|
||||
|
||||
private onLoginTypeChange = ev => {
|
||||
const loginType = ev.target.value;
|
||||
this.setState({ loginType });
|
||||
this.props.onUsernameChanged(""); // Reset because email and username use the same state
|
||||
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
|
||||
};
|
||||
|
||||
private onPhoneCountryChanged = country => {
|
||||
this.props.onPhoneCountryChanged(country.iso2);
|
||||
};
|
||||
|
||||
private onPhoneNumberChanged = ev => {
|
||||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
};
|
||||
|
||||
private onPhoneNumberFocus = () => {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
|
||||
};
|
||||
|
||||
private onPhoneNumberBlur = ev => {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
|
||||
};
|
||||
|
||||
private onPasswordChanged = ev => {
|
||||
this.setState({password: ev.target.value});
|
||||
};
|
||||
|
||||
private async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
this.state.loginType,
|
||||
LoginField.Password,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
// values for required fields.
|
||||
for (const fieldID of fieldIDsInDisplayOrder) {
|
||||
const field = this[fieldID];
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
// We must wait for these validations to finish before queueing
|
||||
// up the setState below so our setState goes in the queue after
|
||||
// all the setStates from these validate calls (that's how we
|
||||
// know they've finished).
|
||||
await field.validate({ allowEmpty: false });
|
||||
}
|
||||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise<void>(resolve => this.setState({}, resolve));
|
||||
|
||||
if (this.allFieldsValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
|
||||
|
||||
if (!invalidField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus the first invalid field and show feedback in the stricter mode
|
||||
// that no longer allows empty values for required fields.
|
||||
invalidField.focus();
|
||||
invalidField.validate({ allowEmpty: false, focused: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
private allFieldsValid() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private findFirstInvalidField(fieldIDs: LoginField[]) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private markFieldValid(fieldID: LoginField, valid: boolean) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
fieldValid,
|
||||
});
|
||||
}
|
||||
|
||||
private validateUsernameRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter username"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onUsernameValidate = async (fieldState) => {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(LoginField.MatrixId, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validateEmailRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address"),
|
||||
}, {
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onEmailValidate = async (fieldState) => {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(LoginField.Email, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePhoneNumberRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number"),
|
||||
}, {
|
||||
key: "number",
|
||||
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
|
||||
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPhoneNumberValidate = async (fieldState) => {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(LoginField.Password, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePasswordRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter password"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPasswordValidate = async (fieldState) => {
|
||||
const result = await this.validatePasswordRules(fieldState);
|
||||
this.markFieldValid(LoginField.Password, result.valid);
|
||||
return result;
|
||||
}
|
||||
|
||||
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
|
||||
const classes = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
switch (loginType) {
|
||||
case LoginField.Email:
|
||||
classes.error = this.props.loginIncorrect && !this.props.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="email_input"
|
||||
type="text"
|
||||
label={_t("Email")}
|
||||
placeholder="joe@example.com"
|
||||
value={this.props.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onEmailValidate}
|
||||
ref={field => this[LoginField.Email] = field}
|
||||
/>;
|
||||
case LoginField.MatrixId:
|
||||
classes.error = this.props.loginIncorrect && !this.props.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={_t("Username")}
|
||||
placeholder={_t("Username").toLocaleLowerCase()}
|
||||
value={this.props.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onUsernameValidate}
|
||||
ref={field => this[LoginField.MatrixId] = field}
|
||||
/>;
|
||||
case LoginField.Phone: {
|
||||
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
|
||||
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.props.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChanged}
|
||||
/>;
|
||||
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="phoneNumber"
|
||||
key="phone_input"
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
value={this.props.phoneNumber}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onFocus={this.onPhoneNumberFocus}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
ref={field => this[LoginField.Password] = field}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isLoginEmpty() {
|
||||
switch (this.state.loginType) {
|
||||
case LoginField.Email:
|
||||
case LoginField.MatrixId:
|
||||
return !this.props.username;
|
||||
case LoginField.Phone:
|
||||
return !this.props.phoneCountry || !this.props.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let forgotPasswordJsx;
|
||||
|
||||
if (this.props.onForgotPasswordClick) {
|
||||
forgotPasswordJsx = <AccessibleButton
|
||||
className="mx_Login_forgot"
|
||||
disabled={this.props.busy}
|
||||
kind="link"
|
||||
onClick={this.onForgotPasswordClick}
|
||||
>
|
||||
{_t("Forgot password?")}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const pwFieldClass = classNames({
|
||||
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
|
||||
});
|
||||
|
||||
// If login is empty, autoFocus login, otherwise autoFocus password.
|
||||
// this is for when auto server discovery remounts us when the user tries to tab from username to password
|
||||
const autoFocusPassword = !this.isLoginEmpty();
|
||||
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
|
||||
|
||||
let loginType;
|
||||
if (!SdkConfig.get().disable_3pid_login) {
|
||||
loginType = (
|
||||
<div className="mx_Login_type_container">
|
||||
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
|
||||
<Field
|
||||
element="select"
|
||||
value={this.state.loginType}
|
||||
onChange={this.onLoginTypeChange}
|
||||
disabled={this.props.disableSubmit}
|
||||
>
|
||||
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
|
||||
{_t('Username')}
|
||||
</option>
|
||||
<option
|
||||
key={LoginField.Email}
|
||||
value={LoginField.Email}
|
||||
>
|
||||
{_t('Email address')}
|
||||
</option>
|
||||
<option key={LoginField.Password} value={LoginField.Password}>
|
||||
{_t('Phone')}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
{loginType}
|
||||
{loginField}
|
||||
<Field
|
||||
className={pwFieldClass}
|
||||
type="password"
|
||||
name="password"
|
||||
label={_t('Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChanged}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocusPassword}
|
||||
onValidate={this.onPasswordValidate}
|
||||
ref={field => this[LoginField.Password] = field}
|
||||
/>
|
||||
{forgotPasswordJsx}
|
||||
{ !this.props.busy && <input className="mx_Login_submit"
|
||||
type="submit"
|
||||
value={_t('Sign in')}
|
||||
disabled={this.props.disableSubmit}
|
||||
/> }
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016, 2017, 2018, 2019, 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.
|
||||
|
@ -18,7 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import * as Email from '../../../email';
|
||||
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
|
||||
|
@ -29,34 +27,60 @@ import { SAFE_LOCALPART_REGEX } from '../../../Registration';
|
|||
import withValidation from '../elements/Validation';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import PassphraseField from "./PassphraseField";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import Field from '../elements/Field';
|
||||
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
|
||||
|
||||
const FIELD_EMAIL = 'field_email';
|
||||
const FIELD_PHONE_NUMBER = 'field_phone_number';
|
||||
const FIELD_USERNAME = 'field_username';
|
||||
const FIELD_PASSWORD = 'field_password';
|
||||
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
|
||||
enum RegistrationField {
|
||||
Email = "field_email",
|
||||
PhoneNumber = "field_phone_number",
|
||||
Username = "field_username",
|
||||
Password = "field_password",
|
||||
PasswordConfirm = "field_password_confirm",
|
||||
}
|
||||
|
||||
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
|
||||
|
||||
interface IProps {
|
||||
// Values pre-filled in the input boxes when the component loads
|
||||
defaultEmail?: string;
|
||||
defaultPhoneCountry?: string;
|
||||
defaultPhoneNumber?: string;
|
||||
defaultUsername?: string;
|
||||
defaultPassword?: string;
|
||||
flows: {
|
||||
stages: string[];
|
||||
}[];
|
||||
serverConfig: ValidatedServerConfig;
|
||||
canSubmit?: boolean;
|
||||
|
||||
onRegisterClick(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
phoneCountry?: string;
|
||||
phoneNumber?: string;
|
||||
}): Promise<void>;
|
||||
onEditServerDetailsClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
// Field error codes by field ID
|
||||
fieldValid: Partial<Record<RegistrationField, boolean>>;
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
passwordComplexity?: number;
|
||||
}
|
||||
|
||||
/*
|
||||
* A pure UI component which displays a registration form.
|
||||
*/
|
||||
export default class RegistrationForm extends React.Component {
|
||||
static propTypes = {
|
||||
// Values pre-filled in the input boxes when the component loads
|
||||
defaultEmail: PropTypes.string,
|
||||
defaultPhoneCountry: PropTypes.string,
|
||||
defaultPhoneNumber: PropTypes.string,
|
||||
defaultUsername: PropTypes.string,
|
||||
defaultPassword: PropTypes.string,
|
||||
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
canSubmit: PropTypes.bool,
|
||||
serverRequiresIdServer: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onValidationChange: console.error,
|
||||
canSubmit: true,
|
||||
|
@ -66,9 +90,7 @@ export default class RegistrationForm extends React.Component {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
// Field error codes by field ID
|
||||
fieldValid: {},
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: this.props.defaultPhoneCountry,
|
||||
username: this.props.defaultUsername || "",
|
||||
email: this.props.defaultEmail || "",
|
||||
|
@ -77,57 +99,53 @@ export default class RegistrationForm extends React.Component {
|
|||
passwordConfirm: this.props.defaultPassword || "",
|
||||
passwordComplexity: null,
|
||||
};
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_registration_begin");
|
||||
}
|
||||
|
||||
onSubmit = async ev => {
|
||||
private onSubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
ev.persist();
|
||||
|
||||
if (!this.props.canSubmit) return;
|
||||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
if (this.state.email === '') {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
|
||||
let desc;
|
||||
if (this.props.serverRequiresIdServer && !haveIs) {
|
||||
desc = _t(
|
||||
"No identity server is configured so you cannot add an email address in order to " +
|
||||
"reset your password in the future.",
|
||||
);
|
||||
} else if (this._showEmail()) {
|
||||
desc = _t(
|
||||
"If you don't specify an email address, you won't be able to reset your password. " +
|
||||
"Are you sure?",
|
||||
);
|
||||
if (this.showEmail()) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
|
||||
Modal.createTrackedDialog("Email prompt dialog", '', RegistrationEmailPromptDialog, {
|
||||
onFinished: async (confirmed: boolean, email?: string) => {
|
||||
if (confirmed) {
|
||||
this.setState({
|
||||
email,
|
||||
}, () => {
|
||||
this.doSubmit(ev);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// user can't set an e-mail so don't prompt them to
|
||||
self._doSubmit(ev);
|
||||
this.doSubmit(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
|
||||
title: _t("Warning!"),
|
||||
description: desc,
|
||||
button: _t("Continue"),
|
||||
onFinished(confirmed) {
|
||||
if (confirmed) {
|
||||
self._doSubmit(ev);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self._doSubmit(ev);
|
||||
this.doSubmit(ev);
|
||||
}
|
||||
};
|
||||
|
||||
_doSubmit(ev) {
|
||||
private doSubmit(ev) {
|
||||
const email = this.state.email.trim();
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
|
||||
email: !!email,
|
||||
});
|
||||
|
||||
const promise = this.props.onRegisterClick({
|
||||
username: this.state.username.trim(),
|
||||
password: this.state.password.trim(),
|
||||
|
@ -144,20 +162,20 @@ export default class RegistrationForm extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
async verifyFieldsBeforeSubmit() {
|
||||
private async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement;
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
FIELD_USERNAME,
|
||||
FIELD_PASSWORD,
|
||||
FIELD_PASSWORD_CONFIRM,
|
||||
FIELD_EMAIL,
|
||||
FIELD_PHONE_NUMBER,
|
||||
RegistrationField.Username,
|
||||
RegistrationField.Password,
|
||||
RegistrationField.PasswordConfirm,
|
||||
RegistrationField.Email,
|
||||
RegistrationField.PhoneNumber,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
|
@ -176,7 +194,7 @@ export default class RegistrationForm extends React.Component {
|
|||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise(resolve => this.setState({}, resolve));
|
||||
await new Promise<void>(resolve => this.setState({}, resolve));
|
||||
|
||||
if (this.allFieldsValid()) {
|
||||
return true;
|
||||
|
@ -198,7 +216,7 @@ export default class RegistrationForm extends React.Component {
|
|||
/**
|
||||
* @returns {boolean} true if all fields were valid last time they were validated.
|
||||
*/
|
||||
allFieldsValid() {
|
||||
private allFieldsValid() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
|
@ -208,7 +226,7 @@ export default class RegistrationForm extends React.Component {
|
|||
return true;
|
||||
}
|
||||
|
||||
findFirstInvalidField(fieldIDs) {
|
||||
private findFirstInvalidField(fieldIDs: RegistrationField[]) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
|
@ -217,7 +235,7 @@ export default class RegistrationForm extends React.Component {
|
|||
return null;
|
||||
}
|
||||
|
||||
markFieldValid(fieldID, valid) {
|
||||
private markFieldValid(fieldID: RegistrationField, valid: boolean) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
|
@ -225,25 +243,26 @@ export default class RegistrationForm extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
onEmailChange = ev => {
|
||||
private onEmailChange = ev => {
|
||||
this.setState({
|
||||
email: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onEmailValidate = async fieldState => {
|
||||
private onEmailValidate = async fieldState => {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(FIELD_EMAIL, result.valid);
|
||||
this.markFieldValid(RegistrationField.Email, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
validateEmailRules = withValidation({
|
||||
private validateEmailRules = withValidation({
|
||||
description: () => _t("Use an email address to recover your account"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
|
||||
test(this: RegistrationForm, { value, allowEmpty }) {
|
||||
return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address (required on this homeserver)"),
|
||||
},
|
||||
|
@ -255,29 +274,29 @@ export default class RegistrationForm extends React.Component {
|
|||
],
|
||||
});
|
||||
|
||||
onPasswordChange = ev => {
|
||||
private onPasswordChange = ev => {
|
||||
this.setState({
|
||||
password: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onPasswordValidate = result => {
|
||||
this.markFieldValid(FIELD_PASSWORD, result.valid);
|
||||
private onPasswordValidate = result => {
|
||||
this.markFieldValid(RegistrationField.Password, result.valid);
|
||||
};
|
||||
|
||||
onPasswordConfirmChange = ev => {
|
||||
private onPasswordConfirmChange = ev => {
|
||||
this.setState({
|
||||
passwordConfirm: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onPasswordConfirmValidate = async fieldState => {
|
||||
private onPasswordConfirmValidate = async fieldState => {
|
||||
const result = await this.validatePasswordConfirmRules(fieldState);
|
||||
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
|
||||
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
validatePasswordConfirmRules = withValidation({
|
||||
private validatePasswordConfirmRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -286,65 +305,66 @@ export default class RegistrationForm extends React.Component {
|
|||
},
|
||||
{
|
||||
key: "match",
|
||||
test({ value }) {
|
||||
test(this: RegistrationForm, { value }) {
|
||||
return !value || value === this.state.password;
|
||||
},
|
||||
invalid: () => _t("Passwords don't match"),
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
onPhoneCountryChange = newVal => {
|
||||
private onPhoneCountryChange = newVal => {
|
||||
this.setState({
|
||||
phoneCountry: newVal.iso2,
|
||||
phonePrefix: newVal.prefix,
|
||||
});
|
||||
};
|
||||
|
||||
onPhoneNumberChange = ev => {
|
||||
private onPhoneNumberChange = ev => {
|
||||
this.setState({
|
||||
phoneNumber: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onPhoneNumberValidate = async fieldState => {
|
||||
private onPhoneNumberValidate = async fieldState => {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
|
||||
this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
validatePhoneNumberRules = withValidation({
|
||||
private validatePhoneNumberRules = withValidation({
|
||||
description: () => _t("Other users can invite you to rooms using your contact details"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
|
||||
test(this: RegistrationForm, { value, allowEmpty }) {
|
||||
return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number (required on this homeserver)"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || phoneNumberLooksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid phone number"),
|
||||
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
onUsernameChange = ev => {
|
||||
private onUsernameChange = ev => {
|
||||
this.setState({
|
||||
username: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onUsernameValidate = async fieldState => {
|
||||
private onUsernameValidate = async fieldState => {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(FIELD_USERNAME, result.valid);
|
||||
this.markFieldValid(RegistrationField.Username, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
validateUsernameRules = withValidation({
|
||||
private validateUsernameRules = withValidation({
|
||||
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -365,7 +385,7 @@ export default class RegistrationForm extends React.Component {
|
|||
* @param {string} step A stage name to check
|
||||
* @returns {boolean} Whether it is required
|
||||
*/
|
||||
_authStepIsRequired(step) {
|
||||
private authStepIsRequired(step: string) {
|
||||
return this.props.flows.every((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
|
@ -377,86 +397,80 @@ export default class RegistrationForm extends React.Component {
|
|||
* @param {string} step A stage name to check
|
||||
* @returns {boolean} Whether it is used
|
||||
*/
|
||||
_authStepIsUsed(step) {
|
||||
private authStepIsUsed(step: string) {
|
||||
return this.props.flows.some((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
}
|
||||
|
||||
_showEmail() {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (
|
||||
(this.props.serverRequiresIdServer && !haveIs) ||
|
||||
!this._authStepIsUsed('m.login.email.identity')
|
||||
) {
|
||||
private showEmail() {
|
||||
if (!this.authStepIsUsed('m.login.email.identity')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_showPhoneNumber() {
|
||||
private showPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (
|
||||
!threePidLogin ||
|
||||
(this.props.serverRequiresIdServer && !haveIs) ||
|
||||
!this._authStepIsUsed('m.login.msisdn')
|
||||
) {
|
||||
if (!threePidLogin || !this.authStepIsUsed('m.login.msisdn')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
renderEmail() {
|
||||
if (!this._showEmail()) {
|
||||
private renderEmail() {
|
||||
if (!this.showEmail()) {
|
||||
return null;
|
||||
}
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
|
||||
const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ?
|
||||
_t("Email") :
|
||||
_t("Email (optional)");
|
||||
return <Field
|
||||
ref={field => this[FIELD_EMAIL] = field}
|
||||
ref={field => this[RegistrationField.Email] = field}
|
||||
type="text"
|
||||
label={emailPlaceholder}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}
|
||||
onValidate={this.onEmailValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
renderPassword() {
|
||||
private renderPassword() {
|
||||
return <PassphraseField
|
||||
id="mx_RegistrationForm_password"
|
||||
fieldRef={field => this[FIELD_PASSWORD] = field}
|
||||
fieldRef={field => this[RegistrationField.Password] = field}
|
||||
minScore={PASSWORD_MIN_SCORE}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChange}
|
||||
onValidate={this.onPasswordValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_password_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_password_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
renderPasswordConfirm() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_passwordConfirm"
|
||||
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
|
||||
ref={field => this[RegistrationField.PasswordConfirm] = field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={_t("Confirm")}
|
||||
label={_t("Confirm password")}
|
||||
value={this.state.passwordConfirm}
|
||||
onChange={this.onPasswordConfirmChange}
|
||||
onValidate={this.onPasswordConfirmValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
renderPhoneNumber() {
|
||||
if (!this._showPhoneNumber()) {
|
||||
if (!this.showPhoneNumber()) {
|
||||
return null;
|
||||
}
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
|
||||
const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
|
||||
_t("Phone") :
|
||||
_t("Phone (optional)");
|
||||
const phoneCountry = <CountryDropdown
|
||||
|
@ -466,7 +480,7 @@ export default class RegistrationForm extends React.Component {
|
|||
onOptionChange={this.onPhoneCountryChange}
|
||||
/>;
|
||||
return <Field
|
||||
ref={field => this[FIELD_PHONE_NUMBER] = field}
|
||||
ref={field => this[RegistrationField.PhoneNumber] = field}
|
||||
type="text"
|
||||
label={phoneLabel}
|
||||
value={this.state.phoneNumber}
|
||||
|
@ -477,86 +491,49 @@ export default class RegistrationForm extends React.Component {
|
|||
}
|
||||
|
||||
renderUsername() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_username"
|
||||
ref={field => this[FIELD_USERNAME] = field}
|
||||
ref={field => this[RegistrationField.Username] = field}
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
label={_t("Username")}
|
||||
placeholder={_t("Username").toLocaleLowerCase()}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChange}
|
||||
onValidate={this.onUsernameValidate}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_username_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_username_blur")}
|
||||
/>;
|
||||
}
|
||||
|
||||
render() {
|
||||
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let editLink = null;
|
||||
if (this.props.onEditServerDetailsClick) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.props.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
const registerButton = (
|
||||
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
|
||||
);
|
||||
|
||||
let emailHelperText = null;
|
||||
if (this._showEmail()) {
|
||||
if (this._showPhoneNumber()) {
|
||||
if (this.showEmail()) {
|
||||
if (this.showPhoneNumber()) {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
||||
"Use email or phone to optionally be discoverable by existing contacts.",
|
||||
)}
|
||||
{
|
||||
_t("Add an email to be able to reset your password.")
|
||||
} {
|
||||
_t("Use email or phone to optionally be discoverable by existing contacts.")
|
||||
}
|
||||
</div>;
|
||||
} else {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
||||
"Use email to optionally be discoverable by existing contacts.",
|
||||
)}
|
||||
{
|
||||
_t("Add an email to be able to reset your password.")
|
||||
} {
|
||||
_t("Use email to optionally be discoverable by existing contacts.")
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
let noIsText = null;
|
||||
if (this.props.serverRequiresIdServer && !haveIs) {
|
||||
noIsText = <div>
|
||||
{_t(
|
||||
"No identity server is configured so you cannot add an email address in order to " +
|
||||
"reset your password in the future.",
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>
|
||||
{yourMatrixAccountText}
|
||||
{editLink}
|
||||
</h3>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
{this.renderUsername()}
|
||||
|
@ -570,7 +547,6 @@ export default class RegistrationForm extends React.Component {
|
|||
{this.renderPhoneNumber()}
|
||||
</div>
|
||||
{ emailHelperText }
|
||||
{ noIsText }
|
||||
{ registerButton }
|
||||
</form>
|
||||
</div>
|
|
@ -1,288 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../../../Modal';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { createClient } from 'matrix-js-sdk/src/matrix';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/*
|
||||
* A pure UI component which displays the HS and IS to use.
|
||||
*/
|
||||
|
||||
export default class ServerConfig extends React.PureComponent {
|
||||
static propTypes = {
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
|
||||
// The current configuration that the user is expecting to change.
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
|
||||
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
|
||||
|
||||
// Called after the component calls onServerConfigChange
|
||||
onAfterSubmit: PropTypes.func,
|
||||
|
||||
// Optional text for the submit button. If falsey, no button will be shown.
|
||||
submitText: PropTypes.string,
|
||||
|
||||
// Optional class for the submit button. Only applies if the submit button
|
||||
// is to be rendered.
|
||||
submitClass: PropTypes.string,
|
||||
|
||||
// Whether the flow this component is embedded in requires an identity
|
||||
// server when the homeserver says it will need one. Default false.
|
||||
showIdentityServerIfRequiredByHomeserver: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onServerConfigChange: function() {},
|
||||
delayTimeMs: 0,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
errorText: "",
|
||||
hsUrl: props.serverConfig.hsUrl,
|
||||
isUrl: props.serverConfig.isUrl,
|
||||
showIdentityServer: false,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
|
||||
if (newProps.serverConfig.hsUrl === this.state.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.state.isUrl) return;
|
||||
|
||||
this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
|
||||
}
|
||||
|
||||
async validateServer() {
|
||||
// TODO: Do we want to support .well-known lookups here?
|
||||
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
|
||||
// find their homeserver without demanding they use "https://matrix.org"
|
||||
const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If the UI flow this component is embedded in requires an identity
|
||||
// server when the homeserver says it will need one, check first and
|
||||
// reveal this field if not already shown.
|
||||
// XXX: This a backward compatibility path for homeservers that require
|
||||
// an identity server to be passed during certain flows.
|
||||
// See also https://github.com/matrix-org/synapse/pull/5868.
|
||||
if (
|
||||
this.props.showIdentityServerIfRequiredByHomeserver &&
|
||||
!this.state.showIdentityServer &&
|
||||
await this.isIdentityServerRequiredByHomeserver()
|
||||
) {
|
||||
this.setState({
|
||||
showIdentityServer: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async validateAndApplyServer(hsUrl, isUrl) {
|
||||
// Always try and use the defaults first
|
||||
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
|
||||
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
|
||||
this.setState({
|
||||
hsUrl: defaultConfig.hsUrl,
|
||||
isUrl: defaultConfig.isUrl,
|
||||
busy: false,
|
||||
errorText: "",
|
||||
});
|
||||
this.props.onServerConfigChange(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
hsUrl,
|
||||
isUrl,
|
||||
busy: true,
|
||||
errorText: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
if (!stateForError.isFatalError) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
// carry on anyway
|
||||
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true);
|
||||
this.props.onServerConfigChange(result);
|
||||
return result;
|
||||
} else {
|
||||
let message = _t("Unable to validate homeserver/identity server");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: message,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async isIdentityServerRequiredByHomeserver() {
|
||||
// XXX: We shouldn't have to create a whole new MatrixClient just to
|
||||
// check if the homeserver requires an identity server... Should it be
|
||||
// extracted to a static utils function...?
|
||||
return createClient({
|
||||
baseUrl: this.state.hsUrl,
|
||||
}).doesServerRequireIdServerParam();
|
||||
}
|
||||
|
||||
onHomeserverBlur = (ev) => {
|
||||
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
|
||||
this.validateServer();
|
||||
});
|
||||
};
|
||||
|
||||
onHomeserverChange = (ev) => {
|
||||
const hsUrl = ev.target.value;
|
||||
this.setState({ hsUrl });
|
||||
};
|
||||
|
||||
onIdentityServerBlur = (ev) => {
|
||||
this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => {
|
||||
this.validateServer();
|
||||
});
|
||||
};
|
||||
|
||||
onIdentityServerChange = (ev) => {
|
||||
const isUrl = ev.target.value;
|
||||
this.setState({ isUrl });
|
||||
};
|
||||
|
||||
onSubmit = async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const result = await this.validateServer();
|
||||
if (!result) return; // Do not continue.
|
||||
|
||||
if (this.props.onAfterSubmit) {
|
||||
this.props.onAfterSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
_waitThenInvoke(existingTimeoutId, fn) {
|
||||
if (existingTimeoutId) {
|
||||
clearTimeout(existingTimeoutId);
|
||||
}
|
||||
return setTimeout(fn.bind(this), this.props.delayTimeMs);
|
||||
}
|
||||
|
||||
showHelpPopup = () => {
|
||||
const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog');
|
||||
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
|
||||
};
|
||||
|
||||
_renderHomeserverSection() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <div>
|
||||
{_t("Enter your custom homeserver URL <a>What does this mean?</a>", {}, {
|
||||
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
|
||||
{sub}
|
||||
</a>,
|
||||
})}
|
||||
<Field
|
||||
id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Homeserver URL")}
|
||||
placeholder={this.props.serverConfig.hsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
_renderIdentityServerSection() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const classes = classNames({
|
||||
"mx_ServerConfig_identityServer": true,
|
||||
"mx_ServerConfig_identityServer_shown": this.state.showIdentityServer,
|
||||
});
|
||||
return <div className={classes}>
|
||||
{_t("Enter your custom identity server URL <a>What does this mean?</a>", {}, {
|
||||
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
|
||||
{sub}
|
||||
</a>,
|
||||
})}
|
||||
<Field
|
||||
label={_t("Identity Server URL")}
|
||||
placeholder={this.props.serverConfig.isUrl}
|
||||
value={this.state.isUrl || ''}
|
||||
onBlur={this.onIdentityServerBlur}
|
||||
onChange={this.onIdentityServerChange}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const errorText = this.state.errorText
|
||||
? <span className='mx_ServerConfig_error'>{this.state.errorText}</span>
|
||||
: null;
|
||||
|
||||
const submitButton = this.props.submitText
|
||||
? <AccessibleButton
|
||||
element="button"
|
||||
type="submit"
|
||||
className={this.props.submitClass}
|
||||
onClick={this.onSubmit}
|
||||
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<form className="mx_ServerConfig" onSubmit={this.onSubmit} autoComplete="off">
|
||||
<h3>{_t("Other servers")}</h3>
|
||||
{errorText}
|
||||
{this._renderHomeserverSection()}
|
||||
{this._renderIdentityServerSection()}
|
||||
{submitButton}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,153 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
|
||||
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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import classnames from 'classnames';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import {makeType} from "../../../utils/TypeUtils";
|
||||
|
||||
const MODULAR_URL = 'https://element.io/matrix-services' +
|
||||
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
|
||||
|
||||
export const FREE = 'Free';
|
||||
export const PREMIUM = 'Premium';
|
||||
export const ADVANCED = 'Advanced';
|
||||
|
||||
export const TYPES = {
|
||||
FREE: {
|
||||
id: FREE,
|
||||
label: () => _t('Free'),
|
||||
logo: () => <img src={require('../../../../res/img/matrix-org-bw-logo.svg')} />,
|
||||
description: () => _t('Join millions for free on the largest public server'),
|
||||
serverConfig: makeType(ValidatedServerConfig, {
|
||||
hsUrl: "https://matrix-client.matrix.org",
|
||||
hsName: "matrix.org",
|
||||
hsNameIsDifferent: false,
|
||||
isUrl: "https://vector.im",
|
||||
}),
|
||||
},
|
||||
PREMIUM: {
|
||||
id: PREMIUM,
|
||||
label: () => _t('Premium'),
|
||||
logo: () => <img src={require('../../../../res/img/ems-logo.svg')} height={16} />,
|
||||
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
|
||||
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
</a>,
|
||||
}),
|
||||
identityServerUrl: "https://vector.im",
|
||||
},
|
||||
ADVANCED: {
|
||||
id: ADVANCED,
|
||||
label: () => _t('Advanced'),
|
||||
logo: () => <div>
|
||||
<img src={require('../../../../res/img/feather-customised/globe.svg')} />
|
||||
{_t('Other')}
|
||||
</div>,
|
||||
description: () => _t('Find other public servers or use a custom server'),
|
||||
},
|
||||
};
|
||||
|
||||
export function getTypeFromServerConfig(config) {
|
||||
const {hsUrl} = config;
|
||||
if (!hsUrl) {
|
||||
return null;
|
||||
} else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) {
|
||||
return FREE;
|
||||
} else if (new URL(hsUrl).hostname.endsWith('.modular.im')) {
|
||||
// This is an unlikely case to reach, as Modular defaults to hiding the
|
||||
// server type selector.
|
||||
return PREMIUM;
|
||||
} else {
|
||||
return ADVANCED;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ServerTypeSelector extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The default selected type.
|
||||
selected: PropTypes.string,
|
||||
// Handler called when the selected type changes.
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
selected,
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
updateSelectedType(type) {
|
||||
if (this.state.selected === type) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
selected: type,
|
||||
});
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(type);
|
||||
}
|
||||
}
|
||||
|
||||
onClick = (e) => {
|
||||
e.stopPropagation();
|
||||
const type = e.currentTarget.dataset.id;
|
||||
this.updateSelectedType(type);
|
||||
};
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const serverTypes = [];
|
||||
for (const type of Object.values(TYPES)) {
|
||||
const { id, label, logo, description } = type;
|
||||
const classes = classnames(
|
||||
"mx_ServerTypeSelector_type",
|
||||
`mx_ServerTypeSelector_type_${id}`,
|
||||
{
|
||||
"mx_ServerTypeSelector_type_selected": id === this.state.selected,
|
||||
},
|
||||
);
|
||||
|
||||
serverTypes.push(<div className={classes} key={id} >
|
||||
<div className="mx_ServerTypeSelector_label">
|
||||
{label()}
|
||||
</div>
|
||||
<AccessibleButton onClick={this.onClick} data-id={id}>
|
||||
<div className="mx_ServerTypeSelector_logo">
|
||||
{logo()}
|
||||
</div>
|
||||
<div className="mx_ServerTypeSelector_description">
|
||||
{description()}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return <div className="mx_ServerTypeSelector">
|
||||
{serverTypes}
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 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 from 'react';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import * as sdk from "../../../index";
|
||||
import PropTypes from "prop-types";
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
|
||||
export default class SignInToText extends React.PureComponent {
|
||||
static propTypes = {
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
};
|
||||
|
||||
render() {
|
||||
let signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
signInToText = _t('Sign in to your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let editLink = null;
|
||||
if (this.props.onEditServerDetailsClick) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.props.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
return <h3>
|
||||
{signInToText}
|
||||
{editLink}
|
||||
</h3>;
|
||||
}
|
||||
}
|
|
@ -23,11 +23,18 @@ import AuthPage from "./AuthPage";
|
|||
import {_td} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
// translatable strings for Welcome pages
|
||||
_td("Sign in with SSO");
|
||||
|
||||
export default class Welcome extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_welcome");
|
||||
}
|
||||
|
||||
render() {
|
||||
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
|
||||
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');
|
||||
|
|
|
@ -51,7 +51,8 @@ const calculateUrls = (url, urls) => {
|
|||
_urls = urls || [];
|
||||
|
||||
if (url) {
|
||||
_urls.unshift(url); // put in urls[0]
|
||||
// copy urls and put url first
|
||||
_urls = [url, ..._urls];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ 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 from 'react';
|
||||
import React, {ComponentProps} from 'react';
|
||||
import Room from 'matrix-js-sdk/src/models/room';
|
||||
import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
|
||||
|
||||
|
@ -24,7 +24,7 @@ import Modal from '../../../Modal';
|
|||
import * as Avatar from '../../../Avatar';
|
||||
import {ResizeMethod} from "../../../Avatar";
|
||||
|
||||
interface IProps {
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
|
||||
// Room may be left unset here, but if it is,
|
||||
// oobData.avatarUrl should be set (else there
|
||||
// would be nowhere to get the avatar from)
|
||||
|
@ -35,6 +35,7 @@ interface IProps {
|
|||
height?: number;
|
||||
resizeMethod?: ResizeMethod;
|
||||
viewAvatarOnClick?: boolean;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -130,7 +131,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
public render() {
|
||||
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
|
||||
const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props;
|
||||
|
||||
const roomName = room ? room.name : oobData.name;
|
||||
|
||||
|
@ -139,7 +140,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
name={roomName}
|
||||
idName={room ? room.roomId : null}
|
||||
urls={this.state.urls}
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null}
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
58
src/components/views/avatars/WidgetAvatar.tsx
Normal file
58
src/components/views/avatars/WidgetAvatar.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
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, {ComponentProps, useContext} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {IApp} from "../../../stores/WidgetStore";
|
||||
import BaseAvatar, {BaseAvatarType} from "./BaseAvatar";
|
||||
|
||||
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
|
||||
app: IApp;
|
||||
}
|
||||
|
||||
const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 20, ...props }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
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("jitsi")) {
|
||||
iconUrls = [require("../../../../res/img/element-icons/room/default_video.svg")];
|
||||
} else 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")];
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseAvatar
|
||||
{...props}
|
||||
name={app.id}
|
||||
className={classNames("mx_WidgetAvatar", className)}
|
||||
// MSC2765
|
||||
url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined}
|
||||
urls={iconUrls}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
export default WidgetAvatar;
|
77
src/components/views/context_menus/CallContextMenu.tsx
Normal file
77
src/components/views/context_menus/CallContextMenu.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
Copyright 2020 New Vector Ltd
|
||||
|
||||
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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import CallHandler from '../../../CallHandler';
|
||||
import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
|
||||
import Modal from '../../../Modal';
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
call: MatrixCall;
|
||||
}
|
||||
|
||||
export default class CallContextMenu extends React.Component<IProps> {
|
||||
static propTypes = {
|
||||
// js-sdk User object. Not required because it might not exist.
|
||||
user: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onHoldClick = () => {
|
||||
this.props.call.setRemoteOnHold(true);
|
||||
this.props.onFinished();
|
||||
}
|
||||
|
||||
onUnholdClick = () => {
|
||||
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
|
||||
|
||||
this.props.onFinished();
|
||||
}
|
||||
|
||||
onTransferClick = () => {
|
||||
Modal.createTrackedDialog(
|
||||
'Transfer Call', '', InviteDialog, {kind: KIND_CALL_TRANSFER, call: this.props.call},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
);
|
||||
this.props.onFinished();
|
||||
}
|
||||
|
||||
render() {
|
||||
const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold");
|
||||
const handler = this.props.call.isRemoteOnHold() ? this.onUnholdClick : this.onHoldClick;
|
||||
|
||||
let transferItem;
|
||||
if (this.props.call.opponentCanBeTransferred()) {
|
||||
transferItem = <MenuItem className="mx_CallContextMenu_item" onClick={this.onTransferClick}>
|
||||
{_t("Transfer")}
|
||||
</MenuItem>;
|
||||
}
|
||||
|
||||
return <ContextMenu {...this.props}>
|
||||
<MenuItem className="mx_CallContextMenu_item" onClick={handler}>
|
||||
{holdUnholdCaption}
|
||||
</MenuItem>
|
||||
{transferItem}
|
||||
</ContextMenu>;
|
||||
}
|
||||
}
|
59
src/components/views/context_menus/DialpadContextMenu.tsx
Normal file
59
src/components/views/context_menus/DialpadContextMenu.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import Dialpad from '../voip/DialPad';
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
call: MatrixCall;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default class DialpadContextMenu extends React.Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: '',
|
||||
}
|
||||
}
|
||||
|
||||
onDigitPress = (digit) => {
|
||||
this.props.call.sendDtmfDigit(digit);
|
||||
this.setState({value: this.state.value + digit});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <ContextMenu {...this.props}>
|
||||
<div className="mx_DialPadContextMenu_header">
|
||||
<div>
|
||||
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
|
||||
</div>
|
||||
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div>
|
||||
</div>
|
||||
<div className="mx_DialPadContextMenu_horizSep" />
|
||||
<div className="mx_DialPadContextMenu_dialPad">
|
||||
<Dialpad onDigitPress={this.onDigitPress} hasDialAndDelete={false} />
|
||||
</div>
|
||||
</ContextMenu>;
|
||||
}
|
||||
}
|
|
@ -31,6 +31,7 @@ import SettingsStore from '../../../settings/SettingsStore';
|
|||
import { isUrlPermitted } from '../../../HtmlUtils';
|
||||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
import {MenuItem} from "../../structures/ContextMenu";
|
||||
import {EventType} from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
function canCancel(eventStatus) {
|
||||
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
|
||||
|
@ -72,7 +73,10 @@ export default class MessageContextMenu extends React.Component {
|
|||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
||||
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
|
||||
// We explicitly decline to show the redact option on ACL events as it has a potential
|
||||
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
|
||||
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
|
||||
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl;
|
||||
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
|
||||
|
||||
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
|
||||
|
@ -145,7 +149,7 @@ export default class MessageContextMenu extends React.Component {
|
|||
onRedactClick = () => {
|
||||
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
|
||||
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
|
||||
onFinished: async (proceed) => {
|
||||
onFinished: async (proceed, reason) => {
|
||||
if (!proceed) return;
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
@ -153,6 +157,8 @@ export default class MessageContextMenu extends React.Component {
|
|||
await cli.redactEvent(
|
||||
this.props.mxEvent.getRoomId(),
|
||||
this.props.mxEvent.getId(),
|
||||
undefined,
|
||||
reason ? { reason } : {},
|
||||
);
|
||||
} catch (e) {
|
||||
const code = e.errcode || e.statusCode;
|
||||
|
|
|
@ -1,142 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import {MenuItem} from "../../structures/ContextMenu";
|
||||
|
||||
export default class WidgetContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func,
|
||||
|
||||
// Callback for when the revoke button is clicked. Required.
|
||||
onRevokeClicked: PropTypes.func.isRequired,
|
||||
|
||||
// Callback for when the unpin button is clicked. If absent, unpin will be hidden.
|
||||
onUnpinClicked: PropTypes.func,
|
||||
|
||||
// Callback for when the snapshot button is clicked. Button not shown
|
||||
// without a callback.
|
||||
onSnapshotClicked: PropTypes.func,
|
||||
|
||||
// Callback for when the reload button is clicked. Button not shown
|
||||
// without a callback.
|
||||
onReloadClicked: PropTypes.func,
|
||||
|
||||
// Callback for when the edit button is clicked. Button not shown
|
||||
// without a callback.
|
||||
onEditClicked: PropTypes.func,
|
||||
|
||||
// Callback for when the delete button is clicked. Button not shown
|
||||
// without a callback.
|
||||
onDeleteClicked: PropTypes.func,
|
||||
};
|
||||
|
||||
proxyClick(fn) {
|
||||
fn();
|
||||
if (this.props.onFinished) this.props.onFinished();
|
||||
}
|
||||
|
||||
// XXX: It's annoying that our context menus require us to hit onFinished() to close :(
|
||||
|
||||
onEditClicked = () => {
|
||||
this.proxyClick(this.props.onEditClicked);
|
||||
};
|
||||
|
||||
onReloadClicked = () => {
|
||||
this.proxyClick(this.props.onReloadClicked);
|
||||
};
|
||||
|
||||
onSnapshotClicked = () => {
|
||||
this.proxyClick(this.props.onSnapshotClicked);
|
||||
};
|
||||
|
||||
onDeleteClicked = () => {
|
||||
this.proxyClick(this.props.onDeleteClicked);
|
||||
};
|
||||
|
||||
onRevokeClicked = () => {
|
||||
this.proxyClick(this.props.onRevokeClicked);
|
||||
};
|
||||
|
||||
onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked);
|
||||
|
||||
render() {
|
||||
const options = [];
|
||||
|
||||
if (this.props.onEditClicked) {
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onEditClicked} key='edit'>
|
||||
{_t("Edit")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onUnpinClicked) {
|
||||
options.push(
|
||||
<MenuItem className="mx_WidgetContextMenu_option" onClick={this.onUnpinClicked} key="unpin">
|
||||
{_t("Unpin")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onReloadClicked) {
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onReloadClicked} key='reload'>
|
||||
{_t("Reload")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onSnapshotClicked) {
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onSnapshotClicked} key='snap'>
|
||||
{_t("Take picture")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.onDeleteClicked) {
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onDeleteClicked} key='delete'>
|
||||
{_t("Remove for everyone")}
|
||||
</MenuItem>,
|
||||
);
|
||||
}
|
||||
|
||||
// Push this last so it appears last. It's always present.
|
||||
options.push(
|
||||
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onRevokeClicked} key='revoke'>
|
||||
{_t("Remove for me")}
|
||||
</MenuItem>,
|
||||
);
|
||||
|
||||
// Put separators between the options
|
||||
if (options.length > 1) {
|
||||
const length = options.length;
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
const sep = <hr key={i} className="mx_WidgetContextMenu_separator" />;
|
||||
|
||||
// Insert backwards so the insertions don't affect our math on where to place them.
|
||||
// We also use our cached length to avoid worrying about options.length changing
|
||||
options.splice(length - 1 - i, 0, sep);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="mx_WidgetContextMenu">{options}</div>;
|
||||
}
|
||||
}
|
178
src/components/views/context_menus/WidgetContextMenu.tsx
Normal file
178
src/components/views/context_menus/WidgetContextMenu.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
/*
|
||||
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} from "react";
|
||||
import {MatrixCapabilities} from "matrix-widget-api";
|
||||
|
||||
import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu";
|
||||
import {ChevronFace} from "../../structures/ContextMenu";
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {IApp} from "../../../stores/WidgetStore";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import Modal from "../../../Modal";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
|
||||
app: IApp;
|
||||
userWidget?: boolean;
|
||||
showUnpin?: boolean;
|
||||
// override delete handler
|
||||
onDeleteClick?(): void;
|
||||
}
|
||||
|
||||
const WidgetContextMenu: React.FC<IProps> = ({
|
||||
onFinished,
|
||||
app,
|
||||
userWidget,
|
||||
onDeleteClick,
|
||||
showUnpin,
|
||||
...props
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const {room, roomId} = useContext(RoomContext);
|
||||
|
||||
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
|
||||
const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId);
|
||||
|
||||
let unpinButton;
|
||||
if (showUnpin) {
|
||||
const onUnpinClick = () => {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
unpinButton = <IconizedContextMenuOption onClick={onUnpinClick} label={_t("Unpin")} />;
|
||||
}
|
||||
|
||||
let editButton;
|
||||
if (canModify && WidgetUtils.isManagedByManager(app)) {
|
||||
const onEditClick = () => {
|
||||
WidgetUtils.editWidget(room, app);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
editButton = <IconizedContextMenuOption onClick={onEditClick} label={_t("Edit")} />;
|
||||
}
|
||||
|
||||
let snapshotButton;
|
||||
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);
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
snapshotButton = <IconizedContextMenuOption onClick={onSnapshotClick} label={_t("Take a picture")} />;
|
||||
}
|
||||
|
||||
let deleteButton;
|
||||
if (onDeleteClick || canModify) {
|
||||
const onDeleteClickDefault = () => {
|
||||
// Show delete confirmation dialog
|
||||
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
|
||||
title: _t("Delete Widget"),
|
||||
description: _t(
|
||||
"Deleting a widget removes it for all users in this room." +
|
||||
" Are you sure you want to delete this widget?"),
|
||||
button: _t("Delete widget"),
|
||||
onFinished: (confirmed) => {
|
||||
if (!confirmed) return;
|
||||
WidgetUtils.setRoomWidget(roomId, app.id);
|
||||
},
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
deleteButton = <IconizedContextMenuOption
|
||||
onClick={onDeleteClick || onDeleteClickDefault}
|
||||
label={userWidget ? _t("Remove") : _t("Remove for everyone")}
|
||||
/>;
|
||||
}
|
||||
|
||||
let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId];
|
||||
if (isAllowedWidget === undefined) {
|
||||
isAllowedWidget = app.creatorUserId === cli.getUserId();
|
||||
}
|
||||
|
||||
const isLocalWidget = WidgetType.JITSI.matches(app.type);
|
||||
let revokeButton;
|
||||
if (!userWidget && !isLocalWidget && isAllowedWidget) {
|
||||
const onRevokeClick = () => {
|
||||
console.info("Revoking permission for widget to load: " + app.eventId);
|
||||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||||
current[app.eventId] = false;
|
||||
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
|
||||
SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => {
|
||||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
|
||||
}
|
||||
|
||||
const pinnedWidgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
|
||||
const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id);
|
||||
|
||||
let moveLeftButton;
|
||||
if (showUnpin && widgetIndex > 0) {
|
||||
const onClick = () => {
|
||||
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
moveLeftButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move left")} />;
|
||||
}
|
||||
|
||||
let moveRightButton;
|
||||
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
|
||||
const onClick = () => {
|
||||
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
moveRightButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move right")} />;
|
||||
}
|
||||
|
||||
return <IconizedContextMenu {...props} chevronFace={ChevronFace.None} onFinished={onFinished}>
|
||||
<IconizedContextMenuOptionList>
|
||||
{ editButton }
|
||||
{ revokeButton }
|
||||
{ deleteButton }
|
||||
{ snapshotButton }
|
||||
{ moveLeftButton }
|
||||
{ moveRightButton }
|
||||
{ unpinButton }
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
export default WidgetContextMenu;
|
||||
|
208
src/components/views/dialogs/AddExistingToSpaceDialog.tsx
Normal file
208
src/components/views/dialogs/AddExistingToSpaceDialog.tsx
Normal file
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useState} from "react";
|
||||
import classNames from "classnames";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
|
||||
import {_t} from '../../../languageHandler';
|
||||
import {IDialogProps} from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import FormButton from "../elements/FormButton";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import {getDisplayAliasForRoom} from "../../../Rooms";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import {allSettled} from "../../../utils/promise";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
space: Room;
|
||||
onCreateRoomClick(cli: MatrixClient, space: Room): void;
|
||||
}
|
||||
|
||||
const Entry = ({ room, checked, onChange }) => {
|
||||
return <div className="mx_AddExistingToSpaceDialog_entry">
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span>
|
||||
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const lcQuery = query.toLowerCase();
|
||||
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
|
||||
|
||||
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
|
||||
const existingSubspacesSet = new Set(existingSubspaces);
|
||||
const spaces = SpaceStore.instance.getSpaces().filter(s => {
|
||||
return !existingSubspacesSet.has(s) // not already in space
|
||||
&& space !== s // not the top-level space
|
||||
&& selectedSpace !== s // not the selected space
|
||||
&& s.name.toLowerCase().includes(lcQuery); // contains query
|
||||
});
|
||||
|
||||
const existingRooms = SpaceStore.instance.getChildRooms(space.roomId);
|
||||
const existingRoomsSet = new Set(existingRooms);
|
||||
const rooms = cli.getVisibleRooms().filter(room => {
|
||||
return !existingRoomsSet.has(room) // not already in space
|
||||
&& room.name.toLowerCase().includes(lcQuery) // contains query
|
||||
&& !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM
|
||||
});
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
let spaceOptionSection;
|
||||
if (existingSubspacesSet.size > 0) {
|
||||
const options = [space, ...existingSubspaces].map((space) => {
|
||||
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
|
||||
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
});
|
||||
|
||||
spaceOptionSection = (
|
||||
<Dropdown
|
||||
id="mx_SpaceSelectDropdown"
|
||||
onOptionChange={(key: string) => {
|
||||
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
|
||||
}}
|
||||
value={selectedSpace.roomId}
|
||||
label={_t("Space selection")}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
}
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={selectedSpace} height={40} width={40} />
|
||||
<div>
|
||||
<h1>{ _t("Add existing spaces/rooms") }</h1>
|
||||
{ spaceOptionSection }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
return <BaseDialog
|
||||
title={title}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpaceDialog"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
|
||||
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Filter your rooms and spaces") }
|
||||
onSearch={setQuery}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(space);
|
||||
} else {
|
||||
selectedToAdd.delete(space);
|
||||
}
|
||||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
}}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : null }
|
||||
|
||||
{ rooms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpaceDialog_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
{ rooms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
selectedToAdd.delete(room);
|
||||
}
|
||||
setSelectedToAdd(new Set(selectedToAdd));
|
||||
}}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length + rooms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
|
||||
<div className="mx_AddExistingToSpaceDialog_footer">
|
||||
<span>
|
||||
<div>{ _t("Don't want to add an existing room?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</span>
|
||||
|
||||
<FormButton
|
||||
label={busy ? _t("Applying...") : _t("Apply")}
|
||||
disabled={busy || selectedToAdd.size < 1}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await allSettled(Array.from(selectedToAdd).map((room) =>
|
||||
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
|
||||
onFinished(true);
|
||||
} catch (e) {
|
||||
console.error("Failed to add rooms to space", e);
|
||||
setError(_t("Failed to add rooms to space"));
|
||||
}
|
||||
setBusy(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default AddExistingToSpaceDialog;
|
||||
|
|
@ -23,15 +23,17 @@ import { _t } from '../../../languageHandler';
|
|||
*/
|
||||
export default class ConfirmRedactDialog extends React.Component {
|
||||
render() {
|
||||
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
|
||||
const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog');
|
||||
return (
|
||||
<QuestionDialog onFinished={this.props.onFinished}
|
||||
<TextInputDialog onFinished={this.props.onFinished}
|
||||
title={_t("Confirm Removal")}
|
||||
description={
|
||||
_t("Are you sure you wish to remove (delete) this event? " +
|
||||
"Note that if you delete a room name or topic change, it could undo the change.")}
|
||||
placeholder={_t("Reason (optional)")}
|
||||
focus
|
||||
button={_t("Remove")}>
|
||||
</QuestionDialog>
|
||||
</TextInputDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,8 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import withValidation from '../elements/Validation';
|
||||
|
@ -30,6 +32,7 @@ export default class CreateRoomDialog extends React.Component {
|
|||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
defaultPublic: PropTypes.bool,
|
||||
parentSpace: PropTypes.instanceOf(Room),
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -85,6 +88,10 @@ export default class CreateRoomDialog extends React.Component {
|
|||
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||
}
|
||||
|
||||
if (this.props.parentSpace) {
|
||||
opts.parentSpace = this.props.parentSpace;
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,12 @@ import {
|
|||
PHASE_STARTED,
|
||||
PHASE_CANCELLED,
|
||||
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
|
||||
import {SETTINGS} from "../../../settings/Settings";
|
||||
import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "./ErrorDialog";
|
||||
|
||||
class GenericEditor extends React.PureComponent {
|
||||
// static propTypes = {onBack: PropTypes.func.isRequired};
|
||||
|
@ -701,6 +707,377 @@ class VerificationExplorer extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
class WidgetExplorer extends React.Component {
|
||||
static getLabel() {
|
||||
return _t("Active Widgets");
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
query: '',
|
||||
editWidget: null, // set to an IApp when editing
|
||||
};
|
||||
}
|
||||
|
||||
onWidgetStoreUpdate = () => {
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
onQueryChange = (query) => {
|
||||
this.setState({query});
|
||||
};
|
||||
|
||||
onEditWidget = (widget) => {
|
||||
this.setState({editWidget: widget});
|
||||
};
|
||||
|
||||
onBack = () => {
|
||||
const widgets = WidgetStore.instance.getApps(this.props.room.roomId);
|
||||
if (this.state.editWidget && widgets.includes(this.state.editWidget)) {
|
||||
this.setState({editWidget: null});
|
||||
} else {
|
||||
this.props.onBack();
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||
}
|
||||
|
||||
render() {
|
||||
const room = this.props.room;
|
||||
|
||||
const editWidget = this.state.editWidget;
|
||||
const widgets = WidgetStore.instance.getApps(room.roomId);
|
||||
if (editWidget && widgets.includes(editWidget)) {
|
||||
const allState = Array.from(Array.from(room.currentState.events.values()).map(e => e.values()))
|
||||
.reduce((p, c) => {p.push(...c); return p;}, []);
|
||||
const stateEv = allState.find(ev => ev.getId() === editWidget.eventId);
|
||||
if (!stateEv) { // "should never happen"
|
||||
return <div>
|
||||
{_t("There was an error finding this widget.")}
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{_t("Back")}</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
return <SendCustomEvent
|
||||
onBack={this.onBack}
|
||||
room={room}
|
||||
forceStateEvent={true}
|
||||
inputs={{
|
||||
eventType: stateEv.getType(),
|
||||
evContent: JSON.stringify(stateEv.getContent(), null, '\t'),
|
||||
stateKey: stateEv.getStateKey(),
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (<div>
|
||||
<div className="mx_Dialog_content">
|
||||
<FilteredList query={this.state.query} onChange={this.onQueryChange}>
|
||||
{widgets.map(w => {
|
||||
return <button
|
||||
className='mx_DevTools_RoomStateExplorer_button'
|
||||
key={w.url + w.eventId}
|
||||
onClick={() => this.onEditWidget(w)}
|
||||
>{w.url}</button>;
|
||||
})}
|
||||
</FilteredList>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{_t("Back")}</button>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsExplorer extends React.Component {
|
||||
static getLabel() {
|
||||
return _t("Settings Explorer");
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
query: '',
|
||||
editSetting: null, // set to a setting ID when editing
|
||||
viewSetting: null, // set to a setting ID when exploring in detail
|
||||
|
||||
explicitValues: null, // stringified JSON for edit view
|
||||
explicitRoomValues: null, // stringified JSON for edit view
|
||||
};
|
||||
}
|
||||
|
||||
onQueryChange = (ev) => {
|
||||
this.setState({query: ev.target.value});
|
||||
};
|
||||
|
||||
onExplValuesEdit = (ev) => {
|
||||
this.setState({explicitValues: ev.target.value});
|
||||
};
|
||||
|
||||
onExplRoomValuesEdit = (ev) => {
|
||||
this.setState({explicitRoomValues: ev.target.value});
|
||||
};
|
||||
|
||||
onBack = () => {
|
||||
if (this.state.editSetting) {
|
||||
this.setState({editSetting: null});
|
||||
} else if (this.state.viewSetting) {
|
||||
this.setState({viewSetting: null});
|
||||
} else {
|
||||
this.props.onBack();
|
||||
}
|
||||
};
|
||||
|
||||
onViewClick = (ev, settingId) => {
|
||||
ev.preventDefault();
|
||||
this.setState({viewSetting: settingId});
|
||||
};
|
||||
|
||||
onEditClick = (ev, settingId) => {
|
||||
ev.preventDefault();
|
||||
this.setState({
|
||||
editSetting: settingId,
|
||||
explicitValues: this.renderExplicitSettingValues(settingId, null),
|
||||
explicitRoomValues: this.renderExplicitSettingValues(settingId, this.props.room.roomId),
|
||||
});
|
||||
};
|
||||
|
||||
onSaveClick = async () => {
|
||||
try {
|
||||
const settingId = this.state.editSetting;
|
||||
const parsedExplicit = JSON.parse(this.state.explicitValues);
|
||||
const parsedExplicitRoom = JSON.parse(this.state.explicitRoomValues);
|
||||
for (const level of Object.keys(parsedExplicit)) {
|
||||
console.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`);
|
||||
try {
|
||||
const val = parsedExplicit[level];
|
||||
await SettingsStore.setValue(settingId, null, level, val);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
const roomId = this.props.room.roomId;
|
||||
for (const level of Object.keys(parsedExplicit)) {
|
||||
console.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`);
|
||||
try {
|
||||
const val = parsedExplicitRoom[level];
|
||||
await SettingsStore.setValue(settingId, roomId, level, val);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
viewSetting: settingId,
|
||||
editSetting: null,
|
||||
});
|
||||
} catch (e) {
|
||||
Modal.createTrackedDialog('Devtools - Failed to save settings', '', ErrorDialog, {
|
||||
title: _t("Failed to save settings"),
|
||||
description: e.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderSettingValue(val) {
|
||||
// Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us
|
||||
const toStringTypes = ['boolean', 'number'];
|
||||
if (toStringTypes.includes(typeof(val))) {
|
||||
return val.toString();
|
||||
} else {
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
}
|
||||
|
||||
renderExplicitSettingValues(setting, roomId) {
|
||||
const vals = {};
|
||||
for (const level of LEVEL_ORDER) {
|
||||
try {
|
||||
vals[level] = SettingsStore.getValueAt(level, setting, roomId, true, true);
|
||||
if (vals[level] === undefined) {
|
||||
vals[level] = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(vals, null, 4);
|
||||
}
|
||||
|
||||
renderCanEditLevel(roomId, level) {
|
||||
const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level);
|
||||
const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable';
|
||||
return <td className={className}><code>{canEdit.toString()}</code></td>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const room = this.props.room;
|
||||
|
||||
if (!this.state.viewSetting && !this.state.editSetting) {
|
||||
// view all settings
|
||||
const allSettings = Object.keys(SETTINGS)
|
||||
.filter(n => this.state.query ? n.toLowerCase().includes(this.state.query.toLowerCase()) : true);
|
||||
return (
|
||||
<div>
|
||||
<div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
|
||||
<Field
|
||||
label={_t('Filter results')} autoFocus={true} size={64}
|
||||
type="text" autoComplete="off" value={this.state.query} onChange={this.onQueryChange}
|
||||
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
|
||||
/>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{_t("Setting ID")}</th>
|
||||
<th>{_t("Value")}</th>
|
||||
<th>{_t("Value in this room")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allSettings.map(i => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<a href="" onClick={(e) => this.onViewClick(e, i)}>
|
||||
<code>{i}</code>
|
||||
</a>
|
||||
<a href="" onClick={(e) => this.onEditClick(e, i)}
|
||||
className='mx_DevTools_SettingsExplorer_edit'
|
||||
>
|
||||
✏
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>
|
||||
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{_t("Back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.editSetting) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
|
||||
<h3>{_t("Setting:")} <code>{this.state.editSetting}</code></h3>
|
||||
|
||||
<div className='mx_DevTools_SettingsExplorer_warning'>
|
||||
<b>{_t("Caution:")}</b> {_t(
|
||||
"This UI does NOT check the types of the values. Use at your own risk.",
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{_t("Setting definition:")}
|
||||
<pre><code>{JSON.stringify(SETTINGS[this.state.editSetting], null, 4)}</code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{_t("Level")}</th>
|
||||
<th>{_t("Settable at global")}</th>
|
||||
<th>{_t("Settable at room")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{LEVEL_ORDER.map(lvl => (
|
||||
<tr key={lvl}>
|
||||
<td><code>{lvl}</code></td>
|
||||
{this.renderCanEditLevel(null, lvl)}
|
||||
{this.renderCanEditLevel(room.roomId, lvl)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field
|
||||
id="valExpl" label={_t("Values at explicit levels")} type="text"
|
||||
className="mx_DevTools_textarea" element="textarea"
|
||||
autoComplete="off" value={this.state.explicitValues}
|
||||
onChange={this.onExplValuesEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field
|
||||
id="valExpl" label={_t("Values at explicit levels in this room")} type="text"
|
||||
className="mx_DevTools_textarea" element="textarea"
|
||||
autoComplete="off" value={this.state.explicitRoomValues}
|
||||
onChange={this.onExplRoomValuesEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onSaveClick}>{_t("Save setting values")}</button>
|
||||
<button onClick={this.onBack}>{_t("Back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.viewSetting) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mx_Dialog_content mx_DevTools_SettingsExplorer">
|
||||
<h3>{_t("Setting:")} <code>{this.state.viewSetting}</code></h3>
|
||||
|
||||
<div>
|
||||
{_t("Setting definition:")}
|
||||
<pre><code>{JSON.stringify(SETTINGS[this.state.viewSetting], null, 4)}</code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{_t("Value:")}
|
||||
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting))}</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{_t("Value in this room:")}
|
||||
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting, room.roomId))}</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{_t("Values at explicit levels:")}
|
||||
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, null)}</code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{_t("Values at explicit levels in this room:")}
|
||||
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, room.roomId)}</code></pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={(e) => this.onEditClick(e, this.state.viewSetting)}>{_t("Edit Values")}</button>
|
||||
<button onClick={this.onBack}>{_t("Back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Entries = [
|
||||
SendCustomEvent,
|
||||
RoomStateExplorer,
|
||||
|
@ -708,6 +1085,8 @@ const Entries = [
|
|||
AccountDataExplorer,
|
||||
ServersInRoomList,
|
||||
VerificationExplorer,
|
||||
WidgetExplorer,
|
||||
SettingsExplorer,
|
||||
];
|
||||
|
||||
export default class DevtoolsDialog extends React.PureComponent {
|
||||
|
|
|
@ -50,6 +50,10 @@ export default class ErrorDialog extends React.Component {
|
|||
button: null,
|
||||
};
|
||||
|
||||
onClick = () => {
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
return (
|
||||
|
@ -64,7 +68,7 @@ export default class ErrorDialog extends React.Component {
|
|||
{ this.props.description || _t('An error has occurred.') }
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
|
||||
<button className="mx_Dialog_primary" onClick={this.onClick} autoFocus={this.props.focus}>
|
||||
{ this.props.button || _t('OK') }
|
||||
</button>
|
||||
</div>
|
||||
|
|
138
src/components/views/dialogs/FeedbackDialog.js
Normal file
138
src/components/views/dialogs/FeedbackDialog.js
Normal file
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
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, {useState} from 'react';
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import Modal from "../../../Modal";
|
||||
import BugReportDialog from "./BugReportDialog";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import StyledRadioGroup from "../elements/StyledRadioGroup";
|
||||
|
||||
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
|
||||
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
|
||||
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
|
||||
|
||||
|
||||
export default (props) => {
|
||||
const [rating, setRating] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
const onDebugLogsLinkClick = () => {
|
||||
props.onFinished();
|
||||
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
|
||||
};
|
||||
|
||||
const hasFeedback = CountlyAnalytics.instance.canEnable();
|
||||
const onFinished = (sendFeedback) => {
|
||||
if (hasFeedback && sendFeedback) {
|
||||
CountlyAnalytics.instance.reportFeedback(parseInt(rating, 10), comment);
|
||||
Modal.createTrackedDialog('Feedback sent', '', InfoDialog, {
|
||||
title: _t('Feedback sent'),
|
||||
description: _t('Thank you!'),
|
||||
});
|
||||
}
|
||||
props.onFinished();
|
||||
};
|
||||
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
let countlyFeedbackSection;
|
||||
if (hasFeedback) {
|
||||
countlyFeedbackSection = <React.Fragment>
|
||||
<hr />
|
||||
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
|
||||
<h3>{_t("Rate %(brand)s", { brand })}</h3>
|
||||
|
||||
<p>{_t("Tell us below how you feel about %(brand)s so far.", { brand })}</p>
|
||||
<p>{_t("Please go into as much detail as you like, so we can track down the problem.")}</p>
|
||||
|
||||
<StyledRadioGroup
|
||||
name="feedbackRating"
|
||||
value={rating}
|
||||
onChange={setRating}
|
||||
definitions={[
|
||||
{ value: "1", label: "😠" },
|
||||
{ value: "2", label: "😞" },
|
||||
{ value: "3", label: "😑" },
|
||||
{ value: "4", label: "😄" },
|
||||
{ value: "5", label: "😍" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Add comment")}
|
||||
placeholder={_t("Comment")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
let subheading;
|
||||
if (hasFeedback) {
|
||||
subheading = (
|
||||
<h2>{_t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand })}</h2>
|
||||
);
|
||||
}
|
||||
|
||||
return (<QuestionDialog
|
||||
className="mx_FeedbackDialog"
|
||||
hasCancelButton={!!hasFeedback}
|
||||
title={_t("Feedback")}
|
||||
description={<React.Fragment>
|
||||
{ subheading }
|
||||
|
||||
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_reportBug">
|
||||
<h3>{_t("Report a bug")}</h3>
|
||||
<p>{
|
||||
_t("Please view <existingIssuesLink>existing bugs on Github</existingIssuesLink> first. " +
|
||||
"No match? <newIssueLink>Start a new one</newIssueLink>.", {}, {
|
||||
existingIssuesLink: (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
|
||||
},
|
||||
newIssueLink: (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
|
||||
},
|
||||
})
|
||||
}</p>
|
||||
<p>{
|
||||
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
|
||||
"to help us track down the problem.", {}, {
|
||||
debugLogsLink: sub => (
|
||||
<AccessibleButton kind="link" onClick={onDebugLogsLinkClick}>{sub}</AccessibleButton>
|
||||
),
|
||||
})
|
||||
}</p>
|
||||
</div>
|
||||
{ countlyFeedbackSection }
|
||||
</React.Fragment>}
|
||||
button={hasFeedback ? _t("Send feedback") : _t("Go back")}
|
||||
buttonDisabled={hasFeedback && rating === ""}
|
||||
onFinished={onFinished}
|
||||
/>);
|
||||
};
|
291
src/components/views/dialogs/HostSignupDialog.tsx
Normal file
291
src/components/views/dialogs/HostSignupDialog.tsx
Normal file
|
@ -0,0 +1,291 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Modal from "../../../Modal";
|
||||
import PersistedElement from "../elements/PersistedElement";
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import classNames from "classnames";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { HostSignupStore } from "../../../stores/HostSignupStore";
|
||||
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
|
||||
import {
|
||||
IHostSignupConfig,
|
||||
IPostmessage,
|
||||
IPostmessageResponseData,
|
||||
PostmessageAction,
|
||||
} from "./HostSignupDialogTypes";
|
||||
|
||||
const HOST_SIGNUP_KEY = "host_signup";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
completed: boolean;
|
||||
error: string;
|
||||
minimized: boolean;
|
||||
}
|
||||
|
||||
export default class HostSignupDialog extends React.PureComponent<IProps, IState> {
|
||||
private iframeRef: React.RefObject<HTMLIFrameElement> = React.createRef();
|
||||
private readonly config: IHostSignupConfig;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
completed: false,
|
||||
error: null,
|
||||
minimized: false,
|
||||
};
|
||||
|
||||
this.config = SdkConfig.get().hostSignup;
|
||||
}
|
||||
|
||||
private messageHandler = async (message: IPostmessage) => {
|
||||
if (!this.config.url.startsWith(message.origin)) {
|
||||
return;
|
||||
}
|
||||
switch (message.data.action) {
|
||||
case PostmessageAction.HostSignupAccountDetailsRequest:
|
||||
this.onAccountDetailsRequest();
|
||||
break;
|
||||
case PostmessageAction.Maximize:
|
||||
this.setState({
|
||||
minimized: false,
|
||||
});
|
||||
break;
|
||||
case PostmessageAction.Minimize:
|
||||
this.setState({
|
||||
minimized: true,
|
||||
});
|
||||
break;
|
||||
case PostmessageAction.SetupComplete:
|
||||
this.setState({
|
||||
completed: true,
|
||||
});
|
||||
break;
|
||||
case PostmessageAction.CloseDialog:
|
||||
return this.closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
private maximizeDialog = () => {
|
||||
this.setState({
|
||||
minimized: false,
|
||||
});
|
||||
// Send this action to the iframe so it can act accordingly
|
||||
this.sendMessage({
|
||||
action: PostmessageAction.Maximize,
|
||||
});
|
||||
}
|
||||
|
||||
private minimizeDialog = () => {
|
||||
this.setState({
|
||||
minimized: true,
|
||||
});
|
||||
// Send this action to the iframe so it can act accordingly
|
||||
this.sendMessage({
|
||||
action: PostmessageAction.Minimize,
|
||||
});
|
||||
}
|
||||
|
||||
private closeDialog = async () => {
|
||||
window.removeEventListener("message", this.messageHandler);
|
||||
// Ensure we destroy the host signup persisted element
|
||||
PersistedElement.destroyElement("host_signup");
|
||||
// Finally clear the flag in
|
||||
return HostSignupStore.instance.setHostSignupActive(false);
|
||||
}
|
||||
|
||||
private onCloseClick = async () => {
|
||||
if (this.state.completed) {
|
||||
// We're done, close
|
||||
return this.closeDialog();
|
||||
} else {
|
||||
Modal.createDialog(
|
||||
QuestionDialog,
|
||||
{
|
||||
title: _t("Confirm abort of host creation"),
|
||||
description: _t(
|
||||
"Are you sure you wish to abort creation of the host? The process cannot be continued.",
|
||||
),
|
||||
button: _t("Abort"),
|
||||
onFinished: result => {
|
||||
if (result) {
|
||||
return this.closeDialog();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessage = (message: IPostmessageResponseData) => {
|
||||
this.iframeRef.current.contentWindow.postMessage(message, this.config.url);
|
||||
}
|
||||
|
||||
private async sendAccountDetails() {
|
||||
const openIdToken = await MatrixClientPeg.get().getOpenIdToken();
|
||||
if (!openIdToken || !openIdToken.access_token) {
|
||||
console.warn("Failed to connect to homeserver for OpenID token.")
|
||||
this.setState({
|
||||
completed: true,
|
||||
error: _t("Failed to connect to your homeserver. Please close this dialog and try again."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.sendMessage({
|
||||
action: PostmessageAction.HostSignupAccountDetails,
|
||||
account: {
|
||||
accessToken: await MatrixClientPeg.get().getAccessToken(),
|
||||
name: OwnProfileStore.instance.displayName,
|
||||
openIdToken: openIdToken.access_token,
|
||||
serverName: await MatrixClientPeg.get().getDomain(),
|
||||
userLocalpart: await MatrixClientPeg.get().getUserIdLocalpart(),
|
||||
termsAccepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private onAccountDetailsDialogFinished = async (result) => {
|
||||
if (result) {
|
||||
return this.sendAccountDetails();
|
||||
}
|
||||
return this.closeDialog();
|
||||
}
|
||||
|
||||
private onAccountDetailsRequest = () => {
|
||||
const textComponent = (
|
||||
<>
|
||||
<p>
|
||||
{_t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " +
|
||||
"account to fetch verified email addresses. This data is not stored.", {
|
||||
hostSignupBrand: this.config.brand,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{_t("Learn more in our <privacyPolicyLink />, <termsOfServiceLink /> and <cookiePolicyLink />.",
|
||||
{},
|
||||
{
|
||||
cookiePolicyLink: () => (
|
||||
<a href={this.config.cookiePolicyUrl} target="_blank" rel="noreferrer noopener">
|
||||
{_t("Cookie Policy")}
|
||||
</a>
|
||||
),
|
||||
privacyPolicyLink: () => (
|
||||
<a href={this.config.privacyPolicyUrl} target="_blank" rel="noreferrer noopener">
|
||||
{_t("Privacy Policy")}
|
||||
</a>
|
||||
),
|
||||
termsOfServiceLink: () => (
|
||||
<a href={this.config.termsOfServiceUrl} target="_blank" rel="noreferrer noopener">
|
||||
{_t("Terms of Service")}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
Modal.createDialog(
|
||||
QuestionDialog,
|
||||
{
|
||||
title: _t("You should know"),
|
||||
description: textComponent,
|
||||
button: _t("Continue"),
|
||||
onFinished: this.onAccountDetailsDialogFinished,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
window.addEventListener("message", this.messageHandler);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (HostSignupStore.instance.isHostSignupActive) {
|
||||
// Run the close dialog actions if we're still active, otherwise good to go
|
||||
return this.closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_HostSignup_persisted">
|
||||
<PersistedElement key={HOST_SIGNUP_KEY} persistKey={HOST_SIGNUP_KEY}>
|
||||
<div className={classNames({ "mx_Dialog_wrapper": !this.state.minimized })}>
|
||||
<div
|
||||
className={classNames("mx_Dialog",
|
||||
{
|
||||
"mx_HostSignupDialog_minimized": this.state.minimized,
|
||||
"mx_HostSignupDialog": !this.state.minimized,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{this.state.minimized &&
|
||||
<div className="mx_Dialog_header mx_Dialog_headerWithButton">
|
||||
<div className="mx_Dialog_title">
|
||||
{_t("%(hostSignupBrand)s Setup", {
|
||||
hostSignupBrand: this.config.brand,
|
||||
})}
|
||||
</div>
|
||||
<AccessibleButton
|
||||
className="mx_HostSignup_maximize_button"
|
||||
onClick={this.maximizeDialog}
|
||||
aria-label={_t("Maximize dialog")}
|
||||
title={_t("Maximize dialog")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{!this.state.minimized &&
|
||||
<div className="mx_Dialog_header mx_Dialog_headerWithCancel">
|
||||
<AccessibleButton
|
||||
onClick={this.minimizeDialog}
|
||||
className="mx_HostSignup_minimize_button"
|
||||
aria-label={_t("Minimize dialog")}
|
||||
title={_t("Minimize dialog")}
|
||||
/>
|
||||
<AccessibleButton
|
||||
onClick={this.onCloseClick}
|
||||
className="mx_Dialog_cancelButton"
|
||||
aria-label={_t("Close dialog")}
|
||||
title={_t("Close dialog")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{this.state.error &&
|
||||
<div>
|
||||
{this.state.error}
|
||||
</div>
|
||||
}
|
||||
{!this.state.error &&
|
||||
<iframe
|
||||
src={this.config.url}
|
||||
ref={this.iframeRef}
|
||||
sandbox="allow-forms allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PersistedElement>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
56
src/components/views/dialogs/HostSignupDialogTypes.ts
Normal file
56
src/components/views/dialogs/HostSignupDialogTypes.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export enum PostmessageAction {
|
||||
CloseDialog = "close_dialog",
|
||||
HostSignupAccountDetails = "host_signup_account_details",
|
||||
HostSignupAccountDetailsRequest = "host_signup_account_details_request",
|
||||
Minimize = "host_signup_minimize",
|
||||
Maximize = "host_signup_maximize",
|
||||
SetupComplete = "setup_complete",
|
||||
}
|
||||
|
||||
interface IAccountData {
|
||||
accessToken: string;
|
||||
name: string;
|
||||
openIdToken: string;
|
||||
serverName: string;
|
||||
userLocalpart: string;
|
||||
termsAccepted: boolean;
|
||||
}
|
||||
|
||||
export interface IPostmessageRequestData {
|
||||
action: PostmessageAction;
|
||||
}
|
||||
|
||||
export interface IPostmessageResponseData {
|
||||
action: PostmessageAction;
|
||||
account?: IAccountData;
|
||||
}
|
||||
|
||||
export interface IPostmessage {
|
||||
data: IPostmessageRequestData;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export interface IHostSignupConfig {
|
||||
brand: string;
|
||||
cookiePolicyUrl: string;
|
||||
domains: Array<string>;
|
||||
privacyPolicyUrl: string;
|
||||
termsOfServiceUrl: string;
|
||||
url: string;
|
||||
}
|
|
@ -27,10 +27,11 @@ export default class InfoDialog extends React.Component {
|
|||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.node,
|
||||
button: PropTypes.string,
|
||||
button: PropTypes.oneOfType(PropTypes.string, PropTypes.bool),
|
||||
onFinished: PropTypes.func,
|
||||
hasCloseButton: PropTypes.bool,
|
||||
onKeyDown: PropTypes.func,
|
||||
fixedWidth: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -54,15 +55,16 @@ export default class InfoDialog extends React.Component {
|
|||
contentId='mx_Dialog_content'
|
||||
hasCancel={this.props.hasCloseButton}
|
||||
onKeyDown={this.props.onKeyDown}
|
||||
fixedWidth={this.props.fixedWidth}
|
||||
>
|
||||
<div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
|
||||
{ this.props.description }
|
||||
</div>
|
||||
<DialogButtons primaryButton={this.props.button || _t('OK')}
|
||||
{ this.props.button !== false && <DialogButtons primaryButton={this.props.button || _t('OK')}
|
||||
onPrimaryButtonClick={this.onFinished}
|
||||
hasCancel={false}
|
||||
>
|
||||
</DialogButtons>
|
||||
</DialogButtons> }
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,13 +15,12 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import {_t, _td} from "../../../languageHandler";
|
||||
import * as sdk from "../../../index";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import {RoomMember} from "matrix-js-sdk/src/matrix";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
|
||||
import * as Email from "../../../email";
|
||||
|
@ -31,7 +30,7 @@ import dis from "../../../dispatcher/dispatcher";
|
|||
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||
import Modal from "../../../Modal";
|
||||
import {humanizeTime} from "../../../utils/humanize";
|
||||
import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom";
|
||||
import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom";
|
||||
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
@ -40,12 +39,17 @@ import RoomListStore from "../../../stores/room-list/RoomListStore";
|
|||
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
|
||||
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
export const KIND_DM = "dm";
|
||||
export const KIND_INVITE = "invite";
|
||||
export const KIND_SPACE_INVITE = "space_invite";
|
||||
export const KIND_CALL_TRANSFER = "call_transfer";
|
||||
|
||||
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
|
||||
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
|
||||
|
@ -130,12 +134,12 @@ class ThreepidMember extends Member {
|
|||
}
|
||||
}
|
||||
|
||||
class DMUserTile extends React.PureComponent {
|
||||
static propTypes = {
|
||||
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
|
||||
onRemove: PropTypes.func, // takes 1 argument, the member being removed
|
||||
};
|
||||
interface IDMUserTileProps {
|
||||
member: RoomMember;
|
||||
onRemove: (RoomMember) => any;
|
||||
}
|
||||
|
||||
class DMUserTile extends React.PureComponent<IDMUserTileProps> {
|
||||
_onRemove = (e) => {
|
||||
// Stop the browser from highlighting text
|
||||
e.preventDefault();
|
||||
|
@ -171,7 +175,9 @@ class DMUserTile extends React.PureComponent {
|
|||
className='mx_InviteDialog_userTile_remove'
|
||||
onClick={this._onRemove}
|
||||
>
|
||||
<img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
|
||||
<img src={require("../../../../res/img/icon-pill-remove.svg")}
|
||||
alt={_t('Remove')} width={8} height={8}
|
||||
/>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
@ -188,15 +194,15 @@ class DMUserTile extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
class DMRoomTile extends React.PureComponent {
|
||||
static propTypes = {
|
||||
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
|
||||
lastActiveTs: PropTypes.number,
|
||||
onToggle: PropTypes.func.isRequired, // takes 1 argument, the member being toggled
|
||||
highlightWord: PropTypes.string,
|
||||
isSelected: PropTypes.bool,
|
||||
};
|
||||
interface IDMRoomTileProps {
|
||||
member: RoomMember;
|
||||
lastActiveTs: number;
|
||||
onToggle: (RoomMember) => any;
|
||||
highlightWord: string;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
|
||||
_onClick = (e) => {
|
||||
// Stop the browser from highlighting text
|
||||
e.preventDefault();
|
||||
|
@ -279,42 +285,74 @@ class DMRoomTile extends React.PureComponent {
|
|||
</span>
|
||||
);
|
||||
|
||||
const caption = this.props.member.isEmail
|
||||
? _t("Invite by email")
|
||||
: this._highlightName(this.props.member.userId);
|
||||
|
||||
return (
|
||||
<div className='mx_InviteDialog_roomTile' onClick={this._onClick}>
|
||||
{stackedAvatar}
|
||||
<span className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</span>
|
||||
<span className='mx_InviteDialog_roomTile_userId'>{this._highlightName(this.props.member.userId)}</span>
|
||||
<span className="mx_InviteDialog_roomTile_nameStack">
|
||||
<div className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</div>
|
||||
<div className='mx_InviteDialog_roomTile_userId'>{caption}</div>
|
||||
</span>
|
||||
{timestamp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class InviteDialog extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// Takes an array of user IDs/emails to invite.
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
interface IInviteDialogProps {
|
||||
// Takes an array of user IDs/emails to invite.
|
||||
onFinished: (toInvite?: string[]) => any;
|
||||
|
||||
// The kind of invite being performed. Assumed to be KIND_DM if
|
||||
// not provided.
|
||||
kind: PropTypes.string,
|
||||
// The kind of invite being performed. Assumed to be KIND_DM if
|
||||
// not provided.
|
||||
kind: string,
|
||||
|
||||
// The room ID this dialog is for. Only required for KIND_INVITE.
|
||||
roomId: PropTypes.string,
|
||||
};
|
||||
// The room ID this dialog is for. Only required for KIND_INVITE and KIND_SPACE_INVITE.
|
||||
roomId: string,
|
||||
|
||||
// The call to transfer. Only required for KIND_CALL_TRANSFER.
|
||||
call: MatrixCall,
|
||||
|
||||
// Initial value to populate the filter with
|
||||
initialText: string,
|
||||
}
|
||||
|
||||
interface IInviteDialogState {
|
||||
targets: RoomMember[]; // array of Member objects (see interface above)
|
||||
filterText: string;
|
||||
recents: { user: Member, userId: string }[];
|
||||
numRecentsShown: number;
|
||||
suggestions: { user: Member, userId: string }[];
|
||||
numSuggestionsShown: number;
|
||||
serverResultsMixin: { user: Member, userId: string }[];
|
||||
threepidResultsMixin: { user: Member, userId: string}[];
|
||||
canUseIdentityServer: boolean;
|
||||
tryingIdentityServer: boolean;
|
||||
|
||||
// These two flags are used for the 'Go' button to communicate what is going on.
|
||||
busy: boolean,
|
||||
errorText: string,
|
||||
}
|
||||
|
||||
export default class InviteDialog extends React.PureComponent<IInviteDialogProps, IInviteDialogState> {
|
||||
static defaultProps = {
|
||||
kind: KIND_DM,
|
||||
initialText: "",
|
||||
};
|
||||
|
||||
_debounceTimer: number = null;
|
||||
_debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser
|
||||
_editorRef: any = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if (props.kind === KIND_INVITE && !props.roomId) {
|
||||
throw new Error("When using KIND_INVITE a roomId is required for an InviteDialog");
|
||||
if ((props.kind === KIND_INVITE || props.kind === KIND_SPACE_INVITE) && !props.roomId) {
|
||||
throw new Error("When using KIND_INVITE or KIND_SPACE_INVITE a roomId is required for an InviteDialog");
|
||||
} else if (props.kind === KIND_CALL_TRANSFER && !props.call) {
|
||||
throw new Error("When using KIND_CALL_TRANSFER a call is required for an InviteDialog");
|
||||
}
|
||||
|
||||
const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get()['welcomeUserId']]);
|
||||
|
@ -325,17 +363,19 @@ export default class InviteDialog extends React.PureComponent {
|
|||
room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId));
|
||||
// add banned users, so we don't try to invite them
|
||||
room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId));
|
||||
|
||||
CountlyAnalytics.instance.trackBeginInvite(props.roomId);
|
||||
}
|
||||
|
||||
this.state = {
|
||||
targets: [], // array of Member objects (see interface above)
|
||||
filterText: "",
|
||||
filterText: this.props.initialText,
|
||||
recents: InviteDialog.buildRecents(alreadyInvited),
|
||||
numRecentsShown: INITIAL_ROOMS_SHOWN,
|
||||
suggestions: this._buildSuggestions(alreadyInvited),
|
||||
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
|
||||
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
|
||||
threepidResultsMixin: [], // { user: ThreepidMember, userId: string}[], like recents and suggestions
|
||||
serverResultsMixin: [],
|
||||
threepidResultsMixin: [],
|
||||
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
|
||||
tryingIdentityServer: false,
|
||||
|
||||
|
@ -347,7 +387,13 @@ export default class InviteDialog extends React.PureComponent {
|
|||
this._editorRef = createRef();
|
||||
}
|
||||
|
||||
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
|
||||
componentDidMount() {
|
||||
if (this.props.initialText) {
|
||||
this._updateSuggestions(this.props.initialText);
|
||||
}
|
||||
}
|
||||
|
||||
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] {
|
||||
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
|
||||
|
||||
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
|
||||
|
@ -410,7 +456,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
return recents;
|
||||
}
|
||||
|
||||
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember} {
|
||||
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
|
||||
const maxConsideredMembers = 200;
|
||||
const joinedRooms = MatrixClientPeg.get().getRooms()
|
||||
.filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
|
||||
|
@ -450,7 +496,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
}, {});
|
||||
|
||||
// Generates { userId: {member, numRooms, score} }
|
||||
const memberScores = Object.values(memberRooms).reduce((scores, entry) => {
|
||||
const memberScores = Object.values(memberRooms).reduce((scores, entry: {member: RoomMember, rooms: Room[]}) => {
|
||||
const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0);
|
||||
const maxRange = maxConsideredMembers * entry.rooms.length;
|
||||
scores[entry.member.userId] = {
|
||||
|
@ -566,7 +612,12 @@ export default class InviteDialog extends React.PureComponent {
|
|||
const targetIds = targets.map(t => t.userId);
|
||||
|
||||
// Check if there is already a DM with these people and reuse it if possible.
|
||||
const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
|
||||
let existingRoom: Room;
|
||||
if (targetIds.length === 1) {
|
||||
existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]);
|
||||
} else {
|
||||
existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
|
||||
}
|
||||
if (existingRoom) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
|
@ -578,7 +629,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
const createRoomOptions = {inlineErrors: true};
|
||||
const createRoomOptions = {inlineErrors: true} as any; // XXX: Type out `createRoomOptions`
|
||||
|
||||
if (privateShouldBeEncrypted()) {
|
||||
// Check whether all users have uploaded device keys before.
|
||||
|
@ -595,7 +646,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
// Check if it's a traditional DM and create the room if required.
|
||||
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
|
||||
let createRoomPromise = Promise.resolve();
|
||||
let createRoomPromise = Promise.resolve(null) as Promise<string | null | boolean>;
|
||||
const isSelf = targetIds.length === 1 && targetIds[0] === MatrixClientPeg.get().getUserId();
|
||||
if (targetIds.length === 1 && !isSelf) {
|
||||
createRoomOptions.dmUserId = targetIds[0];
|
||||
|
@ -627,6 +678,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
};
|
||||
|
||||
_inviteUsers = () => {
|
||||
const startTime = CountlyAnalytics.getTimestamp();
|
||||
this.setState({busy: true});
|
||||
this._convertFilter();
|
||||
const targets = this._convertFilter();
|
||||
|
@ -643,6 +695,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
}
|
||||
|
||||
inviteMultipleToRoom(this.props.roomId, targetIds).then(result => {
|
||||
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
|
||||
if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too
|
||||
this.props.onFinished();
|
||||
}
|
||||
|
@ -657,13 +710,154 @@ export default class InviteDialog extends React.PureComponent {
|
|||
});
|
||||
};
|
||||
|
||||
_transferCall = async () => {
|
||||
this._convertFilter();
|
||||
const targets = this._convertFilter();
|
||||
const targetIds = targets.map(t => t.userId);
|
||||
if (targetIds.length > 1) {
|
||||
this.setState({
|
||||
errorText: _t("A call can only be transferred to a single user."),
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({busy: true});
|
||||
try {
|
||||
await this.props.call.transfer(targetIds[0]);
|
||||
this.setState({busy: false});
|
||||
this.props.onFinished();
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: _t("Failed to transfer call"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onKeyDown = (e) => {
|
||||
// when the field is empty and the user hits backspace remove the right-most target
|
||||
if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE &&
|
||||
!e.ctrlKey && !e.shiftKey && !e.metaKey
|
||||
) {
|
||||
if (this.state.busy) return;
|
||||
const value = e.target.value.trim();
|
||||
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
|
||||
if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) {
|
||||
// when the field is empty and the user hits backspace remove the right-most target
|
||||
e.preventDefault();
|
||||
this._removeMember(this.state.targets[this.state.targets.length - 1]);
|
||||
} else if (value && e.key === Key.ENTER && !hasModifiers) {
|
||||
// when the user hits enter with something in their field try to convert it
|
||||
e.preventDefault();
|
||||
this._convertFilter();
|
||||
} else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) {
|
||||
// when the user hits space and their input looks like an e-mail/MXID then try to convert it
|
||||
e.preventDefault();
|
||||
this._convertFilter();
|
||||
}
|
||||
};
|
||||
|
||||
_updateSuggestions = async (term) => {
|
||||
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
|
||||
if (term !== this.state.filterText) {
|
||||
// Discard the results - we were probably too slow on the server-side to make
|
||||
// these results useful. This is a race we want to avoid because we could overwrite
|
||||
// more accurate results.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.results) r.results = [];
|
||||
|
||||
// While we're here, try and autocomplete a search result for the mxid itself
|
||||
// if there's no matches (and the input looks like a mxid).
|
||||
if (term[0] === '@' && term.indexOf(':') > 1) {
|
||||
try {
|
||||
const profile = await MatrixClientPeg.get().getProfileInfo(term);
|
||||
if (profile) {
|
||||
// If we have a profile, we have enough information to assume that
|
||||
// the mxid can be invited - add it to the list. We stick it at the
|
||||
// top so it is most obviously presented to the user.
|
||||
r.results.splice(0, 0, {
|
||||
user_id: term,
|
||||
display_name: profile['displayname'],
|
||||
avatar_url: profile['avatar_url'],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Non-fatal error trying to make an invite for a user ID");
|
||||
console.warn(e);
|
||||
|
||||
// Add a result anyways, just without a profile. We stick it at the
|
||||
// top so it is most obviously presented to the user.
|
||||
r.results.splice(0, 0, {
|
||||
user_id: term,
|
||||
display_name: term,
|
||||
avatar_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
serverResultsMixin: r.results.map(u => ({
|
||||
userId: u.user_id,
|
||||
user: new DirectoryMember(u),
|
||||
})),
|
||||
});
|
||||
}).catch(e => {
|
||||
console.error("Error searching user directory:");
|
||||
console.error(e);
|
||||
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
|
||||
});
|
||||
|
||||
// Whenever we search the directory, also try to search the identity server. It's
|
||||
// all debounced the same anyways.
|
||||
if (!this.state.canUseIdentityServer) {
|
||||
// The user doesn't have an identity server set - warn them of that.
|
||||
this.setState({tryingIdentityServer: true});
|
||||
return;
|
||||
}
|
||||
if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
|
||||
// Start off by suggesting the plain email while we try and resolve it
|
||||
// to a real account.
|
||||
this.setState({
|
||||
// per above: the userId is a lie here - it's just a regular identifier
|
||||
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
|
||||
});
|
||||
try {
|
||||
const authClient = new IdentityAuthClient();
|
||||
const token = await authClient.getAccessToken();
|
||||
if (term !== this.state.filterText) return; // abandon hope
|
||||
|
||||
const lookup = await MatrixClientPeg.get().lookupThreePid(
|
||||
'email',
|
||||
term,
|
||||
undefined, // callback
|
||||
token,
|
||||
);
|
||||
if (term !== this.state.filterText) return; // abandon hope
|
||||
|
||||
if (!lookup || !lookup.mxid) {
|
||||
// We weren't able to find anyone - we're already suggesting the plain email
|
||||
// as an alternative, so do nothing.
|
||||
return;
|
||||
}
|
||||
|
||||
// We append the user suggestion to give the user an option to click
|
||||
// the email anyways, and so we don't cause things to jump around. In
|
||||
// theory, the user would see the user pop up and think "ah yes, that
|
||||
// person!"
|
||||
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
|
||||
if (term !== this.state.filterText || !profile) return; // abandon hope
|
||||
this.setState({
|
||||
threepidResultsMixin: [...this.state.threepidResultsMixin, {
|
||||
user: new DirectoryMember({
|
||||
user_id: lookup.mxid,
|
||||
display_name: profile.displayname,
|
||||
avatar_url: profile.avatar_url,
|
||||
}),
|
||||
userId: lookup.mxid,
|
||||
}],
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error searching identity server:");
|
||||
console.error(e);
|
||||
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -677,113 +871,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
if (this._debounceTimer) {
|
||||
clearTimeout(this._debounceTimer);
|
||||
}
|
||||
this._debounceTimer = setTimeout(async () => {
|
||||
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
|
||||
if (term !== this.state.filterText) {
|
||||
// Discard the results - we were probably too slow on the server-side to make
|
||||
// these results useful. This is a race we want to avoid because we could overwrite
|
||||
// more accurate results.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!r.results) r.results = [];
|
||||
|
||||
// While we're here, try and autocomplete a search result for the mxid itself
|
||||
// if there's no matches (and the input looks like a mxid).
|
||||
if (term[0] === '@' && term.indexOf(':') > 1) {
|
||||
try {
|
||||
const profile = await MatrixClientPeg.get().getProfileInfo(term);
|
||||
if (profile) {
|
||||
// If we have a profile, we have enough information to assume that
|
||||
// the mxid can be invited - add it to the list. We stick it at the
|
||||
// top so it is most obviously presented to the user.
|
||||
r.results.splice(0, 0, {
|
||||
user_id: term,
|
||||
display_name: profile['displayname'],
|
||||
avatar_url: profile['avatar_url'],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Non-fatal error trying to make an invite for a user ID");
|
||||
console.warn(e);
|
||||
|
||||
// Add a result anyways, just without a profile. We stick it at the
|
||||
// top so it is most obviously presented to the user.
|
||||
r.results.splice(0, 0, {
|
||||
user_id: term,
|
||||
display_name: term,
|
||||
avatar_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
serverResultsMixin: r.results.map(u => ({
|
||||
userId: u.user_id,
|
||||
user: new DirectoryMember(u),
|
||||
})),
|
||||
});
|
||||
}).catch(e => {
|
||||
console.error("Error searching user directory:");
|
||||
console.error(e);
|
||||
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
|
||||
});
|
||||
|
||||
// Whenever we search the directory, also try to search the identity server. It's
|
||||
// all debounced the same anyways.
|
||||
if (!this.state.canUseIdentityServer) {
|
||||
// The user doesn't have an identity server set - warn them of that.
|
||||
this.setState({tryingIdentityServer: true});
|
||||
return;
|
||||
}
|
||||
if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
|
||||
// Start off by suggesting the plain email while we try and resolve it
|
||||
// to a real account.
|
||||
this.setState({
|
||||
// per above: the userId is a lie here - it's just a regular identifier
|
||||
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
|
||||
});
|
||||
try {
|
||||
const authClient = new IdentityAuthClient();
|
||||
const token = await authClient.getAccessToken();
|
||||
if (term !== this.state.filterText) return; // abandon hope
|
||||
|
||||
const lookup = await MatrixClientPeg.get().lookupThreePid(
|
||||
'email',
|
||||
term,
|
||||
undefined, // callback
|
||||
token,
|
||||
);
|
||||
if (term !== this.state.filterText) return; // abandon hope
|
||||
|
||||
if (!lookup || !lookup.mxid) {
|
||||
// We weren't able to find anyone - we're already suggesting the plain email
|
||||
// as an alternative, so do nothing.
|
||||
return;
|
||||
}
|
||||
|
||||
// We append the user suggestion to give the user an option to click
|
||||
// the email anyways, and so we don't cause things to jump around. In
|
||||
// theory, the user would see the user pop up and think "ah yes, that
|
||||
// person!"
|
||||
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
|
||||
if (term !== this.state.filterText || !profile) return; // abandon hope
|
||||
this.setState({
|
||||
threepidResultsMixin: [...this.state.threepidResultsMixin, {
|
||||
user: new DirectoryMember({
|
||||
user_id: lookup.mxid,
|
||||
display_name: profile.displayname,
|
||||
avatar_url: profile.avatar_url,
|
||||
}),
|
||||
userId: lookup.mxid,
|
||||
}],
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error searching identity server:");
|
||||
console.error(e);
|
||||
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
|
||||
}
|
||||
}
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
this._updateSuggestions(term);
|
||||
}, 150); // 150ms debounce (human reaction time + some)
|
||||
};
|
||||
|
||||
|
@ -806,6 +895,10 @@ export default class InviteDialog extends React.PureComponent {
|
|||
filterText = ""; // clear the filter when the user accepts a suggestion
|
||||
}
|
||||
this.setState({targets, filterText});
|
||||
|
||||
if (this._editorRef && this._editorRef.current) {
|
||||
this._editorRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
_removeMember = (member: Member) => {
|
||||
|
@ -815,6 +908,10 @@ export default class InviteDialog extends React.PureComponent {
|
|||
targets.splice(idx, 1);
|
||||
this.setState({targets});
|
||||
}
|
||||
|
||||
if (this._editorRef && this._editorRef.current) {
|
||||
this._editorRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
_onPaste = async (e) => {
|
||||
|
@ -824,7 +921,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent the text being pasted into the textarea
|
||||
// Prevent the text being pasted into the input
|
||||
e.preventDefault();
|
||||
|
||||
// Process it as a list of addresses to add instead
|
||||
|
@ -930,7 +1027,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
sectionSubname = _t("May include members not in %(communityName)s", {communityName});
|
||||
}
|
||||
|
||||
if (this.props.kind === KIND_INVITE) {
|
||||
if (this.props.kind === KIND_INVITE || this.props.kind === KIND_SPACE_INVITE) {
|
||||
sectionName = kind === 'recents' ? _t("Recently Direct Messaged") : _t("Suggestions");
|
||||
}
|
||||
|
||||
|
@ -942,7 +1039,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin;
|
||||
if (this.state.filterText && hasMixins && kind === 'suggestions') {
|
||||
// We don't want to duplicate members though, so just exclude anyone we've already seen.
|
||||
const notAlreadyExists = (u: Member): boolean => {
|
||||
// The type of u is a pain to define but members of both mixins have the 'userId' property
|
||||
const notAlreadyExists = (u: any): boolean => {
|
||||
return !sourceMembers.some(m => m.userId === u.userId)
|
||||
&& !priorityAdditionalMembers.some(m => m.userId === u.userId)
|
||||
&& !otherAdditionalMembers.some(m => m.userId === u.userId);
|
||||
|
@ -1019,8 +1117,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
<DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} />
|
||||
));
|
||||
const input = (
|
||||
<textarea
|
||||
rows={1}
|
||||
<input
|
||||
type="text"
|
||||
onKeyDown={this._onKeyDown}
|
||||
onChange={this._updateFilter}
|
||||
value={this.state.filterText}
|
||||
|
@ -1028,6 +1126,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
onPaste={this._onPaste}
|
||||
autoFocus={true}
|
||||
disabled={this.state.busy}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
|
@ -1098,7 +1197,7 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
if (identityServersEnabled) {
|
||||
helpText = _t(
|
||||
"Start a conversation with someone using their name, username (like <userId/>) or email address.",
|
||||
"Start a conversation with someone using their name, email address or username (like <userId/>).",
|
||||
{},
|
||||
{userId: () => {
|
||||
return (
|
||||
|
@ -1120,7 +1219,8 @@ export default class InviteDialog extends React.PureComponent {
|
|||
|
||||
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
|
||||
const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
|
||||
const inviteText = _t("This won't invite them to %(communityName)s. " +
|
||||
const inviteText = _t(
|
||||
"This won't invite them to %(communityName)s. " +
|
||||
"To invite someone to %(communityName)s, click <a>here</a>",
|
||||
{communityName}, {
|
||||
userId: () => {
|
||||
|
@ -1148,36 +1248,43 @@ export default class InviteDialog extends React.PureComponent {
|
|||
}
|
||||
buttonText = _t("Go");
|
||||
goButtonFn = this._startDm;
|
||||
} else { // KIND_INVITE
|
||||
title = _t("Invite to this room");
|
||||
} else if (this.props.kind === KIND_INVITE || this.props.kind === KIND_SPACE_INVITE) {
|
||||
title = this.props.kind === KIND_INVITE ? _t("Invite to this room") : _t("Invite to this space");
|
||||
|
||||
if (identityServersEnabled) {
|
||||
helpText = _t(
|
||||
"Invite someone using their name, username (like <userId/>), email address or " +
|
||||
"<a>share this room</a>.",
|
||||
{},
|
||||
{
|
||||
userId: () =>
|
||||
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
|
||||
a: (sub) =>
|
||||
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
helpText = _t(
|
||||
"Invite someone using their name, username (like <userId/>) or <a>share this room</a>.",
|
||||
{},
|
||||
{
|
||||
userId: () =>
|
||||
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
|
||||
a: (sub) =>
|
||||
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
|
||||
},
|
||||
);
|
||||
let helpTextUntranslated;
|
||||
if (this.props.kind === KIND_INVITE) {
|
||||
if (identityServersEnabled) {
|
||||
helpTextUntranslated = _td("Invite someone using their name, email address, username " +
|
||||
"(like <userId/>) or <a>share this room</a>.");
|
||||
} else {
|
||||
helpTextUntranslated = _td("Invite someone using their name, username " +
|
||||
"(like <userId/>) or <a>share this room</a>.");
|
||||
}
|
||||
} else { // KIND_SPACE_INVITE
|
||||
if (identityServersEnabled) {
|
||||
helpTextUntranslated = _td("Invite someone using their name, email address, username " +
|
||||
"(like <userId/>) or <a>share this space</a>.");
|
||||
} else {
|
||||
helpTextUntranslated = _td("Invite someone using their name, username " +
|
||||
"(like <userId/>) or <a>share this space</a>.");
|
||||
}
|
||||
}
|
||||
|
||||
helpText = _t(helpTextUntranslated, {}, {
|
||||
userId: () =>
|
||||
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>,
|
||||
a: (sub) =>
|
||||
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{sub}</a>,
|
||||
});
|
||||
|
||||
buttonText = _t("Invite");
|
||||
goButtonFn = this._inviteUsers;
|
||||
} else if (this.props.kind === KIND_CALL_TRANSFER) {
|
||||
title = _t("Transfer");
|
||||
buttonText = _t("Transfer");
|
||||
goButtonFn = this._transferCall;
|
||||
} else {
|
||||
console.error("Unknown kind of InviteDialog: " + this.props.kind);
|
||||
}
|
||||
|
||||
const hasSelection = this.state.targets.length > 0
|
203
src/components/views/dialogs/ModalWidgetDialog.tsx
Normal file
203
src/components/views/dialogs/ModalWidgetDialog.tsx
Normal file
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
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 * as React from 'react';
|
||||
import BaseDialog from './BaseDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import {
|
||||
ClientWidgetApi,
|
||||
IModalWidgetCloseRequest,
|
||||
IModalWidgetOpenRequestData,
|
||||
IModalWidgetReturnData,
|
||||
ISetModalButtonEnabledActionRequest,
|
||||
IWidgetApiAcknowledgeResponseData,
|
||||
IWidgetApiErrorResponseData,
|
||||
BuiltInModalButtonID,
|
||||
ModalButtonID,
|
||||
ModalButtonKind,
|
||||
Widget,
|
||||
WidgetApiFromWidgetAction,
|
||||
WidgetKind,
|
||||
} from "matrix-widget-api";
|
||||
import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
|
||||
import { arrayFastClone } from "../../../utils/arrays";
|
||||
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
|
||||
|
||||
interface IProps {
|
||||
widgetDefinition: IModalWidgetOpenRequestData;
|
||||
widgetRoomId?: string;
|
||||
sourceWidgetId: string;
|
||||
onFinished(success: boolean, data?: IModalWidgetReturnData): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
messaging?: ClientWidgetApi;
|
||||
disabledButtonIds: ModalButtonID[];
|
||||
}
|
||||
|
||||
const MAX_BUTTONS = 3;
|
||||
|
||||
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
|
||||
private readonly widget: Widget;
|
||||
private readonly possibleButtons: ModalButtonID[];
|
||||
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
|
||||
|
||||
state: IState = {
|
||||
disabledButtonIds: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.widget = new ElementWidget({
|
||||
...this.props.widgetDefinition,
|
||||
creatorUserId: MatrixClientPeg.get().getUserId(),
|
||||
id: `modal_${this.props.sourceWidgetId}`,
|
||||
});
|
||||
this.possibleButtons = (this.props.widgetDefinition.buttons || []).map(b => b.id);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const driver = new StopGapWidgetDriver( [], this.widget, WidgetKind.Modal);
|
||||
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
|
||||
this.setState({messaging});
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.state.messaging.off("ready", this.onReady);
|
||||
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
|
||||
this.state.messaging.stop();
|
||||
}
|
||||
|
||||
private onReady = () => {
|
||||
this.state.messaging.sendWidgetConfig(this.props.widgetDefinition);
|
||||
};
|
||||
|
||||
private onLoad = () => {
|
||||
this.state.messaging.once("ready", this.onReady);
|
||||
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
|
||||
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.SetModalButtonEnabled}`, this.onButtonEnableToggle);
|
||||
};
|
||||
|
||||
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>) => {
|
||||
this.props.onFinished(true, ev.detail.data);
|
||||
}
|
||||
|
||||
private onButtonEnableToggle = (ev: CustomEvent<ISetModalButtonEnabledActionRequest>) => {
|
||||
ev.preventDefault();
|
||||
const isClose = ev.detail.data.button === BuiltInModalButtonID.Close;
|
||||
if (isClose || !this.possibleButtons.includes(ev.detail.data.button)) {
|
||||
return this.state.messaging.transport.reply(ev.detail, {
|
||||
error: {message: "Invalid button"},
|
||||
} as IWidgetApiErrorResponseData);
|
||||
}
|
||||
|
||||
let buttonIds: ModalButtonID[];
|
||||
if (ev.detail.data.enabled) {
|
||||
buttonIds = arrayFastClone(this.state.disabledButtonIds).filter(i => i !== ev.detail.data.button);
|
||||
} else {
|
||||
// use a set to swap the operation to avoid memory leaky arrays.
|
||||
const tempSet = new Set(this.state.disabledButtonIds);
|
||||
tempSet.add(ev.detail.data.button);
|
||||
buttonIds = Array.from(tempSet);
|
||||
}
|
||||
this.setState({disabledButtonIds: buttonIds});
|
||||
this.state.messaging.transport.reply(ev.detail, {} as IWidgetApiAcknowledgeResponseData);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const templated = this.widget.getCompleteUrl({
|
||||
widgetRoomId: this.props.widgetRoomId,
|
||||
currentUserId: MatrixClientPeg.get().getUserId(),
|
||||
userDisplayName: OwnProfileStore.instance.displayName,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
|
||||
});
|
||||
|
||||
const parsed = new URL(templated);
|
||||
|
||||
// Add in some legacy support sprinkles (for non-popout widgets)
|
||||
// TODO: Replace these with proper widget params
|
||||
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
|
||||
parsed.searchParams.set('widgetId', this.widget.id);
|
||||
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
|
||||
|
||||
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
|
||||
// in HTTP, but URL parsers encode them anyways.
|
||||
const widgetUrl = parsed.toString().replace(/%24/g, '$');
|
||||
|
||||
let buttons;
|
||||
if (this.props.widgetDefinition.buttons) {
|
||||
// show first button rightmost for a more natural specification
|
||||
buttons = this.props.widgetDefinition.buttons.slice(0, MAX_BUTTONS).reverse().map(def => {
|
||||
let kind = "secondary";
|
||||
switch (def.kind) {
|
||||
case ModalButtonKind.Primary:
|
||||
kind = "primary";
|
||||
break;
|
||||
case ModalButtonKind.Secondary:
|
||||
kind = "primary_outline";
|
||||
break
|
||||
case ModalButtonKind.Danger:
|
||||
kind = "danger";
|
||||
break;
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
this.state.messaging.notifyModalWidgetButtonClicked(def.id);
|
||||
};
|
||||
|
||||
const isDisabled = this.state.disabledButtonIds.includes(def.id);
|
||||
|
||||
return <AccessibleButton key={def.id} kind={kind} onClick={onClick} disabled={isDisabled}>
|
||||
{ def.label }
|
||||
</AccessibleButton>;
|
||||
});
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
title={this.props.widgetDefinition.name || _t("Modal Widget")}
|
||||
className="mx_ModalWidgetDialog"
|
||||
contentId="mx_Dialog_content"
|
||||
onFinished={this.props.onFinished}
|
||||
>
|
||||
<div className="mx_ModalWidgetDialog_warning">
|
||||
<img
|
||||
src={require("../../../../res/img/element-icons/warning-badge.svg")}
|
||||
height="16"
|
||||
width="16"
|
||||
alt=""
|
||||
/>
|
||||
{_t("Data on this screen is shared with %(widgetDomain)s", {
|
||||
widgetDomain: parsed.hostname,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<iframe
|
||||
ref={this.appFrame}
|
||||
sandbox="allow-forms allow-scripts allow-same-origin"
|
||||
src={widgetUrl}
|
||||
onLoad={this.onLoad}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_ModalWidgetDialog_buttons">
|
||||
{ buttons }
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ export default class NewSessionReviewDialog extends React.PureComponent {
|
|||
<li>{_t("The internet connection either session is using")}</li>
|
||||
</ul>
|
||||
<div>
|
||||
{_t("We recommend you change your password and recovery key in Settings immediately")}
|
||||
{_t("We recommend you change your password and Security Key in Settings immediately")}
|
||||
</div>
|
||||
</div>,
|
||||
onFinished: () => this.props.onFinished(false),
|
||||
|
|
|
@ -17,6 +17,8 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from "classnames";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
|
@ -26,12 +28,14 @@ export default class QuestionDialog extends React.Component {
|
|||
description: PropTypes.node,
|
||||
extraButtons: PropTypes.node,
|
||||
button: PropTypes.string,
|
||||
buttonDisabled: PropTypes.bool,
|
||||
danger: PropTypes.bool,
|
||||
focus: PropTypes.bool,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
headerImage: PropTypes.string,
|
||||
quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x].
|
||||
fixedWidth: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -61,7 +65,7 @@ export default class QuestionDialog extends React.Component {
|
|||
}
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_QuestionDialog"
|
||||
className={classNames("mx_QuestionDialog", this.props.className)}
|
||||
onFinished={this.props.onFinished}
|
||||
title={this.props.title}
|
||||
contentId='mx_Dialog_content'
|
||||
|
@ -74,6 +78,7 @@ export default class QuestionDialog extends React.Component {
|
|||
</div>
|
||||
<DialogButtons primaryButton={this.props.button || _t('OK')}
|
||||
primaryButtonClass={primaryButtonClass}
|
||||
primaryDisabled={this.props.buttonDisabled}
|
||||
cancelButton={this.props.cancelButton}
|
||||
hasCancel={this.props.hasCancelButton && !this.props.quitOnly}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
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 from 'react';
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default (props) => {
|
||||
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
|
||||
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
|
||||
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
|
||||
|
||||
const description1 =
|
||||
_t("If you run into any bugs or have feedback you'd like to share, " +
|
||||
"please let us know on GitHub.");
|
||||
const description2 = _t("To help avoid duplicate issues, " +
|
||||
"please <existingIssuesLink>view existing issues</existingIssuesLink> " +
|
||||
"first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> " +
|
||||
"if you can't find it.", {},
|
||||
{
|
||||
existingIssuesLink: (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
|
||||
},
|
||||
newIssueLink: (sub) => {
|
||||
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
|
||||
},
|
||||
});
|
||||
|
||||
return (<QuestionDialog
|
||||
hasCancelButton={false}
|
||||
title={_t("Report bugs & give feedback")}
|
||||
description={<div><p>{description1}</p><p>{description2}</p></div>}
|
||||
button={_t("Go back")}
|
||||
onFinished={props.onFinished}
|
||||
/>);
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
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 * as React from "react";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import {useRef, useState} from "react";
|
||||
import Field from "../elements/Field";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import withValidation from "../elements/Validation";
|
||||
import * as Email from "../../../email";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
onFinished(continued: boolean, email?: string): void;
|
||||
}
|
||||
|
||||
const validation = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const RegistrationEmailPromptDialog: React.FC<IProps> = ({onFinished}) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const fieldRef = useRef<Field>();
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (email) {
|
||||
const valid = await fieldRef.current.validate({ allowEmpty: false });
|
||||
|
||||
if (!valid) {
|
||||
fieldRef.current.focus();
|
||||
fieldRef.current.validate({ allowEmpty: false, focused: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onFinished(true, email);
|
||||
};
|
||||
|
||||
return <BaseDialog
|
||||
title={_t("Continuing without email")}
|
||||
className="mx_RegistrationEmailPromptDialog"
|
||||
contentId="mx_RegistrationEmailPromptDialog"
|
||||
onFinished={() => onFinished(false)}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<div className="mx_Dialog_content" id="mx_RegistrationEmailPromptDialog">
|
||||
<p>{_t("Just a heads up, if you don't add an email and forget your password, you could " +
|
||||
"<b>permanently lose access to your account</b>.", {}, {
|
||||
b: sub => <b>{sub}</b>,
|
||||
})}</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Field
|
||||
ref={fieldRef}
|
||||
autoFocus={true}
|
||||
type="text"
|
||||
label={_t("Email (optional)")}
|
||||
value={email}
|
||||
onChange={ev => {
|
||||
setEmail(ev.target.value);
|
||||
}}
|
||||
onValidate={async fieldState => await validation(fieldState)}
|
||||
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email2_focus")}
|
||||
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email2_blur")}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Continue")}
|
||||
onPrimaryButtonClick={onSubmit}
|
||||
hasCancel={false}
|
||||
/>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default RegistrationEmailPromptDialog;
|
|
@ -53,9 +53,9 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
}
|
||||
|
||||
_onAction = (payload) => {
|
||||
// When room changes below us, close the room settings
|
||||
// When view changes below us, close the room settings
|
||||
// whilst the modal is open this can only be triggered when someone hits Leave Room
|
||||
if (payload.action === 'view_next_room') {
|
||||
if (payload.action === 'view_home_page') {
|
||||
this.props.onFinished();
|
||||
}
|
||||
};
|
||||
|
|
235
src/components/views/dialogs/ServerPickerDialog.tsx
Normal file
235
src/components/views/dialogs/ServerPickerDialog.tsx
Normal file
|
@ -0,0 +1,235 @@
|
|||
/*
|
||||
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, {createRef} from "react";
|
||||
import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery";
|
||||
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import BaseDialog from './BaseDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import Field from "../elements/Field";
|
||||
import StyledRadioButton from "../elements/StyledRadioButton";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import withValidation, {IFieldState} from "../elements/Validation";
|
||||
|
||||
interface IProps {
|
||||
title?: string;
|
||||
serverConfig: ValidatedServerConfig;
|
||||
onFinished(config?: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
defaultChosen: boolean;
|
||||
otherHomeserver: string;
|
||||
}
|
||||
|
||||
export default class ServerPickerDialog extends React.PureComponent<IProps, IState> {
|
||||
private readonly defaultServer: ValidatedServerConfig;
|
||||
private readonly fieldRef = createRef<Field>();
|
||||
private validatedConf: ValidatedServerConfig;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const config = SdkConfig.get();
|
||||
this.defaultServer = config["validated_server_config"] as ValidatedServerConfig;
|
||||
const { serverConfig } = this.props;
|
||||
|
||||
let otherHomeserver = "";
|
||||
if (!serverConfig.isDefault) {
|
||||
if (serverConfig.isNameResolvable && serverConfig.hsName) {
|
||||
otherHomeserver = serverConfig.hsName;
|
||||
} else {
|
||||
otherHomeserver = serverConfig.hsUrl;
|
||||
}
|
||||
}
|
||||
|
||||
this.state = {
|
||||
defaultChosen: serverConfig.isDefault,
|
||||
otherHomeserver,
|
||||
};
|
||||
}
|
||||
|
||||
private onDefaultChosen = () => {
|
||||
this.setState({ defaultChosen: true });
|
||||
};
|
||||
|
||||
private onOtherChosen = () => {
|
||||
this.setState({ defaultChosen: false });
|
||||
};
|
||||
|
||||
private onHomeserverChange = (ev) => {
|
||||
this.setState({ otherHomeserver: ev.target.value });
|
||||
};
|
||||
|
||||
// TODO: Do we want to support .well-known lookups here?
|
||||
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
|
||||
// find their homeserver without demanding they use "https://matrix.org"
|
||||
private validate = withValidation<this, { error?: string }>({
|
||||
deriveData: async ({ value }) => {
|
||||
let hsUrl = value.trim(); // trim to account for random whitespace
|
||||
|
||||
// if the URL has no protocol, try validate it as a serverName via well-known
|
||||
if (!hsUrl.includes("://")) {
|
||||
try {
|
||||
const discoveryResult = await AutoDiscovery.findClientConfig(hsUrl);
|
||||
this.validatedConf = AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(hsUrl, discoveryResult);
|
||||
return {}; // we have a validated config, we don't need to try the other paths
|
||||
} catch (e) {
|
||||
console.error(`Attempted ${hsUrl} as a server_name but it failed`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// if we got to this stage then either the well-known failed or the URL had a protocol specified,
|
||||
// so validate statically only. If the URL has no protocol, default to https.
|
||||
if (!hsUrl.includes("://")) {
|
||||
hsUrl = "https://" + hsUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl);
|
||||
return {};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
if (stateForError.isFatalError) {
|
||||
let error = _t("Unable to validate homeserver");
|
||||
if (e.translatedMessage) {
|
||||
error = e.translatedMessage;
|
||||
}
|
||||
return { error };
|
||||
}
|
||||
|
||||
// try to carry on anyway
|
||||
try {
|
||||
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, null, true);
|
||||
return {};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { error: _t("Invalid URL") };
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test: ({ value, allowEmpty }) => allowEmpty || !!value,
|
||||
invalid: () => _t("Specify a homeserver"),
|
||||
}, {
|
||||
key: "valid",
|
||||
test: async function({ value }, { error }) {
|
||||
if (!value) return true;
|
||||
return !error;
|
||||
},
|
||||
invalid: function({ error }) {
|
||||
return error;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onHomeserverValidate = (fieldState: IFieldState) => this.validate(fieldState);
|
||||
|
||||
private onSubmit = async (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const valid = await this.fieldRef.current.validate({ allowEmpty: false });
|
||||
|
||||
if (!valid && !this.state.defaultChosen) {
|
||||
this.fieldRef.current.focus();
|
||||
this.fieldRef.current.validate({ allowEmpty: false, focused: true });
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onFinished(this.state.defaultChosen ? this.defaultServer : this.validatedConf);
|
||||
};
|
||||
|
||||
public render() {
|
||||
let text;
|
||||
if (this.defaultServer.hsName === "matrix.org") {
|
||||
text = _t("Matrix.org is the biggest public homeserver in the world, so it’s a good place for many.");
|
||||
}
|
||||
|
||||
let defaultServerName = this.defaultServer.hsName;
|
||||
if (this.defaultServer.hsNameIsDifferent) {
|
||||
defaultServerName = (
|
||||
<TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>
|
||||
{this.defaultServer.hsName}
|
||||
</TextWithTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
title={this.props.title || _t("Sign into your homeserver")}
|
||||
className="mx_ServerPickerDialog"
|
||||
contentId="mx_ServerPickerDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
fixedWidth={false}
|
||||
hasCancel={true}
|
||||
>
|
||||
<form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}>
|
||||
<p>
|
||||
{_t("We call the places where you can host your account ‘homeservers’.")} {text}
|
||||
</p>
|
||||
|
||||
<StyledRadioButton
|
||||
name="defaultChosen"
|
||||
value="true"
|
||||
checked={this.state.defaultChosen}
|
||||
onChange={this.onDefaultChosen}
|
||||
>
|
||||
{defaultServerName}
|
||||
</StyledRadioButton>
|
||||
|
||||
<StyledRadioButton
|
||||
name="defaultChosen"
|
||||
value="false"
|
||||
className="mx_ServerPickerDialog_otherHomeserverRadio"
|
||||
checked={!this.state.defaultChosen}
|
||||
onChange={this.onOtherChosen}
|
||||
>
|
||||
<Field
|
||||
type="text"
|
||||
className="mx_ServerPickerDialog_otherHomeserver"
|
||||
label={_t("Other homeserver")}
|
||||
onChange={this.onHomeserverChange}
|
||||
onClick={this.onOtherChosen}
|
||||
ref={this.fieldRef}
|
||||
onValidate={this.onHomeserverValidate}
|
||||
value={this.state.otherHomeserver}
|
||||
validateOnChange={false}
|
||||
validateOnFocus={false}
|
||||
/>
|
||||
</StyledRadioButton>
|
||||
<p>
|
||||
{_t("Use your preferred Matrix homeserver if you have one, or host your own.")}
|
||||
</p>
|
||||
|
||||
<AccessibleButton className="mx_ServerPickerDialog_continue" kind="primary" onClick={this.onSubmit}>
|
||||
{_t("Continue")}
|
||||
</AccessibleButton>
|
||||
|
||||
<h4>{_t("Learn more")}</h4>
|
||||
<a href="https://matrix.org/faq/#what-is-a-homeserver%3F" target="_blank" rel="noreferrer noopener">
|
||||
{_t("About homeservers")}
|
||||
</a>
|
||||
</form>
|
||||
</BaseDialog>;
|
||||
}
|
||||
}
|
|
@ -146,7 +146,7 @@ export default class ShareDialog extends React.PureComponent<IProps, IState> {
|
|||
const events = this.props.target.getLiveTimeline().getEvents();
|
||||
matrixToUrl = this.state.permalinkCreator.forEvent(events[events.length - 1].getId());
|
||||
} else {
|
||||
matrixToUrl = this.state.permalinkCreator.forRoom();
|
||||
matrixToUrl = this.state.permalinkCreator.forShareableRoom();
|
||||
}
|
||||
} else if (this.props.target instanceof User || this.props.target instanceof RoomMember) {
|
||||
matrixToUrl = makeUserPermalink(this.props.target.userId);
|
||||
|
|
162
src/components/views/dialogs/SpaceSettingsDialog.tsx
Normal file
162
src/components/views/dialogs/SpaceSettingsDialog.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useState} from 'react';
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
import {EventType} from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import {_t} from '../../../languageHandler';
|
||||
import {IDialogProps} from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DevtoolsDialog from "./DevtoolsDialog";
|
||||
import SpaceBasicSettings from '../spaces/SpaceBasicSettings';
|
||||
import {getTopic} from "../elements/RoomTopic";
|
||||
import {avatarUrlForRoom} from "../../../Avatar";
|
||||
import ToggleSwitch from "../elements/ToggleSwitch";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import FormButton from "../elements/FormButton";
|
||||
import Modal from "../../../Modal";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {allSettled} from "../../../utils/promise";
|
||||
import {useDispatcher} from "../../../hooks/useDispatcher";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
space: Room;
|
||||
}
|
||||
|
||||
const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFinished }) => {
|
||||
useDispatcher(defaultDispatcher, ({action, ...params}) => {
|
||||
if (action === "after_leave_room" && params.room_id === space.roomId) {
|
||||
onFinished(false);
|
||||
}
|
||||
});
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const userId = cli.getUserId();
|
||||
|
||||
const [newAvatar, setNewAvatar] = useState<File>(null); // undefined means to remove avatar
|
||||
const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId);
|
||||
const avatarChanged = newAvatar !== null;
|
||||
|
||||
const [name, setName] = useState<string>(space.name);
|
||||
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
|
||||
const nameChanged = name !== space.name;
|
||||
|
||||
const currentTopic = getTopic(space);
|
||||
const [topic, setTopic] = useState<string>(currentTopic);
|
||||
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
|
||||
const topicChanged = topic !== currentTopic;
|
||||
|
||||
const currentJoinRule = space.getJoinRule();
|
||||
const [joinRule, setJoinRule] = useState(currentJoinRule);
|
||||
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
|
||||
const joinRuleChanged = joinRule !== currentJoinRule;
|
||||
|
||||
const onSave = async () => {
|
||||
setBusy(true);
|
||||
const promises = [];
|
||||
|
||||
if (avatarChanged) {
|
||||
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
|
||||
url: await cli.uploadContent(newAvatar),
|
||||
}, ""));
|
||||
}
|
||||
|
||||
if (nameChanged) {
|
||||
promises.push(cli.setRoomName(space.roomId, name));
|
||||
}
|
||||
|
||||
if (topicChanged) {
|
||||
promises.push(cli.setRoomTopic(space.roomId, topic));
|
||||
}
|
||||
|
||||
if (joinRuleChanged) {
|
||||
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, ""));
|
||||
}
|
||||
|
||||
const results = await allSettled(promises);
|
||||
setBusy(false);
|
||||
const failures = results.filter(r => r.status === "rejected");
|
||||
if (failures.length > 0) {
|
||||
console.error("Failed to save space settings: ", failures);
|
||||
setError(_t("Failed to save space settings."));
|
||||
}
|
||||
};
|
||||
|
||||
return <BaseDialog
|
||||
title={_t("Space settings")}
|
||||
className="mx_SpaceSettingsDialog"
|
||||
contentId="mx_SpaceSettingsDialog"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<div className="mx_SpaceSettingsDialog_content" id="mx_SpaceSettingsDialog">
|
||||
<div>{ _t("Edit settings relating to your space.") }</div>
|
||||
|
||||
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
|
||||
|
||||
<SpaceBasicSettings
|
||||
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
|
||||
avatarDisabled={!canSetAvatar}
|
||||
setAvatar={setNewAvatar}
|
||||
name={name}
|
||||
nameDisabled={!canSetName}
|
||||
setName={setName}
|
||||
topic={topic}
|
||||
topicDisabled={!canSetTopic}
|
||||
setTopic={setTopic}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{ _t("Make this space private") }
|
||||
<ToggleSwitch
|
||||
checked={joinRule === "private"}
|
||||
onChange={checked => setJoinRule(checked ? "private" : "invite")}
|
||||
disabled={!canSetJoinRule}
|
||||
aria-label={_t("Make this space private")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormButton
|
||||
kind="danger"
|
||||
label={_t("Leave Space")}
|
||||
onClick={() => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: space.roomId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceSettingsDialog_buttons">
|
||||
<AccessibleButton onClick={() => Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}>
|
||||
{ _t("View dev tools") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={onFinished} disabled={busy} kind="link">
|
||||
{ _t("Cancel") }
|
||||
</AccessibleButton>
|
||||
<FormButton onClick={onSave} disabled={busy} label={busy ? _t("Saving...") : _t("Save Changes")} />
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default SpaceSettingsDialog;
|
||||
|
|
@ -18,6 +18,7 @@ import React, {createRef} from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import Field from "../elements/Field";
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
|
||||
export default class TextInputDialog extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -29,6 +30,7 @@ export default class TextInputDialog extends React.Component {
|
|||
value: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
button: PropTypes.string,
|
||||
busyMessage: PropTypes.string, // pass _td string
|
||||
focus: PropTypes.bool,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
hasCancel: PropTypes.bool,
|
||||
|
@ -40,6 +42,7 @@ export default class TextInputDialog extends React.Component {
|
|||
title: "",
|
||||
value: "",
|
||||
description: "",
|
||||
busyMessage: _td("Loading..."),
|
||||
focus: true,
|
||||
hasCancel: true,
|
||||
};
|
||||
|
@ -51,6 +54,7 @@ export default class TextInputDialog extends React.Component {
|
|||
|
||||
this.state = {
|
||||
value: this.props.value,
|
||||
busy: false,
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
@ -66,11 +70,13 @@ export default class TextInputDialog extends React.Component {
|
|||
onOk = async ev => {
|
||||
ev.preventDefault();
|
||||
if (this.props.validator) {
|
||||
this.setState({ busy: true });
|
||||
await this._field.current.validate({ allowEmpty: false });
|
||||
|
||||
if (!this._field.current.state.valid) {
|
||||
this._field.current.focus();
|
||||
this._field.current.validate({ allowEmpty: false, focused: true });
|
||||
this.setState({ busy: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -125,7 +131,8 @@ export default class TextInputDialog extends React.Component {
|
|||
</div>
|
||||
</form>
|
||||
<DialogButtons
|
||||
primaryButton={this.props.button}
|
||||
primaryButton={this.state.busy ? _t(this.props.busyMessage) : this.props.button}
|
||||
disabled={this.state.busy}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
onCancel={this.onCancel}
|
||||
hasCancel={this.props.hasCancel}
|
||||
|
|
147
src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
Normal file
147
src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
Normal file
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
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 from 'react';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import {
|
||||
Capability,
|
||||
Widget,
|
||||
WidgetEventCapability,
|
||||
WidgetKind,
|
||||
} from "matrix-widget-api";
|
||||
import { objectShallowClone } from "../../../utils/objects";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import { CapabilityText } from "../../../widgets/CapabilityText";
|
||||
|
||||
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
|
||||
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
|
||||
}
|
||||
|
||||
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
|
||||
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
|
||||
}
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
requestedCapabilities: Set<Capability>;
|
||||
widget: Widget;
|
||||
widgetKind: WidgetKind; // TODO: Refactor into the Widget class
|
||||
}
|
||||
|
||||
interface IBooleanStates {
|
||||
// @ts-ignore - TS wants a string key, but we know better
|
||||
[capability: Capability]: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
booleanStates: IBooleanStates;
|
||||
rememberSelection: boolean;
|
||||
}
|
||||
|
||||
export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<IProps, IState> {
|
||||
private eventPermissionsMap = new Map<Capability, WidgetEventCapability>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const parsedEvents = WidgetEventCapability.findEventCapabilities(this.props.requestedCapabilities);
|
||||
parsedEvents.forEach(e => this.eventPermissionsMap.set(e.raw, e));
|
||||
|
||||
const states: IBooleanStates = {};
|
||||
this.props.requestedCapabilities.forEach(c => states[c] = true);
|
||||
|
||||
this.state = {
|
||||
booleanStates: states,
|
||||
rememberSelection: true,
|
||||
};
|
||||
}
|
||||
|
||||
private onToggle = (capability: Capability) => {
|
||||
const newStates = objectShallowClone(this.state.booleanStates);
|
||||
newStates[capability] = !newStates[capability];
|
||||
this.setState({booleanStates: newStates});
|
||||
};
|
||||
|
||||
private onRememberSelectionChange = (newVal: boolean) => {
|
||||
this.setState({rememberSelection: newVal});
|
||||
};
|
||||
|
||||
private onSubmit = async (ev) => {
|
||||
this.closeAndTryRemember(Object.entries(this.state.booleanStates)
|
||||
.filter(([_, isSelected]) => isSelected)
|
||||
.map(([cap]) => cap));
|
||||
};
|
||||
|
||||
private onReject = async (ev) => {
|
||||
this.closeAndTryRemember([]); // nothing was approved
|
||||
};
|
||||
|
||||
private closeAndTryRemember(approved: Capability[]) {
|
||||
if (this.state.rememberSelection) {
|
||||
setRememberedCapabilitiesForWidget(this.props.widget, approved);
|
||||
}
|
||||
this.props.onFinished({approved});
|
||||
}
|
||||
|
||||
public render() {
|
||||
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
|
||||
const text = CapabilityText.for(cap, this.props.widgetKind);
|
||||
const byline = text.byline
|
||||
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{text.byline}</span>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx_WidgetCapabilitiesPromptDialog_cap" key={cap + i}>
|
||||
<StyledCheckbox
|
||||
checked={isChecked}
|
||||
onChange={() => this.onToggle(cap)}
|
||||
>{text.primary}</StyledCheckbox>
|
||||
{byline}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_WidgetCapabilitiesPromptDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Approve widget permissions")}
|
||||
>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="text-muted">{_t("This widget would like to:")}</div>
|
||||
{checkboxRows}
|
||||
<DialogButtons
|
||||
primaryButton={_t("Approve")}
|
||||
cancelButton={_t("Decline All")}
|
||||
onPrimaryButtonClick={this.onSubmit}
|
||||
onCancel={this.onReject}
|
||||
additive={
|
||||
<LabelledToggleSwitch
|
||||
value={this.state.rememberSelection}
|
||||
toggleInFront={true}
|
||||
onChange={this.onRememberSelectionChange}
|
||||
label={_t("Remember my selection for this widget")} />}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -17,18 +17,17 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import * as sdk from "../../../index";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import {Widget} from "matrix-widget-api";
|
||||
import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore";
|
||||
|
||||
export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
widgetUrl: PropTypes.string.isRequired,
|
||||
widgetId: PropTypes.string.isRequired,
|
||||
isUserWidget: PropTypes.bool.isRequired,
|
||||
widget: PropTypes.objectOf(Widget).isRequired,
|
||||
widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api
|
||||
inRoomId: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -51,16 +50,10 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
|||
if (this.state.rememberSelection) {
|
||||
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
|
||||
|
||||
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
|
||||
if (!currentValues.allow) currentValues.allow = [];
|
||||
if (!currentValues.deny) currentValues.deny = [];
|
||||
|
||||
const securityKey = WidgetUtils.getWidgetSecurityKey(
|
||||
this.props.widgetId,
|
||||
this.props.widgetUrl,
|
||||
this.props.isUserWidget);
|
||||
(allowed ? currentValues.allow : currentValues.deny).push(securityKey);
|
||||
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
|
||||
WidgetPermissionStore.instance.setOIDCState(
|
||||
this.props.widget, this.props.widgetKind, this.props.inRoomId,
|
||||
allowed ? OIDCState.Allowed : OIDCState.Denied,
|
||||
);
|
||||
}
|
||||
|
||||
this.props.onFinished(allowed);
|
||||
|
@ -77,26 +70,26 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
|||
return (
|
||||
<BaseDialog className='mx_WidgetOpenIDPermissionsDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("A widget would like to verify your identity")}>
|
||||
title={_t("Allow this widget to verify your identity")}>
|
||||
<div className='mx_WidgetOpenIDPermissionsDialog_content'>
|
||||
<p>
|
||||
{_t(
|
||||
"A widget located at %(widgetUrl)s would like to verify your identity. " +
|
||||
"By allowing this, the widget will be able to verify your user ID, but not " +
|
||||
"perform actions as you.", {
|
||||
widgetUrl: this.props.widgetUrl,
|
||||
},
|
||||
)}
|
||||
{_t("The widget will verify your user ID, but won't be able to perform actions for you:")}
|
||||
</p>
|
||||
<p className="text-muted">
|
||||
{/* cheap trim to just get the path */}
|
||||
{this.props.widget.templateUrl.split("?")[0].split("#")[0]}
|
||||
</p>
|
||||
<LabelledToggleSwitch value={this.state.rememberSelection} toggleInFront={true}
|
||||
onChange={this._onRememberSelectionChange}
|
||||
label={_t("Remember my selection for this widget")} />
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Allow")}
|
||||
primaryButton={_t("Continue")}
|
||||
onPrimaryButtonClick={this._onAllow}
|
||||
cancelButton={_t("Deny")}
|
||||
onCancel={this._onDeny}
|
||||
additive={
|
||||
<LabelledToggleSwitch
|
||||
value={this.state.rememberSelection}
|
||||
toggleInFront={true}
|
||||
onChange={this._onRememberSelectionChange}
|
||||
label={_t("Remember this")} />}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -199,11 +199,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
} else if (this.state.recoveryKeyCorrect) {
|
||||
return _t("Looks good!");
|
||||
} else if (this.state.recoveryKeyValid) {
|
||||
return _t("Wrong Recovery Key");
|
||||
return _t("Wrong Security Key");
|
||||
} else if (this.state.recoveryKeyValid === null) {
|
||||
return '';
|
||||
} else {
|
||||
return _t("Invalid Recovery Key");
|
||||
return _t("Invalid Security Key");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -231,7 +231,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4E "}{_t(
|
||||
"Unable to access secret storage. " +
|
||||
"Please verify that you entered the correct recovery passphrase.",
|
||||
"Please verify that you entered the correct Security Phrase.",
|
||||
)}
|
||||
</div>;
|
||||
} else {
|
||||
|
@ -298,7 +298,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
|
||||
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
|
||||
<Field
|
||||
type="text"
|
||||
type="password"
|
||||
label={_t('Security Key')}
|
||||
value={this.state.recoveryKey}
|
||||
onChange={this._onRecoveryKeyChange}
|
||||
|
|
|
@ -34,6 +34,7 @@ import InteractiveAuthDialog from '../InteractiveAuthDialog';
|
|||
export default class CreateCrossSigningDialog extends React.PureComponent {
|
||||
static propTypes = {
|
||||
accountPassword: PropTypes.string,
|
||||
tokenLogin: PropTypes.bool,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -96,6 +97,9 @@ export default class CreateCrossSigningDialog extends React.PureComponent {
|
|||
user: MatrixClientPeg.get().getUserId(),
|
||||
password: this.state.accountPassword,
|
||||
});
|
||||
} else if (this.props.tokenLogin) {
|
||||
// We are hoping the grace period is active
|
||||
await makeRequest({});
|
||||
} else {
|
||||
const dialogAesthetics = {
|
||||
[SSOAuthEntry.PHASE_PREAUTH]: {
|
||||
|
@ -144,6 +148,12 @@ export default class CreateCrossSigningDialog extends React.PureComponent {
|
|||
});
|
||||
this.props.onFinished(true);
|
||||
} catch (e) {
|
||||
if (this.props.tokenLogin) {
|
||||
// ignore any failures, we are relying on grace period here
|
||||
this.props.onFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ error: e });
|
||||
console.error("Error bootstrapping cross-signing", e);
|
||||
}
|
||||
|
|
|
@ -297,19 +297,19 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
} else if (this.state.restoreError) {
|
||||
if (this.state.restoreError.errcode === MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY) {
|
||||
if (this.state.restoreType === RESTORE_TYPE_RECOVERYKEY) {
|
||||
title = _t("Recovery key mismatch");
|
||||
title = _t("Security Key mismatch");
|
||||
content = <div>
|
||||
<p>{_t(
|
||||
"Backup could not be decrypted with this recovery key: " +
|
||||
"please verify that you entered the correct recovery key.",
|
||||
"Backup could not be decrypted with this Security Key: " +
|
||||
"please verify that you entered the correct Security Key.",
|
||||
)}</p>
|
||||
</div>;
|
||||
} else {
|
||||
title = _t("Incorrect recovery passphrase");
|
||||
title = _t("Incorrect Security Phrase");
|
||||
content = <div>
|
||||
<p>{_t(
|
||||
"Backup could not be decrypted with this recovery passphrase: " +
|
||||
"please verify that you entered the correct recovery passphrase.",
|
||||
"Backup could not be decrypted with this Security Phrase: " +
|
||||
"please verify that you entered the correct Security Phrase.",
|
||||
)}</p>
|
||||
</div>;
|
||||
}
|
||||
|
@ -342,7 +342,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
} else if (backupHasPassphrase && !this.state.forceRecoveryKey) {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
title = _t("Enter recovery passphrase");
|
||||
title = _t("Enter Security Phrase");
|
||||
content = <div>
|
||||
<p>{_t(
|
||||
"<b>Warning</b>: you should only set up key backup " +
|
||||
|
@ -351,7 +351,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
)}</p>
|
||||
<p>{_t(
|
||||
"Access your secure message history and set up secure " +
|
||||
"messaging by entering your recovery passphrase.",
|
||||
"messaging by entering your Security Phrase.",
|
||||
)}</p>
|
||||
|
||||
<form className="mx_RestoreKeyBackupDialog_primaryContainer">
|
||||
|
@ -371,8 +371,8 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
/>
|
||||
</form>
|
||||
{_t(
|
||||
"If you've forgotten your recovery passphrase you can "+
|
||||
"<button1>use your recovery key</button1> or " +
|
||||
"If you've forgotten your Security Phrase you can "+
|
||||
"<button1>use your Security Key</button1> or " +
|
||||
"<button2>set up new recovery options</button2>"
|
||||
, {}, {
|
||||
button1: s => <AccessibleButton className="mx_linkButton"
|
||||
|
@ -390,7 +390,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
})}
|
||||
</div>;
|
||||
} else {
|
||||
title = _t("Enter recovery key");
|
||||
title = _t("Enter Security Key");
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
|
@ -399,11 +399,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus"></div>;
|
||||
} else if (this.state.recoveryKeyValid) {
|
||||
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus">
|
||||
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
|
||||
{"\uD83D\uDC4D "}{_t("This looks like a valid Security Key!")}
|
||||
</div>;
|
||||
} else {
|
||||
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus">
|
||||
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")}
|
||||
{"\uD83D\uDC4E "}{_t("Not a valid Security Key")}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -415,7 +415,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
)}</p>
|
||||
<p>{_t(
|
||||
"Access your secure message history and set up secure " +
|
||||
"messaging by entering your recovery key.",
|
||||
"messaging by entering your Security Key.",
|
||||
)}</p>
|
||||
|
||||
<div className="mx_RestoreKeyBackupDialog_primaryContainer">
|
||||
|
@ -434,7 +434,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
/>
|
||||
</div>
|
||||
{_t(
|
||||
"If you've forgotten your recovery key you can "+
|
||||
"If you've forgotten your Security Key you can "+
|
||||
"<button>set up new recovery options</button>"
|
||||
, {}, {
|
||||
button: s => <AccessibleButton className="mx_linkButton"
|
||||
|
|
|
@ -39,7 +39,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
|
|||
tabIndex?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?(e?: ButtonEvent): void;
|
||||
onClick(e?: ButtonEvent): void;
|
||||
}
|
||||
|
||||
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
|
||||
|
|
|
@ -26,6 +26,7 @@ interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
|
|||
tooltip?: React.ReactNode;
|
||||
tooltipClassName?: string;
|
||||
forceHide?: boolean;
|
||||
yOffset?: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -63,12 +64,13 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
|
|||
|
||||
render() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {title, tooltip, children, tooltipClassName, forceHide, ...props} = this.props;
|
||||
const {title, tooltip, children, tooltipClassName, forceHide, yOffset, ...props} = this.props;
|
||||
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_AccessibleTooltipButton_container"
|
||||
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
|
||||
label={tooltip || title}
|
||||
yOffset={yOffset}
|
||||
/> : <div />;
|
||||
return (
|
||||
<AccessibleButton
|
||||
|
|
|
@ -46,7 +46,7 @@ export default class AddressSelector extends React.Component {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(props) {
|
||||
UNSAFE_componentWillReceiveProps(props) { // eslint-disable-line camelcase
|
||||
// Make sure the selected item isn't outside the list bounds
|
||||
const selected = this.state.selected;
|
||||
const maxSelected = this._maxSelected(props.addressList);
|
||||
|
|
|
@ -22,56 +22,52 @@ import React, {createRef} from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import AppPermission from './AppPermission';
|
||||
import AppWarning from './AppWarning';
|
||||
import Spinner from './Spinner';
|
||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||
import classNames from 'classnames';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import PersistedElement from "./PersistedElement";
|
||||
import {aboveLeftOf, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import PersistedElement, {getPersistKey} from "./PersistedElement";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
|
||||
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
|
||||
import {MatrixCapabilities} from "matrix-widget-api";
|
||||
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
|
||||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||
|
||||
export default class AppTile extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// The key used for PersistedElement
|
||||
this._persistKey = 'widget_' + this.props.app.id;
|
||||
this._persistKey = getPersistKey(this.props.app.id);
|
||||
this._sgWidget = new StopGapWidget(this.props);
|
||||
this._sgWidget.on("preparing", this._onWidgetPrepared);
|
||||
this._sgWidget.on("ready", this._onWidgetReady);
|
||||
this.iframe = null; // ref to the iframe (callback style)
|
||||
|
||||
this.state = this._getNewState(props);
|
||||
|
||||
this._onAction = this._onAction.bind(this);
|
||||
this._onEditClick = this._onEditClick.bind(this);
|
||||
this._onDeleteClick = this._onDeleteClick.bind(this);
|
||||
this._onRevokeClicked = this._onRevokeClicked.bind(this);
|
||||
this._onSnapshotClick = this._onSnapshotClick.bind(this);
|
||||
this.onClickMenuBar = this.onClickMenuBar.bind(this);
|
||||
this._onMinimiseClick = this._onMinimiseClick.bind(this);
|
||||
this._grantWidgetPermission = this._grantWidgetPermission.bind(this);
|
||||
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
|
||||
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
|
||||
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
|
||||
|
||||
this._contextMenuButton = createRef();
|
||||
this._menu_bar = createRef();
|
||||
|
||||
this._allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
|
||||
}
|
||||
|
||||
// This is a function to make the impact of calling SettingsStore slightly less
|
||||
hasPermissionToLoad = (props) => {
|
||||
if (this._usingLocalWidget()) return true;
|
||||
if (!props.room) return true; // user widgets always have permissions
|
||||
|
||||
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
|
||||
if (currentlyAllowedWidgets[props.app.eventId] === undefined) {
|
||||
return props.userId === props.creatorUserId;
|
||||
}
|
||||
return !!currentlyAllowedWidgets[props.app.eventId];
|
||||
};
|
||||
|
||||
/**
|
||||
* Set initial component state when the App wUrl (widget URL) is being updated.
|
||||
* Component props *must* be passed (rather than relying on this.props).
|
||||
|
@ -79,28 +75,32 @@ export default class AppTile extends React.Component {
|
|||
* @return {Object} Updated component state to be set with setState
|
||||
*/
|
||||
_getNewState(newProps) {
|
||||
// This is a function to make the impact of calling SettingsStore slightly less
|
||||
const hasPermissionToLoad = () => {
|
||||
if (this._usingLocalWidget()) return true;
|
||||
|
||||
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId);
|
||||
return !!currentlyAllowedWidgets[newProps.app.eventId];
|
||||
};
|
||||
|
||||
return {
|
||||
initialising: true, // True while we are mangling the widget URL
|
||||
// True while the iframe content is loading
|
||||
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
|
||||
// Assume that widget has permission to load if we are the user who
|
||||
// added it to the room, or if explicitly granted by the user
|
||||
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
|
||||
hasPermissionToLoad: this.hasPermissionToLoad(newProps),
|
||||
error: null,
|
||||
deleting: false,
|
||||
widgetPageTitle: newProps.widgetPageTitle,
|
||||
menuDisplayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
onAllowedWidgetsChange = () => {
|
||||
const hasPermissionToLoad = this.hasPermissionToLoad(this.props);
|
||||
|
||||
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
|
||||
// Force the widget to be non-persistent (able to be deleted/forgotten)
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
|
||||
this.setState({ hasPermissionToLoad });
|
||||
};
|
||||
|
||||
isMixedContent() {
|
||||
const parentContentProtocol = window.location.protocol;
|
||||
const u = url.parse(this.props.app.url);
|
||||
|
@ -115,7 +115,7 @@ export default class AppTile extends React.Component {
|
|||
|
||||
componentDidMount() {
|
||||
// Only fetch IM token on mount if we're showing and have permission to load
|
||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||
if (this.state.hasPermissionToLoad) {
|
||||
this._startWidget();
|
||||
}
|
||||
|
||||
|
@ -136,6 +136,8 @@ export default class AppTile extends React.Component {
|
|||
if (this._sgWidget) {
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
|
||||
SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef);
|
||||
}
|
||||
|
||||
_resetWidget(newProps) {
|
||||
|
@ -167,21 +169,8 @@ export default class AppTile extends React.Component {
|
|||
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
|
||||
if (nextProps.app.url !== this.props.app.url) {
|
||||
this._getNewState(nextProps);
|
||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||
this._resetWidget(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextProps.show && !this.props.show) {
|
||||
// We assume that persisted widgets are loaded and don't need a spinner.
|
||||
if (this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey)) {
|
||||
this.setState({
|
||||
loading: true,
|
||||
});
|
||||
}
|
||||
// Start the widget now that we're showing if we already have permission to load
|
||||
if (this.state.hasPermissionToLoad) {
|
||||
this._startWidget();
|
||||
this._resetWidget(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -192,35 +181,6 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_canUserModify() {
|
||||
// User widgets should always be modifiable by their creator
|
||||
if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) {
|
||||
return true;
|
||||
}
|
||||
// Check if the current user can modify widgets in the current room
|
||||
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
|
||||
}
|
||||
|
||||
_onEditClick() {
|
||||
console.log("Edit widget ID ", this.props.app.id);
|
||||
if (this.props.onEditClick) {
|
||||
this.props.onEditClick();
|
||||
} else {
|
||||
WidgetUtils.editWidget(this.props.room, this.props.app);
|
||||
}
|
||||
}
|
||||
|
||||
_onSnapshotClick() {
|
||||
this._sgWidget.widgetApi.takeScreenshot().then(data => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: data.screenshot,
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error("Failed to take screenshot: ", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends all widget interaction, such as cancelling calls and disabling webcams.
|
||||
* @private
|
||||
|
@ -250,57 +210,6 @@ export default class AppTile extends React.Component {
|
|||
this._sgWidget.stop({forceDestroy: true});
|
||||
}
|
||||
|
||||
/* If user has permission to modify widgets, delete the widget,
|
||||
* otherwise revoke access for the widget to load in the user's browser
|
||||
*/
|
||||
_onDeleteClick() {
|
||||
if (this.props.onDeleteClick) {
|
||||
this.props.onDeleteClick();
|
||||
} else if (this._canUserModify()) {
|
||||
// Show delete confirmation dialog
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
|
||||
title: _t("Delete Widget"),
|
||||
description: _t(
|
||||
"Deleting a widget removes it for all users in this room." +
|
||||
" Are you sure you want to delete this widget?"),
|
||||
button: _t("Delete widget"),
|
||||
onFinished: (confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
this.setState({deleting: true});
|
||||
|
||||
this._endWidgetActions().then(() => {
|
||||
return WidgetUtils.setRoomWidget(
|
||||
this.props.room.roomId,
|
||||
this.props.app.id,
|
||||
);
|
||||
}).catch((e) => {
|
||||
console.error('Failed to delete widget', e);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
Modal.createTrackedDialog('Failed to remove widget', '', ErrorDialog, {
|
||||
title: _t('Failed to remove widget'),
|
||||
description: _t('An error ocurred whilst trying to remove the widget from the room'),
|
||||
});
|
||||
}).finally(() => {
|
||||
this.setState({deleting: false});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onUnpinClicked = () => {
|
||||
WidgetStore.instance.unpinWidget(this.props.app.id);
|
||||
}
|
||||
|
||||
_onRevokeClicked() {
|
||||
console.info("Revoke widget permissions - %s", this.props.app.id);
|
||||
this._revokeWidgetPermission();
|
||||
}
|
||||
|
||||
_onWidgetPrepared = () => {
|
||||
this.setState({loading: false});
|
||||
};
|
||||
|
@ -311,7 +220,7 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
_onAction(payload) {
|
||||
_onAction = payload => {
|
||||
if (payload.widgetId === this.props.app.id) {
|
||||
switch (payload.action) {
|
||||
case 'm.sticker':
|
||||
|
@ -321,24 +230,17 @@ export default class AppTile extends React.Component {
|
|||
console.warn('Ignoring sticker message. Invalid capability');
|
||||
}
|
||||
break;
|
||||
|
||||
case Action.AppTileDelete:
|
||||
this._onDeleteClick();
|
||||
break;
|
||||
|
||||
case Action.AppTileRevoke:
|
||||
this._onRevokeClicked();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_grantWidgetPermission() {
|
||||
_grantWidgetPermission = () => {
|
||||
const roomId = this.props.room.roomId;
|
||||
console.info("Granting permission for widget to load: " + this.props.app.eventId);
|
||||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||||
current[this.props.app.eventId] = true;
|
||||
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => {
|
||||
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
|
||||
SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => {
|
||||
this.setState({hasPermissionToLoad: true});
|
||||
|
||||
// Fetch a token for the integration manager, now that we're allowed to
|
||||
|
@ -347,26 +249,7 @@ export default class AppTile extends React.Component {
|
|||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
});
|
||||
}
|
||||
|
||||
_revokeWidgetPermission() {
|
||||
const roomId = this.props.room.roomId;
|
||||
console.info("Revoking permission for widget to load: " + this.props.app.eventId);
|
||||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||||
current[this.props.app.eventId] = false;
|
||||
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => {
|
||||
this.setState({hasPermissionToLoad: false});
|
||||
|
||||
// Force the widget to be non-persistent (able to be deleted/forgotten)
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
this._sgWidget.stop();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
formatAppTileName() {
|
||||
let appTileName = "No name";
|
||||
|
@ -376,32 +259,6 @@ export default class AppTile extends React.Component {
|
|||
return appTileName;
|
||||
}
|
||||
|
||||
onClickMenuBar(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
// Ignore clicks on menu bar children
|
||||
if (ev.target !== this._menu_bar.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle the view state of the apps drawer
|
||||
if (this.props.userWidget) {
|
||||
this._onMinimiseClick();
|
||||
} else {
|
||||
if (this.props.show) {
|
||||
// if we were being shown, end the widget as we're about to be minimized.
|
||||
this._endWidgetActions();
|
||||
} else {
|
||||
// restart the widget actions
|
||||
this._resetWidget(this.props);
|
||||
}
|
||||
dis.dispatch({
|
||||
action: 'appsDrawer',
|
||||
show: !this.props.show,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we're using a local version of the widget rather than loading the
|
||||
* actual widget URL
|
||||
|
@ -421,22 +278,18 @@ export default class AppTile extends React.Component {
|
|||
|
||||
return (
|
||||
<span>
|
||||
<WidgetAvatar app={this.props.app} />
|
||||
<b>{ name }</b>
|
||||
<span>{ title ? titleSpacer : '' }{ title }</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
_onMinimiseClick(e) {
|
||||
if (this.props.onMinimiseClick) {
|
||||
this.props.onMinimiseClick();
|
||||
}
|
||||
}
|
||||
|
||||
_onPopoutWidgetClick() {
|
||||
// TODO replace with full screen interactions
|
||||
_onPopoutWidgetClick = () => {
|
||||
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them
|
||||
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
|
||||
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
this._endWidgetActions().then(() => {
|
||||
if (this.iframe) {
|
||||
// Reload iframe
|
||||
|
@ -449,13 +302,7 @@ export default class AppTile extends React.Component {
|
|||
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
||||
Object.assign(document.createElement('a'),
|
||||
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click();
|
||||
}
|
||||
|
||||
_onReloadWidgetClick() {
|
||||
// Reload iframe in this way to avoid cross-origin restrictions
|
||||
// eslint-disable-next-line no-self-assign
|
||||
this.iframe.src = this.iframe.src;
|
||||
}
|
||||
};
|
||||
|
||||
_onContextMenuClick = () => {
|
||||
this.setState({ menuDisplayed: true });
|
||||
|
@ -468,11 +315,6 @@ export default class AppTile extends React.Component {
|
|||
render() {
|
||||
let appTileBody;
|
||||
|
||||
// Don't render widget if it is in the process of being deleted
|
||||
if (this.state.deleting) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
|
||||
// because that would allow the iframe to programmatically remove the sandbox attribute, but
|
||||
// this would only be for content hosted on the same origin as the element client: anything
|
||||
|
@ -487,71 +329,69 @@ export default class AppTile extends React.Component {
|
|||
|
||||
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
|
||||
|
||||
if (this.props.show) {
|
||||
const loadingElement = (
|
||||
<div className="mx_AppLoading_spinner_fadeIn">
|
||||
<Spinner message={_t("Loading...")} />
|
||||
const loadingElement = (
|
||||
<div className="mx_AppLoading_spinner_fadeIn">
|
||||
<Spinner message={_t("Loading...")} />
|
||||
</div>
|
||||
);
|
||||
if (!this.state.hasPermissionToLoad) {
|
||||
// only possible for room widgets, can assert this.props.room here
|
||||
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass}>
|
||||
<AppPermission
|
||||
roomId={this.props.room.roomId}
|
||||
creatorUserId={this.props.creatorUserId}
|
||||
url={this._sgWidget.embedUrl}
|
||||
isRoomEncrypted={isEncrypted}
|
||||
onPermissionGranted={this._grantWidgetPermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (!this.state.hasPermissionToLoad) {
|
||||
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
|
||||
} else if (this.state.initialising) {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ loadingElement }
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
if (this.isMixedContent()) {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass}>
|
||||
<AppPermission
|
||||
roomId={this.props.room.roomId}
|
||||
creatorUserId={this.props.creatorUserId}
|
||||
url={this._sgWidget.embedUrl}
|
||||
isRoomEncrypted={isEncrypted}
|
||||
onPermissionGranted={this._grantWidgetPermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.initialising) {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ loadingElement }
|
||||
<AppWarning errorMsg="Error - Mixed content" />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
if (this.isMixedContent()) {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass}>
|
||||
<AppWarning errorMsg="Error - Mixed content" />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ this.state.loading && loadingElement }
|
||||
<iframe
|
||||
allow={iframeFeatures}
|
||||
ref={this._iframeRefChange}
|
||||
src={this._sgWidget.embedUrl}
|
||||
allowFullScreen={true}
|
||||
sandbox={sandboxFlags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// 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 = <div className="mx_AppTile_persistedWrapper">
|
||||
<PersistedElement persistKey={this._persistKey}>
|
||||
{appTileBody}
|
||||
</PersistedElement>
|
||||
</div>;
|
||||
}
|
||||
appTileBody = (
|
||||
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ this.state.loading && loadingElement }
|
||||
<iframe
|
||||
allow={iframeFeatures}
|
||||
ref={this._iframeRefChange}
|
||||
src={this._sgWidget.embedUrl}
|
||||
allowFullScreen={true}
|
||||
sandbox={sandboxFlags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!this.props.userWidget) {
|
||||
// All room widgets can theoretically be allowed to remain on screen, so we
|
||||
// wrap them all in a PersistedElement from the get-go. If we wait, the iframe
|
||||
// will be re-mounted later, which means the widget has to start over, which is
|
||||
// bad.
|
||||
|
||||
// Also wrap the PersistedElement in a div to fix the height, otherwise
|
||||
// AppTile's border is in the wrong place
|
||||
appTileBody = <div className="mx_AppTile_persistedWrapper">
|
||||
<PersistedElement persistKey={this._persistKey}>
|
||||
{appTileBody}
|
||||
</PersistedElement>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +400,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 = (
|
||||
<ContextMenu {...aboveLeftOf(elementRect, null)} onFinished={this._closeContextMenu}>
|
||||
<WidgetContextMenu
|
||||
onUnpinClicked={
|
||||
ActiveWidgetStore.getWidgetPersistence(this.props.app.id) ? null : this._onUnpinClicked
|
||||
}
|
||||
onRevokeClicked={this._onRevokeClicked}
|
||||
onEditClicked={showEditButton ? this._onEditClick : undefined}
|
||||
onDeleteClicked={showDeleteButton ? this._onDeleteClick : undefined}
|
||||
onSnapshotClicked={showPictureSnapshotButton ? this._onSnapshotClick : undefined}
|
||||
onReloadClicked={this.props.showReload ? this._onReloadWidgetClick : undefined}
|
||||
onFinished={this._closeContextMenu}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<RoomWidgetContextMenu
|
||||
{...aboveLeftOf(this._contextMenuButton.current.getBoundingClientRect(), null)}
|
||||
app={this.props.app}
|
||||
onFinished={this._closeContextMenu}
|
||||
showUnpin={!this.props.userWidget}
|
||||
userWidget={this.props.userWidget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<div className={appTileClasses} id={this.props.app.id}>
|
||||
{ this.props.showMenubar &&
|
||||
<div ref={this._menu_bar} className={menuBarClasses} onClick={this.onClickMenuBar}>
|
||||
<div className="mx_AppTileMenuBar">
|
||||
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
|
||||
{ /* Minimise widget */ }
|
||||
{ showMinimiseButton && <AccessibleButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_minimise"
|
||||
title={_t('Minimize widget')}
|
||||
onClick={this._onMinimiseClick}
|
||||
/> }
|
||||
{ /* Maximise widget */ }
|
||||
{ showMaximiseButton && <AccessibleButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_maximise"
|
||||
title={_t('Maximize widget')}
|
||||
onClick={this._onMinimiseClick}
|
||||
/> }
|
||||
{ /* Title */ }
|
||||
{ this.props.showTitle && this._getTileTitle() }
|
||||
</span>
|
||||
<span className="mx_AppTileMenuBarWidgets">
|
||||
{ /* Popout widget */ }
|
||||
{ this.props.showPopout && <AccessibleButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
|
||||
title={_t('Popout widget')}
|
||||
onClick={this._onPopoutWidgetClick}
|
||||
/> }
|
||||
{ /* Context menu */ }
|
||||
{ <ContextMenuButton
|
||||
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
|
||||
label={_t('More options')}
|
||||
label={_t("Options")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
inputRef={this._contextMenuButton}
|
||||
onClick={this._onContextMenuClick}
|
||||
|
@ -645,7 +449,9 @@ AppTile.displayName = 'AppTile';
|
|||
|
||||
AppTile.propTypes = {
|
||||
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.
|
||||
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
|
||||
fullWidth: PropTypes.bool,
|
||||
|
@ -657,8 +463,6 @@ AppTile.propTypes = {
|
|||
creatorUserId: PropTypes.string,
|
||||
waitForIframeLoad: PropTypes.bool,
|
||||
showMenubar: PropTypes.bool,
|
||||
// Should the AppTile render itself
|
||||
show: PropTypes.bool,
|
||||
// Optional onEditClickHandler (overrides default behaviour)
|
||||
onEditClick: PropTypes.func,
|
||||
// Optional onDeleteClickHandler (overrides default behaviour)
|
||||
|
@ -667,23 +471,10 @@ AppTile.propTypes = {
|
|||
onMinimiseClick: PropTypes.func,
|
||||
// Optionally hide the tile title
|
||||
showTitle: PropTypes.bool,
|
||||
// Optionally hide the tile minimise icon
|
||||
showMinimise: PropTypes.bool,
|
||||
// Optionally handle minimise button pointer events (default false)
|
||||
handleMinimisePointerEvents: PropTypes.bool,
|
||||
// Optionally hide the delete icon
|
||||
showDelete: PropTypes.bool,
|
||||
// Optionally hide the popout widget icon
|
||||
showPopout: PropTypes.bool,
|
||||
// Optionally show the reload widget icon
|
||||
// This is not currently intended for use with production widgets. However
|
||||
// it can be useful when developing persistent widgets in order to avoid
|
||||
// having to reload all of Element to get new widget content.
|
||||
showReload: PropTypes.bool,
|
||||
// Widget capabilities to allow by default (without user confirmation)
|
||||
// NOTE -- Use with caution. This is intended to aid better integration / UX
|
||||
// basic widget capabilities, e.g. injecting sticker message events.
|
||||
whitelistCapabilities: PropTypes.array,
|
||||
// Is this an instance of a user widget
|
||||
userWidget: PropTypes.bool,
|
||||
};
|
||||
|
@ -692,12 +483,8 @@ AppTile.defaultProps = {
|
|||
waitForIframeLoad: true,
|
||||
showMenubar: true,
|
||||
showTitle: true,
|
||||
showMinimise: true,
|
||||
showDelete: true,
|
||||
showPopout: true,
|
||||
showReload: false,
|
||||
handleMinimisePointerEvents: false,
|
||||
whitelistCapabilities: [],
|
||||
userWidget: false,
|
||||
miniMode: false,
|
||||
};
|
||||
|
|
173
src/components/views/elements/DesktopCapturerSourcePicker.tsx
Normal file
173
src/components/views/elements/DesktopCapturerSourcePicker.tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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 from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "..//dialogs/BaseDialog"
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import {getDesktopCapturerSources} from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
export interface DesktopCapturerSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailURL;
|
||||
}
|
||||
|
||||
export enum Tabs {
|
||||
Screens = "screens",
|
||||
Windows = "windows",
|
||||
}
|
||||
|
||||
export interface DesktopCapturerSourceIProps {
|
||||
source: DesktopCapturerSource;
|
||||
onSelect(source: DesktopCapturerSource): void;
|
||||
}
|
||||
|
||||
export class ExistingSource extends React.Component<DesktopCapturerSourceIProps> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onClick = (ev) => {
|
||||
this.props.onSelect(this.props.source);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_desktopCapturerSourcePicker_stream_button"
|
||||
title={this.props.source.name}
|
||||
onClick={this.onClick} >
|
||||
<img
|
||||
className="mx_desktopCapturerSourcePicker_stream_thumbnail"
|
||||
src={this.props.source.thumbnailURL}
|
||||
/>
|
||||
<span className="mx_desktopCapturerSourcePicker_stream_name">{this.props.source.name}</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopCapturerSourcePickerIState {
|
||||
selectedTab: Tabs;
|
||||
sources: Array<DesktopCapturerSource>;
|
||||
}
|
||||
export interface DesktopCapturerSourcePickerIProps {
|
||||
onFinished(source: DesktopCapturerSource): void;
|
||||
}
|
||||
|
||||
export default class DesktopCapturerSourcePicker extends React.Component<
|
||||
DesktopCapturerSourcePickerIProps,
|
||||
DesktopCapturerSourcePickerIState
|
||||
> {
|
||||
interval;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedTab: Tabs.Screens,
|
||||
sources: [],
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
// setInterval() first waits and then executes, therefore
|
||||
// we call getDesktopCapturerSources() here without any delay.
|
||||
// Otherwise the dialog would be left empty for some time.
|
||||
this.setState({
|
||||
sources: await getDesktopCapturerSources(),
|
||||
});
|
||||
|
||||
// We update the sources every 500ms to get newer thumbnails
|
||||
this.interval = setInterval(async () => {
|
||||
this.setState({
|
||||
sources: await getDesktopCapturerSources(),
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
onSelect = (source) => {
|
||||
this.props.onFinished(source);
|
||||
}
|
||||
|
||||
onScreensClick = (ev) => {
|
||||
this.setState({selectedTab: Tabs.Screens});
|
||||
}
|
||||
|
||||
onWindowsClick = (ev) => {
|
||||
this.setState({selectedTab: Tabs.Windows});
|
||||
}
|
||||
|
||||
onCloseClick = (ev) => {
|
||||
this.props.onFinished(null);
|
||||
}
|
||||
|
||||
render() {
|
||||
let sources;
|
||||
if (this.state.selectedTab === Tabs.Screens) {
|
||||
sources = this.state.sources
|
||||
.filter((source) => {
|
||||
return source.id.startsWith("screen");
|
||||
})
|
||||
.map((source) => {
|
||||
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
|
||||
});
|
||||
} else {
|
||||
sources = this.state.sources
|
||||
.filter((source) => {
|
||||
return source.id.startsWith("window");
|
||||
})
|
||||
.map((source) => {
|
||||
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
|
||||
});
|
||||
}
|
||||
|
||||
const buttonStyle = "mx_desktopCapturerSourcePicker_tabLabel";
|
||||
const screensButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Screens) ? "_selected" : "");
|
||||
const windowsButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Windows) ? "_selected" : "");
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_desktopCapturerSourcePicker"
|
||||
onFinished={this.onCloseClick}
|
||||
title={_t("Share your screen")}
|
||||
>
|
||||
<div className="mx_desktopCapturerSourcePicker_tabLabels">
|
||||
<AccessibleButton
|
||||
className={screensButtonStyle}
|
||||
onClick={this.onScreensClick}
|
||||
>
|
||||
{_t("Screens")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className={windowsButtonStyle}
|
||||
onClick={this.onWindowsClick}
|
||||
>
|
||||
{_t("Windows")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<div className="mx_desktopCapturerSourcePicker_panel">
|
||||
{ sources }
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -54,6 +54,9 @@ export default class DialogButtons extends React.Component {
|
|||
|
||||
// disables only the primary button
|
||||
primaryDisabled: PropTypes.bool,
|
||||
|
||||
// something to stick next to the buttons, optionally
|
||||
additive: PropTypes.element,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -85,8 +88,14 @@ export default class DialogButtons extends React.Component {
|
|||
</button>;
|
||||
}
|
||||
|
||||
let additive = null;
|
||||
if (this.props.additive) {
|
||||
additive = <div className="mx_Dialog_buttons_additive">{this.props.additive}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_Dialog_buttons">
|
||||
{ additive }
|
||||
{ cancelButton }
|
||||
{ this.props.children }
|
||||
<button type={this.props.primaryIsSubmit ? 'submit' : 'button'}
|
||||
|
|
|
@ -16,13 +16,12 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default class DirectorySearchBox extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._collectInput = this._collectInput.bind(this);
|
||||
this._onClearClick = this._onClearClick.bind(this);
|
||||
this._onChange = this._onChange.bind(this);
|
||||
|
@ -32,7 +31,7 @@ export default class DirectorySearchBox extends React.Component {
|
|||
this.input = null;
|
||||
|
||||
this.state = {
|
||||
value: '',
|
||||
value: this.props.initialText || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -78,28 +77,33 @@ export default class DirectorySearchBox extends React.Component {
|
|||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const searchbox_classes = {
|
||||
const searchboxClasses = {
|
||||
mx_DirectorySearchBox: true,
|
||||
};
|
||||
searchbox_classes[this.props.className] = true;
|
||||
searchboxClasses[this.props.className] = true;
|
||||
|
||||
let join_button;
|
||||
let joinButton;
|
||||
if (this.props.showJoinButton) {
|
||||
join_button = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
|
||||
joinButton = <AccessibleButton className="mx_DirectorySearchBox_joinButton"
|
||||
onClick={this._onJoinButtonClick}
|
||||
>{_t("Join")}</AccessibleButton>;
|
||||
}
|
||||
|
||||
return <div className={`mx_DirectorySearchBox ${this.props.className} mx_textinput`}>
|
||||
<input type="text" name="dirsearch" value={this.state.value}
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
ref={this._collectInput}
|
||||
onChange={this._onChange} onKeyUp={this._onKeyUp}
|
||||
placeholder={this.props.placeholder} autoFocus
|
||||
/>
|
||||
{ join_button }
|
||||
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick}></AccessibleButton>
|
||||
</div>;
|
||||
<input
|
||||
type="text"
|
||||
name="dirsearch"
|
||||
value={this.state.value}
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
ref={this._collectInput}
|
||||
onChange={this._onChange}
|
||||
onKeyUp={this._onKeyUp}
|
||||
placeholder={this.props.placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
{ joinButton }
|
||||
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -110,4 +114,5 @@ DirectorySearchBox.propTypes = {
|
|||
onJoinClick: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
showJoinButton: PropTypes.bool,
|
||||
initialText: PropTypes.string,
|
||||
};
|
||||
|
|
|
@ -124,7 +124,7 @@ export default class EditableItemList extends React.Component {
|
|||
<Field label={this.props.placeholder} type="text"
|
||||
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
|
||||
list={this.props.suggestionsListId} />
|
||||
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit">
|
||||
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
|
||||
{_t("Add")}
|
||||
</AccessibleButton>
|
||||
</form>
|
||||
|
|
94
src/components/views/elements/EffectsOverlay.tsx
Normal file
94
src/components/views/elements/EffectsOverlay.tsx
Normal file
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
Copyright 2020 Nurjin Jafar
|
||||
Copyright 2020 Nordeck IT + Consulting GmbH.
|
||||
|
||||
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, { FunctionComponent, useEffect, useRef } from 'react';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import ICanvasEffect from '../../../effects/ICanvasEffect';
|
||||
import {CHAT_EFFECTS} from '../../../effects'
|
||||
|
||||
interface IProps {
|
||||
roomWidth: number;
|
||||
}
|
||||
|
||||
const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const effectsRef = useRef<Map<string, ICanvasEffect>>(new Map<string, ICanvasEffect>());
|
||||
|
||||
const lazyLoadEffectModule = async (name: string): Promise<ICanvasEffect> => {
|
||||
if (!name) return null;
|
||||
let effect: ICanvasEffect | null = effectsRef.current[name] || null;
|
||||
if (effect === null) {
|
||||
const options = CHAT_EFFECTS.find((e) => e.command === name)?.options
|
||||
try {
|
||||
const { default: Effect } = await import(`../../../effects/${name}`);
|
||||
effect = new Effect(options);
|
||||
effectsRef.current[name] = effect;
|
||||
} catch (err) {
|
||||
console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err);
|
||||
}
|
||||
}
|
||||
return effect;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.height = window.innerHeight;
|
||||
}
|
||||
};
|
||||
const onAction = (payload: { action: string }) => {
|
||||
const actionPrefix = 'effects.';
|
||||
if (payload.action.indexOf(actionPrefix) === 0) {
|
||||
const effect = payload.action.substr(actionPrefix.length);
|
||||
lazyLoadEffectModule(effect).then((module) => module?.start(canvasRef.current));
|
||||
}
|
||||
}
|
||||
const dispatcherRef = dis.register(onAction);
|
||||
const canvas = canvasRef.current;
|
||||
canvas.height = window.innerHeight;
|
||||
window.addEventListener('resize', resize, true);
|
||||
|
||||
return () => {
|
||||
dis.unregister(dispatcherRef);
|
||||
window.removeEventListener('resize', resize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored
|
||||
for (const effect in currentEffects) {
|
||||
const effectModule: ICanvasEffect = currentEffects[effect];
|
||||
if (effectModule && effectModule.isRunning) {
|
||||
effectModule.stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={roomWidth}
|
||||
style={{
|
||||
display: 'block',
|
||||
zIndex: 999999,
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default EffectsOverlay;
|
|
@ -22,6 +22,7 @@ import * as Avatar from '../../../Avatar';
|
|||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import EventTile from '../rooms/EventTile';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {Layout} from "../../../settings/Layout";
|
||||
import {UIFeature} from "../../../settings/UIFeature";
|
||||
|
||||
interface IProps {
|
||||
|
@ -33,7 +34,7 @@ interface IProps {
|
|||
/**
|
||||
* Whether to use the irc layout or not
|
||||
*/
|
||||
useIRCLayout: boolean;
|
||||
layout: Layout;
|
||||
|
||||
/**
|
||||
* classnames to apply to the wrapper of the preview
|
||||
|
@ -121,14 +122,14 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
|
|||
const event = this.fakeEvent(this.state);
|
||||
|
||||
const className = classnames(this.props.className, {
|
||||
"mx_IRCLayout": this.props.useIRCLayout,
|
||||
"mx_GroupLayout": !this.props.useIRCLayout,
|
||||
"mx_IRCLayout": this.props.layout == Layout.IRC,
|
||||
"mx_GroupLayout": this.props.layout == Layout.Group,
|
||||
});
|
||||
|
||||
return <div className={className}>
|
||||
<EventTile
|
||||
mxEvent={event}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
layout={this.props.layout}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
/>
|
||||
</div>;
|
||||
|
|
|
@ -61,10 +61,14 @@ interface IProps {
|
|||
tooltipClassName?: string;
|
||||
// If specified, an additional class name to apply to the field container
|
||||
className?: string;
|
||||
// On what events should validation occur; by default on all
|
||||
validateOnFocus?: boolean;
|
||||
validateOnBlur?: boolean;
|
||||
validateOnChange?: boolean;
|
||||
// All other props pass through to the <input>.
|
||||
}
|
||||
|
||||
interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
// The element to create. Defaults to "input".
|
||||
element?: "input";
|
||||
// The input's value. This is a controlled component, so the value is required.
|
||||
|
@ -100,6 +104,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
public static readonly defaultProps = {
|
||||
element: "input",
|
||||
type: "text",
|
||||
validateOnFocus: true,
|
||||
validateOnBlur: true,
|
||||
validateOnChange: true,
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -137,9 +144,11 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
this.setState({
|
||||
focused: true,
|
||||
});
|
||||
this.validate({
|
||||
focused: true,
|
||||
});
|
||||
if (this.props.validateOnFocus) {
|
||||
this.validate({
|
||||
focused: true,
|
||||
});
|
||||
}
|
||||
// Parent component may have supplied its own `onFocus` as well
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
|
@ -147,7 +156,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
};
|
||||
|
||||
private onChange = (ev) => {
|
||||
this.validateOnChange();
|
||||
if (this.props.validateOnChange) {
|
||||
this.validateOnChange();
|
||||
}
|
||||
// Parent component may have supplied its own `onChange` as well
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(ev);
|
||||
|
@ -158,16 +169,18 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
this.setState({
|
||||
focused: false,
|
||||
});
|
||||
this.validate({
|
||||
focused: false,
|
||||
});
|
||||
if (this.props.validateOnBlur) {
|
||||
this.validate({
|
||||
focused: false,
|
||||
});
|
||||
}
|
||||
// Parent component may have supplied its own `onBlur` as well
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private async validate({ focused, allowEmpty = true }: {focused: boolean, allowEmpty?: boolean}) {
|
||||
public async validate({ focused, allowEmpty = true }: {focused?: boolean, allowEmpty?: boolean}) {
|
||||
if (!this.props.onValidate) {
|
||||
return;
|
||||
}
|
||||
|
@ -196,12 +209,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
feedbackVisible: false,
|
||||
});
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
public render() {
|
||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const { element, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
|
||||
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
|
||||
...inputProps} = this.props;
|
||||
|
||||
// Set some defaults for the <input> element
|
||||
const ref = input => this.input = input;
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
export default function IconButton(props) {
|
||||
const {icon, className, ...restProps} = props;
|
||||
|
||||
let newClassName = (className || "") + " mx_IconButton";
|
||||
newClassName = newClassName + " mx_IconButton_icon_" + icon;
|
||||
|
||||
const allProps = Object.assign({}, restProps, {className: newClassName});
|
||||
|
||||
return React.createElement(AccessibleButton, allProps);
|
||||
}
|
||||
|
||||
IconButton.propTypes = Object.assign({
|
||||
icon: PropTypes.string,
|
||||
}, AccessibleButton.propTypes);
|
|
@ -100,10 +100,10 @@ export default class LanguageDropdown extends React.Component {
|
|||
let language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
|
||||
let value = null;
|
||||
if (language) {
|
||||
value = this.props.value || language;
|
||||
value = this.props.value || language;
|
||||
} else {
|
||||
language = navigator.language || navigator.userLanguage;
|
||||
value = this.props.value || language;
|
||||
language = navigator.language || navigator.userLanguage;
|
||||
value = this.props.value || language;
|
||||
}
|
||||
|
||||
return <Dropdown
|
||||
|
|
102
src/components/views/elements/MiniAvatarUploader.tsx
Normal file
102
src/components/views/elements/MiniAvatarUploader.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
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, useRef, useState} from 'react';
|
||||
import {EventType} from 'matrix-js-sdk/src/@types/event';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {useTimeout} from "../../../hooks/useTimeout";
|
||||
import Analytics from "../../../Analytics";
|
||||
import CountlyAnalytics from '../../../CountlyAnalytics';
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
|
||||
export const AVATAR_SIZE = 52;
|
||||
|
||||
interface IProps {
|
||||
hasAvatar: boolean;
|
||||
noAvatarLabel?: string;
|
||||
hasAvatarLabel?: string;
|
||||
setAvatarUrl(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useTimeout(() => {
|
||||
setShow(true);
|
||||
}, 3000); // show after 3 seconds
|
||||
useTimeout(() => {
|
||||
setShow(false);
|
||||
}, 13000); // hide after being shown for 10 seconds
|
||||
|
||||
const uploadRef = useRef<HTMLInputElement>();
|
||||
|
||||
const label = (hasAvatar || busy) ? hasAvatarLabel : noAvatarLabel;
|
||||
|
||||
const {room} = useContext(RoomContext);
|
||||
const canSetAvatar = room?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId());
|
||||
if (!canSetAvatar) return <React.Fragment>{ children }</React.Fragment>;
|
||||
|
||||
const visible = !!label && (hover || show);
|
||||
return <React.Fragment>
|
||||
<input
|
||||
type="file"
|
||||
ref={uploadRef}
|
||||
className="mx_MiniAvatarUploader_input"
|
||||
onChange={async (ev) => {
|
||||
if (!ev.target.files?.length) return;
|
||||
setBusy(true);
|
||||
Analytics.trackEvent("mini_avatar", "upload");
|
||||
CountlyAnalytics.instance.track("mini_avatar_upload");
|
||||
const file = ev.target.files[0];
|
||||
const uri = await cli.uploadContent(file);
|
||||
await setAvatarUrl(uri);
|
||||
setBusy(false);
|
||||
}}
|
||||
accept="image/*"
|
||||
/>
|
||||
|
||||
<AccessibleButton
|
||||
className={classNames("mx_MiniAvatarUploader", {
|
||||
mx_MiniAvatarUploader_busy: busy,
|
||||
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
|
||||
})}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
uploadRef.current.click();
|
||||
}}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
{ children }
|
||||
|
||||
<div className={classNames("mx_Tooltip", {
|
||||
"mx_Tooltip_visible": visible,
|
||||
"mx_Tooltip_invisible": !visible,
|
||||
})}>
|
||||
<div className="mx_Tooltip_chevron" />
|
||||
{ label }
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
export default MiniAvatarUploader;
|
|
@ -21,6 +21,9 @@ import {throttle} from "lodash";
|
|||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import {isNullOrUndefined} from "matrix-js-sdk/src/utils";
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
|
@ -59,6 +62,9 @@ export default class PersistedElement extends React.Component {
|
|||
// Any PersistedElements with the same persistKey will use
|
||||
// the same DOM container.
|
||||
persistKey: PropTypes.string.isRequired,
|
||||
|
||||
// z-index for the element. Defaults to 9.
|
||||
zIndex: PropTypes.number,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -144,9 +150,11 @@ export default class PersistedElement extends React.Component {
|
|||
}
|
||||
|
||||
renderApp() {
|
||||
const content = <div ref={this.collectChild} style={this.props.style}>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
const content = <MatrixClientContext.Provider value={MatrixClientPeg.get()}>
|
||||
<div ref={this.collectChild} style={this.props.style}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</MatrixClientContext.Provider>;
|
||||
|
||||
ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey));
|
||||
}
|
||||
|
@ -161,6 +169,7 @@ export default class PersistedElement extends React.Component {
|
|||
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
Object.assign(child.style, {
|
||||
zIndex: isNullOrUndefined(this.props.zIndex) ? 9 : this.props.zIndex,
|
||||
position: 'absolute',
|
||||
top: parentRect.top + 'px',
|
||||
left: parentRect.left + 'px',
|
||||
|
@ -173,3 +182,5 @@ export default class PersistedElement extends React.Component {
|
|||
return <div ref={this.collectChildContainer} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const getPersistKey = (appId: string) => 'widget_' + appId;
|
||||
|
|
|
@ -31,6 +31,7 @@ export default class PersistentApp extends React.Component {
|
|||
componentDidMount() {
|
||||
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
|
||||
ActiveWidgetStore.on('update', this._onActiveWidgetStoreUpdate);
|
||||
MatrixClientPeg.get().on("Room.myMembership", this._onMyMembership);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -38,6 +39,9 @@ export default class PersistentApp extends React.Component {
|
|||
this._roomStoreToken.remove();
|
||||
}
|
||||
ActiveWidgetStore.removeListener('update', this._onActiveWidgetStoreUpdate);
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Room.myMembership", this._onMyMembership);
|
||||
}
|
||||
}
|
||||
|
||||
_onRoomViewStoreUpdate = payload => {
|
||||
|
@ -53,16 +57,28 @@ export default class PersistentApp extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
_onMyMembership = async (room, membership) => {
|
||||
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
|
||||
if (membership !== "join") {
|
||||
// we're not in the room anymore - delete
|
||||
if (room.roomId === persistentWidgetInRoomId) {
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.state.persistentWidgetId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.persistentWidgetId) {
|
||||
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
|
||||
if (this.state.roomId !== persistentWidgetInRoomId) {
|
||||
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
|
||||
|
||||
// Sanity check the room - the widget may have been destroyed between render cycles, and
|
||||
// thus no room is associated anymore.
|
||||
if (!persistentWidgetInRoom) return null;
|
||||
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
|
||||
|
||||
// Sanity check the room - the widget may have been destroyed between render cycles, and
|
||||
// thus no room is associated anymore.
|
||||
if (!persistentWidgetInRoom) return null;
|
||||
|
||||
const myMembership = persistentWidgetInRoom.getMyMembership();
|
||||
if (this.state.roomId !== persistentWidgetInRoomId && myMembership === "join") {
|
||||
// get the widget data
|
||||
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
|
||||
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
|
||||
|
@ -71,7 +87,6 @@ export default class PersistentApp extends React.Component {
|
|||
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
|
||||
persistentWidgetInRoomId, appEvent.getId(),
|
||||
);
|
||||
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
|
||||
const AppTile = sdk.getComponent('elements.AppTile');
|
||||
return <AppTile
|
||||
key={app.id}
|
||||
|
@ -79,13 +94,9 @@ 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}
|
||||
/>;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -23,23 +23,11 @@ import { Room, RoomMember } from 'matrix-js-sdk';
|
|||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import FlairStore from "../../../stores/FlairStore";
|
||||
import {getPrimaryPermalinkEntity} from "../../../utils/permalinks/Permalinks";
|
||||
import {getPrimaryPermalinkEntity, parseAppLocalLink} from "../../../utils/permalinks/Permalinks";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
// For URLs of matrix.to links in the timeline which have been reformatted by
|
||||
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
|
||||
const REGEX_LOCAL_PERMALINK = /^#\/(?:user|room|group)\/(([#!@+]).*?)(?=\/|\?|$)/;
|
||||
|
||||
class Pill extends React.Component {
|
||||
static isPillUrl(url) {
|
||||
return !!getPrimaryPermalinkEntity(url);
|
||||
}
|
||||
|
||||
static isMessagePillUrl(url) {
|
||||
return !!REGEX_LOCAL_PERMALINK.exec(url);
|
||||
}
|
||||
|
||||
static roomNotifPos(text) {
|
||||
return text.indexOf("@room");
|
||||
}
|
||||
|
@ -56,7 +44,7 @@ class Pill extends React.Component {
|
|||
static propTypes = {
|
||||
// The Type of this Pill. If url is given, this is auto-detected.
|
||||
type: PropTypes.string,
|
||||
// The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl)
|
||||
// The URL to pillify (no validation is done)
|
||||
url: PropTypes.string,
|
||||
// Whether the pill is in a message
|
||||
inMessage: PropTypes.bool,
|
||||
|
@ -90,12 +78,9 @@ class Pill extends React.Component {
|
|||
|
||||
if (nextProps.url) {
|
||||
if (nextProps.inMessage) {
|
||||
// Default to the empty array if no match for simplicity
|
||||
// resource and prefix will be undefined instead of throwing
|
||||
const matrixToMatch = REGEX_LOCAL_PERMALINK.exec(nextProps.url) || [];
|
||||
|
||||
resourceId = matrixToMatch[1]; // The room/user ID
|
||||
prefix = matrixToMatch[2]; // The first character of prefix
|
||||
const parts = parseAppLocalLink(nextProps.url);
|
||||
resourceId = parts.primaryEntityId; // The room/user ID
|
||||
prefix = parts.sigil; // The first character of prefix
|
||||
} else {
|
||||
resourceId = getPrimaryPermalinkEntity(nextProps.url);
|
||||
prefix = resourceId ? resourceId[0] : undefined;
|
||||
|
|
|
@ -24,6 +24,7 @@ import {wantsDateSeparator} from '../../../DateUtils';
|
|||
import {MatrixEvent} from 'matrix-js-sdk';
|
||||
import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {LayoutPropType} from "../../../settings/Layout";
|
||||
import escapeHtml from "escape-html";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
@ -42,7 +43,7 @@ export default class ReplyThread extends React.Component {
|
|||
onHeightChanged: PropTypes.func.isRequired,
|
||||
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
|
||||
// Specifies which layout to use.
|
||||
useIRCLayout: PropTypes.bool,
|
||||
layout: LayoutPropType,
|
||||
};
|
||||
|
||||
static contextType = MatrixClientContext;
|
||||
|
@ -209,7 +210,7 @@ export default class ReplyThread extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, useIRCLayout) {
|
||||
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) {
|
||||
if (!ReplyThread.getParentEventId(parentEv)) {
|
||||
return <div className="mx_ReplyThread_wrapper_empty" />;
|
||||
}
|
||||
|
@ -218,7 +219,7 @@ export default class ReplyThread extends React.Component {
|
|||
onHeightChanged={onHeightChanged}
|
||||
ref={ref}
|
||||
permalinkCreator={permalinkCreator}
|
||||
useIRCLayout={useIRCLayout}
|
||||
layout={layout}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@ -386,7 +387,7 @@ export default class ReplyThread extends React.Component {
|
|||
permalinkCreator={this.props.permalinkCreator}
|
||||
isRedacted={ev.isRedacted()}
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
layout={this.props.layout}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
replacingEventId={ev.replacingEventId()}
|
||||
/>
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
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 from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
const RoomDirectoryButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action={Action.ViewRoomDirectory}
|
||||
mouseOverAction={props.callout ? "callout_room_directory" : null}
|
||||
label={_t("Room directory")}
|
||||
iconPath={require("../../../../res/img/icons-directory.svg")}
|
||||
size={props.size}
|
||||
tooltip={props.tooltip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
RoomDirectoryButton.propTypes = {
|
||||
size: PropTypes.string,
|
||||
tooltip: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default RoomDirectoryButton;
|
40
src/components/views/elements/RoomName.tsx
Normal file
40
src/components/views/elements/RoomName.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
children?(name: string): JSX.Element;
|
||||
}
|
||||
|
||||
const RoomName = ({ room, children }: IProps): JSX.Element => {
|
||||
const [name, setName] = useState(room?.name);
|
||||
useEventEmitter(room, "Room.name", () => {
|
||||
setName(room?.name);
|
||||
});
|
||||
useEffect(() => {
|
||||
setName(room?.name);
|
||||
}, [room]);
|
||||
|
||||
if (children) return children(name);
|
||||
return name || "";
|
||||
};
|
||||
|
||||
export default RoomName;
|
45
src/components/views/elements/RoomTopic.tsx
Normal file
45
src/components/views/elements/RoomTopic.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {EventType} from "matrix-js-sdk/src/@types/event";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import {useEventEmitter} from "../../../hooks/useEventEmitter";
|
||||
import {linkifyElement} from "../../../HtmlUtils";
|
||||
|
||||
interface IProps {
|
||||
room?: Room;
|
||||
children?(topic: string, ref: (element: HTMLElement) => void): JSX.Element;
|
||||
}
|
||||
|
||||
export const getTopic = room => room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic;
|
||||
|
||||
const RoomTopic = ({ room, children }: IProps): JSX.Element => {
|
||||
const [topic, setTopic] = useState(getTopic(room));
|
||||
useEventEmitter(room.currentState, "RoomState.events", () => {
|
||||
setTopic(getTopic(room));
|
||||
});
|
||||
useEffect(() => {
|
||||
setTopic(getTopic(room));
|
||||
}, [room]);
|
||||
|
||||
const ref = e => e && linkifyElement(e);
|
||||
if (children) return children(topic, ref);
|
||||
return <span ref={ref}>{ topic }</span>;
|
||||
};
|
||||
|
||||
export default RoomTopic;
|
|
@ -1,42 +0,0 @@
|
|||
/*
|
||||
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 from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import {_t} from "../../../languageHandler";
|
||||
|
||||
const SSOButton = ({matrixClient, loginType, fragmentAfterLogin, ...props}) => {
|
||||
const onClick = () => {
|
||||
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin);
|
||||
};
|
||||
|
||||
return (
|
||||
<AccessibleButton {...props} kind="primary" onClick={onClick}>
|
||||
{_t("Sign in with single sign-on")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
|
||||
SSOButton.propTypes = {
|
||||
matrixClient: PropTypes.object.isRequired, // does not use context as may use a temporary client
|
||||
loginType: PropTypes.oneOf(["sso", "cas"]), // defaults to "sso" in base-apis
|
||||
fragmentAfterLogin: PropTypes.string,
|
||||
};
|
||||
|
||||
export default SSOButton;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue