Merge remote-tracking branch 'upstream/develop' into feature/image-view-load-anim/18186

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-09-11 15:32:44 +02:00
commit 1a128c3a00
No known key found for this signature in database
GPG key ID: 55C211A1226CB17D
567 changed files with 23292 additions and 9067 deletions

View file

@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
style={style}
className={["mx_AutoHideScrollbar", className].join(" ")}
onWheel={onWheel}
tabIndex={tabIndex}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex={tabIndex ?? -1}
>
{ children }
</div>);

View file

@ -0,0 +1,44 @@
/*
Copyright 2021 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, { CSSProperties } from "react";
interface IProps {
backgroundImage?: string;
blurMultiplier?: number;
}
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
if (!backgroundImage) return null;
const styles: CSSProperties = {};
if (blurMultiplier) {
const rootStyle = getComputedStyle(document.documentElement);
const blurValue = rootStyle.getPropertyValue('--lp-background-blur');
const pixelsValue = blurValue.replace('px', '');
const parsed = parseInt(pixelsValue, 10);
if (!isNaN(parsed)) {
styles.filter = `blur(${parsed * blurMultiplier}px)`;
}
}
return <div className="mx_BackdropPanel">
<img
style={styles}
className="mx_BackdropPanel--image"
src={backgroundImage} />
</div>;
};
export default BackdropPanel;

View file

@ -25,11 +25,18 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
export enum CallEventGrouperEvent {
StateChanged = "state_changed",
SilencedChanged = "silenced_changed",
LengthChanged = "length_changed",
}
const CONNECTING_STATES = [
CallState.Connecting,
CallState.WaitLocalMedia,
CallState.CreateOffer,
CallState.CreateAnswer,
];
const SUPPORTED_STATES = [
CallState.Connected,
CallState.Connecting,
CallState.Ringing,
];
@ -61,6 +68,10 @@ export default class CallEventGrouper extends EventEmitter {
return [...this.events].find((event) => event.getType() === EventType.CallReject);
}
private get selectAnswer(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
}
public get isVoice(): boolean {
const invite = this.invite;
if (!invite) return;
@ -74,6 +85,19 @@ export default class CallEventGrouper extends EventEmitter {
return this.hangup?.getContent()?.reason;
}
public get rejectParty(): string {
return this.reject?.getSender();
}
public get gotRejected(): boolean {
return Boolean(this.reject);
}
public get duration(): Date {
if (!this.hangup || !this.selectAnswer) return;
return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime());
}
/**
* Returns true if there are only events from the other side - we missed the call
*/
@ -81,8 +105,12 @@ export default class CallEventGrouper extends EventEmitter {
return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId());
}
private get callId(): string {
return [...this.events][0].getContent().call_id;
private get callId(): string | undefined {
return [...this.events][0]?.getContent()?.call_id;
}
private get roomId(): string | undefined {
return [...this.events][0]?.getRoomId();
}
private onSilencedCallsChanged = () => {
@ -90,19 +118,29 @@ export default class CallEventGrouper extends EventEmitter {
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
};
private onLengthChanged = (length: number): void => {
this.emit(CallEventGrouperEvent.LengthChanged, length);
};
public answerCall = () => {
this.call?.answer();
defaultDispatcher.dispatch({
action: 'answer',
room_id: this.roomId,
});
};
public rejectCall = () => {
this.call?.reject();
defaultDispatcher.dispatch({
action: 'reject',
room_id: this.roomId,
});
};
public callBack = () => {
defaultDispatcher.dispatch({
action: 'place_call',
type: this.isVoice ? CallType.Voice : CallType.Video,
room_id: [...this.events][0]?.getRoomId(),
room_id: this.roomId,
});
};
@ -116,10 +154,13 @@ export default class CallEventGrouper extends EventEmitter {
private setCallListeners() {
if (!this.call) return;
this.call.addListener(CallEvent.State, this.setState);
this.call.addListener(CallEvent.LengthChanged, this.onLengthChanged);
}
private setState = () => {
if (SUPPORTED_STATES.includes(this.call?.state)) {
if (CONNECTING_STATES.includes(this.call?.state)) {
this.state = CallState.Connecting;
} else if (SUPPORTED_STATES.includes(this.call?.state)) {
this.state = this.call.state;
} else {
if (this.callWasMissed) this.state = CustomCallState.Missed;

View file

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { CSSProperties, RefObject, useRef, useState } from "react";
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
@ -80,6 +80,10 @@ export interface IProps extends IPosition {
managed?: boolean;
wrapperClassName?: string;
// If true, this context menu will be mounted as a child to the parent container. Otherwise
// it will be mounted to a container at the root of the DOM.
mountAsChild?: boolean;
// Function to be called on menu close
onFinished();
// on resize callback
@ -390,7 +394,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
}
render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
if (this.props.mountAsChild) {
// Render as a child of the current parent
return this.renderMenu();
} else {
// Render as a child of a container at the root of the DOM
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
}
}
@ -461,10 +471,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
const open = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(true);
};
const close = () => {
const close = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(false);
};

View file

@ -120,8 +120,7 @@ export default class EmbeddedPage extends React.PureComponent {
const content = <div className={`${className}_body`}
dangerouslySetInnerHTML={{ __html: this.state.page }}
>
</div>;
/>;
if (this.props.scrollbar) {
return <AutoHideScrollbar className={classes}>

View file

@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { EventSubscription } from "fbemitter";
import React from 'react';
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
@ -30,22 +31,43 @@ import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
interface IGroupFilterPanelProps {
}
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
type OrderedTagsTemporaryType = Array<{}>;
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
type SelectedTagsTemporaryType = Array<{}>;
interface IGroupFilterPanelState {
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
orderedTags: OrderedTagsTemporaryType;
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
selectedTags: SelectedTagsTemporaryType;
}
@replaceableComponent("structures.GroupFilterPanel")
class GroupFilterPanel extends React.Component {
static contextType = MatrixClientContext;
class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFilterPanelState> {
public static contextType = MatrixClientContext;
state = {
public state = {
orderedTags: [],
selectedTags: [],
};
componentDidMount() {
this.unmounted = false;
this.context.on("Group.myMembership", this._onGroupMyMembership);
this.context.on("sync", this._onClientSync);
private ref = React.createRef<HTMLDivElement>();
private unmounted = false;
private groupFilterOrderStoreToken?: EventSubscription;
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
public componentDidMount() {
this.unmounted = false;
this.context.on("Group.myMembership", this.onGroupMyMembership);
this.context.on("sync", this.onClientSync);
this.groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
if (this.unmounted) {
return;
}
@ -56,23 +78,25 @@ class GroupFilterPanel extends React.Component {
});
// This could be done by anything with a matrix client
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
UIStore.instance.trackElementDimensions("GroupPanel", this.ref.current);
}
componentWillUnmount() {
public componentWillUnmount() {
this.unmounted = true;
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.removeListener("sync", this._onClientSync);
if (this._groupFilterOrderStoreToken) {
this._groupFilterOrderStoreToken.remove();
this.context.removeListener("Group.myMembership", this.onGroupMyMembership);
this.context.removeListener("sync", this.onClientSync);
if (this.groupFilterOrderStoreToken) {
this.groupFilterOrderStoreToken.remove();
}
UIStore.instance.stopTrackingElementDimensions("GroupPanel");
}
_onGroupMyMembership = () => {
private onGroupMyMembership = () => {
if (this.unmounted) return;
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
};
_onClientSync = (syncState, prevState) => {
private onClientSync = (syncState, prevState) => {
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
@ -82,18 +106,18 @@ class GroupFilterPanel extends React.Component {
}
};
onClick = e => {
private onClick = e => {
// only dispatch if its not a no-op
if (this.state.selectedTags.length > 0) {
dis.dispatch({ action: 'deselect_tags' });
}
};
onClearFilterClick = ev => {
private onClearFilterClick = ev => {
dis.dispatch({ action: 'deselect_tags' });
};
renderGlobalIcon() {
private renderGlobalIcon() {
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
return (
@ -104,7 +128,7 @@ class GroupFilterPanel extends React.Component {
);
}
render() {
public render() {
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const ActionButton = sdk.getComponent('elements.ActionButton');
@ -147,7 +171,7 @@ class GroupFilterPanel extends React.Component {
);
}
return <div className={classes} onClick={this.onClearFilterClick}>
return <div className={classes} onClick={this.onClearFilterClick} ref={this.ref}>
<AutoHideScrollbar
className="mx_GroupFilterPanel_scroller"
onClick={this.onClick}

View file

@ -41,6 +41,9 @@ import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { mediaFromMxc } from "../../customisations/Media";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { createSpaceFromCommunity } from "../../utils/space";
import { Action } from "../../dispatcher/actions";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -222,7 +225,7 @@ class FeaturedRoom extends React.Component {
let roomNameNode = null;
if (permalink) {
roomNameNode = <a href={permalink} onClick={this.onClick} >{ roomName }</a>;
roomNameNode = <a href={permalink} onClick={this.onClick}>{ roomName }</a>;
} else {
roomNameNode = <span>{ roomName }</span>;
}
@ -399,6 +402,8 @@ class FeaturedUser extends React.Component {
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
@replaceableComponent("structures.GroupView")
export default class GroupView extends React.Component {
static propTypes = {
@ -422,6 +427,7 @@ export default class GroupView extends React.Component {
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
};
componentDidMount() {
@ -807,6 +813,22 @@ export default class GroupView extends React.Component {
showGroupAddRoomDialog(this.props.groupId);
};
_dismissUpgradeNotice = () => {
localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
this.setState({ showUpgradeNotice: false });
}
_onCreateSpaceClick = () => {
createSpaceFromCommunity(this._matrixClient, this.props.groupId);
};
_onAdminsLinkClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.GroupMemberList,
});
};
_getGroupSection() {
const groupSettingsSectionClasses = classnames({
"mx_GroupView_group": this.state.editing,
@ -843,10 +865,46 @@ export default class GroupView extends React.Component {
},
) }
</div> : <div />;
let communitiesUpgradeNotice;
if (this.state.showUpgradeNotice) {
let text;
if (this.state.isUserPrivileged) {
text = _t("You can create a Space from this community <a>here</a>.", {}, {
a: sub => <AccessibleButton onClick={this._onCreateSpaceClick} kind="link">
{ sub }
</AccessibleButton>,
});
} else {
text = _t("Ask the <a>admins</a> of this community to make it into a Space " +
"and keep a look out for the invite.", {}, {
a: sub => <AccessibleButton onClick={this._onAdminsLinkClick} kind="link">
{ sub }
</AccessibleButton>,
});
}
communitiesUpgradeNotice = <div className="mx_GroupView_spaceUpgradePrompt">
<h2>{ _t("Communities can now be made into Spaces") }</h2>
<p>
{ _t("Spaces are a new way to make a community, with new features coming.") }
&nbsp;
{ text }
&nbsp;
{ _t("Communities won't receive further updates.") }
</p>
<AccessibleButton
className="mx_GroupView_spaceUpgradePrompt_close"
onClick={this._dismissUpgradeNotice}
/>
</div>;
}
return <div className={groupSettingsSectionClasses}>
{ header }
{ hostingSignup }
{ changeDelayWarning }
{ communitiesUpgradeNotice }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() }
{ this._getRoomsNode() }
@ -1185,10 +1243,13 @@ export default class GroupView extends React.Component {
avatarImage = <Spinner />;
} else {
const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
avatarImage = <GroupAvatar groupId={this.props.groupId}
avatarImage = <GroupAvatar
groupId={this.props.groupId}
groupName={this.state.profileForm.name}
groupAvatarUrl={this.state.profileForm.avatar_url}
width={28} height={28} resizeMethod='crop'
width={28}
height={28}
resizeMethod='crop'
/>;
}
@ -1199,9 +1260,12 @@ export default class GroupView extends React.Component {
</label>
<div className="mx_GroupView_avatarPicker_edit">
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
<img src={require("../../../res/img/camera.svg")}
alt={_t("Upload avatar")} title={_t("Upload avatar")}
width="17" height="15" />
<img
src={require("../../../res/img/camera.svg")}
alt={_t("Upload avatar")}
title={_t("Upload avatar")}
width="17"
height="15" />
</label>
<input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected} />
</div>
@ -1238,7 +1302,8 @@ export default class GroupView extends React.Component {
groupAvatarUrl={groupAvatarUrl}
groupName={groupName}
onClick={onGroupHeaderItemClick}
width={28} height={28}
width={28}
height={28}
/>;
if (summary.profile && summary.profile.name) {
nameNode = <div onClick={onGroupHeaderItemClick}>
@ -1269,28 +1334,32 @@ export default class GroupView extends React.Component {
key="_cancelButton"
onClick={this._onCancelClick}
>
<img src={require("../../../res/img/cancel.svg")} className="mx_filterFlipColor"
width="18" height="18" alt={_t("Cancel")} />
<img
src={require("../../../res/img/cancel.svg")}
className="mx_filterFlipColor"
width="18"
height="18"
alt={_t("Cancel")} />
</AccessibleButton>,
);
} else {
if (summary.user && summary.user.membership === 'join') {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_editButton"
<AccessibleButton
className="mx_GroupHeader_button mx_GroupHeader_editButton"
key="_editButton"
onClick={this._onEditClick}
title={_t("Community Settings")}
>
</AccessibleButton>,
/>,
);
}
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_shareButton"
<AccessibleButton
className="mx_GroupHeader_button mx_GroupHeader_shareButton"
key="_shareButton"
onClick={this._onShareClick}
title={_t('Share Community')}
>
</AccessibleButton>,
/>,
);
}

View file

@ -1,300 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
import * as sdk from '../../index';
import { replaceableComponent } from "../../utils/replaceableComponent";
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
@replaceableComponent("structures.InteractiveAuthComponent")
export default class InteractiveAuthComponent extends React.Component {
static propTypes = {
// matrix client to use for UI auth requests
matrixClient: PropTypes.object.isRequired,
// response from initial request. If not supplied, will do a request on
// mount.
authData: PropTypes.shape({
flows: PropTypes.array,
params: PropTypes.object,
session: PropTypes.string,
}),
// callback
makeRequest: PropTypes.func.isRequired,
// callback called when the auth process has finished,
// successfully or unsuccessfully.
// @param {bool} status True if the operation requiring
// auth was completed sucessfully, false if canceled.
// @param {object} result The result of the authenticated call
// if successful, otherwise the error object.
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
// the auth session.
// * clientSecret {string} The client secret used in auth
// sessions with the identity server.
onAuthFinished: PropTypes.func.isRequired,
// Inputs provided by the user to the auth process
// and used by various stages. As passed to js-sdk
// interactive-auth
inputs: PropTypes.object,
// As js-sdk interactive-auth
requestEmailToken: PropTypes.func,
sessionId: PropTypes.string,
clientSecret: PropTypes.string,
emailSid: PropTypes.string,
// If true, poll to see if the auth flow has been completed
// out-of-band
poll: PropTypes.bool,
// If true, components will be told that the 'Continue' button
// is managed by some other party and should not be managed by
// the component itself.
continueIsManaged: PropTypes.bool,
// Called when the stage changes, or the stage's phase changes. First
// argument is the stage, second is the phase. Some stages do not have
// phases and will be counted as 0 (numeric).
onStagePhaseChange: PropTypes.func,
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText: PropTypes.string,
continueKind: PropTypes.string,
};
constructor(props) {
super(props);
this.state = {
authStage: null,
busy: false,
errorText: null,
stageErrorText: null,
submitButtonEnabled: false,
};
this._unmounted = false;
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
doRequest: this._requestCallback,
busyChanged: this._onBusyChanged,
inputs: this.props.inputs,
stateUpdated: this._authStateUpdated,
matrixClient: this.props.matrixClient,
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this._requestEmailToken,
});
this._intervalId = null;
if (this.props.poll) {
this._intervalId = setInterval(() => {
this._authLogic.poll();
}, 2000);
}
this._stageComponent = createRef();
}
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
this._authLogic.attemptAuth().then((result) => {
const extra = {
emailSid: this._authLogic.getEmailSid(),
clientSecret: this._authLogic.getClientSecret(),
};
this.props.onAuthFinished(true, result, extra);
}).catch((error) => {
this.props.onAuthFinished(false, error);
console.error("Error during user-interactive auth:", error);
if (this._unmounted) {
return;
}
const msg = error.message || error.toString();
this.setState({
errorText: msg,
});
});
}
componentWillUnmount() {
this._unmounted = true;
if (this._intervalId !== null) {
clearInterval(this._intervalId);
}
}
_requestEmailToken = async (...args) => {
this.setState({
busy: true,
});
try {
return await this.props.requestEmailToken(...args);
} finally {
this.setState({
busy: false,
});
}
};
tryContinue = () => {
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
this._stageComponent.current.tryContinue();
}
};
_authStateUpdated = (stageType, stageState) => {
const oldStage = this.state.authStage;
this.setState({
busy: false,
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
}, () => {
if (oldStage !== stageType) {
this._setFocus();
} else if (
!stageState.error && this._stageComponent.current &&
this._stageComponent.current.attemptFailed
) {
this._stageComponent.current.attemptFailed();
}
});
};
_requestCallback = (auth) => {
// This wrapper just exists because the js-sdk passes a second
// 'busy' param for backwards compat. This throws the tests off
// so discard it here.
return this.props.makeRequest(auth);
};
_onBusyChanged = (busy) => {
// if we've started doing stuff, reset the error messages
if (busy) {
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
}
// The JS SDK eagerly reports itself as "not busy" right after any
// immediate work has completed, but that's not really what we want at
// the UI layer, so we ignore this signal and show a spinner until
// there's a new screen to show the user. This is implemented by setting
// `busy: false` in `_authStateUpdated`.
// See also https://github.com/vector-im/element-web/issues/12546
};
_setFocus() {
if (this._stageComponent.current && this._stageComponent.current.focus) {
this._stageComponent.current.focus();
}
}
_submitAuthDict = authData => {
this._authLogic.submitAuthDict(authData);
};
_onPhaseChange = newPhase => {
if (this.props.onStagePhaseChange) {
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
}
};
_onStageCancel = () => {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
};
_renderCurrentStage() {
const stage = this.state.authStage;
if (!stage) {
if (this.state.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
} else {
return null;
}
}
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent
ref={this._stageComponent}
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this._authLogic.getSessionId()}
clientSecret={this._authLogic.getClientSecret()}
stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict}
errorText={this.state.stageErrorText}
busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this._onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this._onStageCancel}
/>
);
}
_onAuthStageFailed = e => {
this.props.onAuthFinished(false, e);
};
_setEmailSid = sid => {
this._authLogic.setEmailSid(sid);
};
render() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this._renderCurrentStage() }
{ error }
</div>
</div>
);
}
}

View file

@ -0,0 +1,300 @@
/*
Copyright 2017 - 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 {
AuthType,
IAuthData,
IAuthDict,
IInputs,
InteractiveAuth,
IStageStatus,
} from "matrix-js-sdk/src/interactive-auth";
import { MatrixClient } from "matrix-js-sdk/src/client";
import React, { createRef } from 'react';
import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents';
import Spinner from "../views/elements/Spinner";
import { replaceableComponent } from "../../utils/replaceableComponent";
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
interface IProps {
// matrix client to use for UI auth requests
matrixClient: MatrixClient;
// response from initial request. If not supplied, will do a request on mount.
authData?: IAuthData;
// Inputs provided by the user to the auth process
// and used by various stages. As passed to js-sdk
// interactive-auth
inputs?: IInputs;
sessionId?: string;
clientSecret?: string;
emailSid?: string;
// If true, poll to see if the auth flow has been completed out-of-band
poll?: boolean;
// If true, components will be told that the 'Continue' button
// is managed by some other party and should not be managed by
// the component itself.
continueIsManaged?: boolean;
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText?: string;
continueKind?: string;
// callback
makeRequest(auth: IAuthData): Promise<IAuthData>;
// callback called when the auth process has finished,
// successfully or unsuccessfully.
// @param {boolean} status True if the operation requiring
// auth was completed successfully, false if canceled.
// @param {object} result The result of the authenticated call
// if successful, otherwise the error object.
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
// the auth session.
// * clientSecret {string} The client secret used in auth
// sessions with the ID server.
onAuthFinished(
status: boolean,
result: IAuthData | Error,
extra?: { emailSid?: string, clientSecret?: string },
): void;
// As js-sdk interactive-auth
requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
// Called when the stage changes, or the stage's phase changes. First
// argument is the stage, second is the phase. Some stages do not have
// phases and will be counted as 0 (numeric).
onStagePhaseChange?(stage: string, phase: string | number): void;
}
interface IState {
authStage?: AuthType;
stageState?: IStageStatus;
busy: boolean;
errorText?: string;
stageErrorText?: string;
submitButtonEnabled: boolean;
}
@replaceableComponent("structures.InteractiveAuthComponent")
export default class InteractiveAuthComponent extends React.Component<IProps, IState> {
private readonly authLogic: InteractiveAuth;
private readonly intervalId: number = null;
private readonly stageComponent = createRef<IStageComponent>();
private unmounted = false;
constructor(props) {
super(props);
this.state = {
authStage: null,
busy: false,
errorText: null,
stageErrorText: null,
submitButtonEnabled: false,
};
this.authLogic = new InteractiveAuth({
authData: this.props.authData,
doRequest: this.requestCallback,
busyChanged: this.onBusyChanged,
inputs: this.props.inputs,
stateUpdated: this.authStateUpdated,
matrixClient: this.props.matrixClient,
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this.requestEmailToken,
});
if (this.props.poll) {
this.intervalId = setInterval(() => {
this.authLogic.poll();
}, 2000);
}
}
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line @typescript-eslint/naming-convention, camelcase
this.authLogic.attemptAuth().then((result) => {
const extra = {
emailSid: this.authLogic.getEmailSid(),
clientSecret: this.authLogic.getClientSecret(),
};
this.props.onAuthFinished(true, result, extra);
}).catch((error) => {
this.props.onAuthFinished(false, error);
console.error("Error during user-interactive auth:", error);
if (this.unmounted) {
return;
}
const msg = error.message || error.toString();
this.setState({
errorText: msg,
});
});
}
componentWillUnmount() {
this.unmounted = true;
if (this.intervalId !== null) {
clearInterval(this.intervalId);
}
}
private requestEmailToken = async (
email: string,
secret: string,
attempt: number,
session: string,
): Promise<{sid: string}> => {
this.setState({
busy: true,
});
try {
return await this.props.requestEmailToken(email, secret, attempt, session);
} finally {
this.setState({
busy: false,
});
}
};
private tryContinue = (): void => {
this.stageComponent.current?.tryContinue?.();
};
private authStateUpdated = (stageType: AuthType, stageState: IStageStatus): void => {
const oldStage = this.state.authStage;
this.setState({
busy: false,
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
}, () => {
if (oldStage !== stageType) {
this.setFocus();
} else if (!stageState.error) {
this.stageComponent.current?.attemptFailed?.();
}
});
};
private requestCallback = (auth: IAuthData, background: boolean): Promise<IAuthData> => {
// This wrapper just exists because the js-sdk passes a second
// 'busy' param for backwards compat. This throws the tests off
// so discard it here.
return this.props.makeRequest(auth);
};
private onBusyChanged = (busy: boolean): void => {
// if we've started doing stuff, reset the error messages
if (busy) {
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
}
// The JS SDK eagerly reports itself as "not busy" right after any
// immediate work has completed, but that's not really what we want at
// the UI layer, so we ignore this signal and show a spinner until
// there's a new screen to show the user. This is implemented by setting
// `busy: false` in `authStateUpdated`.
// See also https://github.com/vector-im/element-web/issues/12546
};
private setFocus(): void {
this.stageComponent.current?.focus?.();
}
private submitAuthDict = (authData: IAuthDict): void => {
this.authLogic.submitAuthDict(authData);
};
private onPhaseChange = (newPhase: number): void => {
this.props.onStagePhaseChange?.(this.state.authStage, newPhase || 0);
};
private onStageCancel = (): void => {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
};
private renderCurrentStage(): JSX.Element {
const stage = this.state.authStage;
if (!stage) {
if (this.state.busy) {
return <Spinner />;
} else {
return null;
}
}
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent
ref={this.stageComponent as any}
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this.authLogic.getSessionId()}
clientSecret={this.authLogic.getClientSecret()}
stageParams={this.authLogic.getStageParams(stage)}
submitAuthDict={this.submitAuthDict}
errorText={this.state.stageErrorText}
busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
fail={this.onAuthStageFailed}
setEmailSid={this.setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this.onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this.onStageCancel}
/>
);
}
private onAuthStageFailed = (e: Error): void => {
this.props.onAuthFinished(false, e);
};
private setEmailSid = (sid: string): void => {
this.authLogic.setEmailSid(sid);
};
render() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this.renderCurrentStage() }
{ error }
</div>
</div>
);
}
}

View file

@ -19,8 +19,6 @@ import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import GroupFilterPanel from "./GroupFilterPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
@ -33,15 +31,12 @@ import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { mediaFromMxc } from "../../customisations/Media";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
@ -53,7 +48,6 @@ interface IProps {
interface IState {
showBreadcrumbs: boolean;
showGroupFilterPanel: boolean;
activeSpace?: Room;
}
@ -70,8 +64,6 @@ const cssClasses = [
export default class LeftPanel extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef();
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private groupFilterPanelWatcherRef: string;
private bgImageWatcherRef: string;
private focusedElement = null;
private isDoingStickyHeaders = false;
@ -80,22 +72,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible,
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
activeSpace: SpaceStore.instance.activeSpace,
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
this.bgImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({ showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel") });
});
}
public componentDidMount() {
UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current);
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
// Using the passive option to not block the main thread
@ -104,11 +90,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
UIStore.instance.stopTrackingElementDimensions("ListContainer");
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
@ -149,23 +132,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
};
private onBackgroundImageUpdate = () => {
// Note: we do this in the LeftPanel as it uses this variable most prominently.
const avatarSize = 32; // arbitrary
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
if (settingBgMxc) {
avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
}
const avatarUrlProp = `url(${avatarUrl})`;
if (!avatarUrl) {
document.body.style.removeProperty("--avatar-url");
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
document.body.style.setProperty("--avatar-url", avatarUrlProp);
}
};
private handleStickyHeaders(list: HTMLDivElement) {
if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true;
@ -392,9 +358,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>
@ -436,23 +399,15 @@ export default class LeftPanel extends React.Component<IProps, IState> {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
})}
onClick={this.onExplore}
title={_t("Explore rooms")}
title={this.state.activeSpace
? _t("Explore %(spaceName)s", { spaceName: this.state.activeSpace.name })
: _t("Explore rooms")}
/>
</div>
);
}
public render(): React.ReactNode {
let leftLeftPanel;
if (this.state.showGroupFilterPanel) {
leftLeftPanel = (
<div className="mx_LeftPanel_GroupFilterPanelContainer">
<GroupFilterPanel />
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
</div>
);
}
const roomList = <RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={this.props.resizeNotifier}
@ -476,7 +431,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
return (
<div className={containerClasses} ref={this.ref}>
{ leftLeftPanel }
<aside className="mx_LeftPanel_roomListContainer">
{ this.renderHeader() }
{ this.renderSearchDialExplore() }

View file

@ -115,7 +115,7 @@ const LeftPanelWidget: React.FC = () => {
aria-expanded={expanded}
aria-level={1}
onClick={() => {
setExpanded(e => !e);
setExpanded(!expanded);
}}
>
<span className={classNames({

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018, 2020 New Vector Ltd
Copyright 2015 - 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.
@ -55,15 +53,22 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import { replaceableComponent } from "../../utils/replaceableComponent";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import CallHandler from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import RoomView from './RoomView';
import ToastContainer from './ToastContainer';
import MyGroups from "./MyGroups";
import UserView from "./UserView";
import GroupView from "./GroupView";
import BackdropPanel from "./BackdropPanel";
import SpaceStore from "../../stores/SpaceStore";
import classNames from 'classnames';
import GroupFilterPanel from './GroupFilterPanel';
import CustomRoomTagPanel from './CustomRoomTagPanel';
import { mediaFromMxc } from "../../customisations/Media";
// 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.
@ -127,6 +132,7 @@ interface IState {
usageLimitEventTs?: number;
useCompactLayout: boolean;
activeCalls: Array<MatrixCall>;
backgroundImage?: string;
}
/**
@ -142,10 +148,13 @@ interface IState {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
private dispatcherRef: string;
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
protected readonly resizeHandler: React.RefObject<HTMLDivElement>;
protected compactLayoutWatcherRef: string;
protected backgroundImageWatcherRef: string;
protected resizer: Resizer;
constructor(props, context) {
@ -156,7 +165,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: [],
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
};
// stash the MatrixClient in case we log out before we are unmounted
@ -168,11 +177,12 @@ class LoggedInView extends React.Component<IProps, IState> {
this._roomView = React.createRef();
this._resizeContainer = React.createRef();
this.resizeHandler = React.createRef();
}
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this.dispatcherRef = dis.register(this.onAction);
this.updateServerNoticeEvents();
@ -189,26 +199,51 @@ class LoggedInView extends React.Component<IProps, IState> {
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
this.backgroundImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.refreshBackgroundImage,
);
this.resizer = this.createResizer();
this.resizer.attach();
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
this.loadResizerPreferences();
this.refreshBackgroundImage();
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
dis.unregister(this.dispatcherRef);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage);
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
this.resizer.detach();
}
private onCallsChanged = () => {
this.setState({
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
});
private refreshBackgroundImage = async (): Promise<void> => {
let backgroundImage = SettingsStore.getValue("RoomList.backgroundImage");
if (backgroundImage) {
// convert to http before going much further
backgroundImage = mediaFromMxc(backgroundImage).srcHttp;
} else {
backgroundImage = OwnProfileStore.instance.getHttpAvatarUrl();
}
this.setState({ backgroundImage });
};
private onAction = (payload): void => {
switch (payload.action) {
case 'call_state': {
const activeCalls = CallHandler.sharedInstance().getAllActiveCalls();
if (activeCalls !== this.state.activeCalls) {
this.setState({ activeCalls });
}
break;
}
}
};
public canResetTimelineInRoom = (roomId: string) => {
@ -247,6 +282,7 @@ class LoggedInView extends React.Component<IProps, IState> {
isItemCollapsed: domNode => {
return domNode.classList.contains("mx_LeftPanel_minimized");
},
handler: this.resizeHandler.current,
};
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames({
@ -262,7 +298,7 @@ class LoggedInView extends React.Component<IProps, IState> {
if (isNaN(lhsSize)) {
lhsSize = 350;
}
this.resizer.forHandleAt(0).resize(lhsSize);
this.resizer.forHandleWithId('lp-resizer').resize(lhsSize);
}
private onAccountData = (event: MatrixEvent) => {
@ -529,24 +565,24 @@ class LoggedInView extends React.Component<IProps, IState> {
}
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
if (!isModifier && !ev.ctrlKey && !ev.metaKey) {
// The above condition is crafted to _allow_ characters with Shift
// already pressed (but not the Shift key down itself).
const isClickShortcut = ev.target !== document.body &&
(ev.key === Key.SPACE || ev.key === Key.ENTER);
// Do not capture the context menu key to improve keyboard accessibility
if (ev.key === Key.CONTEXT_MENU) {
return;
}
// We explicitly allow alt to be held due to it being a common accent modifier.
// XXX: Forwarding Dead keys in this way does not work as intended but better to at least
// move focus to the composer so the user can re-type the dead key correctly.
const isPrintable = ev.key.length === 1 || ev.key === "Dead";
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
// If the user is entering a printable character outside of an input field
// redirect it to the composer for them.
if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input
dis.fire(Action.FocusSendMessageComposer, true);
ev.stopPropagation();
// we should *not* preventDefault() here as
// that would prevent typing in the now-focussed composer
// we should *not* preventDefault() here as that would prevent typing in the now-focused composer
}
}
};
@ -601,10 +637,14 @@ class LoggedInView extends React.Component<IProps, IState> {
break;
}
let bodyClasses = 'mx_MatrixChat';
if (this.state.useCompactLayout) {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
const wrapperClasses = classNames({
'mx_MatrixChat_wrapper': true,
'mx_MatrixChat_useCompactLayout': this.state.useCompactLayout,
});
const bodyClasses = classNames({
'mx_MatrixChat': true,
'mx_MatrixChat--with-avatar': this.state.backgroundImage,
});
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
@ -617,18 +657,47 @@ class LoggedInView extends React.Component<IProps, IState> {
<div
onPaste={this.onPaste}
onKeyDown={this.onReactKeyDown}
className='mx_MatrixChat_wrapper'
className={wrapperClasses}
aria-hidden={this.props.hideToSRUsers}
>
<ToastContainer />
<div ref={this._resizeContainer} className={bodyClasses}>
{ SpaceStore.spacesEnabled ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
<ResizeHandle />
{ pageElement }
<div className={bodyClasses}>
<div className='mx_LeftPanel_wrapper'>
{ SettingsStore.getValue('TagPanel.enableTagPanel') &&
(<div className="mx_GroupFilterPanelContainer">
<BackdropPanel
blurMultiplier={0.5}
backgroundImage={this.state.backgroundImage}
/>
<GroupFilterPanel />
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
</div>)
}
{ SpaceStore.spacesEnabled ? <>
<BackdropPanel
blurMultiplier={0.5}
backgroundImage={this.state.backgroundImage}
/>
<SpacePanel />
</> : null }
<BackdropPanel
backgroundImage={this.state.backgroundImage}
/>
<div
className="mx_LeftPanel_wrapper--user"
ref={this._resizeContainer}
data-collapsed={this.props.collapseLhs ? true : undefined}
>
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
</div>
</div>
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
<div className="mx_RoomView_wrapper">
{ pageElement }
</div>
</div>
</div>
<CallContainer />

View file

@ -107,6 +107,8 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import SoftLogout from './auth/SoftLogout';
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
import { initSentry } from "../../sentry";
/** constants for MatrixChat.state.view */
export enum Views {
@ -387,7 +389,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable();
}
PosthogAnalytics.instance.updateAnonymityFromSettings();
PosthogAnalytics.instance.updatePlatformSuperProperties();
CountlyAnalytics.instance.enable(/* anonymous = */ true);
initSentry(SdkConfig.get()["sentry"]);
}
private async postLoginSetup() {
@ -443,6 +451,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
PosthogAnalytics.instance.trackPageView(durationMs);
}
if (this.focusComposer) {
dis.fire(Action.FocusSendMessageComposer);
@ -1007,6 +1016,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setStateForNewView({
view: Views.LOGGED_IN,
justRegistered,
currentRoomId: null,
});
this.setPage(PageTypes.HomePage);
this.notifyNewScreen('home');

View file

@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl];
const groupedEvents = [
EventType.RoomMember,
EventType.RoomThirdPartyInvite,
EventType.RoomServerAcl,
EventType.RoomPinnedEvents,
];
// check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
@ -168,6 +173,8 @@ interface IProps {
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
hideThreadedMessages?: boolean;
}
interface IState {
@ -260,6 +267,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
componentDidMount() {
this.calculateRoomMembersCount();
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
if (SettingsStore.getValue("feature_thread")) {
this.props.room?.getThreads().forEach(thread => thread.fetchReplyChain());
}
this.isMounted = true;
}
@ -438,6 +448,12 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true;
if (mxEv.replyInThread
&& this.props.hideThreadedMessages
&& SettingsStore.getValue("feature_thread")) {
return false;
}
return !shouldHideEvent(mxEv, this.context);
}
@ -618,7 +634,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
for (const Grouper of groupers) {
if (Grouper.canStartGroup(this, mxEv)) {
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile);
grouper = new Grouper(
this,
mxEv,
prevEvent,
lastShownEvent,
this.props.layout,
nextEvent,
nextTile,
);
}
}
if (!grouper) {
@ -681,9 +705,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
let willWantDateSeparator = false;
let lastInSection = true;
if (nextEvent) {
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEvent.getSender();
if (nextEventWithTile) {
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEventWithTile.getDate() || new Date());
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEventWithTile.getSender();
}
// is this a continuation of the previous message?
@ -981,6 +1005,7 @@ abstract class BaseGrouper {
public readonly event: MatrixEvent,
public readonly prevEvent: MatrixEvent,
public readonly lastShownEvent: MatrixEvent,
protected readonly layout: Layout,
public readonly nextEvent?: MatrixEvent,
public readonly nextEventTile?: MatrixEvent,
) {
@ -1107,6 +1132,7 @@ class CreationGrouper extends BaseGrouper {
onToggle={panel.onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]}
summaryText={summaryText}
layout={this.layout}
>
{ eventTiles }
</EventListSummary>,
@ -1134,10 +1160,11 @@ class RedactionGrouper extends BaseGrouper {
ev: MatrixEvent,
prevEvent: MatrixEvent,
lastShownEvent: MatrixEvent,
layout: Layout,
nextEvent: MatrixEvent,
nextEventTile: MatrixEvent,
) {
super(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile);
super(panel, ev, prevEvent, lastShownEvent, layout, nextEvent, nextEventTile);
this.events = [ev];
}
@ -1202,6 +1229,7 @@ class RedactionGrouper extends BaseGrouper {
onToggle={panel.onHeightChanged} // Update scroll state
summaryMembers={Array.from(senders)}
summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })}
layout={this.layout}
>
{ eventTiles }
</EventListSummary>,
@ -1222,7 +1250,7 @@ class RedactionGrouper extends BaseGrouper {
// Wrap consecutive member events in a ListSummary, ignore if redacted
class MemberGrouper extends BaseGrouper {
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType);
return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType);
};
constructor(
@ -1230,8 +1258,9 @@ class MemberGrouper extends BaseGrouper {
public readonly event: MatrixEvent,
public readonly prevEvent: MatrixEvent,
public readonly lastShownEvent: MatrixEvent,
protected readonly layout: Layout,
) {
super(panel, event, prevEvent, lastShownEvent);
super(panel, event, prevEvent, lastShownEvent, layout);
this.events = [event];
}
@ -1239,7 +1268,7 @@ class MemberGrouper extends BaseGrouper {
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
return false;
}
return membershipTypes.includes(ev.getType() as EventType);
return groupedEvents.includes(ev.getType() as EventType);
}
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
@ -1306,6 +1335,7 @@ class MemberGrouper extends BaseGrouper {
events={this.events}
onToggle={panel.onHeightChanged} // Update scroll state
startExpanded={highlightInMels}
layout={this.layout}
>
{ eventTiles }
</MemberEventListSummary>,

View file

@ -109,8 +109,7 @@ export default class MyGroups extends React.Component {
<SimpleRoomHeader title={_t("Communities")} icon={require("../../../res/img/icons-groups.svg")} />
<div className='mx_MyGroups_header'>
<div className="mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
</AccessibleButton>
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick} />
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">
{ _t('Create a new community') }

View file

@ -45,17 +45,23 @@ import GroupRoomInfo from "../views/groups/GroupRoomInfo";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
import ThreadView from "./ThreadView";
import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { throttle } from 'lodash';
import SpaceStore from "../../stores/SpaceStore";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { E2EStatus } from '../../utils/ShieldUtils';
interface IProps {
room?: Room; // if showing panels for a given room, this is set
groupId?: string; // if showing panels for a given group, this is set
user?: User; // used if we know the user ahead of opening the panel
resizeNotifier: ResizeNotifier;
permalinkCreator?: RoomPermalinkCreator;
e2eStatus?: E2EStatus;
}
interface IState {
@ -265,7 +271,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
case RightPanelPhases.EncryptionPanel:
panel = <UserInfo
user={this.state.member}
room={this.state.phase === RightPanelPhases.SpaceMemberInfo ? this.state.space : this.props.room}
room={this.context.getRoom(this.state.member.roomId) ?? this.props.room}
key={roomId || this.state.member.userId}
onClose={this.onClose}
phase={this.state.phase}
@ -309,6 +315,23 @@ export default class RightPanel extends React.Component<IProps, IState> {
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break;
case RightPanelPhases.ThreadView:
panel = <ThreadView
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus} />;
break;
case RightPanelPhases.ThreadPanel:
panel = <ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose} />;
break;
case RightPanelPhases.RoomSummary:
panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
break;

View file

@ -347,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
});
}
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => {
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: React.MouseEvent) => {
// If room was shift-clicked, remove it from the room directory
if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault();
@ -833,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
export function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
}

View file

@ -266,8 +266,12 @@ export default class RoomStatusBar extends React.PureComponent {
<div className="mx_RoomStatusBar">
<div role="alert">
<div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
height="24" title="/!\ " alt="/!\ " />
<img
src={require("../../../res/img/feather-customised/warning-triangle.svg")}
width="24"
height="24"
title="/!\ "
alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }

View file

@ -1740,7 +1740,8 @@ export default class RoomView extends React.Component<IProps, IState> {
onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
canPreview={false}
error={this.state.roomLoadError}
roomAlias={roomAlias}
joining={this.state.joining}
inviterName={inviterName}
@ -1847,6 +1848,19 @@ export default class RoomView extends React.Component<IProps, IState> {
/>;
}
const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
});
// if statusBar does not exist then statusBarArea is blank and takes up unnecessary space on the screen
// show statusBarArea only if statusBar is present
const statusBarArea = statusBar && <div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line" />
{ statusBar }
</div>
</div>;
const roomVersionRecommendation = this.state.upgradeRecommendation;
const showRoomUpgradeBar = (
roomVersionRecommendation &&
@ -1866,7 +1880,7 @@ export default class RoomView extends React.Component<IProps, IState> {
isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)}
/>;
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
aux = <RoomUpgradeWarningBar room={this.state.room} />;
} else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it.
@ -2041,17 +2055,16 @@ export default class RoomView extends React.Component<IProps, IState> {
highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}
/>);
}
const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
});
const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
? <RightPanel
room={this.state.room}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
e2eStatus={this.state.e2eStatus} />
: null;
const timelineClasses = classNames("mx_RoomView_timeline", {
@ -2094,12 +2107,7 @@ export default class RoomView extends React.Component<IProps, IState> {
{ messagePanel }
{ searchResultsPanel }
</div>
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line" />
{ statusBar }
</div>
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
</div>

View file

@ -183,8 +183,14 @@ export default class ScrollPanel extends React.Component<IProps> {
private readonly itemlist = createRef<HTMLOListElement>();
private unmounted = false;
private scrollTimeout: Timer;
// Are we currently trying to backfill?
private isFilling: boolean;
// Is the current fill request caused by a props update?
private isFillingDueToPropsUpdate = false;
// Did another request to check the fill state arrive while we were trying to backfill?
private fillRequestWhileRunning: boolean;
// Is that next fill request scheduled because of a props update?
private pendingFillDueToPropsUpdate: boolean;
private scrollState: IScrollState;
private preventShrinkingState: IPreventShrinkingState;
private unfillDebouncer: number;
@ -213,7 +219,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// adding events to the top).
//
// This will also re-check the fill state, in case the paginate was inadequate
this.checkScroll();
this.checkScroll(true);
this.updatePreventShrinking();
}
@ -251,12 +257,12 @@ export default class ScrollPanel extends React.Component<IProps> {
// after an update to the contents of the panel, check that the scroll is
// where it ought to be, and set off pagination requests if necessary.
public checkScroll = () => {
public checkScroll = (isFromPropsUpdate = false) => {
if (this.unmounted) {
return;
}
this.restoreSavedScrollState();
this.checkFillState();
this.checkFillState(0, isFromPropsUpdate);
};
// return true if the content is fully scrolled down right now; else false.
@ -319,7 +325,7 @@ export default class ScrollPanel extends React.Component<IProps> {
}
// check the scroll state and send out backfill requests if necessary.
public checkFillState = async (depth = 0): Promise<void> => {
public checkFillState = async (depth = 0, isFromPropsUpdate = false): Promise<void> => {
if (this.unmounted) {
return;
}
@ -355,14 +361,20 @@ export default class ScrollPanel extends React.Component<IProps> {
// don't allow more than 1 chain of calls concurrently
// do make a note when a new request comes in while already running one,
// so we can trigger a new chain of calls once done.
// However, we make an exception for when we're already filling due to a
// props (or children) update, because very often the children include
// spinners to say whether we're paginating or not, so this would cause
// infinite paginating.
if (isFirstCall) {
if (this.isFilling) {
if (this.isFilling && !this.isFillingDueToPropsUpdate) {
debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
this.fillRequestWhileRunning = true;
this.pendingFillDueToPropsUpdate = isFromPropsUpdate;
return;
}
debuglog("isFilling: setting");
this.isFilling = true;
this.isFillingDueToPropsUpdate = isFromPropsUpdate;
}
const itemlist = this.itemlist.current;
@ -393,11 +405,14 @@ export default class ScrollPanel extends React.Component<IProps> {
if (isFirstCall) {
debuglog("isFilling: clearing");
this.isFilling = false;
this.isFillingDueToPropsUpdate = false;
}
if (this.fillRequestWhileRunning) {
const refillDueToPropsUpdate = this.pendingFillDueToPropsUpdate;
this.fillRequestWhileRunning = false;
this.checkFillState();
this.pendingFillDueToPropsUpdate = false;
this.checkFillState(0, refillDueToPropsUpdate);
}
};

View file

@ -136,8 +136,8 @@ export default class SearchBox extends React.Component {
key="button"
tabIndex={-1}
className="mx_SearchBox_closeButton"
onClick={() => {this._clearSearch("button"); }}>
</AccessibleButton>) : undefined;
onClick={() => {this._clearSearch("button"); }}
/>) : undefined;
// show a shorter placeholder when blurred, if requested
// this is used for the room filter field that has

View file

@ -0,0 +1,717 @@
/*
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, {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
KeyboardEvent,
KeyboardEventHandler,
useContext,
SetStateAction,
Dispatch,
} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { sortBy } from "lodash";
import dis from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import BaseAvatar from "../views/avatars/BaseAvatar";
import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { useDispatcher } from "../../hooks/useDispatcher";
import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { getDisplayAliasForRoom } from "./RoomDirectory";
import MatrixClientContext from "../../contexts/MatrixClientContext";
interface IProps {
space: Room;
initialText?: string;
additionalButtons?: ReactNode;
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin?: boolean): void;
}
interface ITileProps {
room: IHierarchyRoom;
suggested?: boolean;
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onToggleClick?(): void;
}
const Tile: React.FC<ITileProps> = ({
room,
suggested,
selected,
hasPermissions,
onToggleClick,
onViewRoomClick,
numChildRooms,
children,
}) => {
const cli = useContext(MatrixClientContext);
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
};
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
};
let button;
if (joinedRoom) {
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
}
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
}
}
let avatar;
if (joinedRoom) {
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
} else {
avatar = <BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>;
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms !== undefined) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
if (topic) {
description += " · " + topic;
}
let suggestedSection;
if (suggested) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") }
</InfoTooltip>;
}
const content = <React.Fragment>
{ avatar }
<div className="mx_SpaceHierarchy_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div
className="mx_SpaceHierarchy_roomTile_info"
ref={e => e && linkifyElement(e)}
onClick={ev => {
// prevent clicks on links from bubbling up to the room tile
if ((ev.target as HTMLElement).tagName === "A") {
ev.stopPropagation();
}
}}
>
{ description }
</div>
<div className="mx_SpaceHierarchy_actions">
{ button }
{ checkbox }
</div>
</React.Fragment>;
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
className={classNames("mx_SpaceHierarchy_subspace_toggle", {
mx_SpaceHierarchy_subspace_toggle_shown: showChildren,
})}
onClick={ev => {
ev.stopPropagation();
toggleShowChildren();
}}
/>;
if (showChildren) {
const onChildrenKeyDown = (e) => {
if (e.key === Key.ARROW_LEFT) {
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
}
};
childSection = <div
className="mx_SpaceHierarchy_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children }
</div>;
}
onKeyDown = (e) => {
let handled = false;
switch (e.key) {
case Key.ARROW_LEFT:
if (showChildren) {
handled = true;
toggleShowChildren();
}
break;
case Key.ARROW_RIGHT:
handled = true;
if (showChildren) {
const childSection = ref.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
} else {
toggleShowChildren();
}
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
}
return <li
className="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceHierarchy_roomTile", {
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</li>;
};
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
const room = hierarchy.roomMap.get(roomId);
// 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 (cli.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: Array.from(hierarchy.viaMap.get(roomId) || []),
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 {
root: IHierarchyRoom;
roomSet: Set<IHierarchyRoom>;
hierarchy: RoomHierarchy;
parents: Set<string>;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void;
onToggleClick?(parentId: string, childId: string): void;
}
export const HierarchyLevel = ({
root,
roomSet,
hierarchy,
parents,
selectedMap,
onViewRoomClick,
onToggleClick,
}: IHierarchyLevelProps) => {
const cli = useContext(MatrixClientContext);
const space = cli.getRoom(root.room_id);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy(root.children_state, ev => {
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(room);
}
return result;
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
const newParents = new Set(parents).add(root.room_id);
return <React.Fragment>
{
childRooms.map(room => (
<Tile
key={room.room_id}
room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(room.room_id, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
))
}
{
subspaces.filter(room => !newParents.has(room.room_id)).map(space => (
<Tile
key={space.room_id}
room={space}
numChildRooms={space.children_state.filter(ev => {
const room = hierarchy.roomMap.get(ev.state_key);
return room && roomSet.has(room) && !room.room_type;
}).length}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(space.room_id, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
>
<HierarchyLevel
root={space}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
))
}
</React.Fragment>;
};
const INITIAL_PAGE_SIZE = 20;
export const useSpaceSummary = (space: Room): {
loading: boolean;
rooms: IHierarchyRoom[];
hierarchy: RoomHierarchy;
loadMore(pageSize?: number): Promise <void>;
} => {
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
const [loading, setLoading] = useState(true);
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
const resetHierarchy = useCallback(() => {
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
setHierarchy(hierarchy);
let discard = false;
hierarchy.load().then(() => {
if (discard) return;
setRooms(hierarchy.rooms);
setLoading(false);
});
return () => {
discard = true;
};
}, [space]);
useEffect(resetHierarchy, [resetHierarchy]);
useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setLoading(true);
setRooms([]); // TODO
resetHierarchy();
}
}));
const loadMore = useCallback(async (pageSize?: number) => {
if (!hierarchy.canLoadMore || hierarchy.noSupport) return;
setLoading(true);
await hierarchy.load(pageSize);
setRooms(hierarchy.rooms);
setLoading(false);
}, [hierarchy]);
return { loading, rooms, hierarchy, loadMore };
};
const useIntersectionObserver = (callback: () => void) => {
const handleObserver = (entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting) {
callback();
}
};
const observerRef = useRef<IntersectionObserver>();
return (element: HTMLDivElement) => {
if (observerRef.current) {
observerRef.current.disconnect();
} else if (element) {
observerRef.current = new IntersectionObserver(handleObserver, {
root: element.parentElement,
rootMargin: "0px 0px 600px 0px",
});
}
if (observerRef.current && element) {
observerRef.current.observe(element);
}
};
};
interface IManageButtonsProps {
hierarchy: RoomHierarchy;
selected: Map<string, Set<string>>;
setSelected: Dispatch<SetStateAction<Map<string, Set<string>>>>;
setError: Dispatch<SetStateAction<string>>;
}
const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageButtonsProps) => {
const cli = useContext(MatrixClientContext);
const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false);
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return hierarchy.isSuggested(parentId, childId);
});
const disabled = !selectedRelations.length || removing || saving;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
return <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
hierarchy.removeRelation(parentId, childId);
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
setSelected(new Map());
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
};
const SpaceHierarchy = ({
space,
initialText = "",
showRoom,
additionalButtons,
}: IProps) => {
const cli = useContext(MatrixClientContext);
const [query, setQuery] = useState(initialText);
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms.length) return new Set();
const lcQuery = query.toLowerCase().trim();
if (!lcQuery) return new Set(rooms);
const directMatches = rooms.filter(r => {
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
});
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
const visited = new Set<string>();
const queue = [...directMatches.map(r => r.room_id)];
while (queue.length) {
const roomId = queue.pop();
visited.add(roomId);
hierarchy.backRefs.get(roomId)?.forEach(parentId => {
if (!visited.has(parentId)) {
queue.push(parentId);
}
});
}
return new Set(rooms.filter(r => visited.has(r.room_id)));
}, [rooms, hierarchy, query]);
const [error, setError] = useState("");
const loaderRef = useIntersectionObserver(loadMore);
if (!loading && hierarchy.noSupport) {
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
}
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
state.refs[0]?.current?.focus();
}
};
const onToggleClick = (parentId: string, childId: string): void => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
};
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content: JSX.Element;
let loader: JSX.Element;
if (loading && !rooms.length) {
content = <Spinner />;
} else {
const hasPermissions = space?.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
let results: JSX.Element;
if (filteredRoomSet.size) {
results = <>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(cli, hierarchy, roomId, autoJoin);
}}
/>
</>;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
} else {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
<span>
{ additionalButtons }
{ hasPermissions && (
<ManageButtons
hierarchy={hierarchy}
selected={selected}
setSelected={setSelected}
setError={setError}
/>
) }
</span>
</div>
{ error && <div className="mx_SpaceHierarchy_error">
{ error }
</div> }
<ul
className="mx_SpaceHierarchy_list"
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Space")}
>
{ results }
</ul>
{ loader }
</>;
}
return <>
<SearchBox
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{ content }
</>;
} }
</RovingTabIndexProvider>;
};
export default SpaceHierarchy;

View file

@ -1,642 +0,0 @@
/*
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, { ReactNode, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
import classNames from "classnames";
import { sortBy } from "lodash";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import RoomName from "../views/elements/RoomName";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import { EnhancedMap } from "../../utils/maps";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import AutoHideScrollbar from "./AutoHideScrollbar";
import BaseAvatar from "../views/avatars/BaseAvatar";
import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { getDisplayAliasForAliasSet } from "../../Rooms";
interface IHierarchyProps {
space: Room;
initialText?: string;
refreshToken?: any;
additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
interface ITileProps {
room: ISpaceSummaryRoom;
suggested?: boolean;
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onToggleClick?(): void;
}
const Tile: React.FC<ITileProps> = ({
room,
suggested,
selected,
hasPermissions,
onToggleClick,
onViewRoomClick,
numChildRooms,
children,
}) => {
const cli = MatrixClientPeg.get();
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
};
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
};
let button;
if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton onClick={onJoinClick} kind="primary">
{ _t("Join") }
</AccessibleButton>;
}
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} />
</TextWithTooltip>;
}
}
let avatar;
if (joinedRoom) {
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
} else {
avatar = <BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>;
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms !== undefined) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
if (topic) {
description += " · " + topic;
}
let suggestedSection;
if (suggested) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") }
</InfoTooltip>;
}
const content = <React.Fragment>
{ avatar }
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div
className="mx_SpaceRoomDirectory_roomTile_info"
ref={e => e && linkifyElement(e)}
onClick={ev => {
// prevent clicks on links from bubbling up to the room tile
if ((ev.target as HTMLElement).tagName === "A") {
ev.stopPropagation();
}
}}
>
{ description }
</div>
<div className="mx_SpaceRoomDirectory_actions">
{ button }
{ checkbox }
</div>
</React.Fragment>;
let childToggle;
let childSection;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
className={classNames("mx_SpaceRoomDirectory_subspace_toggle", {
mx_SpaceRoomDirectory_subspace_toggle_shown: showChildren,
})}
onClick={ev => {
ev.stopPropagation();
toggleShowChildren();
}}
/>;
if (showChildren) {
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
{ children }
</div>;
}
}
return <>
<AccessibleButton
className={classNames("mx_SpaceRoomDirectory_roomTile", {
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</>;
};
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>;
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
parents: Set<string>;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void;
onToggleClick?(parentId: string, childId: string): void;
}
export const HierarchyLevel = ({
spaceId,
rooms,
relations,
parents,
selectedMap,
onViewRoomClick,
onToggleClick,
}: IHierarchyLevelProps) => {
const cli = MatrixClientPeg.get();
const space = cli.getRoom(spaceId);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getChildOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key;
if (!rooms.has(roomId)) return result;
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
return result;
}, [[], []]) || [[], []];
const newParents = new Set(parents).add(spaceId);
return <React.Fragment>
{
childRooms.map(roomId => (
<Tile
key={roomId}
room={rooms.get(roomId)}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
/>
))
}
{
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
<Tile
key={roomId}
room={rooms.get(roomId)}
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
.filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
>
<HierarchyLevel
spaceId={roomId}
rooms={rooms}
relations={relations}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
))
}
</React.Fragment>;
};
// mutate argument refreshToken to force a reload
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
null,
ISpaceSummaryRoom[],
Map<string, Map<string, ISpaceSummaryEvent>>?,
Map<string, Set<string>>?,
Map<string, Set<string>>?,
] | [Error] => {
// TODO pagination
return useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId);
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
const childParentRelations = new EnhancedMap<string, Set<string>>();
const viaMap = new EnhancedMap<string, Set<string>>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
}
if (Array.isArray(ev.content.via)) {
const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content.via.forEach(via => set.add(via));
}
});
return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
} catch (e) {
console.error(e); // TODO
return [e];
}
}, [space, refreshToken], [undefined]);
};
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
space,
initialText = "",
showRoom,
refreshToken,
additionalButtons,
children,
}) => {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const [query, setQuery] = useState(initialText);
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
const roomsMap = useMemo(() => {
if (!rooms) return null;
const lcQuery = query.toLowerCase().trim();
const roomsMap = new Map<string, ISpaceSummaryRoom>(rooms.map(r => [r.room_id, r]));
if (!lcQuery) return roomsMap;
const directMatches = rooms.filter(r => {
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
});
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
const visited = new Set<string>();
const queue = [...directMatches.map(r => r.room_id)];
while (queue.length) {
const roomId = queue.pop();
visited.add(roomId);
childParentMap.get(roomId)?.forEach(parentId => {
if (!visited.has(parentId)) {
queue.push(parentId);
}
});
}
// Remove any mappings for rooms which were not visited in the walk
Array.from(roomsMap.keys()).forEach(roomId => {
if (!visited.has(roomId)) {
roomsMap.delete(roomId);
}
});
return roomsMap;
}, [rooms, childParentMap, query]);
const [error, setError] = useState("");
const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false);
if (summaryError) {
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
}
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr;
if (numSpaces > 1) {
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
} else if (numSpaces > 0) {
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
} else {
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
}
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = !selectedRelations.length || removing || saving;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).delete(childId);
if (parentChildMap.get(parentId).size > 0) {
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
} else {
parentChildMap.delete(parentId);
}
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results;
if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
relations={parentChildMap}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
}}
/>
{ children && <hr /> }
</>;
} else {
results = <div className="mx_SpaceRoomDirectory_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
{ results }
{ children }
</AutoHideScrollbar>
</>;
} else {
content = <Spinner />;
}
// TODO loading state/error state
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
/>
{ content }
</>;
};
interface IProps {
space: Room;
initialText?: string;
onFinished(): void;
}
const SpaceRoomDirectory: React.FC<IProps> = ({ space, onFinished, initialText }) => {
const onCreateRoomClick = () => {
dis.dispatch({
action: 'view_create_room',
public: true,
});
onFinished();
};
const title = <React.Fragment>
<RoomAvatar room={space} height={32} width={32} />
<div>
<h1>{ _t("Explore rooms") }</h1>
<div><RoomName room={space} /></div>
</div>
</React.Fragment>;
return (
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
<div className="mx_Dialog_content">
{ _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>;
} },
) }
<SpaceHierarchy
space={space}
showRoom={(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
showRoom(room, viaServers, autoJoin);
onFinished();
}}
initialText={initialText}
>
<AccessibleButton
onClick={onCreateRoomClick}
kind="primary"
className="mx_SpaceRoomDirectory_createRoom"
>
{ _t("Create room") }
</AccessibleButton>
</SpaceHierarchy>
</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 getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { RefObject, useContext, useRef, useState } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { Preset } from "matrix-js-sdk/src/@types/partials";
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventSubscription } from "fbemitter";
@ -47,13 +47,23 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import { useStateArray } from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space";
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
import {
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceSettings,
} from "../../utils/space";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import { useStateToggle } from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog";
import {
AddExistingToSpace,
defaultDmsRenderer,
defaultRoomsRenderer,
defaultSpacesRenderer,
} from "../views/dialogs/AddExistingToSpaceDialog";
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
@ -62,11 +72,12 @@ import IconizedContextMenu, {
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { JoinRule } from "../views/settings/tabs/room/SecurityRoomSettingsTab";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
interface IProps {
space: Room;
@ -78,7 +89,7 @@ interface IProps {
interface IState {
phase: Phase;
createdRooms?: boolean; // internal state for the creation wizard
firstRoomId?: string; // internal state for the creation wizard
showRightPanel: boolean;
myMembership: string;
}
@ -93,26 +104,6 @@ enum Phase {
PrivateExistingRooms,
}
// XXX: Temporary for the Spaces Beta only
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
if (!SdkConfig.get().bug_report_endpoint_url) return null;
return <div className="mx_SpaceFeedbackPrompt">
<hr />
<div>
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
<AccessibleButton kind="link" onClick={() => {
if (onClick) onClick();
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
featureId: "feature_spaces",
});
}}>
{ _t("Feedback") }
</AccessibleButton>
</div>
</div>;
};
const RoomMemberCount = ({ room, children }) => {
const members = useRoomMembers(room);
const count = members.length;
@ -171,7 +162,33 @@ const onBetaClick = () => {
});
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
// XXX: temporary community migration component
const GroupTile = ({ groupId }: { groupId: string }) => {
const cli = useContext(MatrixClientContext);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
if (!groupSummary) return <Spinner />;
return <>
<GroupAvatar
groupId={groupId}
groupName={groupSummary.profile.name}
groupAvatarUrl={groupSummary.profile.avatar_url}
width={16}
height={16}
resizeMethod='crop'
/>
{ groupSummary.profile.name }
</>;
};
interface ISpacePreviewProps {
space: Room;
onJoinButtonClicked(): void;
onRejectButtonClicked(): void;
}
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
@ -205,11 +222,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
if (inviteSender) {
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
<MemberAvatar member={inviter} width={32} height={32} />
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
<div>
<div className="mx_SpaceRoomView_preview_inviter_name">
{ _t("<inviter/> invites you", {}, {
inviter: () => <b>{ inviter.name || inviteSender }</b>,
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
}) }
</div>
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
@ -283,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>;
}
let migratedCommunitySection: JSX.Element;
const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
if (createContent[CreateEventField]) {
migratedCommunitySection = <div className="mx_SpaceRoomView_preview_migratedCommunity">
{ _t("Created from <Community />", {}, {
Community: () => <GroupTile groupId={createContent[CreateEventField]} />,
}) }
</div>;
}
return <div className="mx_SpaceRoomView_preview">
<BetaPill onClick={onBetaClick} />
{ migratedCommunitySection }
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">
@ -306,8 +333,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>;
};
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
const cli = useContext(MatrixClientContext);
const SpaceLandingAddButton = ({ space }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
let contextMenu;
@ -330,25 +356,33 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
e.stopPropagation();
closeMenu();
if (await showCreateNewRoom(cli, space)) {
onNewRoomAdded();
if (await showCreateNewRoom(space)) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
}}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={async (e) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
const [added] = await showAddExistingRooms(cli, space);
if (added) {
onNewRoomAdded();
}
showAddExistingRooms(space);
}}
/>
<IconizedContextMenuOption
label={_t("Add space")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewSubspace(space);
}}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
@ -389,19 +423,17 @@ const SpaceLanding = ({ space }) => {
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButton;
if (canAddRooms) {
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
addRoomButton = <SpaceLandingAddButton space={space} />;
}
let settingsButton;
if (shouldShowSpaceSettings(cli, space)) {
if (shouldShowSpaceSettings(space)) {
settingsButton = <AccessibleTooltipButton
className="mx_SpaceRoomView_landing_settingsButton"
onClick={() => {
showSpaceSettings(cli, space);
showSpaceSettings(space);
}}
title={_t("Settings")}
/>;
@ -416,6 +448,7 @@ const SpaceLanding = ({ space }) => {
};
return <div className="mx_SpaceRoomView_landing">
<SpaceFeedbackPrompt />
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<div className="mx_SpaceRoomView_landing_name">
<RoomName room={space}>
@ -440,15 +473,8 @@ const SpaceLanding = ({ space }) => {
</div>
) }
</RoomTopic>
<SpaceFeedbackPrompt />
<hr />
<SpaceHierarchy
space={space}
showRoom={showRoom}
refreshToken={refreshToken}
additionalButtons={addRoomButton}
/>
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>;
};
@ -470,6 +496,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
onChange={ev => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
disabled={busy}
autoComplete="off"
/>;
});
@ -479,11 +506,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setError("");
setBusy(true);
try {
const isPublic = space.getJoinRule() === JoinRule.Public;
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
await Promise.all(filteredRoomNames.map(name => {
const roomIds = await Promise.all(filteredRoomNames.map(name => {
return createRoom({
createOpts: {
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
name,
},
spinner: false,
@ -491,9 +519,11 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
andView: false,
inlineErrors: true,
parentSpace: space,
joinRule: !isPublic ? JoinRule.Restricted : undefined,
suggested: true,
});
}));
onFinished(filteredRoomNames.length > 0);
onFinished(roomIds[0]);
} catch (e) {
console.error("Failed to create initial space rooms", e);
setError(_t("Failed to create initial space rooms"));
@ -503,7 +533,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
let onClick = (ev) => {
ev.preventDefault();
onFinished(false);
onFinished();
};
let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) {
@ -531,7 +561,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -550,17 +579,20 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
{ _t("Skip for now") }
</AccessibleButton>
}
filterPlaceholder={_t("Search for rooms or spaces")}
onFinished={onFinished}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={defaultSpacesRenderer}
dmsRenderer={defaultDmsRenderer}
/>
<div className="mx_SpaceRoomView_buttons">
</div>
<SpaceFeedbackPrompt />
</div>;
};
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
interface ISpaceSetupPublicShareProps extends Pick<IProps & IState, "justCreatedOpts" | "space" | "firstRoomId"> {
onFinished(): void;
}
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, firstRoomId }: ISpaceSetupPublicShareProps) => {
return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
@ -573,10 +605,9 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" onClick={onFinished}>
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
{ firstRoomId ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -605,9 +636,8 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
</AccessibleButton>
<div className="mx_SpaceRoomView_betaWarning">
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
<p>{ _t("We're working on this, but just want to let you know.") }</p>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -730,7 +760,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -784,6 +813,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
};
private onAction = (payload: ActionPayload) => {
if (payload.action === "view_room" && payload.room_id === this.props.space.roomId) {
this.setState({ phase: Phase.Landing });
return;
}
if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return;
if (payload.action === Action.ViewUser && payload.member) {
@ -814,35 +848,10 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
};
private goToFirstRoom = async () => {
// TODO actually go to the first room
const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId);
if (childRooms.length) {
const room = childRooms[0];
if (this.state.firstRoomId) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.roomId,
});
return;
}
let suggestedRooms = SpaceStore.instance.suggestedRooms;
if (SpaceStore.instance.activeSpace !== this.props.space) {
// the space store has the suggested rooms loaded for a different space, fetch the right ones
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1));
}
if (suggestedRooms.length) {
const room = suggestedRooms[0];
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.room_id,
room_alias: room.canonical_alias || room.aliases?.[0],
via_servers: room.viaServers,
oobData: {
avatarUrl: room.avatar_url,
name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
},
room_id: this.state.firstRoomId,
});
return;
}
@ -872,14 +881,14 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
_t("Let's create a room for each of them.") + "\n" +
_t("You can add more later too, including already existing ones.")
}
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PublicShare, firstRoomId })}
/>;
case Phase.PublicShare:
return <SpaceSetupPublicShare
justCreatedOpts={this.props.justCreatedOpts}
space={this.props.space}
onFinished={this.goToFirstRoom}
createdRooms={this.state.createdRooms}
firstRoomId={this.state.firstRoomId}
/>;
case Phase.PrivateScope:
@ -901,7 +910,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
title={_t("What projects are you working on?")}
description={_t("We'll create rooms for each of them. " +
"You can add more later too, including already existing ones.")}
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.Landing, firstRoomId })}
/>;
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms

View file

@ -0,0 +1,93 @@
/*
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 { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixClientPeg } from '../../MatrixClientPeg';
import ResizeNotifier from '../../utils/ResizeNotifier';
import EventTile from '../views/rooms/EventTile';
interface IProps {
roomId: string;
onClose: () => void;
resizeNotifier: ResizeNotifier;
}
interface IState {
threads?: Thread[];
}
@replaceableComponent("structures.ThreadView")
export default class ThreadPanel extends React.Component<IProps, IState> {
private room: Room;
constructor(props: IProps) {
super(props);
this.room = MatrixClientPeg.get().getRoom(this.props.roomId);
}
public componentDidMount(): void {
this.room.on("Thread.update", this.onThreadEventReceived);
this.room.on("Thread.ready", this.onThreadEventReceived);
}
public componentWillUnmount(): void {
this.room.removeListener("Thread.update", this.onThreadEventReceived);
this.room.removeListener("Thread.ready", this.onThreadEventReceived);
}
private onThreadEventReceived = () => this.updateThreads();
private updateThreads = (callback?: () => void): void => {
this.setState({
threads: this.room.getThreads(),
}, callback);
};
private renderEventTile(event: MatrixEvent): JSX.Element {
return <EventTile
key={event.getId()}
mxEvent={event}
enableFlair={false}
showReadReceipts={false}
as="div"
/>;
}
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadPanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
{
this.state?.threads.map((thread: Thread) => {
if (thread.ready) {
return this.renderEventTile(thread.rootEvent);
}
})
}
</BaseCard>
);
}
}

View file

@ -0,0 +1,160 @@
/*
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 { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import ResizeNotifier from '../../utils/ResizeNotifier';
import { TileShape } from '../views/rooms/EventTile';
import MessageComposer from '../views/rooms/MessageComposer';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { Layout } from '../../settings/Layout';
import TimelinePanel from './TimelinePanel';
import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from '../../dispatcher/payloads';
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
import { Action } from '../../dispatcher/actions';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import { E2EStatus } from '../../utils/ShieldUtils';
interface IProps {
room: Room;
onClose: () => void;
resizeNotifier: ResizeNotifier;
mxEvent: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
e2eStatus?: E2EStatus;
}
interface IState {
replyToEvent?: MatrixEvent;
thread?: Thread;
}
@replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
constructor(props: IProps) {
super(props);
this.state = {};
}
public componentDidMount(): void {
this.setupThread(this.props.mxEvent);
this.dispatcherRef = dis.register(this.onAction);
}
public componentWillUnmount(): void {
this.teardownThread();
dis.unregister(this.dispatcherRef);
}
public componentDidUpdate(prevProps) {
if (prevProps.mxEvent !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(this.props.mxEvent);
}
if (prevProps.room !== this.props.room) {
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
});
}
}
private onAction = (payload: ActionPayload): void => {
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
if (payload.event !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(payload.event);
}
}
};
private setupThread = (mxEv: MatrixEvent) => {
let thread = mxEv.getThread();
if (!thread) {
const client = MatrixClientPeg.get();
thread = new Thread([mxEv], this.props.room, client);
mxEv.setThread(thread);
}
thread.on("Thread.update", this.updateThread);
thread.once("Thread.ready", this.updateThread);
this.updateThread(thread);
};
private teardownThread = () => {
if (this.state.thread) {
this.state.thread.removeListener("Thread.update", this.updateThread);
this.state.thread.removeListener("Thread.ready", this.updateThread);
}
};
private updateThread = (thread?: Thread) => {
if (thread) {
this.setState({
thread,
replyToEvent: thread.replyToEvent,
});
}
this.timelinePanelRef.current?.refreshTimeline();
};
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadView"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
{ this.state.thread && (
<TimelinePanel
ref={this.timelinePanelRef}
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={false}
tileShape={TileShape.Notif}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
/>
) }
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
replyInThread={true}
replyToEvent={this.state?.thread?.replyToEvent}
showReplyPreview={false}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
</BaseCard>
);
}
}

View file

@ -47,11 +47,14 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Spinner from "../views/elements/Spinner";
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import ErrorDialog from '../views/dialogs/ErrorDialog';
import { debounce } from 'lodash';
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
const READ_RECEIPT_INTERVAL_MS = 500;
const READ_MARKER_DEBOUNCE_MS = 100;
const DEBUG = false;
let debuglog = function(...s: any[]) {};
@ -126,6 +129,8 @@ interface IProps {
// callback which is called when we wish to paginate the timeline window.
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
hideThreadedMessages?: boolean;
}
interface IState {
@ -214,6 +219,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
timelineCap: Number.MAX_VALUE,
className: 'mx_RoomView_messagePanel',
sendReadReceiptOnLoad: true,
hideThreadedMessages: true,
};
private lastRRSentEventId: string = undefined;
@ -472,22 +478,35 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
if (this.props.manageReadMarkers) {
const rmPosition = this.getReadMarkerPosition();
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (rmPosition < 0) {
this.setState({ readMarkerVisible: true });
}
// if read marker position goes between 0 and -1/1,
// (and user is active), switch timeout
const timeout = this.readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this.readMarkerActivityTimer.changeTimeout(timeout);
this.doManageReadMarkers();
}
};
/*
* Debounced function to manage read markers because we don't need to
* do this on every tiny scroll update. It also sets state which causes
* a component update, which can in turn reset the scroll position, so
* it's important we allow the browser to scroll a bit before running this
* (hence trailing edge only and debounce rather than throttle because
* we really only need to update this once the user has finished scrolling,
* not periodically while they scroll).
*/
private doManageReadMarkers = debounce(() => {
const rmPosition = this.getReadMarkerPosition();
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (rmPosition < 0) {
this.setState({ readMarkerVisible: true });
}
// if read marker position goes between 0 and -1/1,
// (and user is active), switch timeout
const timeout = this.readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this.readMarkerActivityTimer.changeTimeout(timeout);
}, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true });
private onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case "ignore_state_changed":
@ -757,16 +776,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
this.lastRMSentEventId = this.state.readMarkerEventId;
const roomId = this.props.timelineSet.room.roomId;
const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId);
debuglog('TimelinePanel: Sending Read Markers for ',
this.props.timelineSet.room.roomId,
'rm', this.state.readMarkerEventId,
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
' hidden:' + hiddenRR,
);
MatrixClientPeg.get().setRoomReadMarkers(
this.props.timelineSet.room.roomId,
roomId,
this.state.readMarkerEventId,
lastReadEvent, // Could be null, in which case no RR is sent
{},
{ hidden: hiddenRR },
).catch((e) => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
@ -1172,6 +1195,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setState(this.getEvents());
}
// Force refresh the timeline before threads support pending events
public refreshTimeline(): void {
this.loadTimeline();
this.reloadEvents();
}
// get the list of events from the timeline window and the pending event list
private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
const events: MatrixEvent[] = this.timelineWindow.getEvents();
@ -1507,6 +1536,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
hideThreadedMessages={this.props.hideThreadedMessages}
/>
);
}

View file

@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> {
let containerClasses;
if (totalCount !== 0) {
const topToast = this.state.toasts[0];
const { title, icon, key, component, className, props } = topToast;
const toastClasses = classNames("mx_Toast_toast", {
const { title, icon, key, component, className, bodyClassName, props } = topToast;
const bodyClasses = classNames("mx_Toast_body", bodyClassName);
const toastClasses = classNames("mx_Toast_toast", className, {
"mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon,
}, className);
let countIndicator;
if (isStacked || this.state.countSeen > 0) {
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
}
});
const toastProps = Object.assign({}, props, {
key,
toastKey: key,
});
toast = (<div className={toastClasses}>
<div className="mx_Toast_title">
<h2>{ title }</h2>
<span>{ countIndicator }</span>
const content = React.createElement(component, toastProps);
let countIndicator;
if (title && isStacked || this.state.countSeen > 0) {
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
}
let titleElement;
if (title) {
titleElement = (
<div className="mx_Toast_title">
<h2>{ title }</h2>
<span>{ countIndicator }</span>
</div>
);
}
toast = (
<div className={toastClasses}>
{ titleElement }
<div className={bodyClasses}>{ content }</div>
</div>
<div className="mx_Toast_body">{ React.createElement(component, toastProps) }</div>
</div>);
);
containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,

View file

@ -31,6 +31,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
import { IValidationResult } from "../../views/elements/Validation";
import InlineSpinner from '../../views/elements/InlineSpinner';
enum Phase {
// Show the forgot password inputs
@ -66,13 +67,14 @@ interface IState {
serverDeadError: string;
passwordFieldValid: boolean;
currentHttpRequest?: Promise<any>;
}
@replaceableComponent("structures.auth.ForgotPassword")
export default class ForgotPassword extends React.Component<IProps, IState> {
private reset: PasswordReset;
state = {
state: IState = {
phase: Phase.Forgot,
email: "",
password: "",
@ -148,8 +150,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
console.error("onVerify called before submitPasswordReset!");
return;
}
if (this.state.currentHttpRequest) return;
try {
await this.reset.checkEmailLinkClicked();
await this.handleHttpRequest(this.reset.checkEmailLinkClicked());
this.setState({ phase: Phase.Done });
} catch (err) {
this.showErrorDialog(err.message);
@ -158,9 +162,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
if (this.state.currentHttpRequest) return;
// refresh the server errors, just in case the server came back online
await this.checkServerLiveliness(this.props.serverConfig);
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
await this['password_field'].validate({ allowEmpty: false });
@ -221,6 +226,17 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
});
}
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
this.setState({
currentHttpRequest: request,
});
return request.finally(() => {
this.setState({
currentHttpRequest: undefined,
});
});
}
renderForgot() {
const Field = sdk.getComponent('elements.Field');
@ -315,8 +331,14 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
"link it contains, click below.", { emailAddress: this.state.email }) }
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
<input
className="mx_Login_submit"
type="button"
onClick={this.onVerify}
value={_t('I have verified my email address')} />
{ this.state.currentHttpRequest && (
<div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>)
}
</div>;
}
@ -328,7 +350,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
"push notifications. To re-enable notifications, sign in again on each " +
"device.",
) }</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
<input
className="mx_Login_submit"
type="button"
onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>;
}
@ -351,6 +376,8 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
case Phase.Done:
resetPasswordJsx = this.renderDone();
break;
default:
resetPasswordJsx = <div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>;
}
return (

View file

@ -463,7 +463,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener"
return <a
target="_blank"
rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }

