Merge remote-tracking branch 'origin/develop' into hs/blocked-err

This commit is contained in:
Will Hunt 2021-03-05 18:50:00 +00:00
commit d07069238f
269 changed files with 18170 additions and 6833 deletions

View file

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

View file

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

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

View file

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

View file

@ -39,6 +39,7 @@ 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;
@ -388,12 +389,19 @@ 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}
@ -406,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,
});
@ -417,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()}

View file

@ -56,7 +56,7 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
useEffect(onResize, [expanded]);
useEffect(onResize, [expanded, onResize]);
const [onFocus, isActive, ref] = useRovingTabIndex();
const tabIndex = isActive ? 0 : -1;

View file

@ -54,6 +54,8 @@ import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPa
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.
@ -90,6 +92,7 @@ interface IProps {
currentGroupId?: string;
currentGroupIsNew?: boolean;
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;
}
interface IUsageLimit {
@ -110,7 +113,9 @@ interface IState {
errcode: string;
};
};
usageLimitDismissed: boolean;
usageLimitEventContent?: IUsageLimit;
usageLimitEventTs?: number;
useCompactLayout: boolean;
}
@ -154,6 +159,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
@ -219,10 +225,19 @@ class LoggedInView extends React.Component<IProps, IState> {
_createResizer() {
let size;
let collapsed;
const collapseConfig: ICollapseConfig = {
toggleSize: 260 - 50,
onCollapsed: (collapsed) => {
if (collapsed) {
// 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 {
@ -237,9 +252,12 @@ 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({
@ -303,14 +321,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();
}
@ -321,10 +352,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) {
@ -334,6 +367,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' &&
@ -342,7 +380,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) => {
@ -429,6 +472,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"
@ -584,6 +635,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;
@ -642,6 +694,7 @@ class LoggedInView extends React.Component<IProps, IState> {
</div>
<CallContainer />
<NonUrgentToastContainer />
<HostSignupContainer />
</MatrixClientContext.Provider>
);
}

View file

@ -48,7 +48,7 @@ import * as Lifecycle from '../../Lifecycle';
import '../../stores/LifecycleStore';
import PageTypes from '../../PageTypes';
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";
@ -81,6 +81,9 @@ import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from
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 {
@ -143,6 +146,8 @@ interface IRoomInfo {
oob_data?: object;
via_servers?: string[];
threepid_invite?: IThreepidInvite;
justCreatedOpts?: IOpts;
}
/* eslint-enable camelcase */
@ -200,6 +205,7 @@ interface IState {
viaServers?: string[];
pendingInitialSync?: boolean;
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;
}
export default class MatrixChat extends React.PureComponent<IProps, IState> {
@ -218,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;
@ -323,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
@ -353,6 +368,42 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
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
// eslint-disable-next-line camelcase
UNSAFE_componentWillUpdate(props, state) {
@ -642,10 +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, {
initialText: payload.initialText,
}, '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();
@ -709,6 +767,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
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 &&
@ -874,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);
});
@ -1020,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 = [];
@ -1029,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>
));
}
@ -1042,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>
),
@ -1060,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,
});
}
},
});
@ -1186,6 +1257,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
) {
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() {
@ -1322,6 +1398,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
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();
@ -1332,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',
});
@ -1601,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
@ -1833,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
@ -1910,6 +1963,7 @@ 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.LOGGED_IN) {

View file

@ -23,9 +23,11 @@ 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";
@ -135,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);
@ -207,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) {
@ -224,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"),
@ -612,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>
@ -810,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}

View file

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

View file

@ -477,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";
@ -523,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>&nbsp;
<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>&nbsp;
<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) => {
@ -606,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
@ -617,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}

View file

@ -34,11 +34,10 @@ 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, Key } from '../../Keyboard';
@ -48,6 +47,7 @@ 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";
@ -79,6 +79,9 @@ 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) {};
@ -113,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;
@ -181,13 +185,14 @@ export interface IState {
};
canReact: boolean;
canReply: boolean;
useIRCLayout: boolean;
layout: Layout;
matrixClientIsReady: boolean;
showUrlPreview?: boolean;
e2eStatus?: E2EStatus;
rejecting?: boolean;
rejectError?: Error;
hasPinnedWidgets?: boolean;
dragCounter: number;
}
export default class RoomView extends React.Component<IProps, IState> {
@ -236,8 +241,9 @@ 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(),
dragCounter: 0,
};
this.dispatcherRef = dis.register(this.onAction);
@ -264,13 +270,7 @@ export default class RoomView extends React.Component<IProps, IState> {
this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
this.onReadReceiptsChange);
this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange);
}
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this.onRoomViewStoreUpdate(true);
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange);
}
private onWidgetStoreUpdate = () => {
@ -512,6 +512,8 @@ export default class RoomView extends React.Component<IProps, IState> {
}
componentDidMount() {
this.onRoomViewStoreUpdate(true);
const call = this.getCallForRoom();
const callState = call ? call.state : null;
this.setState({
@ -526,8 +528,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}
shouldComponentUpdate(nextProps, nextState) {
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
!ObjectUtils.shallowEqual(this.state, nextState));
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState));
}
componentDidUpdate() {
@ -536,8 +537,8 @@ export default class RoomView extends React.Component<IProps, IState> {
if (!roomView.ondrop) {
roomView.addEventListener('drop', this.onDrop);
roomView.addEventListener('dragover', this.onDragOver);
roomView.addEventListener('dragleave', this.onDragLeaveOrEnd);
roomView.addEventListener('dragend', this.onDragLeaveOrEnd);
roomView.addEventListener('dragenter', this.onDragEnter);
roomView.addEventListener('dragleave', this.onDragLeave);
}
}
@ -581,8 +582,8 @@ export default class RoomView extends React.Component<IProps, IState> {
const roomView = this.roomView.current;
roomView.removeEventListener('drop', this.onDrop);
roomView.removeEventListener('dragover', this.onDragOver);
roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd);
roomView.removeEventListener('dragend', this.onDragLeaveOrEnd);
roomView.removeEventListener('dragenter', this.onDragEnter);
roomView.removeEventListener('dragleave', this.onDragLeave);
}
dis.unregister(this.dispatcherRef);
if (this.context) {
@ -642,7 +643,7 @@ export default class RoomView extends React.Component<IProps, IState> {
private onLayoutChange = () => {
this.setState({
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
layout: SettingsStore.getValue("layout"),
});
};
@ -766,6 +767,9 @@ export default class RoomView extends React.Component<IProps, IState> {
});
}
break;
case 'focus_search':
this.onSearchClick();
break;
}
};
@ -1139,6 +1143,31 @@ export default class RoomView extends React.Component<IProps, IState> {
this.updateTopUnreadMessagesBar();
};
private onDragEnter = ev => {
ev.stopPropagation();
ev.preventDefault();
this.setState({
dragCounter: this.state.dragCounter + 1,
draggingFile: true,
});
};
private onDragLeave = ev => {
ev.stopPropagation();
ev.preventDefault();
this.setState({
dragCounter: this.state.dragCounter - 1,
});
if (this.state.dragCounter === 0) {
this.setState({
draggingFile: false,
});
}
};
private onDragOver = ev => {
ev.stopPropagation();
ev.preventDefault();
@ -1146,7 +1175,6 @@ export default class RoomView extends React.Component<IProps, IState> {
ev.dataTransfer.dropEffect = 'none';
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
this.setState({ draggingFile: true });
ev.dataTransfer.dropEffect = 'copy';
}
};
@ -1157,14 +1185,12 @@ export default class RoomView extends React.Component<IProps, IState> {
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, this.context,
);
this.setState({ draggingFile: false });
dis.fire(Action.FocusComposer);
};
private onDragLeaveOrEnd = ev => {
ev.stopPropagation();
ev.preventDefault();
this.setState({ draggingFile: false });
this.setState({
draggingFile: false,
dragCounter: this.state.dragCounter - 1,
});
};
private injectSticker(url, info, text) {
@ -1353,6 +1379,14 @@ export default class RoomView extends React.Component<IProps, IState> {
SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned);
};
private onCallPlaced = (type: PlaceCallType) => {
dis.dispatch({
action: 'place_call',
type: type,
room_id: this.state.room.roomId,
});
};
private onSettingsClick = () => {
dis.dispatch({ action: "open_room_settings" });
};
@ -1390,7 +1424,7 @@ export default class RoomView extends React.Component<IProps, IState> {
});
};
private onRejectButtonClicked = ev => {
private onRejectButtonClicked = () => {
this.setState({
rejecting: true,
});
@ -1450,7 +1484,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.
@ -1713,7 +1747,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>
@ -1758,6 +1792,19 @@ export default class RoomView extends React.Component<IProps, IState> {
}
}
let fileDropTarget = null;
if (this.state.draggingFile) {
fileDropTarget = (
<div className="mx_RoomView_fileDropTarget">
<img
src={require("../../../res/img/upload-big.svg")}
className="mx_RoomView_fileDropTarget_image"
/>
{ _t("Drop file here to upload") }
</div>
);
}
// We have successfully loaded this room, and are not previewing.
// Display the "normal" room view.
@ -1842,7 +1889,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 }
@ -1864,12 +1911,23 @@ 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}
fullHeight={false}
userId={this.context.credentials.userId}
draggingFile={this.state.draggingFile}
maxHeight={this.state.auxPanelMaxHeight}
showApps={this.state.showApps}
onResize={this.onResize}
@ -1946,8 +2004,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);
@ -1970,7 +2028,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;
@ -2032,11 +2090,13 @@ export default class RoomView extends React.Component<IProps, IState> {
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">
{auxPanel}
<div className={timelineClasses}>
{fileDropTarget}
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}

View 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] : "");
}

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

View file

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

View file

@ -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);
@ -1442,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)}
/>
);

View file

@ -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 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 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()});
};
@ -272,7 +292,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let topSection;
const signupLink = getHostingLink("user-context-menu");
const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup;
if (MatrixClientPeg.get().isGuest()) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
@ -292,24 +312,19 @@ export default class UserMenu extends React.Component<IProps, IState> {
})}
</div>
)
} else if (signupLink) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_hostingLink">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
</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;
@ -517,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>

View file

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

View file