View file

@ -557,12 +557,16 @@ export default class Registration extends React.Component<IProps, IState> {
loggedInUserId: this.state.differentLoggedInUserId,
},
) }</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={async event => {
const sessionLoaded = await this.onLoginClickWithCheck(event);
if (sessionLoaded) {
dis.dispatch({ action: "view_welcome_page" });
}
}}>
<p><AccessibleButton
element="span"
className="mx_linkButton"
onClick={async event => {
const sessionLoaded = await this.onLoginClickWithCheck(event);
if (sessionLoaded) {
dis.dispatch({ action: "view_welcome_page" });
}
}}
>
{ _t("Continue with previous account") }
</AccessibleButton></p>
</div>;

View file

@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { createRef, ReactNode, RefObject } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlayPauseButton from "./PlayPauseButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { formatBytes } from "../../../utils/FormattingUtils";
@ -25,47 +23,13 @@ import { Key } from "../../../Keyboard";
import { _t } from "../../../languageHandler";
import SeekBar from "./SeekBar";
import PlaybackClock from "./PlaybackClock";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
mediaName: string;
}
interface IState {
playbackPhase: PlaybackState;
error?: boolean;
}
import AudioPlayerBase from "./AudioPlayerBase";
@replaceableComponent("views.audio_messages.AudioPlayer")
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
export default class AudioPlayer extends AudioPlayerBase {
private playPauseRef: RefObject<PlayPauseButton> = createRef();
private seekRef: RefObject<SeekBar> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
this.props.playback.prepare().catch(e => {
console.error("Error processing audio file:", e);
this.setState({ error: true });
});
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
private onKeyDown = (ev: React.KeyboardEvent) => {
// stopPropagation() prevents the FocusComposer catch-all from triggering,
// but we need to do it on key down instead of press (even though the user
@ -91,10 +55,10 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
return `(${formatBytes(bytes)})`;
}
public render(): ReactNode {
protected renderComponent(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility
return <>
return (
<div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
<div className='mx_AudioPlayer_primaryContainer'>
<PlayPauseButton
@ -124,7 +88,6 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div>
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
</>;
);
}
}

View file

@ -0,0 +1,70 @@
/*
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 { Playback, PlaybackState } from "../../../audio/Playback";
import { TileShape } from "../rooms/EventTile";
import React, { ReactNode } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { _t } from "../../../languageHandler";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
mediaName?: string;
tileShape?: TileShape;
}
interface IState {
playbackPhase: PlaybackState;
error?: boolean;
}
@replaceableComponent("views.audio_messages.AudioPlayerBase")
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
this.props.playback.prepare().catch(e => {
console.error("Error processing audio file:", e);
this.setState({ error: true });
});
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
protected abstract renderComponent(): ReactNode;
public render(): ReactNode {
return <>
{ this.renderComponent() }
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
</>;
}
}

View file

@ -15,34 +15,30 @@ limitations under the License.
*/
import React from "react";
import { formatSeconds } from "../../../DateUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export interface IProps {
seconds: number;
}
interface IState {
}
/**
* Simply converts seconds into minutes and seconds. Note that hours will not be
* displayed, making it possible to see "82:29".
*/
@replaceableComponent("views.audio_messages.Clock")
export default class Clock extends React.Component<IProps, IState> {
export default class Clock extends React.Component<IProps> {
public constructor(props) {
super(props);
}
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
const currentFloor = Math.floor(this.props.seconds);
const nextFloor = Math.floor(nextProps.seconds);
return currentFloor !== nextFloor;
}
public render() {
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
return <span className='mx_Clock'>{ minutes }:{ seconds }</span>;
return <span className='mx_Clock'>{ formatSeconds(this.props.seconds) }</span>;
}
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { Playback } from "../../../voice/Playback";
import { Playback } from "../../../audio/Playback";
interface IProps {
playback: Playback;

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from "react";
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { MarkedExecution } from "../../../utils/MarkedExecution";

View file

@ -15,10 +15,9 @@ limitations under the License.
*/
import React from "react";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample } from "../../../utils/arrays";
import { percentageOf } from "../../../utils/numbers";
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
import Waveform from "./Waveform";
import { MarkedExecution } from "../../../utils/MarkedExecution";
@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
constructor(props) {
super(props);
this.state = {
waveform: [],
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
};
}
componentDidMount() {
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
// The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the
// waveform. This results in a slightly more cinematic/animated waveform for the
// user.
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
// The incoming data is between zero and one, so we don't need to clamp/rescale it.
this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
this.scheduledUpdate.mark();
});
}

View file

@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler";
import { Playback, PlaybackState } from "../../../voice/Playback";
import { Playback, PlaybackState } from "../../../audio/Playback";
import classNames from "classnames";
// omitted props are handled by render function

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { Playback, PlaybackState } from "../../../voice/Playback";
import { Playback, PlaybackState } from "../../../audio/Playback";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
interface IProps {

View file

@ -18,7 +18,7 @@ import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
import Waveform from "./Waveform";
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../voice/Playback";
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
import { percentageOf } from "../../../utils/numbers";
interface IProps {

View file

@ -14,68 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { ReactNode } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile";
import PlaybackWaveform from "./PlaybackWaveform";
import { _t } from "../../../languageHandler";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
tileShape?: TileShape;
}
interface IState {
playbackPhase: PlaybackState;
error?: boolean;
}
import AudioPlayerBase from "./AudioPlayerBase";
@replaceableComponent("views.audio_messages.RecordingPlayback")
export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
this.props.playback.prepare().catch(e => {
console.error("Error processing audio file:", e);
this.setState({ error: true });
});
}
export default class RecordingPlayback extends AudioPlayerBase {
private get isWaveformable(): boolean {
return this.props.tileShape !== TileShape.Notif
&& this.props.tileShape !== TileShape.FileGrid
&& this.props.tileShape !== TileShape.Pinned;
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
public render(): ReactNode {
protected renderComponent(): ReactNode {
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
return <>
return (
<div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
<PlaybackClock playback={this.props.playback} />
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
</div>
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
</>;
);
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import { Playback, PlaybackState } from "../../../audio/Playback";
import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MarkedExecution } from "../../../utils/MarkedExecution";

View file

@ -54,9 +54,13 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
'mx_Waveform_bar': true,
'mx_Waveform_bar_100pct': isCompleteBar,
});
return <span key={i} style={{
"--barHeight": h,
} as WaveformCSSProperties} className={classes} />;
return <span
key={i}
style={{
"--barHeight": h,
} as WaveformCSSProperties}
className={classes}
/>;
}) }
</div>;
}