@ -343,8 +343,8 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
};
onTryRegisterClick = ev => {
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");
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.

View file

@ -609,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,
};
}
@ -618,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);
};
@ -656,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>;
}
}
@ -710,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 => {

View file

@ -196,7 +196,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
// 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;

View file

@ -194,7 +194,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
// 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;

View file

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

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

View file

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

View file

@ -34,6 +34,10 @@ import {
} 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};
@ -794,6 +798,286 @@ class WidgetExplorer extends React.Component {
}
}
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:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting))}</code>
</div>
<div>
{_t("Value in this room:")}&nbsp;
<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,
@ -802,6 +1086,7 @@ const Entries = [
ServersInRoomList,
VerificationExplorer,
WidgetExplorer,
SettingsExplorer,
];
export default class DevtoolsDialog extends React.PureComponent {

View file

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

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

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

View file

@ -27,7 +27,7 @@ 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,
@ -60,11 +60,11 @@ export default class InfoDialog extends React.Component {
<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>
);
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React, {createRef} from 'react';
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";
@ -48,6 +48,7 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
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
@ -309,7 +310,7 @@ interface IInviteDialogProps {
// not provided.
kind: string,
// The room ID this dialog is for. Only required for KIND_INVITE.
// 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.
@ -348,8 +349,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
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");
}
@ -1026,7 +1027,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
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");
}
@ -1247,38 +1248,35 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
buttonText = _t("Go");
goButtonFn = this._startDm;
} else if (this.props.kind === 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, email address, 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>,
},
);
} 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) {

View file

@ -35,13 +35,13 @@ import {
} from "matrix-widget-api";
import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RoomViewStore from "../../../stores/RoomViewStore";
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;
}
@ -123,7 +123,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
public render() {
const templated = this.widget.getCompleteUrl({
currentRoomId: RoomViewStore.getRoomId(),
widgetRoomId: this.props.widgetRoomId,
currentUserId: MatrixClientPeg.get().getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),

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

View file

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

View file

@ -70,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.widget.templateUrl.split("?")[0],
},
)}
{_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>
);

View file

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

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

View file

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

View file

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

View file

@ -23,6 +23,7 @@ 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
@ -61,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() {
@ -165,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',

View file

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

View file

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

View file

@ -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()}
/>

View file

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

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

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

View file

@ -22,13 +22,33 @@ import {MatrixClient} from "matrix-js-sdk/src/client";
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
import {IIdentityProvider, ISSOFlow} from "../../../Login";
import {IdentityProviderBrand, IIdentityProvider, ISSOFlow} from "../../../Login";
import AccessibleTooltipButton from "./AccessibleTooltipButton";
interface ISSOButtonProps extends Omit<IProps, "flow"> {
idp: IIdentityProvider;
mini?: boolean;
}
const getIcon = (brand: IdentityProviderBrand | string) => {
switch (brand) {
case IdentityProviderBrand.Apple:
return require(`../../../../res/img/element-icons/brands/apple.svg`);
case IdentityProviderBrand.Facebook:
return require(`../../../../res/img/element-icons/brands/facebook.svg`);
case IdentityProviderBrand.Github:
return require(`../../../../res/img/element-icons/brands/github.svg`);
case IdentityProviderBrand.Gitlab:
return require(`../../../../res/img/element-icons/brands/gitlab.svg`);
case IdentityProviderBrand.Google:
return require(`../../../../res/img/element-icons/brands/google.svg`);
case IdentityProviderBrand.Twitter:
return require(`../../../../res/img/element-icons/brands/twitter.svg`);
default:
return null;
}
}
const SSOButton: React.FC<ISSOButtonProps> = ({
matrixClient,
loginType,
@ -38,7 +58,6 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
mini,
...props
}) => {
const kind = primary ? "primary" : "primary_outline";
const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on");
const onClick = () => {
@ -46,30 +65,35 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
};
let icon;
if (typeof idp?.icon === "string" && (idp.icon.startsWith("mxc://") || idp.icon.startsWith("https://"))) {
icon = <img
src={matrixClient.mxcUrlToHttp(idp.icon, 24, 24, "crop", true)}
height="24"
width="24"
alt={label}
/>;
let brandClass;
const brandIcon = idp ? getIcon(idp.brand) : null;
if (brandIcon) {
const brandName = idp.brand.split(".").pop();
brandClass = `mx_SSOButton_brand_${brandName}`;
icon = <img src={brandIcon} height="24" width="24" alt={brandName} />;
} else if (typeof idp?.icon === "string" && idp.icon.startsWith("mxc://")) {
const src = matrixClient.mxcUrlToHttp(idp.icon, 24, 24, "crop", true);
icon = <img src={src} height="24" width="24" alt={idp.name} />;
}
const classes = classNames("mx_SSOButton", {
[brandClass]: brandClass,
mx_SSOButton_mini: mini,
mx_SSOButton_default: !idp,
mx_SSOButton_primary: primary,
});
if (mini) {
// TODO fallback icon
return (
<AccessibleButton {...props} className={classes} kind={kind} onClick={onClick}>
<AccessibleTooltipButton {...props} title={label} className={classes} onClick={onClick}>
{ icon }
</AccessibleButton>
</AccessibleTooltipButton>
);
}
return (
<AccessibleButton {...props} className={classes} kind={kind} onClick={onClick}>
<AccessibleButton {...props} className={classes} onClick={onClick}>
{ icon }
{ label }
</AccessibleButton>

View file

@ -0,0 +1,126 @@
/*
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 Dropdown from "../../views/elements/Dropdown"
import * as sdk from '../../../index';
import PlatformPeg from "../../../PlatformPeg";
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
function languageMatchesSearchQuery(query, language) {
if (language.label.toUpperCase().includes(query.toUpperCase())) return true;
if (language.value.toUpperCase() === query.toUpperCase()) return true;
return false;
}
interface SpellCheckLanguagesDropdownIProps {
className: string,
value: string,
onOptionChange(language: string),
}
interface SpellCheckLanguagesDropdownIState {
searchQuery: string,
languages: any,
}
export default class SpellCheckLanguagesDropdown extends React.Component<SpellCheckLanguagesDropdownIProps,
SpellCheckLanguagesDropdownIState> {
constructor(props) {
super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this.state = {
searchQuery: '',
languages: null,
};
}
componentDidMount() {
const plaf = PlatformPeg.get();
if (plaf) {
plaf.getAvailableSpellCheckLanguages().then((languages) => {
languages.sort(function(a, b) {
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
const langs = [];
languages.forEach((language) => {
langs.push({
label: language,
value: language,
})
})
this.setState({languages: langs});
}).catch((e) => {
this.setState({languages: ['en']});
});
}
}
_onSearchChange(search) {
this.setState({
searchQuery: search,
});
}
render() {
if (this.state.languages === null) {
const Spinner = sdk.getComponent('elements.Spinner');
return <Spinner />;
}
let displayedLanguages;
if (this.state.searchQuery) {
displayedLanguages = this.state.languages.filter((lang) => {
return languageMatchesSearchQuery(this.state.searchQuery, lang);
});
} else {
displayedLanguages = this.state.languages;
}
const options = displayedLanguages.map((language) => {
return <div key={language.value}>
{ language.label }
</div>;
});
// default value here too, otherwise we need to handle null / undefined;
// values between mounting and the initial value propgating
let language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
let value = null;
if (language) {
value = this.props.value || language;
} else {
language = navigator.language || navigator.userLanguage;
value = this.props.value || language;
}
return <Dropdown
id="mx_LanguageDropdown"
className={this.props.className}
onOptionChange={this.props.onOptionChange}
onSearchChange={this._onSearchChange}
searchEnabled={true}
value={value}
label={_t("Language Dropdown")}>
{ options }
</Dropdown>;
}
}

View file

@ -1,40 +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';
const StartChatButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_create_chat"
mouseOverAction={props.callout ? "callout_start_chat" : null}
label={_t("Start chat")}
iconPath={require("../../../../res/img/icons-people.svg")}
size={props.size}
tooltip={props.tooltip}
/>
);
};
StartChatButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default StartChatButton;

View file

@ -1,63 +0,0 @@
/*
Copyright 2017 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 TintableSvg from './TintableSvg';
import AccessibleButton from './AccessibleButton';
export default class TintableSvgButton extends React.Component {
constructor(props) {
super(props);
}
render() {
let classes = "mx_TintableSvgButton";
if (this.props.className) {
classes += " " + this.props.className;
}
return (
<span
width={this.props.width}
height={this.props.height}
className={classes}>
<TintableSvg
src={this.props.src}
width={this.props.width}
height={this.props.height}
></TintableSvg>
<AccessibleButton
onClick={this.props.onClick}
element='span'
title={this.props.title}
/>
</span>
);
}
}
TintableSvgButton.propTypes = {
src: PropTypes.string,
title: PropTypes.string,
className: PropTypes.string,
width: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
onClick: PropTypes.func,
};
TintableSvgButton.defaultProps = {
onClick: function() {},
};

View file

@ -0,0 +1,36 @@
/*
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 HostSignupDialog from "../dialogs/HostSignupDialog";
import { HostSignupStore } from "../../../stores/HostSignupStore";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
const HostSignupContainer = () => {
const [isActive, setIsActive] = useState(HostSignupStore.instance.isHostSignupActive);
useEventEmitter(HostSignupStore.instance, UPDATE_EVENT, () => {
setIsActive(HostSignupStore.instance.isHostSignupActive);
});
return <div className="mx_HostSignupContainer">
{isActive &&
<HostSignupDialog />
}
</div>;
};
export default HostSignupContainer

View file

@ -288,7 +288,7 @@ export default class MFileBody extends React.Component {
<a ref={this._dummyLink} />
</div>
<iframe
src={`${url}?origin=${encodeURIComponent(window.location.origin)}`}
src={url}
onLoad={onIframeLoad}
ref={this._iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />

View file

@ -37,11 +37,13 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const widgetId = this.props.mxEvent.getStateKey();
const widget = WidgetStore.instance.getRoom(room.roomId).widgets.find(w => w.id === widgetId);
const widget = WidgetStore.instance.getRoom(room.roomId, true).widgets.find(w => w.id === widgetId);
let joinCopy = _t('Join the conference at the top of this room');
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Right)) {
joinCopy = _t('Join the conference from the room information card on the right');
} else if (!widget) {
joinCopy = null;
}
if (!url) {

View file

@ -81,6 +81,7 @@ export default class TextualBody extends React.Component {
}
_applyFormatting() {
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
this.activateSpoilers([this._content.current]);
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
@ -91,29 +92,150 @@ export default class TextualBody extends React.Component {
this.calculateUrlPreview();
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
const blocks = ReactDOM.findDOMNode(this).getElementsByTagName("code");
if (blocks.length > 0) {
// Handle expansion and add buttons
const pres = ReactDOM.findDOMNode(this).getElementsByTagName("pre");
if (pres.length > 0) {
for (let i = 0; i < pres.length; i++) {
// If there already is a div wrapping the codeblock we want to skip this.
// This happens after the codeblock was edited.
if (pres[i].parentNode.className == "mx_EventTile_pre_container") continue;
// Add code element if it's missing since we depend on it
if (pres[i].getElementsByTagName("code").length == 0) {
this._addCodeElement(pres[i]);
}
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally.
const div = this._wrapInDiv(pres[i]);
this._handleCodeBlockExpansion(pres[i]);
this._addCodeExpansionButton(div, pres[i]);
this._addCodeCopyButton(div);
if (showLineNumbers) {
this._addLineNumbers(pres[i]);
}
}
}
// Highlight code
const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code");
if (codes.length > 0) {
// Do this asynchronously: parsing code takes time and we don't
// need to block the DOM update on it.
setTimeout(() => {
if (this._unmounted) return;
for (let i = 0; i < blocks.length; i++) {
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
highlight.highlightBlock(blocks[i]);
} else {
// Only syntax highlight if there's a class starting with language-
const classes = blocks[i].className.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-') && !cl.startsWith('language-_');
});
if (classes.length != 0) {
highlight.highlightBlock(blocks[i]);
}
}
for (let i = 0; i < codes.length; i++) {
// If the code already has the hljs class we want to skip this.
// This happens after the codeblock was edited.
if (codes[i].className.includes("hljs")) continue;
this._highlightCode(codes[i]);
}
}, 10);
}
this._addCodeCopyButton();
}
}
_addCodeElement(pre) {
const code = document.createElement("code");
code.append(...pre.childNodes);
pre.appendChild(code);
}
_addCodeExpansionButton(div, pre) {
// Calculate how many percent does the pre element take up.
// If it's less than 30% we don't add the expansion button.
const percentageOfViewport = pre.offsetHeight / window.innerHeight * 100;
if (percentageOfViewport < 30) return;
const button = document.createElement("span");
button.className = "mx_EventTile_button ";
if (pre.className == "mx_EventTile_collapsedCodeBlock") {
button.className += "mx_EventTile_expandButton";
} else {
button.className += "mx_EventTile_collapseButton";
}
button.onclick = async () => {
button.className = "mx_EventTile_button ";
if (pre.className == "mx_EventTile_collapsedCodeBlock") {
pre.className = "";
button.className += "mx_EventTile_collapseButton";
} else {
pre.className = "mx_EventTile_collapsedCodeBlock";
button.className += "mx_EventTile_expandButton";
}
// By expanding/collapsing we changed
// the height, therefore we call this
this.props.onHeightChanged();
};
div.appendChild(button);
}
_addCodeCopyButton(div) {
const button = document.createElement("span");
button.className = "mx_EventTile_button mx_EventTile_copyButton ";
// Check if expansion button exists. If so
// we put the copy button to the bottom
const expansionButtonExists = div.getElementsByClassName("mx_EventTile_button");
if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
button.onclick = async () => {
const copyCode = button.parentNode.getElementsByTagName("code")[0];
const successful = await copyPlaintext(copyCode.textContent);
const buttonRect = button.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
button.onmouseleave = close;
};
div.appendChild(button);
}
_wrapInDiv(pre) {
const div = document.createElement("div");
div.className = "mx_EventTile_pre_container";
// Insert containing div in place of <pre> block
pre.parentNode.replaceChild(div, pre);
// Append <pre> block and copy button to container
div.appendChild(pre);
return div;
}
_handleCodeBlockExpansion(pre) {
if (!SettingsStore.getValue("expandCodeByDefault")) {
pre.className = "mx_EventTile_collapsedCodeBlock";
}
}
_addLineNumbers(pre) {
pre.innerHTML = '<span class="mx_EventTile_lineNumbers"></span>' + pre.innerHTML + '<span></span>';
const lineNumbers = pre.getElementsByClassName("mx_EventTile_lineNumbers")[0];
// Calculate number of lines in pre
const number = pre.innerHTML.split(/\n/).length;
// Iterate through lines starting with 1 (number of the first line is 1)
for (let i = 1; i < number; i++) {
lineNumbers.innerHTML += '<span class="mx_EventTile_lineNumber">' + i + '</span>';
}
}
_highlightCode(code) {
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
highlight.highlightBlock(code);
} else {
// Only syntax highlight if there's a class starting with language-
const classes = code.className.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-') && !cl.startsWith('language-_');
});
if (classes.length != 0) {
highlight.highlightBlock(code);
}
}
}
@ -254,38 +376,6 @@ export default class TextualBody extends React.Component {
}
}
_addCodeCopyButton() {
// Add 'copy' buttons to pre blocks
Array.from(ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre')).forEach((p) => {
const button = document.createElement("span");
button.className = "mx_EventTile_copyButton";
button.onclick = async () => {
const copyCode = button.parentNode.getElementsByTagName("pre")[0];
const successful = await copyPlaintext(copyCode.textContent);
const buttonRect = button.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
button.onmouseleave = close;
};
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally.
const div = document.createElement("div");
div.className = "mx_EventTile_pre_container";
// Insert containing div in place of <pre> block
p.parentNode.replaceChild(div, p);
// Append <pre> block and copy button to container
div.appendChild(p);
div.appendChild(button);
});
}
onCancelClick = event => {
this.setState({ widgetHidden: true });
// FIXME: persist this somewhere smarter than local storage

View file

@ -33,6 +33,7 @@ interface IProps {
previousPhase?: RightPanelPhases;
closeLabel?: string;
onClose?(): void;
refireParams?;
}
interface IGroupProps {
@ -56,6 +57,7 @@ const BaseCard: React.FC<IProps> = ({
withoutScrollContainer,
previousPhase,
children,
refireParams,
}) => {
let backButton;
if (previousPhase) {
@ -63,6 +65,7 @@ const BaseCard: React.FC<IProps> = ({
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: previousPhase,
refireParams: refireParams,
});
};
backButton = <AccessibleButton className="mx_BaseCard_back" onClick={onBackClick} title={_t("Back")} />;

View file

@ -77,7 +77,7 @@ export const useWidgets = (room: Room) => {
setApps([...WidgetStore.instance.getApps(room.roomId)]);
}, [room]);
useEffect(updateApps, [room]);
useEffect(updateApps, [room, updateApps]);
useEventEmitter(WidgetStore.instance, room.roomId, updateApps);
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateApps);

View file

@ -60,6 +60,9 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
import InfoDialog from "../dialogs/InfoDialog";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
interface IDevice {
deviceId: string;
@ -301,7 +304,8 @@ const UserOptionsSection: React.FC<{
member: RoomMember;
isIgnored: boolean;
canInvite: boolean;
}> = ({member, isIgnored, canInvite}) => {
isSpace?: boolean;
}> = ({member, isIgnored, canInvite, isSpace}) => {
const cli = useContext(MatrixClientContext);
let ignoreButton = null;
@ -341,7 +345,7 @@ const UserOptionsSection: React.FC<{
</AccessibleButton>
);
if (member.roomId) {
if (member.roomId && !isSpace) {
const onReadReceiptButton = function() {
const room = cli.getRoom(member.roomId);
dis.dispatch({
@ -433,14 +437,18 @@ const UserOptionsSection: React.FC<{
);
};
const warnSelfDemote = async () => {
const warnSelfDemote = async (isSpace) => {
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
title: _t("Demote yourself?"),
description:
<div>
{ _t("You will not be able to undo this change as you are demoting yourself, " +
"if you are the last privileged user in the room it will be impossible " +
"to regain privileges.") }
{ isSpace
? _t("You will not be able to undo this change as you are demoting yourself, " +
"if you are the last privileged user in the space it will be impossible " +
"to regain privileges.")
: _t("You will not be able to undo this change as you are demoting yourself, " +
"if you are the last privileged user in the room it will be impossible " +
"to regain privileges.") }
</div>,
button: _t("Demote"),
});
@ -716,7 +724,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels,
// if muting self, warn as it may be irreversible
if (target === cli.getUserId()) {
try {
if (!(await warnSelfDemote())) return;
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
return;
@ -805,7 +813,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (me.powerLevel >= redactPowerLevel) {
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
@ -1084,7 +1092,7 @@ const PowerLevelEditor: React.FC<{
} else if (myUserId === target) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
try {
if (!(await warnSelfDemote())) return;
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
}
@ -1314,12 +1322,10 @@ const BasicUserInfo: React.FC<{
if (!isRoomEncrypted) {
if (!cryptoEnabled) {
text = _t("This client does not support end-to-end encryption.");
} else if (room) {
} else if (room && !room.isSpaceRoom()) {
text = _t("Messages in this room are not end-to-end encrypted.");
} else {
// TODO what to render for GroupMember
}
} else {
} else if (!room.isSpaceRoom()) {
text = _t("Messages in this room are end-to-end encrypted.");
}
@ -1380,7 +1386,9 @@ const BasicUserInfo: React.FC<{
<UserOptionsSection
canInvite={roomPermissions.canInvite}
isIgnored={isIgnored}
member={member} />
member={member}
isSpace={room?.isSpaceRoom()}
/>
{ adminToolsContainer }
@ -1497,7 +1505,7 @@ interface IProps {
user: Member;
groupId?: string;
room?: Room;
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo;
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo | RightPanelPhases.SpaceMemberInfo;
onClose(): void;
}
@ -1534,10 +1542,31 @@ const UserInfo: React.FC<Props> = ({
const classes = ["mx_UserInfo"];
let refireParams;
let previousPhase: RightPanelPhases;
// We have no previousPhase for when viewing a UserInfo from a Group or without a Room at this time
if (room && phase === RightPanelPhases.EncryptionPanel) {
previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = {member: member};
} else if (room) {
previousPhase = previousPhase = room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList;
}
const onEncryptionPanelClose = () => {
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: previousPhase,
refireParams: refireParams,
});
}
let content;
switch (phase) {
case RightPanelPhases.RoomMemberInfo:
case RightPanelPhases.GroupMemberInfo:
case RightPanelPhases.SpaceMemberInfo:
content = (
<BasicUserInfo
room={room}
@ -1553,19 +1582,13 @@ const UserInfo: React.FC<Props> = ({
<EncryptionPanel
{...props as React.ComponentProps<typeof EncryptionPanel>}
member={member}
onClose={onClose}
onClose={onEncryptionPanelClose}
isRoomEncrypted={isRoomEncrypted}
/>
);
break;
}
let previousPhase: RightPanelPhases;
// We have no previousPhase for when viewing a UserInfo from a Group or without a Room at this time
if (room) {
previousPhase = RightPanelPhases.RoomMemberList;
}
let closeLabel = undefined;
if (phase === RightPanelPhases.EncryptionPanel) {
const verificationRequest = (props as React.ComponentProps<typeof EncryptionPanel>).verificationRequest;
@ -1574,13 +1597,25 @@ const UserInfo: React.FC<Props> = ({
}
}
const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} />;
let scopeHeader;
if (room?.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />
</div>;
}
const header = <React.Fragment>
{ scopeHeader }
<UserInfoHeader member={member} e2eStatus={e2eStatus} />
</React.Fragment>;
return <BaseCard
className={classes.join(" ")}
header={header}
onClose={onClose}
closeLabel={closeLabel}
previousPhase={previousPhase}
refireParams={refireParams}
>
{ content }
</BaseCard>;

View file

@ -69,19 +69,24 @@ export default class RoomProfileSettings extends React.Component {
// clear file upload field so same file can be selected
this._avatarUpload.current.value = "";
this.setState({
avatarUrl: undefined,
avatarFile: undefined,
avatarUrl: null,
avatarFile: null,
enableProfileSave: true,
});
};
_clearProfile = async (e) => {
_cancelProfileChanges = async (e) => {
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
this._removeAvatar();
this.setState({enableProfileSave: false, displayName: this.state.originalDisplayName});
this.setState({
enableProfileSave: false,
displayName: this.state.originalDisplayName,
topic: this.state.originalTopic,
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
});
};
_saveProfile = async (e) => {
@ -95,10 +100,11 @@ export default class RoomProfileSettings extends React.Component {
const newState = {};
// TODO: What do we do about errors?
const displayName = this.state.displayName.trim();
if (this.state.originalDisplayName !== this.state.displayName) {
await client.setRoomName(this.props.roomId, this.state.displayName);
newState.originalDisplayName = this.state.displayName;
await client.setRoomName(this.props.roomId, displayName);
newState.originalDisplayName = displayName;
newState.displayName = displayName;
}
if (this.state.avatarFile) {
@ -108,7 +114,7 @@ export default class RoomProfileSettings extends React.Component {
newState.originalAvatarUrl = newState.avatarUrl;
newState.avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {url: undefined}, '');
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {}, '');
}
if (this.state.originalTopic !== this.state.topic) {
@ -164,11 +170,15 @@ export default class RoomProfileSettings extends React.Component {
const AvatarSetting = sdk.getComponent('settings.AvatarSetting');
let profileSettingsButtons;
if (this.state.canSetTopic && this.state.canSetName) {
if (
this.state.canSetName ||
this.state.canSetTopic ||
this.state.canSetAvatar
) {
profileSettingsButtons = (
<div className="mx_ProfileSettings_buttons">
<AccessibleButton
onClick={this._clearProfile}
onClick={this._cancelProfileChanges}
kind="link"
disabled={!this.state.enableProfileSave}
>

View file

@ -17,11 +17,8 @@ limitations under the License.
import React from 'react';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import { Room } from 'matrix-js-sdk/src/models/room'
import * as sdk from '../../../index';
import dis from "../../../dispatcher/dispatcher";
import * as ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
@ -29,6 +26,7 @@ import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {UIFeature} from "../../../settings/UIFeature";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import CallViewForRoom from '../voip/CallViewForRoom';
import {objectHasDiff} from "../../../utils/objects";
interface IProps {
// js-sdk room object
@ -36,9 +34,6 @@ interface IProps {
userId: string,
showApps: boolean, // Render apps
// set to true to show the file drop target
draggingFile: boolean,
// maxHeight attribute for the aux panel and the video
// therein
maxHeight: number,
@ -89,8 +84,7 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
shouldComponentUpdate(nextProps, nextState) {
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
!ObjectUtils.shallowEqual(this.state, nextState));
return objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState);
}
componentDidUpdate(prevProps, prevState) {
@ -150,21 +144,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
let fileDropTarget = null;
if (this.props.draggingFile) {
fileDropTarget = (
<div className="mx_RoomView_fileDropTarget">
<div className="mx_RoomView_fileDropTargetLabel" title={_t("Drop File Here")}>
<TintableSvg src={require("../../../../res/img/upload-big.svg")} width="45" height="59" />
<br />
{ _t("Drop file here to upload") }
</div>
</div>
);
}
const callView = (
<CallViewForRoom
roomId={this.props.room.roomId}
@ -247,7 +226,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
<AutoHideScrollbar className={classes} style={style} >
{ stateViews }
{ appsDrawer }
{ fileDropTarget }
{ callView }
{ this.props.children }
</AutoHideScrollbar>

View file

@ -519,7 +519,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private async tabCompleteName(event: React.KeyboardEvent) {
try {
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
await new Promise<void>(resolve => this.setState({showVisualBell: false}, resolve));
const {model} = this.props;
const caret = this.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);

View file

@ -27,17 +27,18 @@ import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import {Layout, LayoutPropType} from "../../../settings/Layout";
import {EventStatus} from 'matrix-js-sdk';
import {formatTime} from "../../../DateUtils";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
import {toRem} from "../../../utils/units";
import {WidgetType} from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar";
import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStore";
import {objectHasDiff} from "../../../utils/objects";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
@ -227,8 +228,8 @@ export default class EventTile extends React.Component {
// whether to show reactions for this 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,
@ -293,7 +294,7 @@ export default class EventTile extends React.Component {
}
shouldComponentUpdate(nextProps, nextState) {
if (!ObjectUtils.shallowEqual(this.state, nextState)) {
if (objectHasDiff(this.state, nextState)) {
return true;
}
@ -734,7 +735,7 @@ export default class EventTile extends React.Component {
// joins/parts/etc
avatarSize = 14;
needsSenderProfile = false;
} else if (this.props.useIRCLayout) {
} else if (this.props.layout == Layout.IRC) {
avatarSize = 14;
needsSenderProfile = true;
} else if (this.props.continuation && this.props.tileShape !== "file_grid") {
@ -845,10 +846,11 @@ export default class EventTile extends React.Component {
{ timestamp }
</a>;
const groupTimestamp = !this.props.useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = this.props.useIRCLayout ? linkedTimestamp : null;
const groupPadlock = !this.props.useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const ircPadlock = this.props.useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const useIRCLayout = this.props.layout == Layout.IRC;
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
const groupPadlock = !useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const ircPadlock = useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
switch (this.props.tileShape) {
case 'notif': {
@ -943,16 +945,13 @@ export default class EventTile extends React.Component {
this.props.onHeightChanged,
this.props.permalinkCreator,
this._replyThread,
this.props.useIRCLayout,
this.props.layout,
);
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
<div className={classes} tabIndex={-1} aria-live={ariaLive} aria-atomic="true">
{ ircTimestamp }
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{ sender }
{ ircPadlock }
<div className="mx_EventTile_line">
@ -971,6 +970,9 @@ export default class EventTile extends React.Component {
{ reactionsRow }
{ actionBar }
</div>
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{
// The avatar goes after the event tile as it's absolutely positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids

View file

@ -27,6 +27,8 @@ import * as sdk from "../../../index";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -450,22 +452,14 @@ export default class MemberList extends React.Component {
let inviteButton;
if (room && room.getMyMembership() === 'join') {
// assume we can invite until proven false
let canInvite = true;
const plEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const me = room.getMember(cli.getUserId());
if (plEvent && me) {
const content = plEvent.getContent();
if (content && content.invite > me.powerLevel) {
canInvite = false;
}
}
const canInvite = room.canInvite(cli.getUserId());
let inviteButtonText = _t("Invite to this room");
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
} else if (room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space");
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
@ -493,12 +487,26 @@ export default class MemberList extends React.Component {
onSearch={ this.onSearchQueryChanged } />
);
let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader;
if (room?.isSpaceRoom()) {
previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />
</div>;
}
return <BaseCard
className="mx_MemberList"
header={inviteButton}
header={<React.Fragment>
{ scopeHeader }
{ inviteButton }
</React.Fragment>}
footer={footer}
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
previousPhase={previousPhase}
>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2015-2018, 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.
@ -19,7 +17,6 @@ import React, {createRef} from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
@ -33,11 +30,8 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
import {UIFeature} from "../../../settings/UIFeature";
import WidgetStore from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import { PlaceCallType } from "../../../CallHandler";
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -50,95 +44,18 @@ ComposerAvatar.propTypes = {
me: PropTypes.object.isRequired,
};
function CallButton(props) {
const onVoiceCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: PlaceCallType.Voice,
room_id: props.roomId,
});
};
return (<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>);
}
CallButton.propTypes = {
roomId: PropTypes.string.isRequired,
};
function VideoCallButton(props) {
const onCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video,
room_id: props.roomId,
});
};
return <AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_videocall"
onClick={onCallClick}
title={_t('Video call')}
/>;
}
VideoCallButton.propTypes = {
roomId: PropTypes.string.isRequired,
};
function HangupButton(props) {
const onHangupClick = () => {
if (props.isConference) {
dis.dispatch({
action: props.canEndConference ? 'end_conference' : 'hangup_conference',
room_id: props.roomId,
});
return;
}
const call = CallHandler.sharedInstance().getCallForRoom(props.roomId);
if (!call) {
return;
}
const action = call.state === CallState.Ringing ? 'reject' : 'hangup';
dis.dispatch({
action,
// hangup the call for this room. NB. We use the room in props as the room ID
// as call.roomId may be the 'virtual room', and the dispatch actions always
// use the user-facing room (there was a time when we deliberately used
// call.roomId and *not* props.roomId, but that was for the old
// style Freeswitch conference calls and those times are gone.)
room_id: props.roomId,
});
};
let tooltip = _t("Hangup");
if (props.isConference && props.canEndConference) {
tooltip = _t("End conference");
}
const canLeaveConference = !props.isConference ? true : props.isInConference;
function SendButton(props) {
return (
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick}
title={tooltip}
disabled={!canLeaveConference}
className="mx_MessageComposer_sendMessage"
onClick={props.onClick}
title={_t('Send message')}
/>
);
}
HangupButton.propTypes = {
roomId: PropTypes.string.isRequired,
isConference: PropTypes.bool.isRequired,
canEndConference: PropTypes.bool,
isInConference: PropTypes.bool,
SendButton.propTypes = {
onClick: PropTypes.func.isRequired,
};
const EmojiButton = ({addEmoji}) => {
@ -265,9 +182,9 @@ export default class MessageComposer extends React.Component {
this.state = {
tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room),
joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room),
isComposerEmpty: true,
};
}
@ -396,6 +313,16 @@ export default class MessageComposer extends React.Component {
});
}
sendMessage = () => {
this.messageComposerInput._sendMessage();
}
onChange = (model) => {
this.setState({
isComposerEmpty: model.isEmpty,
});
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
@ -405,12 +332,7 @@ export default class MessageComposer extends React.Component {
];
if (!this.state.tombstone && this.state.canSendMessages) {
// This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly
// complex because of conference calls.
const SendMessageComposer = sdk.getComponent("rooms.SendMessageComposer");
const callInProgress = this.props.callState && this.props.callState !== 'ended';
controls.push(
<SendMessageComposer
@ -421,37 +343,21 @@ export default class MessageComposer extends React.Component {
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
/>,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,
);
if (SettingsStore.getValue(UIFeature.Widgets)) {
if (SettingsStore.getValue(UIFeature.Widgets) &&
SettingsStore.getValue("MessageComposerInput.showStickersButton")) {
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
}
if (this.state.showCallButtons) {
if (this.state.hasConference) {
const canEndConf = WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
controls.push(
<HangupButton
key="controls_hangup"
roomId={this.props.room.roomId}
isConference={true}
canEndConference={canEndConf}
isInConference={this.state.joinedConference}
/>,
);
} else if (callInProgress) {
controls.push(
<HangupButton key="controls_hangup" roomId={this.props.room.roomId} isConference={false} />,
);
} else {
controls.push(
<CallButton key="controls_call" roomId={this.props.room.roomId} />,
<VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
}
if (!this.state.isComposerEmpty) {
controls.push(
<SendButton key="controls_send" onClick={this.sendMessage} />,
);
}
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];

View file

@ -100,15 +100,8 @@ const NewRoomIntro = () => {
});
}
let canInvite = inRoom;
const powerLevels = room.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
const me = room.getMember(cli.getUserId());
if (powerLevels && me && powerLevels.invite > me.powerLevel) {
canInvite = false;
}
let buttons;
if (canInvite) {
if (room.canInvite(cli.getUserId())) {
const onInviteClick = () => {
dis.dispatch({ action: "view_invite", roomId });
};

View file

@ -32,7 +32,7 @@ try {
} catch (e) {
}
export default class ReadReceiptMarker extends React.Component {
export default class ReadReceiptMarker extends React.PureComponent {
static propTypes = {
// the RoomMember to show the RR for
member: PropTypes.object,
@ -155,7 +155,15 @@ export default class ReadReceiptMarker extends React.Component {
// then shift to the rightmost column,
// and then it will drop down to its resting position
startStyles.push({ top: startTopOffset+'px', left: '0px' });
//
// XXX: We use a fractional left value to trick velocity-animate into actually animating.
// This is a very annoying bug where if it thinks there's no change to `left` then it'll
// skip applying it, thus making our read receipt at +14px instead of +0px like it
// should be. This does cause a tiny amount of drift for read receipts, however with a
// value so small it's not perceived by a user.
// Note: Any smaller values (or trying to interchange units) might cause read receipts to
// fail to fall down or cause gaps.
startStyles.push({ top: startTopOffset+'px', left: '0.001px' });
enterTransitionOpts.push({
duration: bounce ? Math.min(Math.log(Math.abs(startTopOffset)) * 200, 3000) : 300,
easing: bounce ? 'easeOutBounce' : 'easeOutCubic',

View file

@ -111,7 +111,7 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
appear={true} in={this.state.doAnimation} timeout={640}
classNames='mx_RoomBreadcrumbs'
>
<Toolbar className='mx_RoomBreadcrumbs'>
<Toolbar className='mx_RoomBreadcrumbs' aria-label={_t("Recently visited rooms")}>
{tiles.slice(this.state.skipFirst ? 1 : 0)}
</Toolbar>
</CSSTransition>

View file

@ -15,14 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import RateLimitedFunc from '../../../ratelimitedfunc';
import { linkifyElement } from '../../../HtmlUtils';
import {CancelButton} from './SimpleRoomHeader';
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
@ -30,6 +29,9 @@ import E2EIcon from './E2EIcon';
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import {DefaultTagID} from "../../../stores/room-list/models";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import {PlaceCallType} from "../../../CallHandler";
export default class RoomHeader extends React.Component {
static propTypes = {
@ -44,6 +46,7 @@ export default class RoomHeader extends React.Component {
e2eStatus: PropTypes.string,
onAppsClick: PropTypes.func,
appsShown: PropTypes.bool,
onCallPlaced: PropTypes.func, // (PlaceCallType) => void;
};
static defaultProps = {
@ -52,35 +55,13 @@ export default class RoomHeader extends React.Component {
onCancelClick: null,
};
constructor(props) {
super(props);
this._topic = createRef();
}
componentDidMount() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._onRoomStateEvents);
cli.on("Room.accountData", this._onRoomAccountData);
// When a room name occurs, RoomState.events is fired *before*
// room.name is updated. So we have to listen to Room.name as well as
// RoomState.events.
if (this.props.room) {
this.props.room.on("Room.name", this._onRoomNameChange);
}
}
componentDidUpdate() {
if (this._topic.current) {
linkifyElement(this._topic.current);
}
}
componentWillUnmount() {
if (this.props.room) {
this.props.room.removeListener("Room.name", this._onRoomNameChange);
}
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._onRoomStateEvents);
@ -109,10 +90,6 @@ export default class RoomHeader extends React.Component {
this.forceUpdate();
}, 500);
_onRoomNameChange = (room) => {
this.forceUpdate();
};
_hasUnreadPins() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false;
@ -170,29 +147,28 @@ export default class RoomHeader extends React.Component {
}
}
let roomName = _t("Join Room");
let oobName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) {
roomName = this.props.oobData.name;
} else if (this.props.room) {
roomName = this.props.room.name;
oobName = this.props.oobData.name;
}
const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint });
const name =
<div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}>
<div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>
<RoomName room={this.props.room}>
{(name) => {
const roomName = name || oobName;
return <div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>;
}}
</RoomName>
{ searchStatus }
</div>;
let topic;
if (this.props.room) {
const ev = this.props.room.currentState.getStateEvents('m.room.topic', '');
if (ev) {
topic = ev.getContent().topic;
}
}
const topicElement =
<div className="mx_RoomHeader_topic" ref={this._topic} title={topic} dir="auto">{ topic }</div>;
const topicElement = <RoomTopic room={this.props.room}>
{(topic, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={topic} dir="auto">
{ topic }
</div>}
</RoomTopic>;
let roomAvatar;
if (this.props.room) {
@ -252,8 +228,26 @@ export default class RoomHeader extends React.Component {
title={_t("Search")} />;
}
let voiceCallButton;
let videoCallButton;
if (this.props.inRoom && SettingsStore.getValue("showCallButtonsInComposer")) {
voiceCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
onClick={() => this.props.onCallPlaced(PlaceCallType.Voice)}
title={_t("Voice call")} />;
videoCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={(ev) => this.props.onCallPlaced(
ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video)}
title={_t("Video call")} />;
}
const rightRow =
<div className="mx_RoomHeader_buttons">
{ videoCallButton }
{ voiceCallButton }
{ pinnedEventsButton }
{ forgetButton }
{ appsButton }

View file

@ -47,6 +47,9 @@ import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../con
import AccessibleButton from "../elements/AccessibleButton";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import CallHandler from "../../../CallHandler";
import SpaceStore from "../../../stores/SpaceStore";
import { showAddExistingRooms, showCreateNewRoom } from "../../../utils/space";
import { EventType } from "matrix-js-sdk/src/@types/event";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
@ -152,6 +155,50 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
defaultHidden: false,
addRoomLabel: _td("Add room"),
addRoomContextMenu: (onFinished: () => void) => {
if (SpaceStore.instance.activeSpace) {
const canAddRooms = SpaceStore.instance.activeSpace.currentState.maySendStateEvent(EventType.SpaceChild,
MatrixClientPeg.get().getUserId());
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
showCreateNewRoom(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
: _t("You do not have permissions to create new rooms in this space")}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
showAddExistingRooms(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
: _t("You do not have permissions to add rooms to this space")}
/>
<IconizedContextMenuOption
label={_t("Explore space rooms")}
iconClassName="mx_RoomList_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.fire(Action.ViewRoomDirectory);
}}
/>
</IconizedContextMenuOptionList>;
}
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
@ -455,8 +502,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const unfilteredLists = RoomListStore.instance.unfilteredLists
const unfilteredRooms = unfilteredLists[DefaultTagID.Untagged] || [];
const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || [];
const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || [];
// show a prompt to join/create rooms if the user is in 0 rooms and no historical
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1) {
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1 && unfilteredFavourite < 1) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Use the + to make a new room or explore existing ones below")}</div>
<AccessibleButton

View file

@ -110,6 +110,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
this.props.room.on("Room.name", this.onRoomNameUpdate);
}
private onRoomNameUpdate = (room) => {
this.forceUpdate();
}
private onNotificationUpdate = () => {
@ -150,6 +155,8 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room?.roomId),
this.onCommunityUpdate,
);
prevProps.room?.off("Room.name", this.onRoomNameUpdate);
this.props.room?.on("Room.name", this.onRoomNameUpdate);
}
}
@ -171,6 +178,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
this.props.room.off("Room.name", this.onRoomNameUpdate);
}
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);

View file

@ -117,6 +117,7 @@ export default class SendMessageComposer extends React.Component {
placeholder: PropTypes.string,
permalinkCreator: PropTypes.object.isRequired,
replyToEvent: PropTypes.object,
onChange: PropTypes.func,
};
static contextType = MatrixClientContext;
@ -156,13 +157,14 @@ export default class SendMessageComposer extends React.Component {
this.onVerticalArrow(event, true);
} else if (event.key === Key.ARROW_DOWN) {
this.onVerticalArrow(event, false);
} else if (this._prepareToEncrypt) {
this._prepareToEncrypt();
} else if (event.key === Key.ESCAPE) {
dis.dispatch({
action: 'reply_to_event',
event: null,
});
} else if (this._prepareToEncrypt) {
// This needs to be last!
this._prepareToEncrypt();
}
};
@ -402,6 +404,9 @@ export default class SendMessageComposer extends React.Component {
this._editorRef.clearUndoHistory();
this._editorRef.focus();
this._clearStoredEditorState();
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({action: "scroll_to_bottom"});
}
}
componentWillUnmount() {
@ -534,10 +539,15 @@ export default class SendMessageComposer extends React.Component {
}
}
onChange = () => {
if (this.props.onChange) this.props.onChange(this.model);
}
render() {
return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>
<BasicMessageComposer
onChange={this.onChange}
ref={this._setEditorRef}
model={this.model}
room={this.props.room}

View file

@ -264,7 +264,7 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} style={{zIndex: STICKERPICKER_Z_INDEX}}>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} zIndex={STICKERPICKER_Z_INDEX}>
<AppTile
app={stickerApp}
room={this.props.room}

View file

@ -23,6 +23,8 @@ import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import {isValid3pidInvite} from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
export default class ThirdPartyMemberInfo extends React.Component {
static propTypes = {
@ -32,14 +34,14 @@ export default class ThirdPartyMemberInfo extends React.Component {
constructor(props) {
super(props);
const room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
const me = room.getMember(MatrixClientPeg.get().getUserId());
const powerLevels = room.currentState.getStateEvents("m.room.power_levels", "");
this.room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
const me = this.room.getMember(MatrixClientPeg.get().getUserId());
const powerLevels = this.room.currentState.getStateEvents("m.room.power_levels", "");
let kickLevel = powerLevels ? powerLevels.getContent().kick : 50;
if (typeof(kickLevel) !== 'number') kickLevel = 50;
const sender = room.getMember(this.props.event.getSender());
const sender = this.room.getMember(this.props.event.getSender());
this.state = {
stateKey: this.props.event.getStateKey(),
@ -119,9 +121,18 @@ export default class ThirdPartyMemberInfo extends React.Component {
);
}
let scopeHeader;
if (this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} />
</div>;
}
// We shamelessly rip off the MemberInfo styles here.
return (
<div className="mx_MemberInfo" role="tabpanel">
{ scopeHeader }
<div className="mx_MemberInfo_name">
<AccessibleButton className="mx_MemberInfo_cancel"
onClick={this.onCancel}

View file

@ -81,7 +81,7 @@ export default class WhoIsTypingTile extends React.Component {
};
onRoomTimeline = (event, room) => {
if (room && room.roomId === this.props.room.roomId) {
if (room?.roomId === this.props.room?.roomId) {
const userId = event.getSender();
// remove user from usersTyping
const usersTyping = this.state.usersTyping.filter((m) => m.userId !== userId);

View file

@ -52,19 +52,23 @@ export default class ProfileSettings extends React.Component {
// clear file upload field so same file can be selected
this._avatarUpload.current.value = "";
this.setState({
avatarUrl: undefined,
avatarFile: undefined,
avatarUrl: null,
avatarFile: null,
enableProfileSave: true,
});
};
_clearProfile = async (e) => {
_cancelProfileChanges = async (e) => {
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
this._removeAvatar();
this.setState({enableProfileSave: false, displayName: this.state.originalDisplayName});
this.setState({
enableProfileSave: false,
displayName: this.state.originalDisplayName,
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
});
};
_saveProfile = async (e) => {
@ -77,10 +81,12 @@ export default class ProfileSettings extends React.Component {
const client = MatrixClientPeg.get();
const newState = {};
const displayName = this.state.displayName.trim();
try {
if (this.state.originalDisplayName !== this.state.displayName) {
await client.setDisplayName(this.state.displayName);
newState.originalDisplayName = this.state.displayName;
await client.setDisplayName(displayName);
newState.originalDisplayName = displayName;
newState.displayName = displayName;
}
if (this.state.avatarFile) {
@ -186,7 +192,7 @@ export default class ProfileSettings extends React.Component {
</div>
<div className="mx_ProfileSettings_buttons">
<AccessibleButton
onClick={this._clearProfile}
onClick={this._cancelProfileChanges}
kind="link"
disabled={!this.state.enableProfileSave}
>

View file

@ -0,0 +1,111 @@
/*
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 SpellCheckLanguagesDropdown from "../../../components/views/elements/SpellCheckLanguagesDropdown";
import AccessibleButton from "../../../components/views/elements/AccessibleButton";
import {_t} from "../../../languageHandler";
interface ExistingSpellCheckLanguageIProps {
language: string,
onRemoved(language: string),
}
interface SpellCheckLanguagesIProps {
languages: Array<string>,
onLanguagesChange(languages: Array<string>),
}
interface SpellCheckLanguagesIState {
newLanguage: string,
}
export class ExistingSpellCheckLanguage extends React.Component<ExistingSpellCheckLanguageIProps> {
_onRemove = (e) => {
e.stopPropagation();
e.preventDefault();
return this.props.onRemoved(this.props.language);
};
render() {
return (
<div className="mx_ExistingSpellCheckLanguage">
<span className="mx_ExistingSpellCheckLanguage_language">{this.props.language}</span>
<AccessibleButton onClick={this._onRemove} kind="danger_sm">
{_t("Remove")}
</AccessibleButton>
</div>
);
}
}
export default class SpellCheckLanguages extends React.Component<SpellCheckLanguagesIProps, SpellCheckLanguagesIState> {
constructor(props) {
super(props);
this.state = {
newLanguage: "",
}
}
_onRemoved = (language) => {
const languages = this.props.languages.filter((e) => e !== language);
this.props.onLanguagesChange(languages);
};
_onAddClick = (e) => {
e.stopPropagation();
e.preventDefault();
const language = this.state.newLanguage;
if (!language) return;
if (this.props.languages.includes(language)) return;
this.props.languages.push(language)
this.props.onLanguagesChange(this.props.languages);
};
_onNewLanguageChange = (language: string) => {
if (this.state.newLanguage === language) return;
this.setState({newLanguage: language});
};
render() {
const existingSpellCheckLanguages = this.props.languages.map((e) => {
return <ExistingSpellCheckLanguage language={e} onRemoved={this._onRemoved} key={e} />;
});
const addButton = (
<AccessibleButton onClick={this._onAddClick} kind="primary">
{_t("Add")}
</AccessibleButton>
);
return (
<div className="mx_SpellCheckLanguages">
{existingSpellCheckLanguages}
<form onSubmit={this._onAddClick} noValidate={true}>
<SpellCheckLanguagesDropdown
className="mx_GeneralUserSettingsTab_spellCheckLanguageInput"
value={this.state.newLanguage}
onOptionChange={this._onNewLanguageChange} />
{addButton}
</form>
</div>
);
}
}

View file

@ -178,19 +178,23 @@ export default class EmailAddresses extends React.Component {
e.preventDefault();
this.setState({continueDisabled: true});
this.state.addTask.checkEmailLinkClicked().then(() => {
const email = this.state.newEmailAddress;
this.state.addTask.checkEmailLinkClicked().then(([finished]) => {
let newEmailAddress = this.state.newEmailAddress;
if (finished) {
const email = this.state.newEmailAddress;
const emails = [
...this.props.emails,
{ address: email, medium: "email" },
];
this.props.onEmailsChange(emails);
newEmailAddress = "";
}
this.setState({
addTask: null,
continueDisabled: false,
verifying: false,
newEmailAddress: "",
newEmailAddress,
});
const emails = [
...this.props.emails,
{ address: email, medium: "email" },
];
this.props.onEmailsChange(emails);
}).catch((err) => {
this.setState({continueDisabled: false});
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");

View file

@ -177,21 +177,25 @@ export default class PhoneNumbers extends React.Component {
this.setState({continueDisabled: true});
const token = this.state.newPhoneNumberCode;
const address = this.state.verifyMsisdn;
this.state.addTask.haveMsisdnToken(token).then(() => {
this.state.addTask.haveMsisdnToken(token).then(([finished]) => {
let newPhoneNumber = this.state.newPhoneNumber;
if (finished) {
const msisdns = [
...this.props.msisdns,
{ address, medium: "msisdn" },
];
this.props.onMsisdnsChange(msisdns);
newPhoneNumber = "";
}
this.setState({
addTask: null,
continueDisabled: false,
verifying: false,
verifyMsisdn: "",
verifyError: null,
newPhoneNumber: "",
newPhoneNumber,
newPhoneNumberCode: "",
});
const msisdns = [
...this.props.msisdns,
{ address, medium: "msisdn" },
];
this.props.onMsisdnsChange(msisdns);
}).catch((err) => {
this.setState({continueDisabled: false});
if (err.errcode !== 'M_THREEPID_AUTH_FAILED') {

View file

@ -28,15 +28,14 @@ import { FontWatcher } from "../../../../../settings/watchers/FontWatcher";
import { RecheckThemePayload } from '../../../../../dispatcher/payloads/RecheckThemePayload';
import { Action } from '../../../../../dispatcher/actions';
import { IValidationResult, IFieldState } from '../../../elements/Validation';
import StyledRadioButton from '../../../elements/StyledRadioButton';
import StyledCheckbox from '../../../elements/StyledCheckbox';
import SettingsFlag from '../../../elements/SettingsFlag';
import Field from '../../../elements/Field';
import EventTilePreview from '../../../elements/EventTilePreview';
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
import classNames from 'classnames';
import { SettingLevel } from "../../../../../settings/SettingLevel";
import {UIFeature} from "../../../../../settings/UIFeature";
import {Layout} from "../../../../../settings/Layout";
interface IProps {
}
@ -62,7 +61,7 @@ interface IState extends IThemeState {
useSystemFont: boolean;
systemFont: string;
showAdvanced: boolean;
useIRCLayout: boolean;
layout: Layout;
}
@ -83,7 +82,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
useSystemFont: SettingsStore.getValue("useSystemFont"),
systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false,
useIRCLayout: SettingsStore.getValue("useIRCLayout"),
layout: SettingsStore.getValue("layout"),
};
}
@ -213,15 +212,15 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
this.setState({customThemeUrl: e.target.value});
};
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const val = e.target.value === "true";
this.setState({
useIRCLayout: val,
});
SettingsStore.setValue("useIRCLayout", null, SettingLevel.DEVICE, val);
};
private onIRCLayoutChange = (enabled: boolean) => {
if (enabled) {
this.setState({layout: Layout.IRC});
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
} else {
this.setState({layout: Layout.Group});
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
}
}
private renderThemeSection() {
const themeWatcher = new ThemeWatcher();
@ -306,7 +305,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
<EventTilePreview
className="mx_AppearanceUserSettingsTab_fontSlider_preview"
message={this.MESSAGE_PREVIEW_TEXT}
useIRCLayout={this.state.useIRCLayout}
layout={this.state.layout}
/>
<div className="mx_AppearanceUserSettingsTab_fontSlider">
<div className="mx_AppearanceUserSettingsTab_fontSlider_smallText">Aa</div>
@ -342,50 +341,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
</div>;
}
private renderLayoutSection = () => {
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Layout">
<span className="mx_SettingsTab_subheading">{_t("Message layout")}</span>
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.useIRCLayout,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
useIRCLayout={true}
/>
<StyledRadioButton
name="layout"
value="true"
checked={this.state.useIRCLayout}
onChange={this.onLayoutChange}
>
{_t("Compact")}
</StyledRadioButton>
</div>
<div className="mx_AppearanceUserSettingsTab_spacer" />
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: !this.state.useIRCLayout,
})}>
<EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT}
useIRCLayout={false}
/>
<StyledRadioButton
name="layout"
value="false"
checked={!this.state.useIRCLayout}
onChange={this.onLayoutChange}
>
{_t("Modern")}
</StyledRadioButton>
</div>
</div>
</div>;
};
private renderAdvancedSection() {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
@ -409,14 +364,15 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
name="useCompactLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
disabled={this.state.useIRCLayout}
/>
<SettingsFlag
name="useIRCLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({useIRCLayout: checked})}
disabled={this.state.layout == Layout.IRC}
/>
<StyledCheckbox
checked={this.state.layout == Layout.IRC}
onChange={(ev) => this.onIRCLayoutChange(ev.target.checked)}
>
{_t("Enable experimental, compact IRC style layout")}
</StyledCheckbox>
<SettingsFlag
name="useSystemFont"
level={SettingLevel.DEVICE}

View file

@ -22,6 +22,7 @@ import ProfileSettings from "../../ProfileSettings";
import * as languageHandler from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore";
import LanguageDropdown from "../../../elements/LanguageDropdown";
import SpellCheckSettings from "../../SpellCheckSettings";
import AccessibleButton from "../../../elements/AccessibleButton";
import DeactivateAccountDialog from "../../../dialogs/DeactivateAccountDialog";
import PropTypes from "prop-types";
@ -49,6 +50,7 @@ export default class GeneralUserSettingsTab extends React.Component {
this.state = {
language: languageHandler.getCurrentLanguage(),
spellCheckLanguages: [],
haveIdServer: Boolean(MatrixClientPeg.get().getIdentityServerUrl()),
serverSupportsSeparateAddAndBind: null,
idServerHasUnsignedTerms: false,
@ -85,6 +87,15 @@ export default class GeneralUserSettingsTab extends React.Component {
this._getThreepidState();
}
async componentDidMount() {
const plaf = PlatformPeg.get();
if (plaf) {
this.setState({
spellCheckLanguages: await plaf.getSpellCheckLanguages(),
});
}
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
}
@ -182,6 +193,15 @@ export default class GeneralUserSettingsTab extends React.Component {
PlatformPeg.get().reload();
};
_onSpellCheckLanguagesChange = (languages) => {
this.setState({spellCheckLanguages: languages});
const plaf = PlatformPeg.get();
if (plaf) {
plaf.setSpellCheckLanguages(languages);
}
};
_onPasswordChangeError = (err) => {
// TODO: Figure out a design that doesn't involve replacing the current dialog
let errMsg = err.error || "";
@ -303,6 +323,16 @@ export default class GeneralUserSettingsTab extends React.Component {
);
}
_renderSpellCheckSection() {
return (
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Spell check dictionaries")}</span>
<SpellCheckSettings languages={this.state.spellCheckLanguages}
onLanguagesChange={this._onSpellCheckLanguagesChange} />
</div>
);
}
_renderDiscoverySection() {
const SetIdServer = sdk.getComponent("views.settings.SetIdServer");
@ -381,6 +411,9 @@ export default class GeneralUserSettingsTab extends React.Component {
}
render() {
const plaf = PlatformPeg.get();
const supportsMultiLanguageSpellCheck = plaf.supportsMultiLanguageSpellCheck();
const discoWarning = this.state.requiredPolicyInfo.hasTerms
? <img className='mx_GeneralUserSettingsTab_warningIcon'
src={require("../../../../../../res/img/feather-customised/warning-triangle.svg")}
@ -409,6 +442,7 @@ export default class GeneralUserSettingsTab extends React.Component {
{this._renderProfileSection()}
{this._renderAccountSection()}
{this._renderLanguageSection()}
{supportsMultiLanguageSpellCheck ? this._renderSpellCheckSection() : null}
{ discoverySection }
{this._renderIntegrationManagerSection() /* Has its own title */}
{ accountManagementSection }

View file

@ -34,6 +34,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
'MessageComposerInput.suggestEmoji',
'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend',
'MessageComposerInput.showStickersButton',
];
static TIMELINE_SETTINGS = [
@ -46,12 +47,16 @@ export default class PreferencesUserSettingsTab extends React.Component {
'alwaysShowTimestamps',
'showRedactions',
'enableSyntaxHighlightLanguageDetection',
'expandCodeByDefault',
'scrollToBottomOnMessageSent',
'showCodeLineNumbers',
'showJoinLeaves',
'showAvatarChanges',
'showDisplaynameChanges',
'showImages',
'showChatEffects',
'Pill.shouldShowPillAvatar',
'ctrlFForSearch',
];
static GENERAL_SETTINGS = [

View file

@ -0,0 +1,120 @@
/*
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, {useRef, useState} from "react";
import {_t} from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
interface IProps {
avatarUrl?: string;
avatarDisabled?: boolean;
name?: string,
nameDisabled?: boolean;
topic?: string;
topicDisabled?: boolean;
setAvatar(avatar: File): void;
setName(name: string): void;
setTopic(topic: string): void;
}
const SpaceBasicSettings = ({
avatarUrl,
avatarDisabled = false,
setAvatar,
name = "",
nameDisabled = false,
setName,
topic = "",
topicDisabled = false,
setTopic,
}: IProps) => {
const avatarUploadRef = useRef<HTMLInputElement>();
const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache
let avatarSection;
if (avatarDisabled) {
if (avatar) {
avatarSection = <img className="mx_SpaceBasicSettings_avatar" src={avatar} alt="" />;
} else {
avatarSection = <div className="mx_SpaceBasicSettings_avatar" />;
}
} else {
if (avatar) {
avatarSection = <React.Fragment>
<AccessibleButton
className="mx_SpaceBasicSettings_avatar"
onClick={() => avatarUploadRef.current?.click()}
element="img"
src={avatar}
alt=""
/>
<AccessibleButton onClick={() => {
avatarUploadRef.current.value = "";
setAvatarDataUrl(undefined);
setAvatar(undefined);
}} kind="link" className="mx_SpaceBasicSettings_avatar_remove">
{ _t("Delete") }
</AccessibleButton>
</React.Fragment>;
} else {
avatarSection = <React.Fragment>
<div className="mx_SpaceBasicSettings_avatar" onClick={() => avatarUploadRef.current?.click()} />
<AccessibleButton onClick={() => avatarUploadRef.current?.click()} kind="link">
{ _t("Upload") }
</AccessibleButton>
</React.Fragment>;
}
}
return <div className="mx_SpaceBasicSettings">
<div className="mx_SpaceBasicSettings_avatarContainer">
{ avatarSection }
<input type="file" ref={avatarUploadRef} onChange={(e) => {
if (!e.target.files?.length) return;
const file = e.target.files[0];
setAvatar(file);
const reader = new FileReader();
reader.onload = (ev) => {
setAvatarDataUrl(ev.target.result as string);
};
reader.readAsDataURL(file);
}} accept="image/*" />
</div>
<Field
name="spaceName"
label={_t("Name")}
autoFocus={true}
value={name}
onChange={ev => setName(ev.target.value)}
disabled={nameDisabled}
/>
<Field
name="spaceTopic"
element="textarea"
label={_t("Description")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
rows={3}
disabled={topicDisabled}
/>
</div>;
};
export default SpaceBasicSettings;

View file

@ -0,0 +1,175 @@
/*
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, {useContext, useState} from "react";
import classNames from "classnames";
import {EventType, RoomType, RoomCreateTypeField} from "matrix-js-sdk/src/@types/event";
import {_t} from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {ChevronFace, ContextMenu} from "../../structures/ContextMenu";
import FormButton from "../elements/FormButton";
import createRoom, {IStateEvent, Preset} from "../../../createRoom";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import SpaceBasicSettings from "./SpaceBasicSettings";
import AccessibleButton from "../elements/AccessibleButton";
import FocusLock from "react-focus-lock";
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return (
<AccessibleButton className={classNames("mx_SpaceCreateMenuType", className)} onClick={onClick}>
<h3>{ title }</h3>
<span>{ description }</span>
</AccessibleButton>
);
};
enum Visibility {
Public,
Private,
}
const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null);
const [name, setName] = useState("");
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false);
const onSpaceCreateClick = async () => {
if (busy) return;
setBusy(true);
const initialState: IStateEvent[] = [
{
type: EventType.RoomHistoryVisibility,
content: {
"history_visibility": visibility === Visibility.Public ? "world_readable" : "invited",
},
},
];
if (avatar) {
const url = await cli.uploadContent(avatar);
initialState.push({
type: EventType.RoomAvatar,
content: { url },
});
}
if (topic) {
initialState.push({
type: EventType.RoomTopic,
content: { topic },
});
}
try {
await createRoom({
createOpts: {
preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
name,
creation_content: {
// Based on MSC1840
[RoomCreateTypeField]: RoomType.Space,
},
initial_state: initialState,
power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100,
},
},
spinner: false,
encryption: false,
andView: true,
inlineErrors: true,
});
onFinished();
} catch (e) {
console.error(e);
}
};
let body;
if (visibility === null) {
body = <React.Fragment>
<h2>{ _t("Create a space") }</h2>
<p>{ _t("Organise rooms into spaces, for just you or anyone") }</p>
<SpaceCreateMenuType
title={_t("Public")}
description={_t("Open space for anyone, best for communities")}
className="mx_SpaceCreateMenuType_public"
onClick={() => setVisibility(Visibility.Public)}
/>
<SpaceCreateMenuType
title={_t("Private")}
description={_t("Invite only space, best for yourself or teams")}
className="mx_SpaceCreateMenuType_private"
onClick={() => setVisibility(Visibility.Private)}
/>
{/*<p>{ _t("Looking to join an existing space?") }</p>*/}
</React.Fragment>;
} else {
body = <React.Fragment>
<AccessibleTooltipButton
className="mx_SpaceCreateMenu_back"
onClick={() => setVisibility(null)}
title={_t("Go back")}
/>
<h2>
{
visibility === Visibility.Public
? _t("Personalise your public space")
: _t("Personalise your private space")
}
</h2>
<p>
{
_t("Give it a photo, name and description to help you identify it.")
} {
_t("You can change these at any point.")
}
</p>
<SpaceBasicSettings setAvatar={setAvatar} name={name} setName={setName} topic={topic} setTopic={setTopic} />
<FormButton
label={busy ? _t("Creating...") : _t("Create")}
onClick={onSpaceCreateClick}
disabled={!name && !busy}
/>
</React.Fragment>;
}
return <ContextMenu
left={72}
top={62}
chevronOffset={0}
chevronFace={ChevronFace.None}
onFinished={onFinished}
wrapperClassName="mx_SpaceCreateMenu_wrapper"
managed={false}
>
<FocusLock returnFocus={true}>
{ body }
</FocusLock>
</ContextMenu>;
}
export default SpaceCreateMenu;