View file

@ -103,8 +103,8 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
}
private resetRecaptcha() {
if (this.captchaWidgetId !== null) {
global.grecaptcha.reset(this.captchaWidgetId);
if (this.captchaWidgetId) {
global?.grecaptcha?.reset(this.captchaWidgetId);
}
}

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
import classNames from 'classnames';
import { MatrixClient } from "matrix-js-sdk/src/client";
import { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
@ -74,33 +75,6 @@ import CaptchaForm from "./CaptchaForm";
* focus: set the input focus appropriately in the form.
*/
enum AuthType {
Password = "m.login.password",
Recaptcha = "m.login.recaptcha",
Terms = "m.login.terms",
Email = "m.login.email.identity",
Msisdn = "m.login.msisdn",
Sso = "m.login.sso",
SsoUnstable = "org.matrix.login.sso",
}
/* eslint-disable camelcase */
interface IAuthDict {
type?: AuthType;
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
user?: string;
identifier?: any;
password?: string;
response?: string;
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds?: any;
threepidCreds?: any;
}
/* eslint-enable camelcase */
export const DEFAULT_PHASE = 0;
interface IAuthEntryProps {
@ -416,8 +390,10 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
let submitButton;
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this.trySubmit} disabled={!allChecked}>{ _t("Accept") }</button>;
submitButton = <button
className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this.trySubmit}
disabled={!allChecked}>{ _t("Accept") }</button>;
}
return (
@ -616,7 +592,9 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
aria-label={_t("Code")}
/>
<br />
<input type="submit" value={_t("Submit")}
<input
type="submit"
value={_t("Submit")}
className={submitClasses}
disabled={!enableSubmit}
/>
@ -831,7 +809,26 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
}
}
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
export interface IStageComponentProps extends IAuthEntryProps {
clientSecret?: string;
stageParams?: Record<string, any>;
inputs?: IInputs;
stageState?: IStageStatus;
showContinue?: boolean;
continueText?: string;
continueKind?: string;
fail?(e: Error): void;
setEmailSid?(sid: string): void;
onCancel?(): void;
}
export interface IStageComponent extends React.ComponentClass<React.PropsWithRef<IStageComponentProps>> {
tryContinue?(): void;
attemptFailed?(): void;
focus?(): void;
}
export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent {
switch (loginType) {
case AuthType.Password:
return PasswordAuthEntry;

View file

@ -187,7 +187,8 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width),
height: toPx(height),
}}
title={title} alt={_t("Avatar")}
title={title}
alt={_t("Avatar")}
inputRef={inputRef}
{...otherProps} />
);
@ -201,7 +202,8 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width),
height: toPx(height),
}}
title={title} alt=""
title={title}
alt=""
ref={inputRef}
{...otherProps} />
);

View file

@ -36,6 +36,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
viewUserOnClick?: boolean;
title?: string;
style?: any;
}
interface IState {
@ -102,8 +103,12 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
}
return (
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
idName={userId} url={this.state.imageUrl} onClick={onClick} />
<BaseAvatar {...otherProps}
name={this.state.name}
title={this.state.title}
idName={userId}
url={this.state.imageUrl}
onClick={onClick} />
);
}
}

View file

@ -13,9 +13,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import classNames from "classnames";
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
@ -32,11 +34,14 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
// oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from)
room?: Room;
oobData?: IOOBData;
oobData?: IOOBData & {
roomId?: string;
};
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
className?: string;
onClick?(): void;
}
@ -129,15 +134,19 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
};
public render() {
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
const roomName = room ? room.name : oobData.name;
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : null;
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
return (
<BaseAvatar {...otherProps}
<BaseAvatar
{...otherProps}
className={classNames(className, {
mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
})}
name={roomName}
idName={idName}
urls={this.state.urls}

View file

@ -27,6 +27,8 @@ import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
import SdkConfig from "../../../SdkConfig";
import SettingsFlag from "../elements/SettingsFlag";
// XXX: Keep this around for re-use in future Betas
interface IProps {
title?: string;
featureId: string;

View file

@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import AccessibleButton from "../elements/AccessibleButton";
import * as React from "react";
import { createRef } from "react";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
@ -32,6 +33,8 @@ interface IState {
@replaceableComponent("views.context_menus.DialpadContextMenu")
export default class DialpadContextMenu extends React.Component<IProps, IState> {
private numberEntryFieldRef: React.RefObject<Field> = createRef();
constructor(props) {
super(props);
@ -40,15 +43,29 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
};
}
onDigitPress = (digit) => {
onDigitPress = (digit: string, ev: ButtonEvent) => {
this.props.call.sendDtmfDigit(digit);
this.setState({ value: this.state.value + digit });
// Keep the number field focused so that keyboard entry is still available
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
onCancelClick = () => {
this.props.onFinished();
};
onKeyDown = (ev) => {
// Prevent Backspace and Delete keys from functioning in the entry field
if (ev.code === "Backspace" || ev.code === "Delete") {
ev.preventDefault();
}
};
onChange = (ev) => {
this.setState({ value: ev.target.value });
};
@ -60,8 +77,12 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
<AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} />
</div>
<div className="mx_DialPadContextMenu_header">
<Field className="mx_DialPadContextMenu_dialled"
value={this.state.value} autoFocus={true}
<Field
ref={this.numberEntryFieldRef}
className="mx_DialPadContextMenu_dialled"
value={this.state.value}
autoFocus={true}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
/>
</div>

View file

@ -86,14 +86,18 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
<span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />
</MenuItemCheckbox>;
};
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, ...props }) => {
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, children, ...props }) => {
return <MenuItem {...props} label={label}>
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ children }
</MenuItem>;
};

View file