View file

@ -0,0 +1,238 @@
/*
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 {_t} from "../../../languageHandler";
import RoomAvatar from "../avatars/RoomAvatar";
import {useContextMenu} from "../../structures/ContextMenu";
import SpaceCreateMenu from "./SpaceCreateMenu";
import {SpaceItem} from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import SpaceStore, {HOME_SPACE, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState";
import NotificationBadge from "../rooms/NotificationBadge";
import {
RovingAccessibleButton,
RovingAccessibleTooltipButton,
RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex";
import {Key} from "../../../Keyboard";
interface IButtonProps {
space?: Room;
className?: string;
selected?: boolean;
tooltip?: string;
notificationState?: SpaceNotificationState;
isNarrow?: boolean;
onClick(): void;
}
const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
tooltip,
notificationState,
isNarrow,
children,
}) => {
const classes = classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_narrow: isNarrow,
});
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={32} height={32} room={space} />;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge forceCount={false} notification={notificationState} />
</div>;
}
let button;
if (isNarrow) {
button = (
<RovingAccessibleTooltipButton className={classes} title={tooltip} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ notifBadge }
{ children }
</div>
</RovingAccessibleTooltipButton>
);
} else {
button = (
<RovingAccessibleButton className={classes} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
<span className="mx_SpaceButton_name">{ tooltip }</span>
{ notifBadge }
{ children }
</div>
</RovingAccessibleButton>
);
}
return <li className={classNames({
"mx_SpaceItem": true,
"collapsed": isNarrow,
})}>
{ button }
</li>;
}
const useSpaces = (): [Room[], Room | null] => {
const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces);
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces);
const [activeSpace, setActiveSpace] = useState<Room>(SpaceStore.instance.activeSpace);
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace);
return [spaces, activeSpace];
};
const SpacePanel = () => {
// We don't need the handle as we position the menu in a constant location
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
const [spaces, activeSpace] = useSpaces();
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
const newClasses = classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
});
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true;
switch (ev.key) {
case Key.ARROW_UP:
onMoveFocus(ev.target as Element, true);
break;
case Key.ARROW_DOWN:
onMoveFocus(ev.target as Element, false);
break;
default:
handled = false;
}
if (handled) {
// consume all other keys in context menu
ev.stopPropagation();
ev.preventDefault();
}
};
const onMoveFocus = (element: Element, up: boolean) => {
let descending = false; // are we currently descending or ascending through the DOM tree?
let classes: DOMTokenList;
do {
const child = up ? element.lastElementChild : element.firstElementChild;
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
} else if (sibling) {
element = sibling;
} else {
descending = false;
element = element.parentElement;
}
} else {
if (sibling) {
element = sibling;
descending = true;
} else {
element = element.parentElement;
}
}
if (element) {
if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
element = up ? element.lastElementChild : element.firstElementChild;
descending = true;
}
classes = element.classList;
}
} while (element && !classes.contains("mx_SpaceButton"));
if (element) {
(element as HTMLElement).focus();
}
};
const activeSpaces = activeSpace ? [activeSpace] : [];
const expandCollapseButtonTitle = isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel");
// TODO drag and drop for re-arranging order
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => (
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
>
<AutoHideScrollbar className="mx_SpacePanel_spaceTreeWrapper">
<div className="mx_SpaceTreeLevel">
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={_t("Home")}
notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)}
isNarrow={isPanelCollapsed}
/>
{ spaces.map(s => <SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>) }
</div>
<SpaceButton
className={newClasses}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={menuDisplayed ? closeMenu : openMenu}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar>
<AccessibleTooltipButton
className={classNames("mx_SpacePanel_toggleCollapse", {expanded: !isPanelCollapsed})}
onClick={evt => setPanelCollapsed(!isPanelCollapsed)}
title={expandCollapseButtonTitle}
/>
{ contextMenu }
</ul>
)}
</RovingTabIndexProvider>
};
export default SpacePanel;

View file

@ -0,0 +1,65 @@
/*
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 {_t} from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import {copyPlaintext} from "../../../utils/strings";
import {sleep} from "../../../utils/promise";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import {showRoomInviteDialog} from "../../../RoomInvite";
interface IProps {
space: Room;
onFinished(): void;
}
const SpacePublicShare = ({ space, onFinished }: IProps) => {
const [copiedText, setCopiedText] = useState(_t("Click to copy"));
return <div className="mx_SpacePublicShare">
<AccessibleButton
className="mx_SpacePublicShare_shareButton"
onClick={async () => {
const permalinkCreator = new RoomPermalinkCreator(space);
permalinkCreator.load();
const success = await copyPlaintext(permalinkCreator.forRoom());
const text = success ? _t("Copied!") : _t("Failed to copy");
setCopiedText(text);
await sleep(10);
if (copiedText === text) { // if the text hasn't changed by another click then clear it after some time
setCopiedText(_t("Click to copy"));
}
}}
>
{ _t("Share invite link") }
<span>{ copiedText }</span>
</AccessibleButton>
<AccessibleButton
className="mx_SpacePublicShare_inviteButton"
onClick={() => {
showRoomInviteDialog(space.roomId);
onFinished();
}}
>
{ _t("Invite by email or username") }
</AccessibleButton>
</div>;
};
export default SpacePublicShare;

View file

@ -0,0 +1,405 @@
/*
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 classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import RoomAvatar from "../avatars/RoomAvatar";
import SpaceStore from "../../../stores/SpaceStore";
import NotificationBadge from "../rooms/NotificationBadge";
import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton";
import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import {_t} from "../../../languageHandler";
import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import {toRightOf} from "../../structures/ContextMenu";
import {shouldShowSpaceSettings, showCreateNewRoom, showSpaceSettings} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {ButtonEvent} from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
import SpacePublicShare from "./SpacePublicShare";
import {Action} from "../../../dispatcher/actions";
import RoomViewStore from "../../../stores/RoomViewStore";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {showRoomInviteDialog} from "../../../RoomInvite";
import InfoDialog from "../dialogs/InfoDialog";
import {EventType} from "matrix-js-sdk/src/@types/event";
import SpaceRoomDirectory from "../../structures/SpaceRoomDirectory";
interface IItemProps {
space?: Room;
activeSpaces: Room[];
isNested?: boolean;
isPanelCollapsed?: boolean;
onExpand?: Function;
}
interface IItemState {
collapsed: boolean;
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
}
export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
static contextType = MatrixClientContext;
constructor(props) {
super(props);
this.state = {
collapsed: !props.isNested, // default to collapsed for root items
contextMenuPosition: null,
};
}
private toggleCollapse(evt) {
if (this.props.onExpand && this.state.collapsed) {
this.props.onExpand();
}
this.setState({collapsed: !this.state.collapsed});
// don't bubble up so encapsulating button for space
// doesn't get triggered
evt.stopPropagation();
}
private onContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
right: ev.clientX,
top: ev.clientY,
height: 0,
},
});
}
private onClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
SpaceStore.instance.setActiveSpace(this.props.space);
};
private onMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({contextMenuPosition: target.getBoundingClientRect()});
};
private onMenuClose = () => {
this.setState({contextMenuPosition: null});
};
private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
});
this.setState({contextMenuPosition: null}); // also close the menu
};
private onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (this.props.space.getJoinRule() === "public") {
const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, {
title: _t("Invite members"),
description: <React.Fragment>
<span>{ _t("Share your public space") }</span>
<SpacePublicShare space={this.props.space} onFinished={() => modal.close()} />
</React.Fragment>,
fixedWidth: false,
button: false,
className: "mx_SpacePanel_sharePublicSpace",
hasCloseButton: true,
});
} else {
showRoomInviteDialog(this.props.space.roomId);
}
this.setState({contextMenuPosition: null}); // also close the menu
};
private onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(this.context, this.props.space);
this.setState({contextMenuPosition: null}); // also close the menu
};
private onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "leave_room",
room_id: this.props.space.roomId,
});
this.setState({contextMenuPosition: null}); // also close the menu
};
private onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(this.context, this.props.space);
this.setState({contextMenuPosition: null}); // also close the menu
};
private onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: this.props.space },
});
this.setState({contextMenuPosition: null}); // also close the menu
};
private onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog("Space room directory", "Space panel", SpaceRoomDirectory, {
space: this.props.space,
}, "mx_SpaceRoomDirectory_dialogWrapper", false, true);
this.setState({contextMenuPosition: null}); // also close the menu
};
private renderContextMenu(): React.ReactElement {
let contextMenu = null;
if (this.state.contextMenuPosition) {
const userId = this.context.getUserId();
let inviteOption;
if (this.props.space.canInvite(userId)) {
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={this.onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(this.context, this.props.space)) {
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={this.onSettingsClick}
/>
);
} else {
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={this.onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
let newRoomOption;
if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
newRoomOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("New room")}
onClick={this.onNewRoomClick}
/>
);
}
contextMenu = <IconizedContextMenu
{...toRightOf(this.state.contextMenuPosition, 0)}
onFinished={this.onMenuClose}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ this.props.space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHome"
label={_t("Space Home")}
onClick={this.onHomeClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={this.onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={_t("Explore rooms")}
onClick={this.onExploreRoomsClick}
/>
{ newRoomOption }
</IconizedContextMenuOptionList>
{ leaveSection }
</IconizedContextMenu>;
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={this.onMenuOpenClick}
title={_t("Space options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{ contextMenu }
</React.Fragment>
);
}
render() {
const {space, activeSpaces, isNested} = this.props;
const forceCollapsed = this.props.isPanelCollapsed;
const isNarrow = this.props.isPanelCollapsed;
const collapsed = this.state.collapsed || forceCollapsed;
const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId);
const isActive = activeSpaces.includes(space);
const itemClasses = classNames({
"mx_SpaceItem": true,
"collapsed": collapsed,
"hasSubSpaces": childSpaces && childSpaces.length,
});
const classes = classNames("mx_SpaceButton", {
mx_SpaceButton_active: isActive,
mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
mx_SpaceButton_narrow: isNarrow,
});
const notificationState = SpaceStore.instance.getNotificationState(space.roomId);
const childItems = childSpaces && !collapsed ? <SpaceTreeLevel
spaces={childSpaces}
activeSpaces={activeSpaces}
isNested={true}
/> : null;
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge forceCount={false} notification={notificationState} />
</div>;
}
const avatarSize = isNested ? 24 : 32;
const toggleCollapseButton = childSpaces && childSpaces.length ?
<button
className="mx_SpaceButton_toggleCollapse"
onClick={evt => this.toggleCollapse(evt)}
/> : null;
let button;
if (isNarrow) {
button = (
<RovingAccessibleTooltipButton
className={classes}
title={space.name}
onClick={this.onClick}
onContextMenu={this.onContextMenu}
forceHide={!!this.state.contextMenuPosition}
role="treeitem"
>
{ toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper">
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleTooltipButton>
);
} else {
button = (
<RovingAccessibleButton
className={classes}
onClick={this.onClick}
onContextMenu={this.onContextMenu}
role="treeitem"
>
{ toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper">
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
<span className="mx_SpaceButton_name">{ space.name }</span>
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleButton>
);
}
return (
<li className={itemClasses}>
{ button }
{ childItems }
</li>
);
}
}
interface ITreeLevelProps {
spaces: Room[];
activeSpaces: Room[];
isNested?: boolean;
}
const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
spaces,
activeSpaces,
isNested,
}) => {
return <ul className="mx_SpaceTreeLevel">
{spaces.map(s => {
return (<SpaceItem
key={s.roomId}
activeSpaces={activeSpaces}
space={s}
isNested={isNested}
/>);
})}
</ul>;
}
export default SpaceTreeLevel;

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, CSSProperties, ReactNode } from 'react';
import React, { createRef, CSSProperties } from 'react';
import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
@ -494,6 +494,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
if (this.props.call.type === CallType.Video) {
let localVideoFeed = null;
let onHoldContent = null;
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
@ -512,9 +513,12 @@ export default class CallView extends React.Component<IProps, IState> {
backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
onHoldBackground = <div className="mx_CallView_video_holdBackground" style={backgroundStyle} />;
}
if (!this.state.vidMuted) {
localVideoFeed = <VideoFeed type={VideoFeedType.Local} call={this.props.call} />;
}
// if we're fullscreen, we don't want to set a maxHeight on the video element.
const maxVideoHeight = getFullScreenElement() ? null : (
const maxVideoHeight = getFullScreenElement() || !this.props.maxVideoHeight ? null : (
this.props.maxVideoHeight - (HEADER_HEIGHT + BOTTOM_PADDING + BOTTOM_MARGIN_TOP_BOTTOM)
);
contentView = <div className={containerClasses}
@ -527,7 +531,7 @@ export default class CallView extends React.Component<IProps, IState> {
<VideoFeed type={VideoFeedType.Remote} call={this.props.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.props.call} />
{localVideoFeed}
{onHoldContent}
{callControls}
</div>;

View file

@ -24,6 +24,7 @@ import DialPad from './DialPad';
import dis from '../../../dispatcher/dispatcher';
import Modal from "../../../Modal";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
import CallHandler from "../../../CallHandler";
interface IProps {
onFinished: (boolean) => void;
@ -64,9 +65,7 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
}
onDialPress = async () => {
const results = await MatrixClientPeg.get().getThirdpartyUser('im.vector.protocol.pstn', {
'm.id.phone': this.state.value,
});
const results = await CallHandler.sharedInstance().pstnLookup(this.state.value);
if (!results || results.length === 0 || !results[0].userid) {
Modal.createTrackedDialog('', '', ErrorDialog, {
title: _t("Unable to look up phone number"),