@ -0,0 +1,216 @@
/*
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 } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
IProps as IContextMenuProps,
} from "../../structures/ContextMenu";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { _t } from "../../../languageHandler";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { BetaPill } from "../beta/BetaCard";
interface IProps extends IContextMenuProps {
space: Room;
}
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
let inviteOption;
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceInvite(space);
onFinished();
};
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(space);
onFinished();
};
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={onSettingsClick}
/>
);
} else {
const onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
leaveSpace(space);
onFinished();
};
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(space);
onFinished();
};
const onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(space);
onFinished();
};
const onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewSubspace(space);
onFinished();
};
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
}
const onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space },
});
onFinished();
};
const onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
});
onFinished();
};
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
};
export default SpaceContextMenu;

View file

@ -109,8 +109,10 @@ export default class StatusMessageContextMenu extends React.Component {
</AccessibleButton>;
}
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} onClick={this._onSubmit}
actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message}
onClick={this._onSubmit}
>
<span>{ _t("Set status") }</span>
</AccessibleButton>;
@ -121,12 +123,19 @@ export default class StatusMessageContextMenu extends React.Component {
spinner = <Spinner w="24" h="24" />;
}
const form = <form className="mx_StatusMessageContextMenu_form"
autoComplete="off" onSubmit={this._onSubmit}
const form = <form
className="mx_StatusMessageContextMenu_form"
autoComplete="off"
onSubmit={this._onSubmit}
>
<input type="text" className="mx_StatusMessageContextMenu_message"
key="message" placeholder={_t("Set a new status...")}
autoFocus={true} maxLength="60" value={this.state.message}
<input
type="text"
className="mx_StatusMessageContextMenu_message"
key="message"
placeholder={_t("Set a new status...")}
autoFocus={true}
maxLength="60"
value={this.state.message}
onChange={this._onStatusChange}
/>
<div className="mx_StatusMessageContextMenu_actionContainer">

View file

@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
import { createSpaceFromCommunity } from "../../../utils/space";
import GroupStore from "../../../stores/GroupStore";
@replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component {
@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
this.props.onFinished();
};
_onCreateSpaceClick = () => {
createSpaceFromCommunity(this.context, this.props.tag);
this.props.onFinished();
};
_onMoveUp = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
this.props.onFinished();
@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component {
);
}
let createSpaceOption;
if (GroupStore.isUserPrivileged(this.props.tag)) {
createSpaceOption = <>
<hr className="mx_TagTileContextMenu_separator" role="separator" />
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_createSpace" onClick={this._onCreateSpaceClick}>
{ _t("Create Space") }
</MenuItem>
</>;
}
return <div>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _t('View Community') }
@ -88,6 +105,7 @@ export default class TagTileContextMenu extends React.Component {
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
{ _t("Unpin") }
</MenuItem>
{ createSpaceOption }
</div>;
}
}

View file

@ -76,7 +76,8 @@ const WidgetContextMenu: React.FC<IProps> = ({
onFinished();
};
streamAudioStreamButton = <IconizedContextMenuOption
onClick={onStreamAudioClick} label={_t("Start audio stream")}
onClick={onStreamAudioClick}
label={_t("Start audio stream")}
/>;
}

View file

@ -0,0 +1,67 @@
/*
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 BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
interface IProps {
space: Room;
onCreateSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Add existing space")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new space instead?") }</div>
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
{ _t("Create a new space") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for spaces")}
spacesRenderer={defaultSpacesRenderer}
/>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default AddExistingSubspaceDialog;

View file

@ -17,11 +17,10 @@ limitations under the License.
import React, { ReactNode, useContext, useMemo, 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 { sleep } from "matrix-js-sdk/src/utils";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
@ -36,20 +35,20 @@ import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
interface IProps {
space: Room;
onCreateRoomClick(cli: MatrixClient, space: Room): void;
onCreateRoomClick(): void;
onAddSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const Entry = ({ room, checked, onChange }) => {
export const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpace_entry">
{ room?.isSpaceRoom()
? <RoomAvatar room={room} height={32} width={32} />
@ -67,14 +66,36 @@ const Entry = ({ room, checked, onChange }) => {
interface IAddExistingToSpaceProps {
space: Room;
footerPrompt?: ReactNode;
filterPlaceholder: string;
emptySelectionButton?: ReactNode;
onFinished(added: boolean): void;
roomsRenderer?(
rooms: Room[],
selectedToAdd: Set<Room>,
onChange: undefined | ((checked: boolean, room: Room) => void),
truncateAt: number,
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
): ReactNode;
spacesRenderer?(
spaces: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
dmsRenderer?(
dms: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space,
footerPrompt,
emptySelectionButton,
filterPlaceholder,
roomsRenderer,
dmsRenderer,
spacesRenderer,
onFinished,
}) => {
const cli = useContext(MatrixClientContext);
@ -198,7 +219,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</>;
}
const onChange = !busy && !error ? (checked, room) => {
const onChange = !busy && !error ? (checked: boolean, room: Room) => {
if (checked) {
selectedToAdd.add(room);
} else {
@ -208,83 +229,52 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
} : null;
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)}
/>
);
}
let noResults = true;
if ((roomsRenderer && rooms.length > 0) ||
(dmsRenderer && dms.length > 0) ||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
) {
noResults = false;
}
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Filter your rooms and spaces")}
placeholder={filterPlaceholder}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
{ rooms.length > 0 && roomsRenderer ? (
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
<div className="mx_AddExistingToSpace_section_experimental">
<div>{ _t("Feeling experimental?") }</div>
<div>{ _t("You can add existing spaces to a space.") }</div>
</div>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
{ spaces.length > 0 && spacesRenderer ? (
spacesRenderer(spaces, selectedToAdd, onChange)
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
{ dms.length > 0 && dmsRenderer ? (
dmsRenderer(dms, selectedToAdd, onChange)
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ noResults ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
@ -295,69 +285,166 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
rooms, selectedToAdd, onChange, truncateAt, overflowTile,
) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
);
let spaceOptionSection;
if (existingSubspaces.length > 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>;
});
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
);
spaceOptionSection = (
export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
);
interface ISubspaceSelectorProps {
title: string;
space: Room;
value: Room;
onChange(space: Room): void;
}
export const SubspaceSelector = ({ title, space, value, onChange }: ISubspaceSelectorProps) => {
const options = useMemo(() => {
return [space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter(space => {
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.credentials.userId);
})];
}, [space]);
let body;
if (options.length > 1) {
body = (
<Dropdown
id="mx_SpaceSelectDropdown"
className="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
onChange(options.find(space => space.roomId === key) || space);
}}
value={selectedSpace.roomId}
value={value.roomId}
label={_t("Space selection")}
>
{ options }
{ options.map((space) => {
const classes = classNames({
mx_SubspaceSelector_dropdownOptionActive: space === value,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
}) }
</Dropdown>
);
} else {
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
body = (
<div className="mx_SubspaceSelector_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>
);
}
const title = <React.Fragment>
<RoomAvatar room={selectedSpace} height={40} width={40} />
return <div className="mx_SubspaceSelector">
<RoomAvatar room={value} height={40} width={40} />
<div>
<h1>{ _t("Add existing rooms") }</h1>
{ spaceOptionSection }
<h1>{ title }</h1>
{ body }
</div>
</React.Fragment>;
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={title}
title={(
<SubspaceSelector
title={_t("Add existing rooms")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={cli}>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
<AccessibleButton
kind="link"
onClick={() => {
onCreateRoomClick();
onFinished();
}}
>
{ _t("Create a new room") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for rooms")}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={() => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Spaces") }</h3>
<AccessibleButton
kind="link"
onClick={() => {
onAddSubspaceClick();
onFinished();
}}
>
{ _t("Adding spaces has moved.") }
</AccessibleButton>
</div>
)}
dmsRenderer={defaultDmsRenderer}
/>
</MatrixClientContext.Provider>
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
</BaseDialog>;
};

View file

@ -665,8 +665,8 @@ export default class AddressPickerDialog extends React.Component<IProps, IState>
onChange={this.onQueryChanged}
placeholder={this.getPlaceholder()}
defaultValue={this.props.value}
autoFocus={this.props.focus}>
</textarea>,
autoFocus={this.props.focus}
/>,
);
const filteredSuggestedList = this.getFilteredSuggestions();
@ -727,8 +727,12 @@ export default class AddressPickerDialog extends React.Component<IProps, IState>
}
return (
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished} title={this.props.title}>
<BaseDialog
className="mx_AddressPickerDialog"
onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished}
title={this.props.title}
>
{ inputLabel }
<div className="mx_Dialog_content">
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>

View file

@ -118,9 +118,7 @@ export default class BaseDialog extends React.Component {
let headerImage;
if (this.props.headerImage) {
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage}
alt=""
/>;
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage} alt="" />;
}
return (

View file

@ -14,22 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import React from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import { IDialogProps } from "./IDialogProps";
import SettingsStore from "../../../settings/SettingsStore";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog";
// XXX: Keep this around for re-use in future Betas
interface IProps extends IDialogProps {
featureId: string;
@ -38,74 +34,28 @@ interface IProps extends IDialogProps {
const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
const info = SettingsStore.getBetaInfo(featureId);
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => {
o[k] = SettingsStore.getValue(k);
return o;
}, {});
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData);
onFinished(true);
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
title: _t("Beta feedback"),
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_BetaFeedbackDialog"
hasCancelButton={true}
return <GenericFeatureFeedbackDialog
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
description={<React.Fragment>
<div className="mx_BetaFeedbackDialog_subheading">
{ _t(info.feedbackSubheading) }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
<AccessibleButton kind="link" onClick={() => {
onFinished(false);
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}>
{ _t("To leave the beta, visit your settings.") }
</AccessibleButton>
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
subheading={_t(info.feedbackSubheading)}
onFinished={onFinished}
rageshakeLabel={info.feedbackLabel}
rageshakeData={Object.fromEntries((SettingsStore.getBetaInfo(featureId)?.extraSettings || []).map(k => {
return SettingsStore.getValue(k);
}))}
>
<AccessibleButton
kind="link"
onClick={() => {
onFinished(false);
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}
>
{ _t("To leave the beta, visit your settings.") }
</AccessibleButton>
</GenericFeatureFeedbackDialog>;
};
export default BetaFeedbackDialog;

View file

@ -29,11 +29,13 @@ import BaseDialog from "./BaseDialog";
import Field from '../elements/Field';
import Spinner from "../elements/Spinner";
import DialogButtons from "../elements/DialogButtons";
import { sendSentryReport } from "../../../sentry";
interface IProps {
onFinished: (success: boolean) => void;
initialText?: string;
label?: string;
error?: Error;
}
interface IState {
@ -113,6 +115,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
});
}
});
sendSentryReport(this.state.text, this.state.issueUrl, this.props.error);
};
private onDownload = async (): Promise<void> => {
@ -188,7 +192,9 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
}
return (
<BaseDialog className="mx_BugReportDialog" onFinished={this.onCancel}
<BaseDialog
className="mx_BugReportDialog"
onFinished={this.onCancel}
title={_t('Submit debug logs')}
contentId='mx_Dialog_content'
>
@ -198,8 +204,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
{ _t(
"Debug logs contain application usage data including your " +
"username, the IDs or aliases of the rooms or groups you " +
"have visited and the usernames of other users. They do " +
"not contain messages.",
"have visited, which UI elements you last interacted with, " +
"and the usernames of other users. They do not contain messages.",
) }
</p>
<p><b>
@ -209,7 +215,7 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
{
a: (sub) => <a
target="_blank"
href="https://github.com/vector-im/element-web/issues/new"
href="https://github.com/vector-im/element-web/issues/new/choose"
>
{ sub }
</a>,

View file

@ -205,9 +205,12 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
people.push((
<AccessibleButton
onClick={this.onShowMorePeople}
kind="link" key="more"
kind="link"
key="more"
className="mx_CommunityPrototypeInviteDialog_morePeople"
>{ _t("Show more") }</AccessibleButton>
>
{ _t("Show more") }
</AccessibleButton>
));
}
}
@ -240,10 +243,13 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
{ peopleIntro }
{ people }
<AccessibleButton
kind="primary" onClick={this.onSubmit}
kind="primary"
onClick={this.onSubmit}
disabled={this.state.busy}
className="mx_CommunityPrototypeInviteDialog_primaryButton"
>{ buttonText }</AccessibleButton>
>
{ buttonText }
</AccessibleButton>
</div>
</form>
</BaseDialog>

View file

@ -37,8 +37,8 @@ export default class ConfirmRedactDialog extends React.Component<IProps> {
"Note that if you delete a room name or topic change, it could undo the change.")}
placeholder={_t("Reason (optional)")}
focus
button={_t("Remove")}>
</TextInputDialog>
button={_t("Remove")}
/>
);
}
}

View file

@ -104,7 +104,9 @@ export default class ConfirmUserActionDialog extends React.Component<IProps> {
}
return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_ConfirmUserActionDialog"
onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
>

View file

@ -204,8 +204,10 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent<
</div>
<div className="mx_CreateCommunityPrototypeDialog_colAvatar">
<input
type="file" style={{ display: "none" }}
ref={this.avatarUploadRef} accept="image/*"
type="file"
style={{ display: "none" }}
ref={this.avatarUploadRef}
accept="image/*"
onChange={this.onAvatarChanged}
/>
<AccessibleButton

View file

@ -123,7 +123,9 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
}
return (
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_CreateGroupDialog"
onFinished={this.props.onFinished}
title={_t('Create Community')}
>
<form onSubmit={this.onFormSubmit}>
@ -133,8 +135,11 @@ export default class CreateGroupDialog extends React.Component<IProps, IState> {
<label htmlFor="groupname">{ _t('Community Name') }</label>
</div>
<div>
<input id="groupname" className="mx_CreateGroupDialog_input"
autoFocus={true} size={64}
<input
id="groupname"
className="mx_CreateGroupDialog_input"
autoFocus={true}
size={64}
placeholder={_t('Example')}
onChange={this.onGroupNameChange}
value={this.state.groupName}

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
import SdkConfig from '../../../SdkConfig';
import withValidation, { IFieldState } from '../elements/Validation';
@ -31,16 +32,19 @@ import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
import SpaceStore from "../../../stores/SpaceStore";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
defaultPublic?: boolean;
defaultName?: string;
parentSpace?: Room;
defaultEncrypted?: boolean;
onFinished(proceed: boolean, opts?: IOpts): void;
}
interface IState {
joinRule: JoinRule;
isPublic: boolean;
isEncrypted: boolean;
name: string;
@ -54,16 +58,27 @@ interface IState {
@replaceableComponent("views.dialogs.CreateRoomDialog")
export default class CreateRoomDialog extends React.Component<IProps, IState> {
private readonly supportsRestricted: boolean;
private nameField = createRef<Field>();
private aliasField = createRef<RoomAliasField>();
constructor(props) {
super(props);
this.supportsRestricted = this.props.parentSpace && !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
let joinRule = JoinRule.Invite;
if (this.props.defaultPublic) {
joinRule = JoinRule.Public;
} else if (this.supportsRestricted) {
joinRule = JoinRule.Restricted;
}
const config = SdkConfig.get();
this.state = {
isPublic: this.props.defaultPublic || false,
isEncrypted: privateShouldBeEncrypted(),
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(),
joinRule,
name: this.props.defaultName || "",
topic: "",
alias: "",
@ -81,13 +96,18 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
const opts: IOpts = {};
const createOpts: IOpts["createOpts"] = opts.createOpts = {};
createOpts.name = this.state.name;
if (this.state.isPublic) {
if (this.state.joinRule === JoinRule.Public) {
createOpts.visibility = Visibility.Public;
createOpts.preset = Preset.PublicChat;
opts.guestAccess = false;
const { alias } = this.state;
createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
} else {
// If we cannot change encryption we pass `true` for safety, the server should automatically do this for us.
opts.encryption = this.state.canChangeEncryption ? this.state.isEncrypted : true;
}
if (this.state.topic) {
createOpts.topic = this.state.topic;
}
@ -95,22 +115,13 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
createOpts.creation_content = { 'm.federate': false };
}
if (!this.state.isPublic) {
if (this.state.canChangeEncryption) {
opts.encryption = this.state.isEncrypted;
} else {
// the server should automatically do this for us, but for safety
// we'll demand it too.
opts.encryption = true;
}
}
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
}
if (this.props.parentSpace) {
opts.parentSpace = this.props.parentSpace;
opts.parentSpace = this.props.parentSpace;
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
opts.joinRule = JoinRule.Restricted;
}
return opts;
@ -172,8 +183,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
this.setState({ topic: ev.target.value });
};
private onPublicChange = (isPublic: boolean) => {
this.setState({ isPublic });
private onJoinRuleChange = (joinRule: JoinRule) => {
this.setState({ joinRule });
};
private onEncryptedChange = (isEncrypted: boolean) => {
@ -210,7 +221,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
render() {
let aliasField;
if (this.state.isPublic) {
if (this.state.joinRule === JoinRule.Public) {
const domain = MatrixClientPeg.get().getDomain();
aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer">
@ -224,19 +235,52 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
);
}
let publicPrivateLabel = <p>{ _t(
"Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone.",
) }</p>;
let publicPrivateLabel: JSX.Element;
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
publicPrivateLabel = <p>{ _t(
"Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone in this community.",
) }</p>;
publicPrivateLabel = <p>
{ _t(
"Private rooms can be found and joined by invitation only. Public rooms can be " +
"found and joined by anyone in this community.",
) }
</p>;
} else if (this.state.joinRule === JoinRule.Restricted) {
publicPrivateLabel = <p>
{ _t(
"Everyone in <SpaceName/> will be able to find and join this room.", {}, {
SpaceName: () => <b>{ this.props.parentSpace.name }</b>,
},
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Public && this.props.parentSpace) {
publicPrivateLabel = <p>
{ _t(
"Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, {
SpaceName: () => <b>{ this.props.parentSpace.name }</b>,
},
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Public) {
publicPrivateLabel = <p>
{ _t("Anyone will be able to find and join this room.") }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Invite) {
publicPrivateLabel = <p>
{ _t(
"Only people invited will be able to find and join this room.",
) }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
}
let e2eeSection;
if (!this.state.isPublic) {
if (this.state.joinRule !== JoinRule.Public) {
let microcopy;
if (privateShouldBeEncrypted()) {
if (this.state.canChangeEncryption) {
@ -273,15 +317,16 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
);
}
let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room');
let title = _t("Create a room");
if (CommunityPrototypeStore.instance.getSelectedCommunityId()) {
const name = CommunityPrototypeStore.instance.getSelectedCommunityName();
title = _t("Create a room in %(communityName)s", { communityName: name });
} else if (!this.props.parentSpace) {
title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room');
}
return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
title={title}
>
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content">
<Field
@ -298,11 +343,16 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
value={this.state.topic}
className="mx_CreateRoomDialog_topic"
/>
<LabelledToggleSwitch
label={_t("Make this room public")}
onChange={this.onPublicChange}
value={this.state.isPublic}
<JoinRuleDropdown
label={_t("Room visibility")}
labelInvite={_t("Private room (invite only)")}
labelPublic={_t("Public room")}
labelRestricted={this.supportsRestricted ? _t("Visible to space members") : undefined}
value={this.state.joinRule}
onChange={this.onJoinRuleChange}
/>
{ publicPrivateLabel }
{ e2eeSection }
{ aliasField }

View file

@ -0,0 +1,340 @@
/*
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, useRef, useState } from "react";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import { GroupMember } from "../right_panel/UserInfo";
import { parseMembersResponse, parseRoomsResponse } from "../../../stores/GroupStore";
import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/Permalinks";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import Spinner from "../elements/Spinner";
import { mediaFromMxc } from "../../../customisations/Media";
import SpaceStore from "../../../stores/SpaceStore";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
import TagOrderActions from "../../../actions/TagOrderActions";
interface IProps {
matrixClient: MatrixClient;
groupId: string;
onFinished(spaceId?: string): void;
}
export const CreateEventField = "io.element.migrated_from_community";
interface IGroupRoom {
displayname: string;
name?: string;
roomId: string;
canonicalAlias?: string;
avatarUrl?: string;
topic?: string;
numJoinedMembers?: number;
worldReadable?: boolean;
guestCanJoin?: boolean;
isPublic?: boolean;
}
/* eslint-disable camelcase */
export interface IGroupSummary {
profile: {
avatar_url?: string;
is_openly_joinable?: boolean;
is_public?: boolean;
long_description: string;
name: string;
short_description: string;
};
rooms_section: {
rooms: unknown[];
categories: Record<string, unknown>;
total_room_count_estimate: number;
};
user: {
is_privileged: boolean;
is_public: boolean;
is_publicised: boolean;
membership: string;
};
users_section: {
users: unknown[];
roles: Record<string, unknown>;
total_user_count_estimate: number;
};
}
/* eslint-enable camelcase */
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(null);
const [busy, setBusy] = useState(false);
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [alias, setAlias] = useState("#" + groupId.substring(1, groupId.indexOf(":")) + ":" + cli.getDomain());
const spaceAliasField = useRef<RoomAliasField>();
const [topic, setTopic] = useState("");
const [joinRule, setJoinRule] = useState<JoinRule>(JoinRule.Public);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [groupId]);
useEffect(() => {
if (groupSummary) {
setName(groupSummary.profile.name || "");
setTopic(groupSummary.profile.short_description || "");
setJoinRule(groupSummary.profile.is_openly_joinable ? JoinRule.Public : JoinRule.Invite);
setLoading(false);
}
}, [groupSummary]);
if (loading) {
return <Spinner />;
}
const onCreateSpaceClick = async (e) => {
e.preventDefault();
if (busy) return;
setError(null);
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
setBusy(false);
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
setBusy(false);
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
return;
}
try {
const [rooms, members, invitedMembers] = await Promise.all([
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
]);
const viaMap = new Map<string, string[]>();
for (const { roomId, canonicalAlias } of rooms) {
const room = cli.getRoom(roomId);
if (room) {
viaMap.set(roomId, calculateRoomVia(room));
} else if (canonicalAlias) {
try {
const { servers } = await cli.getRoomIdForAlias(canonicalAlias);
viaMap.set(roomId, servers);
} catch (e) {
console.warn("Failed to resolve alias during community migration", e);
}
}
if (!viaMap.get(roomId)?.length) {
// XXX: lets guess the via, this might end up being incorrect.
const str = canonicalAlias || roomId;
viaMap.set(roomId, [str.substring(1, str.indexOf(":"))]);
}
}
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
creation_content: {
[CreateEventField]: groupId,
},
initial_state: rooms.map(({ roomId }) => ({
type: EventType.SpaceChild,
state_key: roomId,
content: {
via: viaMap.get(roomId) || [],
},
})),
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()),
}, {
andView: false,
});
// eagerly remove it from the community panel
dis.dispatch(TagOrderActions.removeTag(cli, groupId));
// don't bother awaiting this, as we don't hugely care if it fails
cli.setGroupProfile(groupId, {
...groupSummary.profile,
long_description: `<a href="${makeRoomPermalink(roomId)}"><h1>` +
_t("This community has been upgraded into a Space") + `</h1></a><br />`
+ groupSummary.profile.long_description,
} as IGroupSummary["profile"]).catch(e => {
console.warn("Failed to update community profile during migration", e);
});
onFinished(roomId);
const onSpaceClick = () => {
dis.dispatch({
action: "view_room",
room_id: roomId,
});
};
const onPreferencesClick = () => {
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Preferences,
});
};
let spacesDisabledCopy;
if (!SpaceStore.spacesEnabled) {
spacesDisabledCopy = _t("To view Spaces, hide communities in <a>Preferences</a>", {}, {
a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
});
}
Modal.createDialog(InfoDialog, {
title: _t("Space created"),
description: <>
<div className="mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark" />
<p>
{ _t("<SpaceName/> has been made and everyone who was a part of the community has " +
"been invited to it.", {}, {
SpaceName: () => <AccessibleButton onClick={onSpaceClick} kind="link">
{ name }
</AccessibleButton>,
}) }
&nbsp;
{ spacesDisabledCopy }
</p>
<p>
{ _t("To create a Space from another community, just pick the community in Preferences.") }
</p>
</>,
button: _t("Preferences"),
onFinished: (openPreferences: boolean) => {
if (openPreferences) {
onPreferencesClick();
}
},
}, "mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog");
} catch (e) {
console.error(e);
setError(e);
}
setBusy(false);
};
let footer;
if (error) {
footer = <>
<img src={require("../../../../res/img/element-icons/warning-badge.svg")} height="24" width="24" alt="" />
<span className="mx_CreateSpaceFromCommunityDialog_error">
<div className="mx_CreateSpaceFromCommunityDialog_errorHeading">{ _t("Failed to migrate community") }</div>
<div className="mx_CreateSpaceFromCommunityDialog_errorCaption">{ _t("Try again") }</div>
</span>
<AccessibleButton className="mx_CreateSpaceFromCommunityDialog_retryButton" onClick={onCreateSpaceClick}>
{ _t("Retry") }
</AccessibleButton>
</>;
} else {
footer = <>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}>
{ busy ? _t("Creating...") : _t("Create Space") }
</AccessibleButton>
</>;
}
return <BaseDialog
title={_t("Create Space from community")}
className="mx_CreateSpaceFromCommunityDialog"
onFinished={onFinished}
fixedWidth={false}
>
<div className="mx_CreateSpaceFromCommunityDialog_content">
<p>
{ _t("A link to the Space will be put in your community description.") }
&nbsp;
{ _t("All rooms will be added and all community members will be invited.") }
</p>
<p className="mx_CreateSpaceFromCommunityDialog_flairNotice">
{ _t("Flair won't be available in Spaces for the foreseeable future.") }
</p>
<SpaceCreateForm
busy={busy}
onSubmit={onCreateSpaceClick}
avatarUrl={groupSummary.profile.avatar_url
? mediaFromMxc(groupSummary.profile.avatar_url).getThumbnailOfSourceHttp(80, 80, "crop")
: undefined
}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={joinRule === JoinRule.Public}
aliasFieldRef={spaceAliasField}
>
<p>{ _t("This description will be shown to people when they view your space") }</p>
<JoinRuleDropdown
label={_t("Space visibility")}
labelInvite={_t("Private space (invite only)")}
labelPublic={_t("Public space")}
value={joinRule}
onChange={setJoinRule}
/>
<p>{ joinRule === JoinRule.Public
? _t("Open space for anyone, best for communities")
: _t("Invite only, best for yourself or teams")
}</p>
{ joinRule !== JoinRule.Public &&
<div className="mx_CreateSpaceFromCommunityDialog_nonPublicSpacer" />
}
</SpaceCreateForm>
</div>
<div className="mx_CreateSpaceFromCommunityDialog_footer">
{ footer }
</div>
</BaseDialog>;
};
export default CreateSpaceFromCommunityDialog;

View file

@ -0,0 +1,187 @@
/*
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 { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import SpaceStore from "../../../stores/SpaceStore";
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
space: Room;
onAddExistingSpaceClick(): void;
onFinished(added?: boolean): void;
}
const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick, onFinished }) => {
const [parentSpace, setParentSpace] = useState(space);
const [busy, setBusy] = useState<boolean>(false);
const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [alias, setAlias] = useState("");
const spaceAliasField = useRef<RoomAliasField>();
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
const supportsRestricted = !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
const spaceJoinRule = space.getJoinRule();
let defaultJoinRule = JoinRule.Invite;
if (spaceJoinRule === JoinRule.Public) {
defaultJoinRule = JoinRule.Public;
} else if (supportsRestricted) {
defaultJoinRule = JoinRule.Restricted;
}
const [joinRule, setJoinRule] = useState<JoinRule>(defaultJoinRule);
const onCreateSubspaceClick = async (e) => {
e.preventDefault();
if (busy) return;
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
return;
}
try {
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule });
onFinished(true);
} catch (e) {
console.error(e);
}
};
let joinRuleMicrocopy: JSX.Element;
if (joinRule === JoinRule.Restricted) {
joinRuleMicrocopy = <p>
{ _t(
"Anyone in <SpaceName/> will be able to find and join.", {}, {
SpaceName: () => <b>{ parentSpace.name }</b>,
},
) }
</p>;
} else if (joinRule === JoinRule.Public) {
joinRuleMicrocopy = <p>
{ _t(
"Anyone will be able to find and join this space, not just members of <SpaceName/>.", {}, {
SpaceName: () => <b>{ parentSpace.name }</b>,
},
) }
</p>;
} else if (joinRule === JoinRule.Invite) {
joinRuleMicrocopy = <p>
{ _t("Only people invited will be able to find and join this space.") }
</p>;
}
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Create a space")}
space={space}
value={parentSpace}
onChange={setParentSpace}
/>
)}
className="mx_CreateSubspaceDialog"
contentId="mx_CreateSubspaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<div className="mx_CreateSubspaceDialog_content">
<div className="mx_CreateSubspaceDialog_betaNotice">
<BetaPill />
{ _t("Add a space to a space you manage.") }
</div>
<SpaceCreateForm
busy={busy}
onSubmit={onCreateSubspaceClick}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={joinRule === JoinRule.Public}
aliasFieldRef={spaceAliasField}
>
<JoinRuleDropdown
label={_t("Space visibility")}
labelInvite={_t("Private space (invite only)")}
labelPublic={_t("Public space")}
labelRestricted={supportsRestricted ? _t("Visible to space members") : undefined}
width={478}
value={joinRule}
onChange={setJoinRule}
/>
{ joinRuleMicrocopy }
</SpaceCreateForm>
</div>
<div className="mx_CreateSubspaceDialog_footer">
<div className="mx_CreateSubspaceDialog_footer_prompt">
<div>{ _t("Want to add an existing space instead?") }</div>
<AccessibleButton
kind="link"
onClick={() => {
onAddExistingSpaceClick();
onFinished();
}}
>
{ _t("Add existing space") }
</AccessibleButton>
</div>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished(false)}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSubspaceClick}>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton>
</div>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default CreateSubspaceDialog;

View file

@ -72,7 +72,7 @@ const CryptoStoreTooNewDialog: React.FC<IProps> = (props: IProps) => {
hasCancel={false}
onPrimaryButtonClick={props.onFinished}
>
<button onClick={_onLogoutClicked} >
<button onClick={_onLogoutClicked}>
{ _t('Sign out') }
</button>
</DialogButtons>

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import { AuthType, IAuthData } from 'matrix-js-sdk/src/interactive-auth';
import Analytics from '../../../Analytics';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -65,7 +66,7 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
this.initAuth(/* shouldErase= */false);
}
private onStagePhaseChange = (stage: string, phase: string): void => {
private onStagePhaseChange = (stage: AuthType, phase: string): void => {
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
body: _t("Confirm your account deactivation by using Single Sign On to prove your identity."),
@ -115,7 +116,10 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") });
};
private onUIAuthComplete = (auth: any): void => {
private onUIAuthComplete = (auth: IAuthData): void => {
// XXX: this should be returning a promise to maintain the state inside the state machine correct
// but given that a deactivation is followed by a local logout and all object instances being thrown away
// this isn't done.
MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase).then(r => {
// Deactivation worked - logout & close this dialog
Analytics.trackEvent('Account', 'Deactivate Account');
@ -180,7 +184,9 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
<InteractiveAuth
matrixClient={MatrixClientPeg.get()}
authData={this.state.authData}
makeRequest={this.onUIAuthComplete}
// XXX: onUIAuthComplete breaches the expected method contract, it gets away with it because it
// knows the entire app is about to die as a result of the account deactivation.
makeRequest={this.onUIAuthComplete as any}
onAuthFinished={this.onUIAuthFinished}
onStagePhaseChange={this.onStagePhaseChange}
continueText={this.state.continueText}

View file

@ -182,14 +182,23 @@ export class SendCustomEvent extends GenericEditor<ISendCustomEventProps, ISendC
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
<Field
id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={this.state.evContent}
onChange={this.onChange}
element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ showTglFlip && <div style={{ float: "right" }}>
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<input
id="isStateEvent"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isStateEvent}
onChange={this.onChange}
@ -282,14 +291,24 @@ class SendAccountData extends GenericEditor<ISendAccountDataProps, ISendAccountD
{ this.textInput('eventType', _t('Event Type')) }
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
<Field
id="evContent"
label={_t("Event Content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={this.state.evContent}
onChange={this.onChange}
element="textarea"
/>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ !this.state.message && <div style={{ float: "right" }}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<input
id="isRoomAccountData"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
disabled={this.props.forceMode}
@ -371,11 +390,18 @@ class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredList
render() {
return <div>
<Field label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
<Field
label={_t('Filter results')}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={this.props.query}
onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
key={this.props.children[0] ? this.props.children[0].key : ''}
/>
<TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount}
@ -459,11 +485,16 @@ class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateEx
render() {
if (this.state.event) {
if (this.state.editing) {
return <SendCustomEvent room={this.props.room} forceStateEvent={true} onBack={this.onBack} inputs={{
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
stateKey: this.state.event.getStateKey(),
}} />;
return <SendCustomEvent
room={this.props.room}
forceStateEvent={true}
onBack={this.onBack}
inputs={{
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
stateKey: this.state.event.getStateKey(),
}}
/>;
}
return <div className="mx_ViewSource">
@ -594,7 +625,9 @@ class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDa
inputs={{
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
}} forceMode={true} />;
}}
forceMode={true}
/>;
}
return <div className="mx_ViewSource">
@ -631,7 +664,9 @@ class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDa
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
<div style={{ float: "right" }}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<input
id="isRoomAccountData"
className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
onChange={this.onChange}
@ -1021,8 +1056,13 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<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}
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>
@ -1040,7 +1080,9 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{ i }</code>
</a>
<a href="" onClick={(e) => this.onEditClick(e, i)}
<a
href=""
onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit'
>
@ -1104,18 +1146,26 @@ class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExpl
<div>
<Field
id="valExpl" label={_t("Values at explicit levels")} type="text"
className="mx_DevTools_textarea" element="textarea"
autoComplete="off" value={this.state.explicitValues}
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}
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>

View file

@ -144,8 +144,10 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent<IP
</div>
<div className="mx_EditCommunityPrototypeDialog_rowAvatar">
<input
type="file" style={{ display: "none" }}
ref={this.avatarUploadRef} accept="image/*"
type="file"
style={{ display: "none" }}
ref={this.avatarUploadRef}
accept="image/*"
onChange={this.onAvatarChanged}
/>
<AccessibleButton

View file

@ -28,7 +28,7 @@ import StyledRadioGroup from "../elements/StyledRadioGroup";
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose";
export default (props) => {
const [rating, setRating] = useState("");

View file

@ -106,12 +106,12 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
className = "mx_ForwardList_sending";
disabled = true;
title = _t("Sending");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
} else if (sendState === SendState.Sent) {
className = "mx_ForwardList_sent";
disabled = true;
title = _t("Sent");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
} else {
className = "mx_ForwardList_sendFailed";
disabled = true;
@ -204,10 +204,16 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)}
/>
);
}

View file

@ -0,0 +1,101 @@
/*
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 QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import { IDialogProps } from "./IDialogProps";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
interface IProps extends IDialogProps {
title: string;
subheading: string;
rageshakeLabel: string;
rageshakeData?: Record<string, string>;
}
const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
title,
subheading,
children,
rageshakeLabel,
rageshakeData = {},
onFinished,
}) => {
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
submitFeedback(SdkConfig.get().bug_report_endpoint_url, rageshakeLabel, comment, canContact, rageshakeData);
onFinished(true);
Modal.createTrackedDialog("Feedback Sent", rageshakeLabel, InfoDialog, {
title,
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_GenericFeatureFeedbackDialog"
hasCancelButton={true}
title={title}
description={<React.Fragment>
<div className="mx_GenericFeatureFeedbackDialog_subheading">
{ subheading }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
{ children }
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onChange={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
};
export default GenericFeatureFeedbackDialog;

View file

@ -133,18 +133,23 @@ export default class IncomingSasDialog extends React.Component {
? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48)
: null;
profile = <div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar name={oppProfile.displayname}
<BaseAvatar
name={oppProfile.displayname}
idName={this.props.verifier.userId}
url={url}
width={48} height={48} resizeMethod='crop'
width={48}
height={48}
resizeMethod='crop'
/>
<h2>{ oppProfile.displayname }</h2>
</div>;
} else if (this.state.opponentProfileError) {
profile = <div>
<BaseAvatar name={this.props.verifier.userId.slice(1)}
<BaseAvatar
name={this.props.verifier.userId.slice(1)}
idName={this.props.verifier.userId}
width={48} height={48}
width={48}
height={48}
/>
<h2>{ this.props.verifier.userId }</h2>
</div>;

View file

@ -1,7 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd.
Copyright 2019 Bastian Masanek, Noxware IT <matrix@noxware.de>
Copyright 2015 - 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.
@ -16,31 +15,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import React, { ReactNode, KeyboardEvent } from 'react';
import classNames from "classnames";
export default class InfoDialog extends React.Component {
static propTypes = {
className: PropTypes.string,
title: PropTypes.string,
description: PropTypes.node,
button: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
fixedWidth: PropTypes.bool,
};
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import { IDialogProps } from "./IDialogProps";
interface IProps extends IDialogProps {
title?: string;
description?: ReactNode;
className?: string;
button?: boolean | string;
hasCloseButton?: boolean;
fixedWidth?: boolean;
onKeyDown?(event: KeyboardEvent): void;
}
export default class InfoDialog extends React.Component<IProps> {
static defaultProps = {
title: '',
description: '',
hasCloseButton: false,
};
onFinished = () => {
private onFinished = () => {
this.props.onFinished();
};
@ -63,8 +62,7 @@ export default class InfoDialog extends React.Component {
{ this.props.button !== false && <DialogButtons primaryButton={this.props.button || _t('OK')}
onPrimaryButtonClick={this.onFinished}
hasCancel={false}
>
</DialogButtons> }
/> }
</BaseDialog>
);
}

View file

@ -55,7 +55,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import { getAddressType } from "../../../UserAddress";
import BaseAvatar from '../avatars/BaseAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import { compare } from '../../../utils/strings';
import { IInvite3PID } from "matrix-js-sdk/src/@types/requests";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
@ -196,7 +196,9 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
? <img
className='mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar'
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} />
width={avatarSize}
height={avatarSize}
/>
: <BaseAvatar
className='mx_InviteDialog_userTile_avatar'
url={this.props.member.getMxcAvatarUrl()
@ -214,8 +216,11 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
className='mx_InviteDialog_userTile_remove'
onClick={this.onRemove}
>
<img src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')} width={8} height={8}
<img
src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')}
width={8}
height={8}
/>
</AccessibleButton>
);
@ -297,7 +302,9 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
const avatar = (this.props.member as ThreepidMember).isEmail
? <img
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} />
width={avatarSize}
height={avatarSize}
/>
: <BaseAvatar
url={this.props.member.getMxcAvatarUrl()
? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize)
@ -387,6 +394,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
private closeCopiedTooltip: () => void;
private debounceTimer: number = null; // actually number because we're in the browser
private editorRef = createRef<HTMLInputElement>();
private numberEntryFieldRef: React.RefObject<Field> = createRef();
private unmounted = false;
constructor(props) {
@ -1276,13 +1284,27 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({ dialPadValue: ev.currentTarget.value });
};
private onDigitPress = digit => {
private onDigitPress = (digit: string, ev: ButtonEvent) => {
this.setState({ dialPadValue: this.state.dialPadValue + digit });
// Keep the number field focused so that keyboard entry is still available
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
private onDeletePress = () => {
private onDeletePress = (ev: ButtonEvent) => {
if (this.state.dialPadValue.length === 0) return;
this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) });
// Keep the number field focused so that keyboard entry is still available
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
private onTabChange = (tabId: TabId) => {
@ -1458,7 +1480,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<p className='mx_InviteDialog_helpText'>
<img
src={require("../../../../res/img/element-icons/info.svg")}
width={14} height={14} />
width={14}
height={14} />
{ " " + _t("Invited people will be able to read old messages.") }
</p>;
}
@ -1534,14 +1557,20 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
// Only show the backspace button if the field has content
let dialPadField;
if (this.state.dialPadValue.length !== 0) {
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
dialPadField = <Field
ref={this.numberEntryFieldRef}
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
postfixComponent={backspaceButton}
/>;
} else {
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
dialPadField = <Field
ref={this.numberEntryFieldRef}
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
@ -1552,14 +1581,19 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<form onSubmit={this.onDialFormSubmit}>
{ dialPadField }
</form>
<Dialpad hasDial={false}
onDigitPress={this.onDigitPress} onDeletePress={this.onDeletePress}
<Dialpad
hasDial={false}
onDigitPress={this.onDigitPress}
onDeletePress={this.onDeletePress}
/>
</div>;
tabs.push(new Tab(TabId.DialPad, _td("Dial pad"), 'mx_InviteDialog_dialPadIcon', dialPadSection));
dialogContent = <React.Fragment>
<TabbedView tabs={tabs} initialTabId={this.state.currentTabId}
tabLocation={TabLocation.TOP} onChange={this.onTabChange}
<TabbedView
tabs={tabs}
initialTabId={this.state.currentTabId}
tabLocation={TabLocation.TOP}
onChange={this.onTabChange}
/>
{ consultConnectSection }
</React.Fragment>;

View file

@ -20,10 +20,10 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
export default function KeySignatureUploadFailedDialog({
failures,
source,
continuation,
onFinished,
failures,
source,
continuation,
onFinished,
}) {
const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');

View file

@ -0,0 +1,197 @@
/*
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, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { Entry } from "./AddExistingToSpaceDialog";
import SearchBox from "../../structures/SearchBox";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import StyledRadioGroup from "../elements/StyledRadioGroup";
enum RoomsToLeave {
All = "All",
Specific = "Specific",
None = "None",
}
const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const filteredRooms = useMemo(() => {
if (!lcQuery) {
return rooms;
}
const matcher = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
return matcher.match(lcQuery);
}, [rooms, lcQuery]);
return <div className="mx_LeaveSpaceDialog_section">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content">
{ filteredRooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange(checked, room);
}}
/>;
}) }
{ filteredRooms.length < 1 ? <span className="mx_LeaveSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.None);
useEffect(() => {
if (state === RoomsToLeave.All) {
setRoomsToLeave(spaceChildren);
} else {
setRoomsToLeave([]);
}
}, [setRoomsToLeave, state, spaceChildren]);
return <div className="mx_LeaveSpaceDialog_section">
<StyledRadioGroup
name="roomsToLeave"
value={state}
onChange={setState}
definitions={[
{
value: RoomsToLeave.None,
label: _t("Don't leave any"),
}, {
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),
},
]}
/>
{ state === RoomsToLeave.Specific && (
<SpaceChildPicker
filterPlaceholder={_t("Search %(spaceName)s", { spaceName: space.name })}
rooms={spaceChildren}
selected={selected}
onChange={(selected: boolean, room: Room) => {
if (selected) {
setRoomsToLeave([room, ...roomsToLeave]);
} else {
setRoomsToLeave(roomsToLeave.filter(r => r !== room));
}
}}
/>
) }
</div>;
};
interface IProps {
space: Room;
onFinished(leave: boolean, rooms?: Room[]): void;
}
const isOnlyAdmin = (room: Room): boolean => {
return !room.getJoinedMembers().some(member => {
return member.userId !== room.client.credentials.userId && member.powerLevelNorm === 100;
});
};
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
let rejoinWarning;
if (space.getJoinRule() !== JoinRule.Public) {
rejoinWarning = _t("You won't be able to rejoin unless you are re-invited.");
}
let onlyAdminWarning;
if (isOnlyAdmin(space)) {
onlyAdminWarning = _t("You're the only admin of this space. " +
"Leaving it will mean no one has control over it.");
} else {
const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdmin).length;
if (numChildrenOnlyAdminIn > 0) {
onlyAdminWarning = _t("You're the only admin of some of the rooms or spaces you wish to leave. " +
"Leaving them will leave them without any admins.");
}
}
return <BaseDialog
title={_t("Leave %(spaceName)s", { spaceName: space.name })}
className="mx_LeaveSpaceDialog"
contentId="mx_LeaveSpaceDialog"
onFinished={() => onFinished(false)}
fixedWidth={false}
>
<div className="mx_Dialog_content" id="mx_LeaveSpaceDialog">
<p>
{ _t("Are you sure you want to leave <spaceName/>?", {}, {
spaceName: () => <b>{ space.name }</b>,
}) }
&nbsp;
{ rejoinWarning }
</p>
{ spaceChildren.length > 0 && <LeaveRoomsPicker
space={space}
spaceChildren={spaceChildren}
roomsToLeave={roomsToLeave}
setRoomsToLeave={setRoomsToLeave}
/> }
{ onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning">
{ onlyAdminWarning }
</div> }
</div>
<DialogButtons
primaryButton={_t("Leave space")}
onPrimaryButtonClick={() => onFinished(true, roomsToLeave)}
hasCancel={true}
onCancel={onFinished}
/>
</BaseDialog>;
};
export default LeaveSpaceDialog;

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import Modal from '../../../Modal';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
onFinished: (success: boolean) => void;
}
interface IState {
shouldLoadBackupStatus: boolean;
loading: boolean;
backupInfo: IKeyBackupInfo;
error?: string;
}
@replaceableComponent("views.dialogs.LogoutDialog")
export default class LogoutDialog extends React.Component {
defaultProps = {
export default class LogoutDialog extends React.Component<IProps, IState> {
static defaultProps = {
onFinished: function() {},
};
constructor() {
super();
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this);
this._onFinished = this._onFinished.bind(this);
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
constructor(props) {
super(props);
const cli = MatrixClientPeg.get();
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component {
};
if (shouldLoadBackupStatus) {
this._loadBackupStatus();
this.loadBackupStatus();
}
}
async _loadBackupStatus() {
private async loadBackupStatus() {
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this.setState({
@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component {
}
}
_onSettingsLinkClick() {
private onSettingsLinkClick = (): void => {
// close dialog
this.props.onFinished();
}
this.props.onFinished(true);
};
_onExportE2eKeysClicked() {
private onExportE2eKeysClicked = (): void => {
Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'),
{
matrixClient: MatrixClientPeg.get(),
},
);
}
};
_onFinished(confirmed) {
private onFinished = (confirmed: boolean): void => {
if (confirmed) {
dis.dispatch({ action: 'logout' });
}
// close dialog
this.props.onFinished();
}
this.props.onFinished(confirmed);
};
_onSetRecoveryMethodClick() {
private onSetRecoveryMethodClick = (): void => {
if (this.state.backupInfo) {
// A key backup exists for this account, but the creating device is not
// verified, so restore the backup which will give us the keys from it and
@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component {
}
// close dialog
this.props.onFinished();
}
this.props.onFinished(true);
};
_onLogoutConfirm() {
private onLogoutConfirm = (): void => {
dis.dispatch({ action: 'logout' });
// close dialog
this.props.onFinished();
}
this.props.onFinished(true);
};
render() {
if (this.state.shouldLoadBackupStatus) {
@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
</div>
<DialogButtons primaryButton={setupButtonCaption}
hasCancel={false}
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
focus={true}
>
<button onClick={this._onLogoutConfirm}>
<button onClick={this.onLogoutConfirm}>
{ _t("I don't want my encrypted messages") }
</button>
</DialogButtons>
<details>
<summary>{ _t("Advanced") }</summary>
<p><button onClick={this._onExportE2eKeysClicked}>
<p><button onClick={this.onExportE2eKeysClicked}>
{ _t("Manually export keys") }
</button></p>
</details>
@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component {
title={_t("You'll lose access to your encrypted messages")}
contentId='mx_Dialog_content'
hasCancel={true}
onFinished={this._onFinished}
onFinished={this.onFinished}
>
{ dialogContent }
</BaseDialog>);
@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component {
"Are you sure you want to sign out?",
)}
button={_t("Sign out")}
onFinished={this._onFinished}
onFinished={this.onFinished}
/>);
}
}

View file

@ -0,0 +1,192 @@
/*
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, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps extends IDialogProps {
room: Room;
selected?: string[];
}
const Entry = ({ room, checked, onChange }) => {
const localRoom = room instanceof Room;
let description;
if (localRoom) {
description = _t("%(count)s members", { count: room.getJoinedMemberCount() });
const numChildRooms = SpaceStore.instance.getChildRooms(room.roomId).length;
if (numChildRooms > 0) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
}
return <label className="mx_ManageRestrictedJoinRuleDialog_entry">
<div>
<div>
{ localRoom
? <RoomAvatar room={room} height={20} width={20} />
: <RoomAvatar oobData={room} height={20} width={20} />
}
<span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{ room.name }</span>
</div>
{ description && <div className="mx_ManageRestrictedJoinRuleDialog_entry_description">
{ description }
</div> }
</div>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
checked={checked}
disabled={!onChange}
/>
</label>;
};
const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [], onFinished }) => {
const cli = room.client;
const [newSelected, setNewSelected] = useState(new Set<string>(selected));
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const [spacesContainingRoom, otherEntries] = useMemo(() => {
const spaces = cli.getVisibleRooms().filter(r => r.getMyMembership() === "join" && r.isSpaceRoom());
return [
spaces.filter(r => SpaceStore.instance.getSpaceFilteredRoomIds(r).has(room.roomId)),
selected.map(roomId => {
const room = cli.getRoom(roomId);
if (!room) {
return { roomId, name: roomId } as Room;
}
if (room.getMyMembership() !== "join" || !room.isSpaceRoom()) {
return room;
}
}).filter(Boolean),
];
}, [cli, selected, room.roomId]);
const [filteredSpacesContainingRooms, filteredOtherEntries] = useMemo(() => [
spacesContainingRoom.filter(r => r.name.toLowerCase().includes(lcQuery)),
otherEntries.filter(r => r.name.toLowerCase().includes(lcQuery)),
], [spacesContainingRoom, otherEntries, lcQuery]);
const onChange = (checked: boolean, room: Room): void => {
if (checked) {
newSelected.add(room.roomId);
} else {
newSelected.delete(room.roomId);
}
setNewSelected(new Set(newSelected));
};
let inviteOnlyWarning;
if (newSelected.size < 1) {
inviteOnlyWarning = <div className="mx_ManageRestrictedJoinRuleDialog_section_info">
{ _t("You're removing all spaces. Access will default to invite only") }
</div>;
}
return <BaseDialog
title={_t("Select spaces")}
className="mx_ManageRestrictedJoinRuleDialog"
onFinished={onFinished}
fixedWidth={false}
>
<p>
{ _t("Decide which spaces can access this room. " +
"If a space is selected, its members can find and join <RoomName/>.", {}, {
RoomName: () => <b>{ room.name }</b>,
}) }
</p>
<MatrixClientContext.Provider value={cli}>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search spaces")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">
{ filteredSpacesContainingRooms.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{ _t("Spaces you know that contain this room") }</h3>
{ filteredSpacesContainingRooms.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>;
}) }
</div>
) : undefined }
{ filteredOtherEntries.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{ _t("Other spaces or rooms you might not know") }</h3>
<div className="mx_ManageRestrictedJoinRuleDialog_section_info">
<div>{ _t("These are likely ones other room admins are a part of.") }</div>
</div>
{ filteredOtherEntries.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>;
}) }
</div>
) : null }
{ filteredSpacesContainingRooms.length + filteredOtherEntries.length < 1
? <span className="mx_ManageRestrictedJoinRuleDialog_noResults">
{ _t("No results") }
</span>
: undefined
}
</AutoHideScrollbar>
<div className="mx_ManageRestrictedJoinRuleDialog_footer">
{ inviteOnlyWarning }
<div className="mx_ManageRestrictedJoinRuleDialog_footer_buttons">
<AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" onClick={() => onFinished(Array.from(newSelected))}>
{ _t("Confirm") }
</AccessibleButton>
</div>
</div>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default ManageRestrictedJoinRuleDialog;

View file

@ -79,7 +79,10 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
ROOM_SECURITY_TAB,
_td("Security & Privacy"),
"mx_RoomSettingsDialog_securityIcon",
<SecurityRoomSettingsTab roomId={this.props.roomId} />,
<SecurityRoomSettingsTab
roomId={this.props.roomId}
closeSettingsFn={() => this.props.onFinished(true)}
/>,
));
tabs.push(new Tab(
ROOM_ROLES_TAB,

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018 - 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,19 +15,29 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { Room } from "matrix-js-sdk/src/models/room";
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import ErrorDialog from './ErrorDialog';
import DialogButtons from '../elements/DialogButtons';
import Spinner from "../elements/Spinner";
interface IProps extends IDialogProps {
room: Room;
}
interface IState {
busy: boolean;
}
@replaceableComponent("views.dialogs.RoomUpgradeDialog")
export default class RoomUpgradeDialog extends React.Component {
static propTypes = {
room: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
};
export default class RoomUpgradeDialog extends React.Component<IProps, IState> {
private targetVersion: string;
state = {
busy: true,
@ -35,20 +45,19 @@ export default class RoomUpgradeDialog extends React.Component {
async componentDidMount() {
const recommended = await this.props.room.getRecommendedVersion();
this._targetVersion = recommended.version;
this.targetVersion = recommended.version;
this.setState({ busy: false });
}
_onCancelClick = () => {
private onCancelClick = (): void => {
this.props.onFinished(false);
};
_onUpgradeClick = () => {
private onUpgradeClick = (): void => {
this.setState({ busy: true });
MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => {
upgradeRoom(this.props.room, this.targetVersion, false, false).then(() => {
this.props.onFinished(true);
}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to upgrade room', '', ErrorDialog, {
title: _t("Failed to upgrade room"),
description: ((err && err.message) ? err.message : _t("The room upgrade could not be completed")),
@ -59,29 +68,22 @@ export default class RoomUpgradeDialog extends React.Component {
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent('views.elements.Spinner');
let buttons;
if (this.state.busy) {
buttons = <Spinner />;
} else {
buttons = <DialogButtons
primaryButton={_t(
'Upgrade this room to version %(version)s',
{ version: this._targetVersion },
)}
primaryButton={_t('Upgrade this room to version %(version)s', { version: this.targetVersion })}
primaryButtonClass="danger"
hasCancel={true}
onPrimaryButtonClick={this._onUpgradeClick}
focus={this.props.focus}
onCancel={this._onCancelClick}
onPrimaryButtonClick={this.onUpgradeClick}
onCancel={this.onCancelClick}
/>;
}
return (
<BaseDialog className="mx_RoomUpgradeDialog"
<BaseDialog
className="mx_RoomUpgradeDialog"
onFinished={this.props.onFinished}
title={_t("Upgrade Room Version")}
contentId='mx_Dialog_content'
@ -97,8 +99,10 @@ export default class RoomUpgradeDialog extends React.Component {
<ol>
<li>{ _t("Create a new room with the same name, description and avatar") }</li>
<li>{ _t("Update any local room aliases to point to the new room") }</li>
<li>{ _t("Stop users from speaking in the old version of the room, and post a message advising users to move to the new room") }</li>
<li>{ _t("Put a link back to the old room at the start of the new room so people can see old messages") }</li>
<li>{ _t("Stop users from speaking in the old version of the room, " +
"and post a message advising users to move to the new room") }</li>
<li>{ _t("Put a link back to the old room at the start of the new room " +
"so people can see old messages") }</li>
</ol>
{ buttons }
</BaseDialog>

View file

@ -1,5 +1,5 @@
/*
Copyright 2019, 2020 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.
@ -14,73 +14,82 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, { ReactNode } from 'react';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IDialogProps } from "./IDialogProps";
import BugReportDialog from './BugReportDialog';
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps extends IDialogProps {
roomId: string;
targetVersion: string;
description?: ReactNode;
}
interface IState {
inviteUsersToNewRoom: boolean;
}
@replaceableComponent("views.dialogs.RoomUpgradeWarningDialog")
export default class RoomUpgradeWarningDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
roomId: PropTypes.string.isRequired,
targetVersion: PropTypes.string.isRequired,
};
export default class RoomUpgradeWarningDialog extends React.Component<IProps, IState> {
private readonly isPrivate: boolean;
private readonly currentVersion: string;
constructor(props) {
super(props);
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const joinRules = room ? room.currentState.getStateEvents("m.room.join_rules", "") : null;
const isPrivate = joinRules ? joinRules.getContent()['join_rule'] !== 'public' : true;
const joinRules = room?.currentState.getStateEvents(EventType.RoomJoinRules, "");
this.isPrivate = joinRules?.getContent()['join_rule'] !== JoinRule.Public ?? true;
this.currentVersion = room?.getVersion() || "1";
this.state = {
currentVersion: room ? room.getVersion() : "1",
isPrivate,
inviteUsersToNewRoom: true,
};
}
_onContinue = () => {
this.props.onFinished({ continue: true, invite: this.state.isPrivate && this.state.inviteUsersToNewRoom });
private onContinue = () => {
this.props.onFinished({ continue: true, invite: this.isPrivate && this.state.inviteUsersToNewRoom });
};
_onCancel = () => {
private onCancel = () => {
this.props.onFinished({ continue: false, invite: false });
};
_onInviteUsersToggle = (newVal) => {
this.setState({ inviteUsersToNewRoom: newVal });
private onInviteUsersToggle = (inviteUsersToNewRoom: boolean) => {
this.setState({ inviteUsersToNewRoom });
};
_openBugReportDialog = (e) => {
private openBugReportDialog = (e) => {
e.preventDefault();
e.stopPropagation();
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
};
render() {
const brand = SdkConfig.get().brand;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let inviteToggle = null;
if (this.state.isPrivate) {
if (this.isPrivate) {
inviteToggle = (
<LabelledToggleSwitch
value={this.state.inviteUsersToNewRoom}
onChange={this._onInviteUsersToggle}
label={_t("Automatically invite users")} />
onChange={this.onInviteUsersToggle}
label={_t("Automatically invite members from this room to the new one")} />
);
}
const title = this.state.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room");
const title = this.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room");
let bugReports = (
<p>
@ -101,7 +110,7 @@ export default class RoomUpgradeWarningDialog extends React.Component {
},
{
"a": (sub) => {
return <a href='#' onClick={this._openBugReportDialog}>{ sub }</a>;
return <a href='#' onClick={this.openBugReportDialog}>{ sub }</a>;
},
},
) }
@ -119,18 +128,26 @@ export default class RoomUpgradeWarningDialog extends React.Component {
>
<div>
<p>
{ _t(
{ this.props.description || _t(
"Upgrading a room is an advanced action and is usually recommended when a room " +
"is unstable due to bugs, missing features or security vulnerabilities.",
) }
</p>
<p>
{ _t(
"<b>Please note upgrading will make a new version of the room</b>. " +
"All current messages will stay in this archived room.", {}, {
b: sub => <b>{ sub }</b>,
},
) }
</p>
{ bugReports }
<p>
{ _t(
"You'll upgrade this room from <oldVersion /> to <newVersion />.",
{},
{
oldVersion: () => <code>{ this.state.currentVersion }</code>,
oldVersion: () => <code>{ this.currentVersion }</code>,
newVersion: () => <code>{ this.props.targetVersion }</code>,
},
) }
@ -139,9 +156,9 @@ export default class RoomUpgradeWarningDialog extends React.Component {
</div>
<DialogButtons
primaryButton={_t("Upgrade")}
onPrimaryButtonClick={this._onContinue}
onPrimaryButtonClick={this.onContinue}
cancelButton={_t("Cancel")}
onCancel={this._onCancel}
onCancel={this.onCancel}
/>
</BaseDialog>
);

View file

@ -85,7 +85,9 @@ export default class SessionRestoreErrorDialog extends React.Component {
}
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={_t('Unable to restore session')}
contentId='mx_Dialog_content'
hasCancel={false}

View file

@ -158,7 +158,7 @@ export default class ShareDialog extends React.PureComponent<IProps, IState> {
if (this.state.linkSpecificEvent) {
matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId());
} else {
matrixToUrl = this.props.permalinkCreator.forRoom();
matrixToUrl = this.props.permalinkCreator.forShareableRoom();
}
}
return matrixToUrl;

View file

@ -29,10 +29,12 @@ import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab";
export enum SpaceSettingsTab {
General = "SPACE_GENERAL_TAB",
Visibility = "SPACE_VISIBILITY_TAB",
Roles = "SPACE_ROLES_TAB",
Advanced = "SPACE_ADVANCED_TAB",
}
@ -60,7 +62,13 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
SpaceSettingsTab.Visibility,
_td("Visibility"),
"mx_SpaceSettingsDialog_visibilityIcon",
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} />,
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} closeSettingsFn={onFinished} />,
),
new Tab(
SpaceSettingsTab.Roles,
_td("Roles & Permissions"),
"mx_RoomSettingsDialog_rolesIcon",
<RolesRoomSettingsTab roomId={space.roomId} />,
),
SettingsStore.getValue(UIFeature.AdvancedSettings)
? new Tab(

View file

@ -54,7 +54,9 @@ export default class StorageEvictedDialog extends React.Component {
}
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={_t('Missing session data')}
contentId='mx_Dialog_content'
hasCancel={false}

View file

@ -114,7 +114,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
UserTab.Preferences,
_td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon",
<PreferencesUserSettingsTab />,
<PreferencesUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
if (SettingsStore.getValue(UIFeature.Voip)) {

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.
@ -20,6 +20,7 @@ import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import {
Capability,
isTimelineCapability,
Widget,
WidgetEventCapability,
WidgetKind,
@ -30,14 +31,7 @@ import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
import { lexicographicCompare } from "matrix-js-sdk/src/utils";
interface IProps extends IDialogProps {
requestedCapabilities: Set<Capability>;
@ -95,14 +89,24 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
};
private closeAndTryRemember(approved: Capability[]) {
if (this.state.rememberSelection) {
setRememberedCapabilitiesForWidget(this.props.widget, approved);
}
this.props.onFinished({ approved });
this.props.onFinished({ approved, remember: this.state.rememberSelection });
}
public render() {
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
// We specifically order the timeline capabilities down to the bottom. The capability text
// generation cares strongly about this.
const orderedCapabilities = Object.entries(this.state.booleanStates).sort(([capA], [capB]) => {
const isTimelineA = isTimelineCapability(capA);
const isTimelineB = isTimelineCapability(capB);
if (!isTimelineA && !isTimelineB) return lexicographicCompare(capA, capB);
if (isTimelineA && !isTimelineB) return 1;
if (!isTimelineA && isTimelineB) return -1;
if (isTimelineA && isTimelineB) return lexicographicCompare(capA, capB);
return 0;
});
const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => {
const text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{ text.byline }</span>

View file

@ -1,5 +1,6 @@
/*
Copyright 2019 Travis Ralston
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.
@ -15,42 +16,46 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from "../../../languageHandler";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { Widget } from "matrix-widget-api";
import { Widget, WidgetKind } from "matrix-widget-api";
import { OIDCState, WidgetPermissionStore } from "../../../stores/widgets/WidgetPermissionStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps extends IDialogProps {
widget: Widget;
widgetKind: WidgetKind;
inRoomId?: string;
}
interface IState {
rememberSelection: boolean;
}
@replaceableComponent("views.dialogs.WidgetOpenIDPermissionsDialog")
export default class WidgetOpenIDPermissionsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
widget: PropTypes.objectOf(Widget).isRequired,
widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api
inRoomId: PropTypes.string,
};
constructor() {
super();
export default class WidgetOpenIDPermissionsDialog extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
rememberSelection: false,
};
}
_onAllow = () => {
this._onPermissionSelection(true);
private onAllow = () => {
this.onPermissionSelection(true);
};
_onDeny = () => {
this._onPermissionSelection(false);
private onDeny = () => {
this.onPermissionSelection(false);
};
_onPermissionSelection(allowed) {
private onPermissionSelection(allowed: boolean) {
if (this.state.rememberSelection) {
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
console.log(`Remembering ${this.props.widget.id} as allowed=${allowed} for OpenID`);
WidgetPermissionStore.instance.setOIDCState(
this.props.widget, this.props.widgetKind, this.props.inRoomId,
@ -61,14 +66,11 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
this.props.onFinished(allowed);
}
_onRememberSelectionChange = (newVal) => {
private onRememberSelectionChange = (newVal: boolean) => {
this.setState({ rememberSelection: newVal });
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
public render() {
return (
<BaseDialog
className='mx_WidgetOpenIDPermissionsDialog'
@ -87,13 +89,13 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
</div>
<DialogButtons
primaryButton={_t("Continue")}
onPrimaryButtonClick={this._onAllow}
onCancel={this._onDeny}
onPrimaryButtonClick={this.onAllow}
onCancel={this.onDeny}
additive={
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this._onRememberSelectionChange}
onChange={this.onRememberSelectionChange}
label={_t("Remember this")} />}
/>
</BaseDialog>

View file

@ -287,7 +287,8 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
<div className="mx_AccessSecretStorageDialog_reset">
{ _t("Forgotten or lost all recovery methods? <a>Reset all</a>", null, {
a: (sub) => <a
href="" onClick={this.onResetAllClick}
href=""
onClick={this.onResetAllClick}
className="mx_AccessSecretStorageDialog_reset_link">{ sub }</a>,
}) }
</div>

View file

@ -399,7 +399,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
let keyStatus;
if (this.state.recoveryKey.length === 0) {
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus"></div>;
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus" />;
} else if (this.state.recoveryKeyValid) {
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus">
{ "\uD83D\uDC4D " }{ _t("This looks like a valid Security Key!") }

View file

@ -19,7 +19,7 @@ import React, { ReactHTML } from 'react';
import { Key } from '../../../Keyboard';
import classnames from 'classnames';
export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element>;
export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element> | React.FormEvent<Element>;
/**
* children: React's magic prop. Represents all children given to the element.
@ -39,7 +39,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
tabIndex?: number;
disabled?: boolean;
className?: string;
onClick(e?: ButtonEvent): void;
onClick(e?: ButtonEvent): void | Promise<void>;
}
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
@ -67,7 +67,9 @@ export default function AccessibleButton({
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
if (!disabled) {
if (disabled) {
newProps["aria-disabled"] = true;
} else {
newProps.onClick = onClick;
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
@ -118,7 +120,7 @@ export default function AccessibleButton({
);
// React.createElement expects InputHTMLAttributes
return React.createElement(element, restProps, children);
return React.createElement(element, newProps, children);
}
AccessibleButton.defaultProps = {

View file

@ -25,6 +25,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
title: string;
tooltip?: React.ReactNode;
label?: React.ReactNode;
tooltipClassName?: string;
forceHide?: boolean;
yOffset?: number;
@ -84,7 +85,8 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
aria-label={title}
>
{ children }
{ tip }
{ this.props.label }
{ (tooltip || title) && tip }
</AccessibleButton>
);
}

View file

@ -122,7 +122,7 @@ export default class AddressTile extends React.Component<IProps> {
let dismiss;
if (this.props.canDismiss) {
dismiss = (
<div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed} >
<div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed}>
<img src={require("../../../../res/img/icon-address-delete.svg")} width="9" height="9" />
</div>
);

View file

@ -62,10 +62,10 @@ export default class AppPermission extends React.Component<IProps, IState> {
// Set all this into the initial state
this.state = {
...urlInfo,
roomMember,
isWrapped: null,
widgetDomain: null,
isWrapped: null,
roomMember,
...urlInfo,
};
}

View file

@ -218,6 +218,7 @@ export default class AppTile extends React.Component {
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
if (this._sgWidget) this._sgWidget.stop({ forceDestroy: true });
}
@ -307,7 +308,6 @@ export default class AppTile extends React.Component {
if (this.iframe) {
// Reload iframe
this.iframe.src = this._sgWidget.embedUrl;
this.setState({});
}
});
}
@ -333,7 +333,7 @@ export default class AppTile extends React.Component {
// this would only be for content hosted on the same origin as the element client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox " +
"allow-same-origin allow-scripts allow-presentation";
// Additional iframe feature pemissions
@ -443,25 +443,25 @@ export default class AppTile extends React.Component {
return <React.Fragment>
<div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar &&
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}>
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
/>
</span>
</div> }
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}>
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
/>
</span>
</div> }
{ appTileBody }
</div>

Some files were not shown because too many files have changed in this diff Show more