Merge branch 'develop' into emoji_quick_shortcut

This commit is contained in:
macekj 2020-12-04 11:05:50 -05:00
commit 0c85cb587d
200 changed files with 10916 additions and 3783 deletions

View file

@ -47,7 +47,7 @@ const LONG_DESC_PLACEHOLDER = _td(
some important <a href="foo">links</a>
</p>
<p>
You can even use 'img' tags
You can even add images with Matrix URLs <img src="mxc://url" />
</p>
`);

View file

@ -31,10 +31,26 @@ import {UPDATE_EVENT} from "../../stores/AsyncStore";
import {useEventEmitter} from "../../hooks/useEventEmitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader";
import Analytics from "../../Analytics";
import CountlyAnalytics from "../../CountlyAnalytics";
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
const onClickSendDm = () => {
Analytics.trackEvent('home_page', 'button', 'dm');
CountlyAnalytics.instance.track("home_page_button", { button: "dm" });
dis.dispatch({action: 'view_create_chat'});
};
const onClickExplore = () => {
Analytics.trackEvent('home_page', 'button', 'room_directory');
CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" });
dis.fire(Action.ViewRoomDirectory);
};
const onClickNewRoom = () => {
Analytics.trackEvent('home_page', 'button', 'create_room');
CountlyAnalytics.instance.track("home_page_button", { button: "create_room" });
dis.dispatch({action: 'view_create_room'});
};
interface IProps {
justRegistered?: boolean;

View file

@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
@ -52,6 +52,7 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
// We need to fetch each pinned message individually (if we don't already have it)
@ -392,6 +393,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
const modKey = isMac ? ev.metaKey : ev.ctrlKey;
switch (ev.key) {
case Key.PAGE_UP:
@ -436,6 +438,16 @@ class LoggedInView extends React.Component<IProps, IState> {
}
break;
case Key.H:
if (ev.altKey && modKey) {
dis.dispatch({
action: 'view_home_page',
});
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
}
break;
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {

View file

@ -34,7 +34,6 @@ import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter";
import dis from "../../dispatcher/dispatcher";
import Notifier from '../../Notifier';
@ -48,7 +47,6 @@ import * as Lifecycle from '../../Lifecycle';
// LifecycleStore is not used but does listen to and dispatch actions
import '../../stores/LifecycleStore';
import PageTypes from '../../PageTypes';
import { getHomePageUrl } from '../../utils/pages';
import createRoom from "../../createRoom";
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
@ -87,38 +85,37 @@ import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
export enum Views {
// a special initial state which is only used at startup, while we are
// trying to re-animate a matrix client or register as a guest.
LOADING = 0,
LOADING,
// we are showing the welcome view
WELCOME = 1,
WELCOME,
// we are showing the login view
LOGIN = 2,
LOGIN,
// we are showing the registration view
REGISTER = 3,
// completing the registration flow
POST_REGISTRATION = 4,
REGISTER,
// showing the 'forgot password' view
FORGOT_PASSWORD = 5,
FORGOT_PASSWORD,
// showing flow to trust this new device with cross-signing
COMPLETE_SECURITY = 6,
COMPLETE_SECURITY,
// flow to setup SSSS / cross-signing on this account
E2E_SETUP = 7,
E2E_SETUP,
// we are logged in with an active matrix client. The logged_in state also
// includes guests users as they too are logged in at the client level.
LOGGED_IN = 8,
LOGGED_IN,
// We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client.
SOFT_LOGOUT = 9,
SOFT_LOGOUT,
}
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
// Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require
// re-factoring to be included in this list in future.
@ -562,11 +559,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
ThemeController.isLogin = true;
this.themeWatcher.recheck();
break;
case 'start_post_registration':
this.setState({
view: Views.POST_REGISTRATION,
});
break;
case 'start_password_recovery':
this.setStateForNewView({
view: Views.FORGOT_PASSWORD,
@ -597,7 +589,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
MatrixClientPeg.get().leave(payload.room_id).then(() => {
modal.close();
if (this.state.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'});
dis.dispatch({action: 'view_home_page'});
}
}, (err) => {
modal.close();
@ -626,9 +618,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
}
case 'view_next_room':
this.viewNextRoom(1);
break;
case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload;
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
@ -653,8 +642,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
case Action.ViewRoomDirectory: {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
'mx_RoomDirectory_dialogWrapper', false, true);
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
@ -677,7 +667,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.chatCreateOrReuse(payload.user_id);
break;
case 'view_create_chat':
showStartChatInviteDialog();
showStartChatInviteDialog(payload.initialText || "");
break;
case 'view_invite':
showRoomInviteDialog(payload.roomId);
@ -807,35 +797,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.notifyNewScreen('register');
}
// TODO: Move to RoomViewStore
private viewNextRoom(roomIndexDelta: number) {
const allRooms = RoomListSorter.mostRecentActivityFirst(
MatrixClientPeg.get().getRooms(),
);
// If there are 0 rooms or 1 room, view the home page because otherwise
// if there are 0, we end up trying to index into an empty array, and
// if there is 1, we end up viewing the same room.
if (allRooms.length < 2) {
dis.dispatch({
action: 'view_home_page',
});
return;
}
let roomIndex = -1;
for (let i = 0; i < allRooms.length; ++i) {
if (allRooms[i].roomId === this.state.currentRoomId) {
roomIndex = i;
break;
}
}
roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
if (roomIndex < 0) roomIndex = allRooms.length - 1;
dis.dispatch({
action: 'view_room',
room_id: allRooms[roomIndex].roomId,
});
}
// switch view to the given room
//
// @param {Object} roomInfo Object containing data about the room to be joined
@ -1102,9 +1063,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private forgetRoom(roomId: string) {
MatrixClientPeg.get().forget(roomId).then(() => {
// Switch to another room view if we're currently viewing the historical room
// Switch to home page if we're currently viewing the forgotten room
if (this.state.currentRoomId === roomId) {
dis.dispatch({ action: "view_next_room" });
dis.dispatch({ action: "view_home_page" });
}
}).catch((err) => {
const errCode = err.errcode || _td("unknown error code");
@ -1238,12 +1199,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_welcome_page'});
} else if (getHomePageUrl(this.props.config)) {
dis.dispatch({action: 'view_home_page'});
} else {
this.firstSyncPromise.promise.then(() => {
dis.dispatch({action: 'view_next_room'});
});
dis.dispatch({action: 'view_home_page'});
}
}
}
@ -1358,18 +1315,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
});
if (SettingsStore.getValue(UIFeature.Voip)) {
cli.on('Call.incoming', function(call) {
// we dispatch this synchronously to make sure that the event
// handlers on the call are set up immediately (so that if
// we get an immediate hangup, we don't get a stuck call)
dis.dispatch({
action: 'incoming_call',
call: call,
}, true);
});
}
cli.on('Session.logged_out', function(errObj) {
if (Lifecycle.isLoggingOut()) return;
@ -1554,6 +1499,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
showScreen(screen: string, params?: {[key: string]: any}) {
const cli = MatrixClientPeg.get();
const isLoggedOutOrGuest = !cli || cli.isGuest();
if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) {
// user is logged in and landing on an auth page which will uproot their session, redirect them home instead
dis.dispatch({ action: "view_home_page" });
return;
}
if (screen === 'register') {
dis.dispatch({
action: 'start_registration',
@ -1570,7 +1523,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
params: params,
});
} else if (screen === 'soft_logout') {
if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) {
if (cli.getUserId() && !Lifecycle.isSoftLogout()) {
// Logged in - visit a room
this.viewLastRoom();
} else {
@ -1621,14 +1574,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.dispatch({
action: 'view_my_groups',
});
} else if (screen === 'complete_security') {
dis.dispatch({
action: 'start_complete_security',
});
} else if (screen === 'post_registration') {
dis.dispatch({
action: 'start_post_registration',
});
} else if (screen.indexOf('room/') === 0) {
// Rooms can have the following formats:
// #room_alias:domain or !opaque_id:domain
@ -1799,14 +1744,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return Lifecycle.setLoggedIn(credentials);
}
onFinishPostRegistration = () => {
// Don't confuse this with "PageType" which is the middle window to show
this.setState({
view: Views.LOGGED_IN,
});
this.showScreen("settings");
};
onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get();
if (!cli) {
@ -1971,13 +1908,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
accountPassword={this.accountPassword}
/>
);
} else if (this.state.view === Views.POST_REGISTRATION) {
// needs to be before normal PageTypes as you are logged in technically
const PostRegistration = sdk.getComponent('structures.auth.PostRegistration');
view = (
<PostRegistration
onComplete={this.onFinishPostRegistration} />
);
} else if (this.state.view === Views.LOGGED_IN) {
// store errors stop the client syncing and require user intervention, so we'll
// be showing a dialog. Don't show anything else.
@ -2041,6 +1971,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onLoginClick={this.onLoginClick}
onServerConfigChange={this.onServerConfigChange}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
fragmentAfterLogin={fragmentAfterLogin}
{...this.getServerProperties()}
/>
);

View file

@ -44,6 +44,7 @@ function track(action) {
export default class RoomDirectory extends React.Component {
static propTypes = {
initialText: PropTypes.string,
onFinished: PropTypes.func.isRequired,
};
@ -61,7 +62,7 @@ export default class RoomDirectory extends React.Component {
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: null,
filterString: this.props.initialText || "",
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
? selectedCommunityId
: null,
@ -686,6 +687,7 @@ export default class RoomDirectory extends React.Component {
onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder}
showJoinButton={showJoinButton}
initialText={this.props.initialText}
/>
{dropdown}
</div>;

View file

@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onBlur={this.onBlur}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={_t("Search")}
placeholder={_t("Filter")}
autoComplete="off"
/>
);
@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
if (this.props.isMinimized) {
icon = (
<AccessibleButton
title={_t("Search rooms")}
title={_t("Filter rooms and people")}
className="mx_RoomSearch_icon mx_RoomSearch_minimizedHandle"
onClick={this.openSearch}
/>

View file

@ -18,13 +18,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t, _td } from '../../languageHandler';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -42,13 +40,6 @@ export default class RoomStatusBar extends React.Component {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
// The active call in the room, if any (means we show the call bar
// along with the status of the call)
callState: PropTypes.string,
// The type of the call in progress, or null if no call is in progress
callType: PropTypes.string,
// true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room.
isPeeking: PropTypes.bool,
@ -115,12 +106,6 @@ export default class RoomStatusBar extends React.Component {
});
};
_showCallBar() {
return (this.props.callState &&
(this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing)
);
}
_onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer);
@ -152,7 +137,7 @@ export default class RoomStatusBar extends React.Component {
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize() {
if (this._shouldShowConnectionError() || this._showCallBar()) {
if (this._shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0) {
return STATUS_BAR_EXPANDED_LARGE;
@ -160,22 +145,6 @@ export default class RoomStatusBar extends React.Component {
return STATUS_BAR_HIDDEN;
}
// return suitable content for the image on the left of the status bar.
_getIndicator() {
if (this._showCallBar()) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
);
}
if (this._shouldShowConnectionError()) {
return null;
}
return null;
}
_shouldShowConnectionError() {
// no conn bar trumps the "some not sent" msg since you can't resend without
// a connection!
@ -266,25 +235,6 @@ export default class RoomStatusBar extends React.Component {
</div>;
}
_getCallStatusText() {
switch (this.props.callState) {
case CallState.CreateOffer:
case CallState.InviteSent:
return _t('Calling...');
case CallState.Connecting:
case CallState.CreateAnswer:
return _t('Call connecting...');
case CallState.Connected:
return _t('Active call');
case CallState.WaitLocalMedia:
if (this.props.callType === CallType.Video) {
return _t('Starting camera...');
} else {
return _t('Starting microphone...');
}
}
}
// return suitable content for the main (text) part of the status bar.
_getContent() {
if (this._shouldShowConnectionError()) {
@ -307,26 +257,14 @@ export default class RoomStatusBar extends React.Component {
return this._getUnsentMessageContent();
}
if (this._showCallBar()) {
return (
<div className="mx_RoomStatusBar_callBar">
<b>{ this._getCallStatusText() }</b>
</div>
);
}
return null;
}
render() {
const content = this._getContent();
const indicator = this._getIndicator();
return (
<div className="mx_RoomStatusBar">
<div className="mx_RoomStatusBar_indicator">
{ indicator }
</div>
<div role="alert">
{ content }
</div>

View file

@ -41,7 +41,7 @@ import rateLimitedFunc from '../../ratelimitedfunc';
import * as ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import eventSearch, {searchPagination} from '../../Searching';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key} from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
@ -56,7 +56,6 @@ import MatrixClientContext from "../../contexts/MatrixClientContext";
import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils';
import {Action} from "../../dispatcher/actions";
import {SettingLevel} from "../../settings/SettingLevel";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import {IMatrixClientCreds} from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
@ -68,10 +67,9 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader";
import TintableSvg from "../views/elements/TintableSvg";
import {XOR} from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import WidgetStore from "../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
@ -508,8 +506,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.onResize();
document.addEventListener("keydown", this.onNativeKeyDown);
}
shouldComponentUpdate(nextProps, nextState) {
@ -592,8 +588,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
document.removeEventListener("keydown", this.onNativeKeyDown);
// Remove RoomStore listener
if (this.roomStoreToken) {
this.roomStoreToken.remove();
@ -642,33 +636,6 @@ export default class RoomView extends React.Component<IProps, IState> {
}
};
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
private onNativeKeyDown = ev => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.key) {
case Key.D:
if (ctrlCmdOnly) {
this.onMuteAudioClick();
handled = true;
}
break;
case Key.E:
if (ctrlCmdOnly) {
this.onMuteVideoClick();
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onReactKeyDown = ev => {
let handled = false;
@ -1324,10 +1291,7 @@ export default class RoomView extends React.Component<IProps, IState> {
};
private onSettingsClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
});
dis.dispatch({ action: "open_room_settings" });
};
private onCancelClick = () => {
@ -1368,7 +1332,7 @@ export default class RoomView extends React.Component<IProps, IState> {
rejecting: true,
});
this.context.leave(this.state.roomId).then(() => {
dis.dispatch({ action: 'view_next_room' });
dis.dispatch({ action: 'view_home_page' });
this.setState({
rejecting: false,
});
@ -1402,7 +1366,7 @@ export default class RoomView extends React.Component<IProps, IState> {
await this.context.setIgnoredUsers(ignoredUsers);
await this.context.leave(this.state.roomId);
dis.dispatch({ action: 'view_next_room' });
dis.dispatch({ action: 'view_home_page' });
this.setState({
rejecting: false,
});
@ -1758,8 +1722,6 @@ export default class RoomView extends React.Component<IProps, IState> {
isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar
room={this.state.room}
callState={this.state.callState}
callType={activeCall ? activeCall.type : null}
isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick}
onVisible={this.onStatusBarVisible}
@ -1883,56 +1845,6 @@ export default class RoomView extends React.Component<IProps, IState> {
};
}
if (activeCall) {
let zoomButton; let videoMuteButton;
if (activeCall.type === CallType.Video) {
zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
<TintableSvg
src={require("../../../res/img/element-icons/call/fullscreen.svg")}
width="29"
height="22"
style={{ marginTop: 1, marginRight: 4 }}
/>
</div>
);
videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<TintableSvg
src={activeCall.isLocalVideoMuted() ?
require("../../../res/img/element-icons/call/video-muted.svg") :
require("../../../res/img/element-icons/call/video-call.svg")}
alt={activeCall.isLocalVideoMuted() ? _t("Click to unmute video") :
_t("Click to mute video")}
width=""
height="27"
/>
</div>;
}
const voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<TintableSvg
src={activeCall.isMicrophoneMuted() ?
require("../../../res/img/element-icons/call/voice-muted.svg") :
require("../../../res/img/element-icons/call/voice-unmuted.svg")}
alt={activeCall.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
width="21"
height="26"
/>
</div>;
// wrap the existing status bar into a 'callStatusBar' which adds more knobs.
statusBar =
<div className="mx_RoomView_callStatusBar">
{ voiceMuteButton }
{ videoMuteButton }
{ zoomButton }
{ statusBar }
</div>;
}
// if we have search results, we keep the messagepanel (so that it preserves its
// scroll state), but hide it.
let searchResultsPanel;

View file

@ -55,11 +55,11 @@ export default class ToastContainer extends React.Component<{}, IState> {
let toast;
if (totalCount !== 0) {
const topToast = this.state.toasts[0];
const {title, icon, key, component, props} = topToast;
const {title, icon, key, component, className, props} = topToast;
const toastClasses = classNames("mx_Toast_toast", {
"mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon,
});
}, className);
let countIndicator;
if (isStacked || this.state.countSeen > 0) {

View file

@ -29,7 +29,7 @@ import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme";
import {getHostingLink} from "../../utils/HostingLink";
import {ButtonEvent} from "../views/elements/AccessibleButton";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
@ -205,6 +205,16 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({contextMenuPosition: null}); // also close the menu
};
private onSignInClick = () => {
dis.dispatch({ action: 'start_login' });
this.setState({contextMenuPosition: null}); // also close the menu
};
private onRegisterClick = () => {
dis.dispatch({ action: 'start_registration' });
this.setState({contextMenuPosition: null}); // also close the menu
};
private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -261,10 +271,29 @@ export default class UserMenu extends React.Component<IProps, IState> {
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let hostingLink;
let topSection;
const signupLink = getHostingLink("user-context-menu");
if (signupLink) {
hostingLink = (
if (MatrixClientPeg.get().isGuest()) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
{_t("Got an account? <a>Sign in</a>", {}, {
a: sub => (
<AccessibleButton kind="link" onClick={this.onSignInClick}>
{sub}
</AccessibleButton>
),
})}
{_t("New here? <a>Create an account</a>", {}, {
a: sub => (
<AccessibleButton kind="link" onClick={this.onRegisterClick}>
{sub}
</AccessibleButton>
),
})}
</div>
)
} else if (signupLink) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_hostingLink">
{_t(
"<a>Upgrade</a> to your own domain", {},
@ -422,6 +451,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
</IconizedContextMenuOptionList>
</React.Fragment>
)
} else if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<React.Fragment>
<IconizedContextMenuOptionList>
{ homeButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
</IconizedContextMenuOptionList>
</React.Fragment>
);
}
const classes = classNames({
@ -451,7 +494,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</AccessibleTooltipButton>
</div>
{hostingLink}
{topSection}
{primaryOptionList}
{secondarySection}
</IconizedContextMenu>;

View file

@ -21,16 +21,14 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics";
import ServerPicker from "../../views/elements/ServerPicker";
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the forgot password inputs
const PHASE_FORGOT = 1;
// Email is in the process of being sent
@ -62,7 +60,6 @@ export default class ForgotPassword extends React.Component {
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
serverRequiresIdServer: null,
};
constructor(props) {
@ -93,12 +90,8 @@ export default class ForgotPassword extends React.Component {
serverConfig.isUrl,
);
const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl);
const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam();
this.setState({
serverIsAlive: true,
serverRequiresIdServer,
});
} catch (e) {
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
@ -177,20 +170,6 @@ export default class ForgotPassword extends React.Component {
});
};
onServerDetailsNextPhaseClick = async () => {
this.setState({
phase: PHASE_FORGOT,
});
};
onEditServerDetailsClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
};
onLoginClick = ev => {
ev.preventDefault();
ev.stopPropagation();
@ -205,24 +184,6 @@ export default class ForgotPassword extends React.Component {
});
}
renderServerDetails() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={0}
showIdentityServerIfRequiredByHomeserver={true}
onAfterSubmit={this.onServerDetailsNextPhaseClick}
submitText={_t("Next")}
submitClass="mx_Login_submit"
/>;
}
renderForgot() {
const Field = sdk.getComponent('elements.Field');
@ -246,57 +207,13 @@ export default class ForgotPassword extends React.Component {
);
}
let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
// If custom URLs are allowed, wire up the server details edit link.
let editLink = null;
if (!SdkConfig.get()['disable_custom_urls']) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) {
return <div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
{_t(
"No identity server is configured: " +
"add one in server settings to reset your password.",
)}
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{_t('Sign in instead')}
</a>
</div>;
}
return <div>
{errorText}
{serverDeadSection}
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
/>
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<Field
@ -319,6 +236,7 @@ export default class ForgotPassword extends React.Component {
onChange={this.onInputChanged.bind(this, "password")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
autoComplete="new-password"
/>
<Field
name="reset_password_confirm"
@ -328,6 +246,7 @@ export default class ForgotPassword extends React.Component {
onChange={this.onInputChanged.bind(this, "password2")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
autoComplete="new-password"
/>
</div>
<span>{_t(
@ -380,9 +299,6 @@ export default class ForgotPassword extends React.Component {
let resetPasswordJsx;
switch (this.state.phase) {
case PHASE_SERVER_DETAILS:
resetPasswordJsx = this.renderServerDetails();
break;
case PHASE_FORGOT:
resetPasswordJsx = this.renderForgot();
break;

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2015, 2016, 2017, 2018, 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,33 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, {ReactNode} from 'react';
import {MatrixError} from "matrix-js-sdk/src/http-api";
import {_t, _td} from '../../../languageHandler';
import * as sdk from '../../../index';
import Login from '../../../Login';
import Login, {ISSOFlow, LoginFlow} from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
import PlatformPeg from '../../../PlatformPeg';
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate login flow(s) for the server
const PHASE_LOGIN = 1;
// Enable phases for login
const PHASES_ENABLED = true;
import {IMatrixClientCreds} from "../../../MatrixClientPeg";
import PasswordLogin from "../../views/auth/PasswordLogin";
import InlineSpinner from "../../views/elements/InlineSpinner";
import Spinner from "../../views/elements/Spinner";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
// These are used in several places, and come from the js-sdk's autodiscovery
// stuff. We define them here so that they'll be picked up by i18n.
@ -55,64 +47,80 @@ _td("Invalid base_url for m.identity_server");
_td("Identity server URL does not appear to be a valid identity server");
_td("General failure");
interface IProps {
serverConfig: ValidatedServerConfig;
// If true, the component will consider itself busy.
busy?: boolean;
isSyncing?: boolean;
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
fallbackHsUrl?: string;
defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string;
// Called when the user has logged in. Params:
// - The object returned by the login API
// - The user's password, if applicable, (may be cached in memory for a
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(data: IMatrixClientCreds, password: string): void;
// login shouldn't know or care how registration, password recovery, etc is done.
onRegisterClick(): void;
onForgotPasswordClick?(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
interface IState {
busy: boolean;
busyLoggingIn?: boolean;
errorText?: ReactNode;
loginIncorrect: boolean;
// can we attempt to log in or are there validation errors?
canTryLogin: boolean;
flows?: LoginFlow[];
// used for preserving form values when changing homeserver
username: string;
phoneCountry?: string;
phoneNumber: string;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
}
/*
* A wire component which glues together login UI components and Login logic
*/
export default class LoginComponent extends React.Component {
static propTypes = {
// Called when the user has logged in. Params:
// - The object returned by the login API
// - The user's password, if applicable, (may be cached in memory for a
// short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired,
export default class LoginComponent extends React.PureComponent<IProps, IState> {
private unmounted = false;
private loginLogic: Login;
// If true, the component will consider itself busy.
busy: PropTypes.bool,
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
fallbackHsUrl: PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// login shouldn't know or care how registration, password recovery,
// etc is done.
onRegisterClick: PropTypes.func.isRequired,
onForgotPasswordClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
isSyncing: PropTypes.bool,
};
private readonly stepRendererMap: Record<string, () => ReactNode>;
constructor(props) {
super(props);
this._unmounted = false;
this.state = {
busy: false,
busyLoggingIn: null,
errorText: null,
loginIncorrect: false,
canTryLogin: true, // can we attempt to log in or are there validation errors?
canTryLogin: true,
flows: null,
// used for preserving form values when changing homeserver
username: "",
phoneCountry: null,
phoneNumber: "",
// Phase of the overall login dialog.
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: null, // we need to load the flows from the server
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
@ -120,12 +128,12 @@ export default class LoginComponent extends React.Component {
// map from login step type to a function which will render a control
// letting you do that login type
this._stepRendererMap = {
'm.login.password': this._renderPasswordStep,
this.stepRendererMap = {
'm.login.password': this.renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to
'm.login.cas': () => this._renderSsoStep("cas"),
'm.login.sso': () => this._renderSsoStep("sso"),
'm.login.cas': () => this.renderSsoStep("cas"),
'm.login.sso': () => this.renderSsoStep("sso"),
};
CountlyAnalytics.instance.track("onboarding_login_begin");
@ -134,11 +142,11 @@ export default class LoginComponent extends React.Component {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this._initLoginLogic();
this.initLoginLogic(this.props.serverConfig);
}
componentWillUnmount() {
this._unmounted = true;
this.unmounted = true;
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -148,16 +156,9 @@ export default class LoginComponent extends React.Component {
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Ensure that we end up actually logging in to the right place
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
this.initLoginLogic(newProps.serverConfig);
}
onPasswordLoginError = errorText => {
this.setState({
errorText,
loginIncorrect: Boolean(errorText),
});
};
isBusy = () => this.state.busy || this.props.busy;
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
@ -194,13 +195,13 @@ export default class LoginComponent extends React.Component {
loginIncorrect: false,
});
this._loginLogic.loginViaPassword(
this.loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data, password);
}, (error) => {
if (this._unmounted) {
if (this.unmounted) {
return;
}
let errorText;
@ -212,21 +213,23 @@ export default class LoginComponent extends React.Component {
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
error.data.admin_contact,
{
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
},
);
const errorDetail = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
error.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
},
);
errorText = (
<div>
<div>{errorTop}</div>
@ -253,7 +256,7 @@ export default class LoginComponent extends React.Component {
}
} else {
// other errors, not specific to doing a password login
errorText = this._errorTextFromError(error);
errorText = this.errorTextFromError(error);
}
this.setState({
@ -291,7 +294,7 @@ export default class LoginComponent extends React.Component {
// the busy state. In the case of a full MXID that resolves to the same
// HS as Element's default HS though, there may not be any server change.
// To avoid this trap, we clear busy here. For cases where the server
// actually has changed, `_initLoginLogic` will be called and manages
// actually has changed, `initLoginLogic` will be called and manages
// busy state for its own liveness check.
this.setState({
busy: false,
@ -304,7 +307,7 @@ export default class LoginComponent extends React.Component {
message = e.translatedMessage;
}
let errorText = message;
let errorText: ReactNode = message;
let discoveryState = {};
if (AutoDiscoveryUtils.isLivelinessError(e)) {
errorText = this.state.errorText;
@ -330,21 +333,6 @@ export default class LoginComponent extends React.Component {
});
};
onPhoneNumberBlur = phoneNumber => {
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({
errorText: _t('The phone number entered looks invalid'),
canTryLogin: false,
});
} else {
this.setState({
errorText: null,
canTryLogin: true,
});
}
};
onRegisterClick = ev => {
ev.preventDefault();
ev.stopPropagation();
@ -352,14 +340,16 @@ export default class LoginComponent extends React.Component {
};
onTryRegisterClick = ev => {
const step = this._getCurrentFlowStep();
if (step === 'm.login.sso' || step === 'm.login.cas') {
// If we're showing SSO it means that registration is also probably disabled,
// so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
const hasPasswordFlow = this.state.flows.find(flow => flow.type === "m.login.password");
const ssoFlow = this.state.flows.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas");
// If has no password flow but an SSO flow guess that the user wants to register with SSO.
// TODO: instead hide the Register button if registration is disabled by checking with the server,
// has no specific errCode currently and uses M_FORBIDDEN.
if (ssoFlow && !hasPasswordFlow) {
ev.preventDefault();
ev.stopPropagation();
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind,
const ssoKind = ssoFlow.type === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind,
this.props.fragmentAfterLogin);
} else {
// Don't intercept - just go through to the register page
@ -367,24 +357,7 @@ export default class LoginComponent extends React.Component {
}
};
onServerDetailsNextPhaseClick = () => {
this.setState({
phase: PHASE_LOGIN,
});
};
onEditServerDetailsClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
};
async _initLoginLogic(hsUrl, isUrl) {
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) {
let isDefaultServer = false;
if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl
@ -397,11 +370,10 @@ export default class LoginComponent extends React.Component {
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
this._loginLogic = loginLogic;
this.loginLogic = loginLogic;
this.setState({
busy: true,
currentFlow: null, // reset flow
loginIncorrect: false,
});
@ -425,42 +397,26 @@ export default class LoginComponent extends React.Component {
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e),
});
if (this.state.serverErrorIsFatal) {
// Server is dead: show server details prompt instead
this.setState({
phase: PHASE_SERVER_DETAILS,
});
return;
}
}
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) {
if (!this._isSupportedFlow(flows[i])) {
continue;
}
const supportedFlows = flows.filter(this.isSupportedFlow);
// we just pick the first flow where we support all the
// steps. (we don't have a UI for multiple logins so let's skip
// that for now).
loginLogic.chooseFlow(i);
if (supportedFlows.length > 0) {
this.setState({
currentFlow: this._getCurrentFlowStep(),
flows: supportedFlows,
});
return;
}
// we got to the end of the list without finding a suitable
// flow.
// we got to the end of the list without finding a suitable flow.
this.setState({
errorText: _t(
"This homeserver doesn't offer any login flows which are " +
"supported by this client.",
),
errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."),
});
}, (err) => {
this.setState({
errorText: this._errorTextFromError(err),
errorText: this.errorTextFromError(err),
loginIncorrect: false,
canTryLogin: false,
});
@ -471,28 +427,24 @@ export default class LoginComponent extends React.Component {
});
}
_isSupportedFlow(flow) {
private isSupportedFlow = (flow: LoginFlow): boolean => {
// technically the flow can have multiple steps, but no one does this
// for login and loginLogic doesn't support it so we can ignore it.
if (!this._stepRendererMap[flow.type]) {
if (!this.stepRendererMap[flow.type]) {
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
return false;
}
return true;
}
};
_getCurrentFlowStep() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
}
_errorTextFromError(err) {
private errorTextFromError(err: MatrixError): ReactNode {
let errCode = err.errcode;
if (!errCode && err.httpStatus) {
errCode = "HTTP " + err.httpStatus;
}
let errorText = _t("Error: Problem communicating with the given homeserver.") +
(errCode ? " (" + errCode + ")" : "");
let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " +
"please try again later.") + (errCode ? " (" + errCode + ")" : "");
if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' &&
@ -502,29 +454,27 @@ export default class LoginComponent extends React.Component {
errorText = <span>
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
</a>;
},
{
'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
</a>;
},
) }
}) }
</span>;
} else {
errorText = <span>
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " +
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
"is not blocking requests.", {},
{
'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>,
},
) }
{
'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>,
}) }
</span>;
}
}
@ -532,121 +482,63 @@ export default class LoginComponent extends React.Component {
return errorText;
}
renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
renderLoginComponentForFlows() {
if (!this.state.flows) return null;
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
// this is the ideal order we want to show the flows in
const order = [
"m.login.password",
"m.login.sso",
];
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
return null;
}
const serverDetailsProps = {};
if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
serverDetailsProps.submitClass = "mx_Login_submit";
}
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean);
return <React.Fragment>
{ flows.map(flow => {
const stepRenderer = this.stepRendererMap[flow.type];
return <React.Fragment key={flow.type}>{ stepRenderer() }</React.Fragment>
}) }
</React.Fragment>
}
renderLoginComponentForStep() {
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) {
return null;
}
const step = this.state.currentFlow;
if (!step) {
return null;
}
const stepRenderer = this._stepRendererMap[step];
if (stepRenderer) {
return stepRenderer();
}
return null;
}
_renderPasswordStep = () => {
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
private renderPasswordStep = () => {
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
onEditServerDetailsClick={onEditServerDetailsClick}
initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
onSubmit={this.onPasswordLogin}
username={this.state.username}
phoneCountry={this.state.phoneCountry}
phoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
/>
);
};
_renderSsoStep = loginType => {
const SignInToText = sdk.getComponent('views.auth.SignInToText');
private renderSsoStep = loginType => {
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// XXX: This link does *not* have a target="_blank" because single sign-on relies on
// redirecting the user back to a URI once they're logged in. On the web, this means
// we use the same window and redirect back to Element. On Electron, this actually
// opens the SSO page in the Electron app itself due to
// https://github.com/electron/electron/issues/8841 and so happens to work.
// If this bug gets fixed, it will break SSO since it will open the SSO page in the
// user's browser, let them log into their SSO provider, then redirect their browser
// to vector://vector which, of course, will not work.
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={onEditServerDetailsClick} />
<SSOButton
className="mx_Login_sso_link mx_Login_submit"
matrixClient={this._loginLogic.createTemporaryClient()}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
</div>
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
/>
);
};
render() {
const Loader = sdk.getComponent("elements.Spinner");
const InlineSpinner = sdk.getComponent("elements.InlineSpinner");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Loader /></div> : null;
<div className="mx_Login_loader"><Spinner /></div> : null;
const errorText = this.state.errorText;
@ -686,9 +578,11 @@ export default class LoginComponent extends React.Component {
</div>;
} else if (SettingsStore.getValue(UIFeature.Registration)) {
footer = (
<a className="mx_AuthBody_changeFlow" onClick={this.onTryRegisterClick} href="#">
{ _t('Create account') }
</a>
<span className="mx_AuthBody_changeFlow">
{_t("New? <a>Create account</a>", {}, {
a: sub => <a onClick={this.onTryRegisterClick} href="#">{ sub }</a>,
})}
</span>
);
}
@ -702,8 +596,11 @@ export default class LoginComponent extends React.Component {
</h2>
{ errorTextSection }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
/>
{ this.renderLoginComponentForFlows() }
{ footer }
</AuthBody>
</AuthPage>

View file

@ -1,77 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import AuthPage from "../../views/auth/AuthPage";
export default class PostRegistration extends React.Component {
static propTypes = {
onComplete: PropTypes.func.isRequired,
};
state = {
avatarUrl: null,
errorString: null,
busy: false,
};
componentDidMount() {
// There is some assymetry between ChangeDisplayName and ChangeAvatar,
// as ChangeDisplayName will auto-get the name but ChangeAvatar expects
// the URL to be passed to you (because it's also used for room avatars).
const cli = MatrixClientPeg.get();
this.setState({busy: true});
const self = this;
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
self.setState({
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
busy: false,
});
}, function(error) {
self.setState({
errorString: _t("Failed to fetch avatar URL"),
busy: false,
});
});
}
render() {
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
const AuthHeader = sdk.getComponent('auth.AuthHeader');
const AuthBody = sdk.getComponent("auth.AuthBody");
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<div className="mx_Login_profile">
{ _t('Set a display name:') }
<ChangeDisplayName />
{ _t('Upload an avatar:') }
<ChangeAvatar
initialAvatarUrl={this.state.avatarUrl} />
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
{ this.state.errorString }
</div>
</AuthBody>
</AuthPage>
);
}
}

View file

@ -1,8 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,109 +15,128 @@ limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import React from 'react';
import PropTypes from 'prop-types';
import React, {ReactNode} from 'react';
import {MatrixClient} from "matrix-js-sdk/src/client";
import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login from "../../../Login";
import Login, {ISSOFlow} from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from '../../views/elements/ServerPicker';
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate registration flow(s) for the server
const PHASE_REGISTRATION = 1;
interface IProps {
serverConfig: ValidatedServerConfig;
defaultDeviceDisplayName: string;
email?: string;
brand?: string;
clientSecret?: string;
sessionId?: string;
idSid?: string;
fragmentAfterLogin?: string;
// Enable phases for registration
const PHASES_ENABLED = true;
// Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(params: {
userId: string;
deviceId: string
homeserverUrl: string;
identityServerUrl?: string;
accessToken: string;
}, password: string): void;
makeRegistrationUrl(params: {
/* eslint-disable camelcase */
client_secret: string;
hs_url: string;
is_url?: string;
session_id: string;
/* eslint-enable camelcase */
}): void;
// registration shouldn't know or care how login is done.
onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
export default class Registration extends React.Component {
static propTypes = {
// Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn: PropTypes.func.isRequired,
interface IState {
busy: boolean;
errorText?: ReactNode;
// true if we're waiting for the user to complete
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: Record<string, string>;
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: boolean;
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: boolean;
flows: {
stages: string[];
}[];
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
clientSecret: PropTypes.string,
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
brand: PropTypes.string,
email: PropTypes.string,
// registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired,
onServerConfigChange: PropTypes.func.isRequired,
defaultDeviceDisplayName: PropTypes.string,
};
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient?: MatrixClient;
// The user ID we've just registered
registeredUsername?: string;
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId?: string;
// the SSO flow definition, this is fetched from /login as that's the only
// place it is exposed.
ssoFlow?: ISSOFlow;
}
export default class Registration extends React.Component<IProps, IState> {
loginLogic: Login;
constructor(props) {
super(props);
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
this.state = {
busy: false,
errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: {
email: this.props.email,
},
// true if we're waiting for the user to complete
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
serverType,
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
flows: null,
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: false,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient: null,
// whether the HS requires an ID server to register with a threepid
serverRequiresIdServer: null,
// The user ID we've just registered
registeredUsername: null,
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId: null,
};
const {hsUrl, isUrl} = this.props.serverConfig;
this.loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
});
}
componentDidMount() {
this._unmounted = false;
this._replaceClient();
this.replaceClient(this.props.serverConfig);
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -129,63 +145,10 @@ export default class Registration extends React.Component {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
this._replaceClient(newProps.serverConfig);
// Handle cases where the user enters "https://matrix.org" for their server
// from the advanced option - we should default to FREE at that point.
const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig);
if (serverType !== this.state.serverType) {
// Reset the phase to default phase for the server type.
this.setState({
serverType,
phase: this.getDefaultPhaseForServerType(serverType),
});
}
this.replaceClient(newProps.serverConfig);
}
getDefaultPhaseForServerType(type) {
switch (type) {
case ServerType.FREE: {
// Move directly to the registration phase since the server
// details are fixed.
return PHASE_REGISTRATION;
}
case ServerType.PREMIUM:
case ServerType.ADVANCED:
return PHASE_SERVER_DETAILS;
}
}
onServerTypeChange = type => {
this.setState({
serverType: type,
});
// When changing server types, set the HS / IS URLs to reasonable defaults for the
// the new type.
switch (type) {
case ServerType.FREE: {
const { serverConfig } = ServerType.TYPES.FREE;
this.props.onServerConfigChange(serverConfig);
break;
}
case ServerType.PREMIUM:
// We can accept whatever server config was the default here as this essentially
// acts as a slightly different "custom server"/ADVANCED option.
break;
case ServerType.ADVANCED:
// Use the default config from the config
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
break;
}
// Reset the phase to default phase for the server type.
this.setState({
phase: this.getDefaultPhaseForServerType(type),
});
};
async _replaceClient(serverConfig) {
private async replaceClient(serverConfig: ValidatedServerConfig) {
this.setState({
errorText: null,
serverDeadError: null,
@ -194,7 +157,6 @@ export default class Registration extends React.Component {
// the UI auth component while we don't have a matrix client)
busy: true,
});
if (!serverConfig) serverConfig = this.props.serverConfig;
// Do a liveliness check on the URLs
try {
@ -222,16 +184,20 @@ export default class Registration extends React.Component {
idBaseUrl: isUrl,
});
let serverRequiresIdServer = true;
this.loginLogic.setHomeserverUrl(hsUrl);
this.loginLogic.setIdentityServerUrl(isUrl);
let ssoFlow: ISSOFlow;
try {
serverRequiresIdServer = await cli.doesServerRequireIdServerParam();
const loginFlows = await this.loginLogic.getFlows();
ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow;
} catch (e) {
console.log("Unable to determine is server needs id_server param", e);
console.error("Failed to get login flows to check for SSO support", e);
}
this.setState({
matrixClient: cli,
serverRequiresIdServer,
ssoFlow,
busy: false,
});
const showGenericError = (e) => {
@ -246,7 +212,7 @@ export default class Registration extends React.Component {
// do SSO instead. If we've already started the UI Auth process though, we don't
// need to.
if (!this.state.doingUIAuth) {
await this._makeRegisterRequest(null);
await this.makeRegisterRequest(null);
// This should never succeed since we specified no auth object.
console.log("Expecting 401 from register request but got success!");
}
@ -259,26 +225,16 @@ export default class Registration extends React.Component {
// At this point registration is pretty much disabled, but before we do that let's
// quickly check to see if the server supports SSO instead. If it does, we'll send
// the user off to the login page to figure their account out.
try {
const loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
if (ssoFlow) {
// Redirect to login page - server probably expects SSO only
dis.dispatch({action: 'start_login'});
} else {
this.setState({
serverErrorIsFatal: true, // fatal because user cannot continue on this server
errorText: _t("Registration has been disabled on this homeserver."),
// add empty flows array to get rid of spinner
flows: [],
});
const flows = await loginLogic.getFlows();
const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas');
if (hasSsoFlow) {
// Redirect to login page - server probably expects SSO only
dis.dispatch({action: 'start_login'});
} else {
this.setState({
serverErrorIsFatal: true, // fatal because user cannot continue on this server
errorText: _t("Registration has been disabled on this homeserver."),
// add empty flows array to get rid of spinner
flows: [],
});
}
} catch (e) {
console.error("Failed to get login flows to check for SSO support", e);
showGenericError(e);
}
} else {
console.log("Unable to query for supported registration methods.", e);
@ -287,7 +243,7 @@ export default class Registration extends React.Component {
}
}
onFormSubmit = formVals => {
private onFormSubmit = formVals => {
this.setState({
errorText: "",
busy: true,
@ -296,7 +252,7 @@ export default class Registration extends React.Component {
});
};
_requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
return this.state.matrixClient.requestRegisterEmailToken(
emailAddress,
clientSecret,
@ -310,28 +266,26 @@ export default class Registration extends React.Component {
);
}
_onUIAuthFinished = async (success, response, extra) => {
private onUIAuthFinished = async (success, response, extra) => {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
response.data.admin_contact,
{
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
'': _td("This homeserver has exceeded one of its resource limits."),
},
);
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
response.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
},
);
msg = <div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
@ -339,11 +293,13 @@ export default class Registration extends React.Component {
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false;
for (const flow of response.available_flows) {
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn');
}
if (!msisdnAvailable) {
msg = _t('This server does not support authentication with a phone number.');
}
} else if (response.errcode === "M_USER_IN_USE") {
msg = _t("That username already exists, please try another.");
}
this.setState({
busy: false,
@ -358,6 +314,10 @@ export default class Registration extends React.Component {
const newState = {
doingUIAuth: false,
registeredUsername: response.user_id,
differentLoggedInUserId: null,
completedNoSignin: false,
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
};
// The user came in through an email validation link. To avoid overwriting
@ -372,8 +332,6 @@ export default class Registration extends React.Component {
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
);
newState.differentLoggedInUserId = sessionOwner;
} else {
newState.differentLoggedInUserId = null;
}
if (response.access_token) {
@ -385,9 +343,7 @@ export default class Registration extends React.Component {
accessToken: response.access_token,
}, this.state.formVals.password);
this._setupPushers();
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
this.setupPushers();
} else {
newState.busy = false;
newState.completedNoSignin = true;
@ -396,7 +352,7 @@ export default class Registration extends React.Component {
this.setState(newState);
};
_setupPushers() {
private setupPushers() {
if (!this.props.brand) {
return Promise.resolve();
}
@ -419,38 +375,23 @@ export default class Registration extends React.Component {
});
}
onLoginClick = ev => {
private onLoginClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
onGoToFormClicked = ev => {
private onGoToFormClicked = ev => {
ev.preventDefault();
ev.stopPropagation();
this._replaceClient();
this.replaceClient(this.props.serverConfig);
this.setState({
busy: false,
doingUIAuth: false,
phase: PHASE_REGISTRATION,
});
};
onServerDetailsNextPhaseClick = async () => {
this.setState({
phase: PHASE_REGISTRATION,
});
};
onEditServerDetailsClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
};
_makeRegisterRequest = auth => {
private makeRegisterRequest = auth => {
// We inhibit login if we're trying to register with an email address: this
// avoids a lot of complex race conditions that can occur if we try to log
// the user in one one or both of the tabs they might end up with after
@ -466,13 +407,15 @@ export default class Registration extends React.Component {
username: this.state.formVals.username,
password: this.state.formVals.password,
initial_device_display_name: this.props.defaultDeviceDisplayName,
auth: undefined,
inhibit_login: undefined,
};
if (auth) registerParams.auth = auth;
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
return this.state.matrixClient.registerRequest(registerParams);
};
_getUIAuthInputs() {
private getUIAuthInputs() {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
@ -483,7 +426,7 @@ export default class Registration extends React.Component {
// Links to the login page shown after registration is completed are routed through this
// which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?)
_onLoginClickWithCheck = async ev => {
private onLoginClickWithCheck = async ev => {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
@ -493,72 +436,7 @@ export default class Registration extends React.Component {
}
};
renderServerComponent() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
// If we're on a different phase, we only show the server type selector,
// which is always shown if we allow custom URLs at all.
// (if there's a fatal server error, we need to show the full server
// config as the user may need to change servers to resolve the error).
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
return <div>
<ServerTypeSelector
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
</div>;
}
const serverDetailsProps = {};
if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
serverDetailsProps.submitClass = "mx_Login_submit";
}
let serverDetails = null;
switch (this.state.serverType) {
case ServerType.FREE:
break;
case ServerType.PREMIUM:
serverDetails = <ModularServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
break;
case ServerType.ADVANCED:
serverDetails = <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
showIdentityServerIfRequiredByHomeserver={true}
{...serverDetailsProps}
/>;
break;
}
return <div>
<ServerTypeSelector
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
{serverDetails}
</div>;
}
renderRegisterComponent() {
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) {
return null;
}
private renderRegisterComponent() {
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent('elements.Spinner');
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
@ -566,10 +444,10 @@ export default class Registration extends React.Component {
if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth
matrixClient={this.state.matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
requestEmailToken={this._requestEmailToken}
makeRequest={this.makeRegisterRequest}
onAuthFinished={this.onUIAuthFinished}
inputs={this.getUIAuthInputs()}
requestEmailToken={this.requestEmailToken}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
@ -582,30 +460,48 @@ export default class Registration extends React.Component {
<Spinner />
</div>;
} else if (this.state.flows.length) {
let onEditServerDetailsClick = null;
// If custom URLs are allowed and we haven't selected the Free server type, wire
// up the server details edit link.
if (
PHASES_ENABLED &&
!SdkConfig.get()['disable_custom_urls'] &&
this.state.serverType !== ServerType.FREE
) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
let ssoSection;
if (this.state.ssoFlow) {
let continueWithSection;
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"]
|| this.state.ssoFlow["identity_providers"] || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context
continueWithSection = <h3 className="mx_AuthBody_centered">
{ _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() }
</h3>;
}
// i18n: ssoButtons & usernamePassword are placeholders to help translators understand context
ssoSection = <React.Fragment>
{ continueWithSection }
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={this.state.ssoFlow}
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
<h3 className="mx_AuthBody_centered">
{ _t("%(ssoButtons)s Or %(usernamePassword)s", { ssoButtons: "", usernamePassword: ""}).trim() }
</h3>
</React.Fragment>;
}
return <RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
serverRequiresIdServer={this.state.serverRequiresIdServer}
/>;
return <React.Fragment>
{ ssoSection }
<RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
onRegisterClick={this.onFormSubmit}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
/>
</React.Fragment>;
}
}
@ -634,13 +530,15 @@ export default class Registration extends React.Component {
);
}
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>;
const signIn = <span className="mx_AuthBody_changeFlow">
{_t("Already have an account? <a>Sign in here</a>", {}, {
a: sub => <a onClick={this.onLoginClick} href="#">{ sub }</a>,
})}
</span>;
// Only show the 'go back' button if you're not looking at the form
let goBack;
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) {
if (this.state.doingUIAuth) {
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
{ _t('Go back') }
</a>;
@ -658,7 +556,7 @@ export default class Registration extends React.Component {
loggedInUserId: this.state.differentLoggedInUserId,
},
)}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
{_t("Continue with previous account")}
</AccessibleButton></p>
</div>;
@ -667,7 +565,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
},
)}</h3>;
} else {
@ -677,7 +575,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t(
"You can now close this window or <a>log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
},
)}</h3>;
}
@ -687,10 +585,15 @@ export default class Registration extends React.Component {
</div>;
} else {
body = <div>
<h2>{ _t('Create your account') }</h2>
<h2>{ _t('Create account') }</h2>
{ errorText }
{ serverDeadSection }
{ this.renderServerComponent() }
<ServerPicker
title={_t("Host account on")}
dialogTitle={_t("Decide where your account is hosted")}
serverConfig={this.props.serverConfig}
onServerConfigChange={this.state.doingUIAuth ? undefined : this.props.onServerConfigChange}
/>
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }

View file

@ -24,8 +24,8 @@ import Modal from '../../../Modal';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login";
import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
import SSOButtons from "../../views/elements/SSOButtons";
const LOGIN_VIEW = {
LOADING: 1,
@ -101,10 +101,11 @@ export default class SoftLogout extends React.Component {
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
// care about login flows here, unless it is the single flow we support.
const client = MatrixClientPeg.get();
const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]);
const flows = (await client.loginFlows()).flows;
const loginViews = flows.map(f => FLOWS_TO_VIEWS[f.type]);
const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED;
this.setState({loginView: chosenView});
this.setState({ flows, loginView: chosenView });
}
onPasswordChange = (ev) => {
@ -240,13 +241,18 @@ export default class SoftLogout extends React.Component {
introText = _t("Sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning)
const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso";
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType);
return (
<div>
<p>{introText}</p>
<SSOButton
<SSOButtons
matrixClient={MatrixClientPeg.get()}
loginType={this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
/>
</div>
);

View file

@ -102,6 +102,10 @@ export default class CaptchaForm extends React.Component {
console.log("Loaded recaptcha script.");
try {
this._renderRecaptcha(DIV_ID);
// clear error if re-rendered
this.setState({
errorText: null,
});
CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded");
} catch (e) {
this.setState({

View file

@ -1,47 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
export default class CustomServerDialog extends React.Component {
render() {
const brand = SdkConfig.get().brand;
return (
<div className="mx_ErrorDialog">
<div className="mx_Dialog_title">
{ _t("Custom Server Options") }
</div>
<div className="mx_Dialog_content">
<p>{_t(
"You can use the custom server options to sign into other " +
"Matrix servers by specifying a different homeserver URL. This " +
"allows you to use %(brand)s with an existing Matrix account on a " +
"different homeserver.",
{ brand },
)}</p>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished} autoFocus={true}>
{ _t("Dismiss") }
</button>
</div>
</div>
);
}
}

View file

@ -18,7 +18,6 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import url from 'url';
import classnames from 'classnames';
import * as sdk from '../../../index';
@ -421,12 +420,12 @@ export class EmailIdentityAuthEntry extends React.Component {
return <Spinner />;
} else {
return (
<div>
<p>{ _t("An email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <i>{ this.props.inputs.emailAddress }</i> },
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
<p>{ _t("A confirmation email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <b>{ this.props.inputs.emailAddress }</b> },
) }
</p>
<p>{ _t("Please check your email to continue registration.") }</p>
<p>{ _t("Open the link in the email to continue registration.") }</p>
</div>
);
}
@ -500,17 +499,11 @@ export class MsisdnAuthEntry extends React.Component {
});
try {
const requiresIdServerParam =
await this.props.matrixClient.doesServerRequireIdServerParam();
let result;
if (this._submitUrl) {
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
);
} else if (requiresIdServerParam) {
result = await this.props.matrixClient.submitMsisdnToken(
this._sid, this.props.clientSecret, this.state.token,
);
} else {
throw new Error("The registration with MSISDN flow is misconfigured");
}
@ -519,12 +512,6 @@ export class MsisdnAuthEntry extends React.Component {
sid: this._sid,
client_secret: this.props.clientSecret,
};
if (requiresIdServerParam) {
const idServerParsedUrl = url.parse(
this.props.matrixClient.getIdentityServerUrl(),
);
creds.id_server = idServerParsedUrl.host;
}
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
// TODO: Remove `threepid_creds` once servers support proper UIA

View file

@ -1,124 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import * as ServerType from '../../views/auth/ServerTypeSelector';
import ServerConfig from "./ServerConfig";
const MODULAR_URL = 'https://element.io/matrix-services' +
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
// TODO: TravisR - Can this extend ServerConfig for most things?
/*
* Configure the Modular server name.
*
* This is a variant of ServerConfig with only the HS field and different body
* text that is specific to the Modular case.
*/
export default class ModularServerConfig extends ServerConfig {
static propTypes = ServerConfig.propTypes;
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
this.setState({
hsUrl,
isUrl,
busy: true,
errorText: "",
});
try {
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(result);
return result;
} catch (e) {
console.error(e);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
return null;
}
}
async validateServer() {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl);
}
render() {
const Field = sdk.getComponent('elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const submitButton = this.props.submitText
? <AccessibleButton
element="button"
type="submit"
className={this.props.submitClass}
onClick={this.onSubmit}
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
: null;
return (
<div className="mx_ServerConfig">
<h3>{_t("Your server")}</h3>
{_t(
"Enter the location of your Element Matrix Services homeserver. It may use your own " +
"domain name or be a subdomain of <a>element.io</a>.",
{}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
{sub}
</a>,
},
)}
<form onSubmit={this.onSubmit} autoComplete="off" action={null}>
<div className="mx_ServerConfig_fields">
<Field
id="mx_ServerConfig_hsUrl"
label={_t("Server Name")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
/>
</div>
{submitButton}
</form>
</div>
);
}
}

View file

@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn";
import SdkConfig from "../../../SdkConfig";
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
import {_t, _td} from "../../../languageHandler";
import Field from "../elements/Field";
import Field, {IInputProps} from "../elements/Field";
interface IProps {
interface IProps extends Omit<IInputProps, "onValidate"> {
autoFocus?: boolean;
id?: string;
className?: string;

View file

@ -1,377 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
/**
* A pure UI component which displays a username/password form.
*/
export default class PasswordLogin extends React.Component {
static propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onEditServerDetailsClick: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,
initialPhoneNumber: PropTypes.string,
initialPassword: PropTypes.string,
onUsernameChanged: PropTypes.func,
onPhoneCountryChanged: PropTypes.func,
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
disableSubmit: PropTypes.bool,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
busy: PropTypes.bool,
};
static defaultProps = {
onError: function() {},
onEditServerDetailsClick: null,
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
onPhoneNumberBlur: function() {},
initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
disableSubmit: false,
};
static LOGIN_FIELD_EMAIL = "login_field_email";
static LOGIN_FIELD_MXID = "login_field_mxid";
static LOGIN_FIELD_PHONE = "login_field_phone";
constructor(props) {
super(props);
this.state = {
username: this.props.initialUsername,
password: this.props.initialPassword,
phoneCountry: this.props.initialPhoneCountry,
phoneNumber: this.props.initialPhoneNumber,
loginType: PasswordLogin.LOGIN_FIELD_MXID,
};
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
this.onSubmitForm = this.onSubmitForm.bind(this);
this.onUsernameChanged = this.onUsernameChanged.bind(this);
this.onUsernameBlur = this.onUsernameBlur.bind(this);
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this);
this.onPasswordChanged = this.onPasswordChanged.bind(this);
this.isLoginEmpty = this.isLoginEmpty.bind(this);
}
onForgotPasswordClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
}
onSubmitForm(ev) {
ev.preventDefault();
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
let error;
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
username = this.state.username;
if (!username) {
error = _t('The email field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_MXID:
username = this.state.username;
if (!username) {
error = _t('The username field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_PHONE:
phoneCountry = this.state.phoneCountry;
phoneNumber = this.state.phoneNumber;
if (!phoneNumber) {
error = _t('The phone number field must not be blank.');
}
break;
}
if (error) {
this.props.onError(error);
return;
}
if (!this.state.password) {
this.props.onError(_t('The password field must not be blank.'));
return;
}
this.props.onSubmit(
username,
phoneCountry,
phoneNumber,
this.state.password,
);
}
onUsernameChanged(ev) {
this.setState({username: ev.target.value});
this.props.onUsernameChanged(ev.target.value);
}
onUsernameFocus() {
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) {
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_focus");
}
}
onUsernameBlur(ev) {
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) {
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_blur");
}
this.props.onUsernameBlur(ev.target.value);
}
onLoginTypeChange(ev) {
const loginType = ev.target.value;
this.props.onError(null); // send a null error to clear any error messages
this.setState({
loginType: loginType,
username: "", // Reset because email and username use the same state
});
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
}
onPhoneCountryChanged(country) {
this.setState({
phoneCountry: country.iso2,
phonePrefix: country.prefix,
});
this.props.onPhoneCountryChanged(country.iso2);
}
onPhoneNumberChanged(ev) {
this.setState({phoneNumber: ev.target.value});
this.props.onPhoneNumberChanged(ev.target.value);
}
onPhoneNumberFocus() {
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
}
onPhoneNumberBlur(ev) {
this.props.onPhoneNumberBlur(ev.target.value);
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
}
onPasswordChanged(ev) {
this.setState({password: ev.target.value});
this.props.onPasswordChanged(ev.target.value);
}
renderLoginField(loginType, autoFocus) {
const Field = sdk.getComponent('elements.Field');
const classes = {};
switch (loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.state.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.state.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
}
}
}
isLoginEmpty() {
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
case PasswordLogin.LOGIN_FIELD_MXID:
return !this.state.username;
case PasswordLogin.LOGIN_FIELD_PHONE:
return !this.state.phoneCountry || !this.state.phoneNumber;
}
}
render() {
const Field = sdk.getComponent('elements.Field');
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
// If login is empty, autoFocus login, otherwise autoFocus password.
// this is for when auto server discovery remounts us when the user tries to tab from username to password
const autoFocusPassword = !this.isLoginEmpty();
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option
key={PasswordLogin.LOGIN_FIELD_MXID}
value={PasswordLogin.LOGIN_FIELD_MXID}
>
{_t('Username')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_EMAIL}
value={PasswordLogin.LOGIN_FIELD_EMAIL}
>
{_t('Email address')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_PHONE}
value={PasswordLogin.LOGIN_FIELD_PHONE}
>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -0,0 +1,485 @@
/*
Copyright 2015, 2016, 2017, 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
interface IProps {
username: string; // also used for email address
phoneCountry: string;
phoneNumber: string;
serverConfig: ValidatedServerConfig;
loginIncorrect?: boolean;
disableSubmit?: boolean;
busy?: boolean;
onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void;
onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void;
onUsernameChanged?(username: string): void;
onUsernameBlur?(username: string): void;
onPhoneCountryChanged?(phoneCountry: string): void;
onPhoneNumberChanged?(phoneNumber: string): void;
onForgotPasswordClick?(): void;
}
interface IState {
fieldValid: Partial<Record<LoginField, boolean>>;
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone,
password: "",
}
enum LoginField {
Email = "login_field_email",
MatrixId = "login_field_mxid",
Phone = "login_field_phone",
Password = "login_field_phone",
}
/*
* A pure UI component which displays a username/password form.
* The email/username/phone fields are fully-controlled, the password field is not.
*/
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
static defaultProps = {
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
loginIncorrect: false,
disableSubmit: false,
};
constructor(props) {
super(props);
this.state = {
// Field error codes by field ID
fieldValid: {},
loginType: LoginField.MatrixId,
password: "",
};
}
private onForgotPasswordClick = ev => {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
};
private onSubmitForm = async ev => {
ev.preventDefault();
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return;
}
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
username = this.props.username;
break;
case LoginField.Phone:
phoneCountry = this.props.phoneCountry;
phoneNumber = this.props.phoneNumber;
break;
}
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
};
private onUsernameChanged = ev => {
this.props.onUsernameChanged(ev.target.value);
};
private onUsernameFocus = () => {
if (this.state.loginType === LoginField.MatrixId) {
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_focus");
}
};
private onUsernameBlur = ev => {
if (this.state.loginType === LoginField.MatrixId) {
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
} else {
CountlyAnalytics.instance.track("onboarding_login_email_blur");
}
this.props.onUsernameBlur(ev.target.value);
};
private onLoginTypeChange = ev => {
const loginType = ev.target.value;
this.setState({ loginType });
this.props.onUsernameChanged(""); // Reset because email and username use the same state
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
};
private onPhoneCountryChanged = country => {
this.props.onPhoneCountryChanged(country.iso2);
};
private onPhoneNumberChanged = ev => {
this.props.onPhoneNumberChanged(ev.target.value);
};
private onPhoneNumberFocus = () => {
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
};
private onPhoneNumberBlur = ev => {
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
};
private onPasswordChanged = ev => {
this.setState({password: ev.target.value});
};
private async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
this.state.loginType,
LoginField.Password,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
}
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
}
private findFirstInvalidField(fieldIDs: LoginField[]) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
}
return null;
}
private markFieldValid(fieldID: LoginField, valid: boolean) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
}
private validateUsernameRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter username"),
},
],
});
private onUsernameValidate = async (fieldState) => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(LoginField.MatrixId, result.valid);
return result;
};
private validateEmailRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter email address"),
}, {
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
private onEmailValidate = async (fieldState) => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(LoginField.Email, result.valid);
return result;
};
private validatePhoneNumberRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter phone number"),
}, {
key: "number",
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
},
],
});
private onPhoneNumberValidate = async (fieldState) => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
};
private validatePasswordRules = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter password"),
},
],
});
private onPasswordValidate = async (fieldState) => {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
}
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
const classes = {
error: false,
};
switch (loginType) {
case LoginField.Email:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onEmailValidate}
ref={field => this[LoginField.Email] = field}
/>;
case LoginField.MatrixId:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
placeholder={_t("Username").toLocaleLowerCase()}
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onUsernameValidate}
ref={field => this[LoginField.MatrixId] = field}
/>;
case LoginField.Phone: {
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.props.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.props.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onPhoneNumberValidate}
ref={field => this[LoginField.Password] = field}
/>;
}
}
}
private isLoginEmpty() {
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
return !this.props.username;
case LoginField.Phone:
return !this.props.phoneCountry || !this.props.phoneNumber;
}
}
render() {
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{_t("Forgot password?")}
</AccessibleButton>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
// If login is empty, autoFocus login, otherwise autoFocus password.
// this is for when auto server discovery remounts us when the user tries to tab from username to password
const autoFocusPassword = !this.isLoginEmpty();
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
{_t('Username')}
</option>
<option
key={LoginField.Email}
value={LoginField.Email}
>
{_t('Email address')}
</option>
<option key={LoginField.Password} value={LoginField.Password}>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
onValidate={this.onPasswordValidate}
ref={field => this[LoginField.Password] = field}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -1,8 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
@ -30,34 +28,59 @@ import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics";
import Field from '../elements/Field';
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number';
const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
enum RegistrationField {
Email = "field_email",
PhoneNumber = "field_phone_number",
Username = "field_username",
Password = "field_password",
PasswordConfirm = "field_password_confirm",
}
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
interface IProps {
// Values pre-filled in the input boxes when the component loads
defaultEmail?: string;
defaultPhoneCountry?: string;
defaultPhoneNumber?: string;
defaultUsername?: string;
defaultPassword?: string;
flows: {
stages: string[];
}[];
serverConfig: ValidatedServerConfig;
canSubmit?: boolean;
onRegisterClick(params: {
username: string;
password: string;
email?: string;
phoneCountry?: string;
phoneNumber?: string;
}): Promise<void>;
onEditServerDetailsClick?(): void;
}
interface IState {
// Field error codes by field ID
fieldValid: Partial<Record<RegistrationField, boolean>>;
// The ISO2 country code selected in the phone number entry
phoneCountry: string;
username: string;
email: string;
phoneNumber: string;
password: string;
passwordConfirm: string;
passwordComplexity?: number;
}
/*
* A pure UI component which displays a registration form.
*/
export default class RegistrationForm extends React.Component {
static propTypes = {
// Values pre-filled in the input boxes when the component loads
defaultEmail: PropTypes.string,
defaultPhoneCountry: PropTypes.string,
defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
canSubmit: PropTypes.bool,
serverRequiresIdServer: PropTypes.bool,
};
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
static defaultProps = {
onValidationChange: console.error,
canSubmit: true,
@ -67,9 +90,7 @@ export default class RegistrationForm extends React.Component {
super(props);
this.state = {
// Field error codes by field ID
fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
username: this.props.defaultUsername || "",
email: this.props.defaultEmail || "",
@ -82,8 +103,9 @@ export default class RegistrationForm extends React.Component {
CountlyAnalytics.instance.track("onboarding_registration_begin");
}
onSubmit = async ev => {
private onSubmit = async ev => {
ev.preventDefault();
ev.persist();
if (!this.props.canSubmit) return;
@ -93,46 +115,31 @@ export default class RegistrationForm extends React.Component {
return;
}
const self = this;
if (this.state.email === '') {
const haveIs = Boolean(this.props.serverConfig.isUrl);
let desc;
if (this.props.serverRequiresIdServer && !haveIs) {
desc = _t(
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
);
} else if (this._showEmail()) {
desc = _t(
"If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?",
);
if (this.showEmail()) {
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
Modal.createTrackedDialog("Email prompt dialog", '', RegistrationEmailPromptDialog, {
onFinished: async (confirmed: boolean, email?: string) => {
if (confirmed) {
this.setState({
email,
}, () => {
this.doSubmit(ev);
});
}
},
});
} else {
// user can't set an e-mail so don't prompt them to
self._doSubmit(ev);
this.doSubmit(ev);
return;
}
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"),
description: desc,
button: _t("Continue"),
onFinished(confirmed) {
if (confirmed) {
self._doSubmit(ev);
}
},
});
} else {
self._doSubmit(ev);
this.doSubmit(ev);
}
};
_doSubmit(ev) {
private doSubmit(ev) {
const email = this.state.email.trim();
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
@ -155,20 +162,20 @@ export default class RegistrationForm extends React.Component {
}
}
async verifyFieldsBeforeSubmit() {
private async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement;
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
FIELD_USERNAME,
FIELD_PASSWORD,
FIELD_PASSWORD_CONFIRM,
FIELD_EMAIL,
FIELD_PHONE_NUMBER,
RegistrationField.Username,
RegistrationField.Password,
RegistrationField.PasswordConfirm,
RegistrationField.Email,
RegistrationField.PhoneNumber,
];
// Run all fields with stricter validation that no longer allows empty
@ -209,7 +216,7 @@ export default class RegistrationForm extends React.Component {
/**
* @returns {boolean} true if all fields were valid last time they were validated.
*/
allFieldsValid() {
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
@ -219,7 +226,7 @@ export default class RegistrationForm extends React.Component {
return true;
}
findFirstInvalidField(fieldIDs) {
private findFirstInvalidField(fieldIDs: RegistrationField[]) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
@ -228,7 +235,7 @@ export default class RegistrationForm extends React.Component {
return null;
}
markFieldValid(fieldID, valid) {
private markFieldValid(fieldID: RegistrationField, valid: boolean) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
@ -236,26 +243,26 @@ export default class RegistrationForm extends React.Component {
});
}
onEmailChange = ev => {
private onEmailChange = ev => {
this.setState({
email: ev.target.value,
});
};
onEmailValidate = async fieldState => {
private onEmailValidate = async fieldState => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid);
this.markFieldValid(RegistrationField.Email, result.valid);
return result;
};
validateEmailRules = withValidation({
private validateEmailRules = withValidation({
description: () => _t("Use an email address to recover your account"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
},
invalid: () => _t("Enter email address (required on this homeserver)"),
},
@ -267,29 +274,29 @@ export default class RegistrationForm extends React.Component {
],
});
onPasswordChange = ev => {
private onPasswordChange = ev => {
this.setState({
password: ev.target.value,
});
};
onPasswordValidate = result => {
this.markFieldValid(FIELD_PASSWORD, result.valid);
private onPasswordValidate = result => {
this.markFieldValid(RegistrationField.Password, result.valid);
};
onPasswordConfirmChange = ev => {
private onPasswordConfirmChange = ev => {
this.setState({
passwordConfirm: ev.target.value,
});
};
onPasswordConfirmValidate = async fieldState => {
private onPasswordConfirmValidate = async fieldState => {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
return result;
};
validatePasswordConfirmRules = withValidation({
private validatePasswordConfirmRules = withValidation({
rules: [
{
key: "required",
@ -298,65 +305,64 @@ export default class RegistrationForm extends React.Component {
},
{
key: "match",
test({ value }) {
test(this: RegistrationForm, { value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
],
],
});
onPhoneCountryChange = newVal => {
private onPhoneCountryChange = newVal => {
this.setState({
phoneCountry: newVal.iso2,
phonePrefix: newVal.prefix,
});
};
onPhoneNumberChange = ev => {
private onPhoneNumberChange = ev => {
this.setState({
phoneNumber: ev.target.value,
});
};
onPhoneNumberValidate = async fieldState => {
private onPhoneNumberValidate = async fieldState => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
return result;
};
validatePhoneNumberRules = withValidation({
private validatePhoneNumberRules = withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
},
invalid: () => _t("Enter phone number (required on this homeserver)"),
},
{
key: "email",
test: ({ value }) => !value || phoneNumberLooksValid(value),
invalid: () => _t("Doesn't look like a valid phone number"),
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
},
],
});
onUsernameChange = ev => {
private onUsernameChange = ev => {
this.setState({
username: ev.target.value,
});
};
onUsernameValidate = async fieldState => {
private onUsernameValidate = async fieldState => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid);
this.markFieldValid(RegistrationField.Username, result.valid);
return result;
};
validateUsernameRules = withValidation({
private validateUsernameRules = withValidation({
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
hideDescriptionIfValid: true,
rules: [
@ -379,7 +385,7 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check
* @returns {boolean} Whether it is required
*/
_authStepIsRequired(step) {
private authStepIsRequired(step: string) {
return this.props.flows.every((flow) => {
return flow.stages.includes(step);
});
@ -391,46 +397,36 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check
* @returns {boolean} Whether it is used
*/
_authStepIsUsed(step) {
private authStepIsUsed(step: string) {
return this.props.flows.some((flow) => {
return flow.stages.includes(step);
});
}
_showEmail() {
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
(this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.email.identity')
) {
private showEmail() {
if (!this.authStepIsUsed('m.login.email.identity')) {
return false;
}
return true;
}
_showPhoneNumber() {
private showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
!threePidLogin ||
(this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.msisdn')
) {
if (!threePidLogin || !this.authStepIsUsed('m.login.msisdn')) {
return false;
}
return true;
}
renderEmail() {
if (!this._showEmail()) {
private renderEmail() {
if (!this.showEmail()) {
return null;
}
const Field = sdk.getComponent('elements.Field');
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
return <Field
ref={field => this[FIELD_EMAIL] = field}
ref={field => this[RegistrationField.Email] = field}
type="text"
label={emailPlaceholder}
value={this.state.email}
@ -441,10 +437,10 @@ export default class RegistrationForm extends React.Component {
/>;
}
renderPassword() {
private renderPassword() {
return <PassphraseField
id="mx_RegistrationForm_password"
fieldRef={field => this[FIELD_PASSWORD] = field}
fieldRef={field => this[RegistrationField.Password] = field}
minScore={PASSWORD_MIN_SCORE}
value={this.state.password}
onChange={this.onPasswordChange}
@ -455,13 +451,12 @@ export default class RegistrationForm extends React.Component {
}
renderPasswordConfirm() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_passwordConfirm"
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
ref={field => this[RegistrationField.PasswordConfirm] = field}
type="password"
autoComplete="new-password"
label={_t("Confirm")}
label={_t("Confirm password")}
value={this.state.passwordConfirm}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
@ -471,12 +466,11 @@ export default class RegistrationForm extends React.Component {
}
renderPhoneNumber() {
if (!this._showPhoneNumber()) {
if (!this.showPhoneNumber()) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const Field = sdk.getComponent('elements.Field');
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
const phoneCountry = <CountryDropdown
@ -486,7 +480,7 @@ export default class RegistrationForm extends React.Component {
onOptionChange={this.onPhoneCountryChange}
/>;
return <Field
ref={field => this[FIELD_PHONE_NUMBER] = field}
ref={field => this[RegistrationField.PhoneNumber] = field}
type="text"
label={phoneLabel}
value={this.state.phoneNumber}
@ -497,13 +491,13 @@ export default class RegistrationForm extends React.Component {
}
renderUsername() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_username"
ref={field => this[FIELD_USERNAME] = field}
ref={field => this[RegistrationField.Username] = field}
type="text"
autoFocus={true}
label={_t("Username")}
placeholder={_t("Username").toLocaleLowerCase()}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
@ -513,72 +507,33 @@ export default class RegistrationForm extends React.Component {
}
render() {
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
);
let emailHelperText = null;
if (this._showEmail()) {
if (this._showPhoneNumber()) {
if (this.showEmail()) {
if (this.showPhoneNumber()) {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email or phone to optionally be discoverable by existing contacts.",
)}
{
_t("Add an email to be able to reset your password.")
} {
_t("Use email or phone to optionally be discoverable by existing contacts.")
}
</div>;
} else {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email to optionally be discoverable by existing contacts.",
)}
{
_t("Add an email to be able to reset your password.")
} {
_t("Use email to optionally be discoverable by existing contacts.")
}
</div>;
}
}
const haveIs = Boolean(this.props.serverConfig.isUrl);
let noIsText = null;
if (this.props.serverRequiresIdServer && !haveIs) {
noIsText = <div>
{_t(
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
)}
</div>;
}
return (
<div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">
{this.renderUsername()}
@ -592,7 +547,6 @@ export default class RegistrationForm extends React.Component {
{this.renderPhoneNumber()}
</div>
{ emailHelperText }
{ noIsText }
{ registerButton }
</form>
</div>

View file

@ -1,291 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import Modal from '../../../Modal';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import { createClient } from 'matrix-js-sdk/src/matrix';
import classNames from 'classnames';
import CountlyAnalytics from "../../../CountlyAnalytics";
/*
* A pure UI component which displays the HS and IS to use.
*/
export default class ServerConfig extends React.PureComponent {
static propTypes = {
onServerConfigChange: PropTypes.func.isRequired,
// The current configuration that the user is expecting to change.
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
// Called after the component calls onServerConfigChange
onAfterSubmit: PropTypes.func,
// Optional text for the submit button. If falsey, no button will be shown.
submitText: PropTypes.string,
// Optional class for the submit button. Only applies if the submit button
// is to be rendered.
submitClass: PropTypes.string,
// Whether the flow this component is embedded in requires an identity
// server when the homeserver says it will need one. Default false.
showIdentityServerIfRequiredByHomeserver: PropTypes.bool,
};
static defaultProps = {
onServerConfigChange: function() {},
delayTimeMs: 0,
};
constructor(props) {
super(props);
this.state = {
busy: false,
errorText: "",
hsUrl: props.serverConfig.hsUrl,
isUrl: props.serverConfig.isUrl,
showIdentityServer: false,
};
CountlyAnalytics.instance.track("onboarding_custom_server");
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
if (newProps.serverConfig.hsUrl === this.state.hsUrl &&
newProps.serverConfig.isUrl === this.state.isUrl) return;
this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
}
async validateServer() {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
if (!result) {
return result;
}
// If the UI flow this component is embedded in requires an identity
// server when the homeserver says it will need one, check first and
// reveal this field if not already shown.
// XXX: This a backward compatibility path for homeservers that require
// an identity server to be passed during certain flows.
// See also https://github.com/matrix-org/synapse/pull/5868.
if (
this.props.showIdentityServerIfRequiredByHomeserver &&
!this.state.showIdentityServer &&
await this.isIdentityServerRequiredByHomeserver()
) {
this.setState({
showIdentityServer: true,
});
return null;
}
return result;
}
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({
hsUrl: defaultConfig.hsUrl,
isUrl: defaultConfig.isUrl,
busy: false,
errorText: "",
});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
this.setState({
hsUrl,
isUrl,
busy: true,
errorText: "",
});
try {
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(result);
return result;
} catch (e) {
console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) {
this.setState({
busy: false,
});
// carry on anyway
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true);
this.props.onServerConfigChange(result);
return result;
} else {
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
return null;
}
}
}
async isIdentityServerRequiredByHomeserver() {
// XXX: We shouldn't have to create a whole new MatrixClient just to
// check if the homeserver requires an identity server... Should it be
// extracted to a static utils function...?
return createClient({
baseUrl: this.state.hsUrl,
}).doesServerRequireIdServerParam();
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.validateServer();
});
};
onHomeserverChange = (ev) => {
const hsUrl = ev.target.value;
this.setState({ hsUrl });
};
onIdentityServerBlur = (ev) => {
this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => {
this.validateServer();
});
};
onIdentityServerChange = (ev) => {
const isUrl = ev.target.value;
this.setState({ isUrl });
};
onSubmit = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
const result = await this.validateServer();
if (!result) return; // Do not continue.
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();
}
};
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
clearTimeout(existingTimeoutId);
}
return setTimeout(fn.bind(this), this.props.delayTimeMs);
}
showHelpPopup = () => {
const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog');
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
};
_renderHomeserverSection() {
const Field = sdk.getComponent('elements.Field');
return <div>
{_t("Enter your custom homeserver URL <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{sub}
</a>,
})}
<Field
id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
disabled={this.state.busy}
/>
</div>;
}
_renderIdentityServerSection() {
const Field = sdk.getComponent('elements.Field');
const classes = classNames({
"mx_ServerConfig_identityServer": true,
"mx_ServerConfig_identityServer_shown": this.state.showIdentityServer,
});
return <div className={classes}>
{_t("Enter your custom identity server URL <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{sub}
</a>,
})}
<Field
label={_t("Identity Server URL")}
placeholder={this.props.serverConfig.isUrl}
value={this.state.isUrl || ''}
onBlur={this.onIdentityServerBlur}
onChange={this.onIdentityServerChange}
disabled={this.state.busy}
/>
</div>;
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const errorText = this.state.errorText
? <span className='mx_ServerConfig_error'>{this.state.errorText}</span>
: null;
const submitButton = this.props.submitText
? <AccessibleButton
element="button"
type="submit"
className={this.props.submitClass}
onClick={this.onSubmit}
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
: null;
return (
<form className="mx_ServerConfig" onSubmit={this.onSubmit} autoComplete="off">
<h3>{_t("Other servers")}</h3>
{errorText}
{this._renderHomeserverSection()}
{this._renderIdentityServerSection()}
{submitButton}
</form>
);
}
}

View file

@ -1,153 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import classnames from 'classnames';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import {makeType} from "../../../utils/TypeUtils";
const MODULAR_URL = 'https://element.io/matrix-services' +
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
export const FREE = 'Free';
export const PREMIUM = 'Premium';
export const ADVANCED = 'Advanced';
export const TYPES = {
FREE: {
id: FREE,
label: () => _t('Free'),
logo: () => <img src={require('../../../../res/img/matrix-org-bw-logo.svg')} />,
description: () => _t('Join millions for free on the largest public server'),
serverConfig: makeType(ValidatedServerConfig, {
hsUrl: "https://matrix-client.matrix.org",
hsName: "matrix.org",
hsNameIsDifferent: false,
isUrl: "https://vector.im",
}),
},
PREMIUM: {
id: PREMIUM,
label: () => _t('Premium'),
logo: () => <img src={require('../../../../res/img/ems-logo.svg')} height={16} />,
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
{sub}
</a>,
}),
identityServerUrl: "https://vector.im",
},
ADVANCED: {
id: ADVANCED,
label: () => _t('Advanced'),
logo: () => <div>
<img src={require('../../../../res/img/feather-customised/globe.svg')} />
{_t('Other')}
</div>,
description: () => _t('Find other public servers or use a custom server'),
},
};
export function getTypeFromServerConfig(config) {
const {hsUrl} = config;
if (!hsUrl) {
return null;
} else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) {
return FREE;
} else if (new URL(hsUrl).hostname.endsWith('.modular.im')) {
// This is an unlikely case to reach, as Modular defaults to hiding the
// server type selector.
return PREMIUM;
} else {
return ADVANCED;
}
}
export default class ServerTypeSelector extends React.PureComponent {
static propTypes = {
// The default selected type.
selected: PropTypes.string,
// Handler called when the selected type changes.
onChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
const {
selected,
} = props;
this.state = {
selected,
};
}
updateSelectedType(type) {
if (this.state.selected === type) {
return;
}
this.setState({
selected: type,
});
if (this.props.onChange) {
this.props.onChange(type);
}
}
onClick = (e) => {
e.stopPropagation();
const type = e.currentTarget.dataset.id;
this.updateSelectedType(type);
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const serverTypes = [];
for (const type of Object.values(TYPES)) {
const { id, label, logo, description } = type;
const classes = classnames(
"mx_ServerTypeSelector_type",
`mx_ServerTypeSelector_type_${id}`,
{
"mx_ServerTypeSelector_type_selected": id === this.state.selected,
},
);
serverTypes.push(<div className={classes} key={id} >
<div className="mx_ServerTypeSelector_label">
{label()}
</div>
<AccessibleButton onClick={this.onClick} data-id={id}>
<div className="mx_ServerTypeSelector_logo">
{logo()}
</div>
<div className="mx_ServerTypeSelector_description">
{description()}
</div>
</AccessibleButton>
</div>);
}
return <div className="mx_ServerTypeSelector">
{serverTypes}
</div>;
}
}

View file

@ -1,62 +0,0 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import {_t} from "../../../languageHandler";
import * as sdk from "../../../index";
import PropTypes from "prop-types";
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
export default class SignInToText extends React.PureComponent {
static propTypes = {
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
onEditServerDetailsClick: PropTypes.func,
};
render() {
let signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
signInToText = _t('Sign in to your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
return <h3>
{signInToText}
{editLink}
</h3>;
}
}

View file

@ -1,28 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
interface IProps {
}
const PulsedAvatar: React.FC<IProps> = (props) => {
return <div className="mx_PulsedAvatar">
{props.children}
</div>;
};
export default PulsedAvatar;

View file

@ -149,7 +149,7 @@ export default class MessageContextMenu extends React.Component {
onRedactClick = () => {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: async (proceed) => {
onFinished: async (proceed, reason) => {
if (!proceed) return;
const cli = MatrixClientPeg.get();
@ -157,6 +157,8 @@ export default class MessageContextMenu extends React.Component {
await cli.redactEvent(
this.props.mxEvent.getRoomId(),
this.props.mxEvent.getId(),
undefined,
reason ? { reason } : {},
);
} catch (e) {
const code = e.errcode || e.statusCode;

View file

@ -57,7 +57,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let unpinButton;
if (showUnpin) {
const onUnpinClick = () => {
WidgetStore.instance.unpinWidget(app.id);
WidgetStore.instance.unpinWidget(room.roomId, app.id);
onFinished();
};
@ -143,7 +143,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let moveLeftButton;
if (showUnpin && widgetIndex > 0) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(app.id, -1);
WidgetStore.instance.movePinnedWidget(roomId, app.id, -1);
onFinished();
};
@ -153,7 +153,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let moveRightButton;
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(app.id, 1);
WidgetStore.instance.movePinnedWidget(roomId, app.id, 1);
onFinished();
};

View file

@ -23,15 +23,17 @@ import { _t } from '../../../languageHandler';
*/
export default class ConfirmRedactDialog extends React.Component {
render() {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog');
return (
<QuestionDialog onFinished={this.props.onFinished}
<TextInputDialog onFinished={this.props.onFinished}
title={_t("Confirm Removal")}
description={
_t("Are you sure you wish to remove (delete) this event? " +
"Note that if you delete a room name or topic change, it could undo the change.")}
placeholder={_t("Reason (optional)")}
focus
button={_t("Remove")}>
</QuestionDialog>
</TextInputDialog>
);
}
}

View file

@ -31,6 +31,7 @@ export default class InfoDialog extends React.Component {
onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
fixedWidth: PropTypes.bool,
};
static defaultProps = {
@ -54,6 +55,7 @@ export default class InfoDialog extends React.Component {
contentId='mx_Dialog_content'
hasCancel={this.props.hasCloseButton}
onKeyDown={this.props.onKeyDown}
fixedWidth={this.props.fixedWidth}
>
<div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
{ this.props.description }

View file

@ -31,7 +31,7 @@ import dis from "../../../dispatcher/dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom";
import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
@ -41,6 +41,7 @@ import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {Room} from "matrix-js-sdk/src/models/room";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
@ -308,10 +309,14 @@ export default class InviteDialog extends React.PureComponent {
// The room ID this dialog is for. Only required for KIND_INVITE.
roomId: PropTypes.string,
// Initial value to populate the filter with
initialText: PropTypes.string,
};
static defaultProps = {
kind: KIND_DM,
initialText: "",
};
_debounceTimer: number = null;
@ -338,7 +343,7 @@ export default class InviteDialog extends React.PureComponent {
this.state = {
targets: [], // array of Member objects (see interface above)
filterText: "",
filterText: this.props.initialText,
recents: InviteDialog.buildRecents(alreadyInvited),
numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(alreadyInvited),
@ -356,6 +361,12 @@ export default class InviteDialog extends React.PureComponent {
this._editorRef = createRef();
}
componentDidMount() {
if (this.props.initialText) {
this._updateSuggestions(this.props.initialText);
}
}
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
@ -575,7 +586,12 @@ export default class InviteDialog extends React.PureComponent {
const targetIds = targets.map(t => t.userId);
// Check if there is already a DM with these people and reuse it if possible.
const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
let existingRoom: Room;
if (targetIds.length === 1) {
existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]);
} else {
existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
}
if (existingRoom) {
dis.dispatch({
action: 'view_room',
@ -687,6 +703,115 @@ export default class InviteDialog extends React.PureComponent {
}
};
_updateSuggestions = async (term) => {
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make
// these results useful. This is a race we want to avoid because we could overwrite
// more accurate results.
return;
}
if (!r.results) r.results = [];
// While we're here, try and autocomplete a search result for the mxid itself
// if there's no matches (and the input looks like a mxid).
if (term[0] === '@' && term.indexOf(':') > 1) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(term);
if (profile) {
// If we have a profile, we have enough information to assume that
// the mxid can be invited - add it to the list. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: profile['displayname'],
avatar_url: profile['avatar_url'],
});
}
} catch (e) {
console.warn("Non-fatal error trying to make an invite for a user ID");
console.warn(e);
// Add a result anyways, just without a profile. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: term,
avatar_url: null,
});
}
}
this.setState({
serverResultsMixin: r.results.map(u => ({
userId: u.user_id,
user: new DirectoryMember(u),
})),
});
}).catch(e => {
console.error("Error searching user directory:");
console.error(e);
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
});
// Whenever we search the directory, also try to search the identity server. It's
// all debounced the same anyways.
if (!this.state.canUseIdentityServer) {
// The user doesn't have an identity server set - warn them of that.
this.setState({tryingIdentityServer: true});
return;
}
if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
// Start off by suggesting the plain email while we try and resolve it
// to a real account.
this.setState({
// per above: the userId is a lie here - it's just a regular identifier
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
});
try {
const authClient = new IdentityAuthClient();
const token = await authClient.getAccessToken();
if (term !== this.state.filterText) return; // abandon hope
const lookup = await MatrixClientPeg.get().lookupThreePid(
'email',
term,
undefined, // callback
token,
);
if (term !== this.state.filterText) return; // abandon hope
if (!lookup || !lookup.mxid) {
// We weren't able to find anyone - we're already suggesting the plain email
// as an alternative, so do nothing.
return;
}
// We append the user suggestion to give the user an option to click
// the email anyways, and so we don't cause things to jump around. In
// theory, the user would see the user pop up and think "ah yes, that
// person!"
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
if (term !== this.state.filterText || !profile) return; // abandon hope
this.setState({
threepidResultsMixin: [...this.state.threepidResultsMixin, {
user: new DirectoryMember({
user_id: lookup.mxid,
display_name: profile.displayname,
avatar_url: profile.avatar_url,
}),
userId: lookup.mxid,
}],
});
} catch (e) {
console.error("Error searching identity server:");
console.error(e);
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
}
}
};
_updateFilter = (e) => {
const term = e.target.value;
this.setState({filterText: term});
@ -697,113 +822,8 @@ export default class InviteDialog extends React.PureComponent {
if (this._debounceTimer) {
clearTimeout(this._debounceTimer);
}
this._debounceTimer = setTimeout(async () => {
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make
// these results useful. This is a race we want to avoid because we could overwrite
// more accurate results.
return;
}
if (!r.results) r.results = [];
// While we're here, try and autocomplete a search result for the mxid itself
// if there's no matches (and the input looks like a mxid).
if (term[0] === '@' && term.indexOf(':') > 1) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(term);
if (profile) {
// If we have a profile, we have enough information to assume that
// the mxid can be invited - add it to the list. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: profile['displayname'],
avatar_url: profile['avatar_url'],
});
}
} catch (e) {
console.warn("Non-fatal error trying to make an invite for a user ID");
console.warn(e);
// Add a result anyways, just without a profile. We stick it at the
// top so it is most obviously presented to the user.
r.results.splice(0, 0, {
user_id: term,
display_name: term,
avatar_url: null,
});
}
}
this.setState({
serverResultsMixin: r.results.map(u => ({
userId: u.user_id,
user: new DirectoryMember(u),
})),
});
}).catch(e => {
console.error("Error searching user directory:");
console.error(e);
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
});
// Whenever we search the directory, also try to search the identity server. It's
// all debounced the same anyways.
if (!this.state.canUseIdentityServer) {
// The user doesn't have an identity server set - warn them of that.
this.setState({tryingIdentityServer: true});
return;
}
if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
// Start off by suggesting the plain email while we try and resolve it
// to a real account.
this.setState({
// per above: the userId is a lie here - it's just a regular identifier
threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}],
});
try {
const authClient = new IdentityAuthClient();
const token = await authClient.getAccessToken();
if (term !== this.state.filterText) return; // abandon hope
const lookup = await MatrixClientPeg.get().lookupThreePid(
'email',
term,
undefined, // callback
token,
);
if (term !== this.state.filterText) return; // abandon hope
if (!lookup || !lookup.mxid) {
// We weren't able to find anyone - we're already suggesting the plain email
// as an alternative, so do nothing.
return;
}
// We append the user suggestion to give the user an option to click
// the email anyways, and so we don't cause things to jump around. In
// theory, the user would see the user pop up and think "ah yes, that
// person!"
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
if (term !== this.state.filterText || !profile) return; // abandon hope
this.setState({
threepidResultsMixin: [...this.state.threepidResultsMixin, {
user: new DirectoryMember({
user_id: lookup.mxid,
display_name: profile.displayname,
avatar_url: profile.avatar_url,
}),
userId: lookup.mxid,
}],
});
} catch (e) {
console.error("Error searching identity server:");
console.error(e);
this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal
}
}
this._debounceTimer = setTimeout(() => {
this._updateSuggestions(term);
}, 150); // 150ms debounce (human reaction time + some)
};

View file

@ -31,12 +31,14 @@ import {
ModalButtonKind,
Widget,
WidgetApiFromWidgetAction,
WidgetKind,
} from "matrix-widget-api";
import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RoomViewStore from "../../../stores/RoomViewStore";
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
import { arrayFastClone } from "../../../utils/arrays";
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
@ -63,7 +65,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
constructor(props) {
super(props);
this.widget = new Widget({
this.widget = new ElementWidget({
...this.props.widgetDefinition,
creatorUserId: MatrixClientPeg.get().getUserId(),
id: `modal_${this.props.sourceWidgetId}`,
@ -72,7 +74,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
}
public componentDidMount() {
const driver = new StopGapWidgetDriver( []);
const driver = new StopGapWidgetDriver( [], this.widget, WidgetKind.Modal);
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
this.setState({messaging});
}
@ -160,7 +162,9 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
this.state.messaging.notifyModalWidgetButtonClicked(def.id);
};
return <AccessibleButton key={def.id} kind={kind} onClick={onClick}>
const isDisabled = this.state.disabledButtonIds.includes(def.id);
return <AccessibleButton key={def.id} kind={kind} onClick={onClick} disabled={isDisabled}>
{ def.label }
</AccessibleButton>;
});

View file

@ -0,0 +1,96 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import {useRef, useState} from "react";
import Field from "../elements/Field";
import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps extends IDialogProps {
onFinished(continued: boolean, email?: string): void;
}
const validation = withValidation({
rules: [
{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
const RegistrationEmailPromptDialog: React.FC<IProps> = ({onFinished}) => {
const [email, setEmail] = useState("");
const fieldRef = useRef<Field>();
const onSubmit = async () => {
if (email) {
const valid = await fieldRef.current.validate({ allowEmpty: false });
if (!valid) {
fieldRef.current.focus();
fieldRef.current.validate({ allowEmpty: false, focused: true });
return;
}
}
onFinished(true, email);
};
return <BaseDialog
title={_t("Continuing without email")}
className="mx_RegistrationEmailPromptDialog"
contentId="mx_RegistrationEmailPromptDialog"
onFinished={() => onFinished(false)}
fixedWidth={false}
>
<div className="mx_Dialog_content" id="mx_RegistrationEmailPromptDialog">
<p>{_t("Just a heads up, if you don't add an email and forget your password, you could " +
"<b>permanently lose access to your account</b>.", {}, {
b: sub => <b>{sub}</b>,
})}</p>
<form onSubmit={onSubmit}>
<Field
ref={fieldRef}
type="text"
label={_t("Email (optional)")}
value={email}
onChange={ev => {
setEmail(ev.target.value);
}}
onValidate={async fieldState => await validation(fieldState)}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email2_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email2_blur")}
/>
</form>
</div>
<DialogButtons
primaryButton={_t("Continue")}
onPrimaryButtonClick={onSubmit}
hasCancel={false}
/>
</BaseDialog>;
};
export default RegistrationEmailPromptDialog;

View file

@ -53,9 +53,9 @@ export default class RoomSettingsDialog extends React.Component {
}
_onAction = (payload) => {
// When room changes below us, close the room settings
// When view changes below us, close the room settings
// whilst the modal is open this can only be triggered when someone hits Leave Room
if (payload.action === 'view_next_room') {
if (payload.action === 'view_home_page') {
this.props.onFinished();
}
};

View file

@ -0,0 +1,203 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import BaseDialog from './BaseDialog';
import { _t } from '../../../languageHandler';
import AccessibleButton from "../elements/AccessibleButton";
import SdkConfig from "../../../SdkConfig";
import Field from "../elements/Field";
import StyledRadioButton from "../elements/StyledRadioButton";
import TextWithTooltip from "../elements/TextWithTooltip";
import withValidation, {IFieldState} from "../elements/Validation";
interface IProps {
title?: string;
serverConfig: ValidatedServerConfig;
onFinished(config?: ValidatedServerConfig): void;
}
interface IState {
defaultChosen: boolean;
otherHomeserver: string;
}
export default class ServerPickerDialog extends React.PureComponent<IProps, IState> {
private readonly defaultServer: ValidatedServerConfig;
private readonly fieldRef = createRef<Field>();
private validatedConf: ValidatedServerConfig;
constructor(props) {
super(props);
const config = SdkConfig.get();
this.defaultServer = config["validated_server_config"] as ValidatedServerConfig;
this.state = {
defaultChosen: this.props.serverConfig.isDefault,
otherHomeserver: this.props.serverConfig.isDefault ? "" : this.props.serverConfig.hsUrl,
};
}
private onDefaultChosen = () => {
this.setState({ defaultChosen: true });
};
private onOtherChosen = () => {
this.setState({ defaultChosen: false });
};
private onHomeserverChange = (ev) => {
this.setState({ otherHomeserver: ev.target.value });
};
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
private validate = withValidation<this, { error?: string }>({
deriveData: async ({ value: hsUrl }) => {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl) return {};
try {
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl);
return {};
} catch (e) {
console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) {
// carry on anyway
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, null, true);
return {};
} else {
let error = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
error = e.translatedMessage;
}
return { error };
}
}
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Specify a homeserver"),
}, {
key: "valid",
test: async function({ value }, { error }) {
if (!value) return true;
return !error;
},
invalid: function({ error }) {
return error;
},
},
],
});
private onHomeserverValidate = (fieldState: IFieldState) => this.validate(fieldState);
private onSubmit = async (ev) => {
ev.preventDefault();
const valid = await this.fieldRef.current.validate({ allowEmpty: false });
if (!valid) {
this.fieldRef.current.focus();
this.fieldRef.current.validate({ allowEmpty: false, focused: true });
return;
}
this.props.onFinished(this.validatedConf);
};
public render() {
let text;
if (this.defaultServer.hsName === "matrix.org") {
text = _t("Matrix.org is the biggest public homeserver in the world, so its a good place for many.");
}
let defaultServerName = this.defaultServer.hsName;
if (this.defaultServer.hsNameIsDifferent) {
defaultServerName = (
<TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>
{this.defaultServer.hsName}
</TextWithTooltip>
);
}
return <BaseDialog
title={this.props.title || _t("Sign into your homeserver")}
className="mx_ServerPickerDialog"
contentId="mx_ServerPickerDialog"
onFinished={this.props.onFinished}
fixedWidth={false}
hasCancel={true}
>
<form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}>
<p>
{_t("We call the places you where you can host your account homeservers.")} {text}
</p>
<StyledRadioButton
name="defaultChosen"
value="true"
checked={this.state.defaultChosen}
onChange={this.onDefaultChosen}
>
{defaultServerName}
</StyledRadioButton>
<StyledRadioButton
name="defaultChosen"
value="false"
className="mx_ServerPickerDialog_otherHomeserverRadio"
checked={!this.state.defaultChosen}
onChange={this.onOtherChosen}
>
<Field
type="text"
className="mx_ServerPickerDialog_otherHomeserver"
label={_t("Other homeserver")}
onChange={this.onHomeserverChange}
onClick={this.onOtherChosen}
ref={this.fieldRef}
onValidate={this.onHomeserverValidate}
value={this.state.otherHomeserver}
validateOnChange={false}
validateOnFocus={false}
/>
</StyledRadioButton>
<p>
{_t("Use your preferred Matrix homeserver if you have one, or host your own.")}
</p>
<AccessibleButton className="mx_ServerPickerDialog_continue" kind="primary" onClick={this.onSubmit}>
{_t("Continue")}
</AccessibleButton>
<h4>{_t("Learn more")}</h4>
<a href="https://matrix.org/faq/#what-is-a-homeserver%3F" target="_blank" rel="noreferrer noopener">
{_t("About homeservers")}
</a>
</form>
</BaseDialog>;
}
}

View file

@ -0,0 +1,147 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import {
Capability,
Widget,
WidgetEventCapability,
WidgetKind,
} from "matrix-widget-api";
import { objectShallowClone } from "../../../utils/objects";
import StyledCheckbox from "../elements/StyledCheckbox";
import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
interface IProps extends IDialogProps {
requestedCapabilities: Set<Capability>;
widget: Widget;
widgetKind: WidgetKind; // TODO: Refactor into the Widget class
}
interface IBooleanStates {
// @ts-ignore - TS wants a string key, but we know better
[capability: Capability]: boolean;
}
interface IState {
booleanStates: IBooleanStates;
rememberSelection: boolean;
}
export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<IProps, IState> {
private eventPermissionsMap = new Map<Capability, WidgetEventCapability>();
constructor(props: IProps) {
super(props);
const parsedEvents = WidgetEventCapability.findEventCapabilities(this.props.requestedCapabilities);
parsedEvents.forEach(e => this.eventPermissionsMap.set(e.raw, e));
const states: IBooleanStates = {};
this.props.requestedCapabilities.forEach(c => states[c] = true);
this.state = {
booleanStates: states,
rememberSelection: true,
};
}
private onToggle = (capability: Capability) => {
const newStates = objectShallowClone(this.state.booleanStates);
newStates[capability] = !newStates[capability];
this.setState({booleanStates: newStates});
};
private onRememberSelectionChange = (newVal: boolean) => {
this.setState({rememberSelection: newVal});
};
private onSubmit = async (ev) => {
this.closeAndTryRemember(Object.entries(this.state.booleanStates)
.filter(([_, isSelected]) => isSelected)
.map(([cap]) => cap));
};
private onReject = async (ev) => {
this.closeAndTryRemember([]); // nothing was approved
};
private closeAndTryRemember(approved: Capability[]) {
if (this.state.rememberSelection) {
setRememberedCapabilitiesForWidget(this.props.widget, approved);
}
this.props.onFinished({approved});
}
public render() {
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
const text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{text.byline}</span>
: null;
return (
<div className="mx_WidgetCapabilitiesPromptDialog_cap" key={cap + i}>
<StyledCheckbox
checked={isChecked}
onChange={() => this.onToggle(cap)}
>{text.primary}</StyledCheckbox>
{byline}
</div>
);
});
return (
<BaseDialog
className="mx_WidgetCapabilitiesPromptDialog"
onFinished={this.props.onFinished}
title={_t("Approve widget permissions")}
>
<form onSubmit={this.onSubmit}>
<div className="mx_Dialog_content">
<div className="text-muted">{_t("This widget would like to:")}</div>
{checkboxRows}
<DialogButtons
primaryButton={_t("Approve")}
cancelButton={_t("Decline All")}
onPrimaryButtonClick={this.onSubmit}
onCancel={this.onReject}
additive={
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this.onRememberSelectionChange}
label={_t("Remember my selection for this widget")} />}
/>
</div>
</form>
</BaseDialog>
);
}
}

View file

@ -17,18 +17,17 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import * as sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import WidgetUtils from "../../../utils/WidgetUtils";
import {SettingLevel} from "../../../settings/SettingLevel";
import {Widget} from "matrix-widget-api";
import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore";
export default class WidgetOpenIDPermissionsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
widgetUrl: PropTypes.string.isRequired,
widgetId: PropTypes.string.isRequired,
isUserWidget: PropTypes.bool.isRequired,
widget: PropTypes.objectOf(Widget).isRequired,
widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api
inRoomId: PropTypes.string,
};
constructor() {
@ -51,16 +50,10 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
if (this.state.rememberSelection) {
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
if (!currentValues.allow) currentValues.allow = [];
if (!currentValues.deny) currentValues.deny = [];
const securityKey = WidgetUtils.getWidgetSecurityKey(
this.props.widgetId,
this.props.widgetUrl,
this.props.isUserWidget);
(allowed ? currentValues.allow : currentValues.deny).push(securityKey);
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
WidgetPermissionStore.instance.setOIDCState(
this.props.widget, this.props.widgetKind, this.props.inRoomId,
allowed ? OIDCState.Allowed : OIDCState.Denied,
);
}
this.props.onFinished(allowed);
@ -84,7 +77,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
"A widget located at %(widgetUrl)s would like to verify your identity. " +
"By allowing this, the widget will be able to verify your user ID, but not " +
"perform actions as you.", {
widgetUrl: this.props.widgetUrl.split("?")[0],
widgetUrl: this.props.widget.templateUrl.split("?")[0],
},
)}
</p>

View file

@ -23,7 +23,6 @@ import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import Spinner from './Spinner';
@ -375,11 +374,13 @@ export default class AppTile extends React.Component {
/>
</div>
);
// if the widget would be allowed to remain on screen, we must put it in
// a PersistedElement from the get-go, otherwise the iframe will be
// re-mounted later when we do.
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
const PersistedElement = sdk.getComponent("elements.PersistedElement");
if (!this.props.userWidget) {
// All room widgets can theoretically be allowed to remain on screen, so we
// wrap them all in a PersistedElement from the get-go. If we wait, the iframe
// will be re-mounted later, which means the widget has to start over, which is
// bad.
// Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper">
@ -474,10 +475,6 @@ AppTile.propTypes = {
handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the popout widget icon
showPopout: PropTypes.bool,
// Widget capabilities to allow by default (without user confirmation)
// NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events.
whitelistCapabilities: PropTypes.array,
// Is this an instance of a user widget
userWidget: PropTypes.bool,
};
@ -488,7 +485,6 @@ AppTile.defaultProps = {
showTitle: true,
showPopout: true,
handleMinimisePointerEvents: false,
whitelistCapabilities: [],
userWidget: false,
miniMode: false,
};

View file

@ -54,6 +54,9 @@ export default class DialogButtons extends React.Component {
// disables only the primary button
primaryDisabled: PropTypes.bool,
// something to stick next to the buttons, optionally
additive: PropTypes.element,
};
static defaultProps = {
@ -85,8 +88,14 @@ export default class DialogButtons extends React.Component {
</button>;
}
let additive = null;
if (this.props.additive) {
additive = <div className="mx_Dialog_buttons_additive">{this.props.additive}</div>;
}
return (
<div className="mx_Dialog_buttons">
{ additive }
{ cancelButton }
{ this.props.children }
<button type={this.props.primaryIsSubmit ? 'submit' : 'button'}

View file

@ -20,8 +20,8 @@ import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
export default class DirectorySearchBox extends React.Component {
constructor() {
super();
constructor(props) {
super(props);
this._collectInput = this._collectInput.bind(this);
this._onClearClick = this._onClearClick.bind(this);
this._onChange = this._onChange.bind(this);
@ -31,7 +31,7 @@ export default class DirectorySearchBox extends React.Component {
this.input = null;
this.state = {
value: '',
value: this.props.initialText || '',
};
}
@ -90,15 +90,20 @@ export default class DirectorySearchBox extends React.Component {
}
return <div className={`mx_DirectorySearchBox ${this.props.className} mx_textinput`}>
<input type="text" name="dirsearch" value={this.state.value}
className="mx_textinput_icon mx_textinput_search"
ref={this._collectInput}
onChange={this._onChange} onKeyUp={this._onKeyUp}
placeholder={this.props.placeholder} autoFocus
/>
{ joinButton }
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick}></AccessibleButton>
</div>;
<input
type="text"
name="dirsearch"
value={this.state.value}
className="mx_textinput_icon mx_textinput_search"
ref={this._collectInput}
onChange={this._onChange}
onKeyUp={this._onKeyUp}
placeholder={this.props.placeholder}
autoFocus
/>
{ joinButton }
<AccessibleButton className="mx_DirectorySearchBox_clear" onClick={this._onClearClick} />
</div>;
}
}
@ -109,4 +114,5 @@ DirectorySearchBox.propTypes = {
onJoinClick: PropTypes.func,
placeholder: PropTypes.string,
showJoinButton: PropTypes.bool,
initialText: PropTypes.string,
};

View file

@ -61,10 +61,14 @@ interface IProps {
tooltipClassName?: string;
// If specified, an additional class name to apply to the field container
className?: string;
// On what events should validation occur; by default on all
validateOnFocus?: boolean;
validateOnBlur?: boolean;
validateOnChange?: boolean;
// All other props pass through to the <input>.
}
interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The element to create. Defaults to "input".
element?: "input";
// The input's value. This is a controlled component, so the value is required.
@ -100,6 +104,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
public static readonly defaultProps = {
element: "input",
type: "text",
validateOnFocus: true,
validateOnBlur: true,
validateOnChange: true,
};
/*
@ -137,9 +144,11 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
this.setState({
focused: true,
});
this.validate({
focused: true,
});
if (this.props.validateOnFocus) {
this.validate({
focused: true,
});
}
// Parent component may have supplied its own `onFocus` as well
if (this.props.onFocus) {
this.props.onFocus(ev);
@ -147,7 +156,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
};
private onChange = (ev) => {
this.validateOnChange();
if (this.props.validateOnChange) {
this.validateOnChange();
}
// Parent component may have supplied its own `onChange` as well
if (this.props.onChange) {
this.props.onChange(ev);
@ -158,16 +169,18 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
this.setState({
focused: false,
});
this.validate({
focused: false,
});
if (this.props.validateOnBlur) {
this.validate({
focused: false,
});
}
// Parent component may have supplied its own `onBlur` as well
if (this.props.onBlur) {
this.props.onBlur(ev);
}
};
private async validate({ focused, allowEmpty = true }: {focused: boolean, allowEmpty?: boolean}) {
public async validate({ focused, allowEmpty = true }: {focused?: boolean, allowEmpty?: boolean}) {
if (!this.props.onValidate) {
return;
}
@ -196,12 +209,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
feedbackVisible: false,
});
}
return valid;
}
public render() {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
...inputProps} = this.props;
// Set some defaults for the <input> element
const ref = input => this.input = input;

View file

@ -21,6 +21,8 @@ import AccessibleButton from "./AccessibleButton";
import Tooltip from './Tooltip';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useTimeout} from "../../../hooks/useTimeout";
import Analytics from "../../../Analytics";
import CountlyAnalytics from '../../../CountlyAnalytics';
export const AVATAR_SIZE = 52;
@ -56,6 +58,8 @@ const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAva
onChange={async (ev) => {
if (!ev.target.files?.length) return;
setBusy(true);
Analytics.trackEvent("mini_avatar", "upload");
CountlyAnalytics.instance.track("mini_avatar_upload");
const file = ev.target.files[0];
const uri = await cli.uploadContent(file);
await setAvatarUrl(uri);

View file

@ -71,7 +71,6 @@ export default class PersistentApp extends React.Component {
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
persistentWidgetInRoomId, appEvent.getId(),
);
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
@ -82,7 +81,6 @@ export default class PersistentApp extends React.Component {
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
miniMode={true}
showMenubar={false}
/>;

View file

@ -1,42 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
const SSOButton = ({matrixClient, loginType, fragmentAfterLogin, ...props}) => {
const onClick = () => {
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin);
};
return (
<AccessibleButton {...props} kind="primary" onClick={onClick}>
{_t("Sign in with single sign-on")}
</AccessibleButton>
);
};
SSOButton.propTypes = {
matrixClient: PropTypes.object.isRequired, // does not use context as may use a temporary client
loginType: PropTypes.oneOf(["sso", "cas"]), // defaults to "sso" in base-apis
fragmentAfterLogin: PropTypes.string,
};
export default SSOButton;

View file

@ -0,0 +1,110 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {MatrixClient} from "matrix-js-sdk/src/client";
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
import {IIdentityProvider, ISSOFlow} from "../../../Login";
import classNames from "classnames";
interface ISSOButtonProps extends Omit<IProps, "flow"> {
idp: IIdentityProvider;
mini?: boolean;
}
const SSOButton: React.FC<ISSOButtonProps> = ({
matrixClient,
loginType,
fragmentAfterLogin,
idp,
primary,
mini,
...props
}) => {
const kind = primary ? "primary" : "primary_outline";
const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on");
const onClick = () => {
PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id);
};
let icon;
if (idp && idp.icon && idp.icon.startsWith("https://")) {
icon = <img src={idp.icon} height="24" width="24" alt={label} />;
}
const classes = classNames("mx_SSOButton", {
mx_SSOButton_mini: mini,
});
if (mini) {
// TODO fallback icon
return (
<AccessibleButton {...props} className={classes} kind={kind} onClick={onClick}>
{ icon }
</AccessibleButton>
);
}
return (
<AccessibleButton {...props} className={classes} kind={kind} onClick={onClick}>
{ icon }
{ label }
</AccessibleButton>
);
};
interface IProps {
matrixClient: MatrixClient;
flow: ISSOFlow;
loginType?: "sso" | "cas";
fragmentAfterLogin?: string;
primary?: boolean;
}
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
const providers = flow.identity_providers || flow["org.matrix.msc2858.identity_providers"] || [];
if (providers.length < 2) {
return <div className="mx_SSOButtons">
<SSOButton
matrixClient={matrixClient}
loginType={loginType}
fragmentAfterLogin={fragmentAfterLogin}
idp={providers[0]}
primary={primary}
/>
</div>;
}
return <div className="mx_SSOButtons">
{ providers.map(idp => (
<SSOButton
key={idp.id}
matrixClient={matrixClient}
loginType={loginType}
fragmentAfterLogin={fragmentAfterLogin}
idp={idp}
mini={true}
primary={primary}
/>
)) }
</div>;
};
export default SSOButtons;

View file

@ -0,0 +1,93 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import AccessibleButton from "./AccessibleButton";
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import {_t} from "../../../languageHandler";
import TextWithTooltip from "./TextWithTooltip";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import ServerPickerDialog from "../dialogs/ServerPickerDialog";
import InfoDialog from "../dialogs/InfoDialog";
interface IProps {
title?: string;
dialogTitle?: string;
serverConfig: ValidatedServerConfig;
onServerConfigChange?(config: ValidatedServerConfig): void;
}
const showPickerDialog = (
title: string,
serverConfig: ValidatedServerConfig,
onFinished: (config: ValidatedServerConfig) => void,
) => {
Modal.createTrackedDialog("Server Picker", "", ServerPickerDialog, { title, serverConfig, onFinished });
};
const onHelpClick = () => {
Modal.createTrackedDialog('Custom Server Dialog', '', InfoDialog, {
title: _t("Server Options"),
description: _t("You can use the custom server options to sign into other Matrix servers by specifying " +
"a different homeserver URL. This allows you to use Element with an existing Matrix account on " +
"a different homeserver."),
button: _t("Dismiss"),
hasCloseButton: false,
fixedWidth: false,
}, "mx_ServerPicker_helpDialog");
};
const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => {
let editBtn;
if (!SdkConfig.get()["disable_custom_urls"] && onServerConfigChange) {
const onClick = () => {
showPickerDialog(dialogTitle, serverConfig, (config?: ValidatedServerConfig) => {
if (config) {
onServerConfigChange(config);
}
});
};
editBtn = <AccessibleButton className="mx_ServerPicker_change" kind="link" onClick={onClick}>
{_t("Edit")}
</AccessibleButton>;
}
let serverName = serverConfig.hsName;
if (serverConfig.hsNameIsDifferent) {
serverName = <TextWithTooltip class="mx_Login_underlinedServerName" tooltip={serverConfig.hsUrl}>
{serverConfig.hsName}
</TextWithTooltip>;
}
let desc;
if (serverConfig.hsName === "matrix.org") {
desc = <span className="mx_ServerPicker_desc">
{_t("Join millions for free on the largest public server")}
</span>;
}
return <div className="mx_ServerPicker">
<h3>{title || _t("Homeserver")}</h3>
<AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} />
<span className="mx_ServerPicker_server">{serverName}</span>
{ editBtn }
{ desc }
</div>
}
export default ServerPicker;

View file

@ -32,7 +32,7 @@ interface IRule<T, D = void> {
interface IArgs<T, D = void> {
rules: IRule<T, D>[];
description(this: T, derivedData: D): React.ReactChild;
description?(this: T, derivedData: D): React.ReactChild;
hideDescriptionIfValid?: boolean;
deriveData?(data: Data): Promise<D>;
}

View file

@ -35,7 +35,7 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
let joinCopy = _t('Join the conference at the top of this room');
if (!WidgetStore.instance.isPinned(this.props.mxEvent.getStateKey())) {
if (!WidgetStore.instance.isPinned(this.props.mxEvent.getRoomId(), this.props.mxEvent.getStateKey())) {
joinCopy = _t('Join the conference from the room information card on the right');
}

View file

@ -39,6 +39,8 @@ interface IState {
}
export default class MVideoBody extends React.PureComponent<IProps, IState> {
private videoRef = React.createRef<HTMLVideoElement>();
constructor(props) {
super(props);
this.state = {
@ -71,7 +73,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
}
}
_getContentUrl(): string|null {
private getContentUrl(): string|null {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedUrl;
@ -80,7 +82,12 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
}
}
_getThumbUrl(): string|null {
private hasContentUrl(): boolean {
const url = this.getContentUrl();
return url && !url.startsWith("data:");
}
private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl;
@ -118,7 +125,10 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
} else {
console.log("NOT preloading video");
this.setState({
decryptedUrl: null,
// For Chrome and Electron, we need to set some non-empty `src` to
// enable the play button. Firefox does not seem to care either
// way, so it's fine to do for all browsers.
decryptedUrl: `data:${content?.info?.mimetype},`,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: null,
});
@ -142,8 +152,8 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
}
}
async _videoOnPlay() {
if (this._getContentUrl() || this.state.fetchingData || this.state.error) {
private videoOnPlay = async () => {
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
// We have the file, we are fetching the file, or there is an error.
return;
}
@ -164,6 +174,9 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
decryptedUrl: contentUrl,
decryptedBlob: decryptedBlob,
fetchingData: false,
}, () => {
if (!this.videoRef.current) return;
this.videoRef.current.play();
});
this.props.onHeightChanged();
}
@ -195,8 +208,8 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
);
}
const contentUrl = this._getContentUrl();
const thumbUrl = this._getThumbUrl();
const contentUrl = this.getContentUrl();
const thumbUrl = this.getThumbUrl();
let height = null;
let width = null;
let poster = null;
@ -215,9 +228,20 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
}
return (
<span className="mx_MVideoBody">
<video className="mx_MVideoBody" src={contentUrl} title={content.body}
controls preload={preload} muted={autoplay} autoPlay={autoplay}
height={height} width={width} poster={poster} onPlay={this._videoOnPlay.bind(this)}>
<video
className="mx_MVideoBody"
ref={this.videoRef}
src={contentUrl}
title={content.body}
controls
preload={preload}
muted={autoplay}
autoPlay={autoplay}
height={height}
width={width}
poster={poster}
onPlay={this.videoOnPlay}
>
</video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>

View file

@ -83,9 +83,10 @@ export const useWidgets = (room: Room) => {
interface IAppRowProps {
app: IApp;
room: Room;
}
const AppRow: React.FC<IAppRowProps> = ({ app }) => {
const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
const name = WidgetUtils.getWidgetName(app);
const dataTitle = WidgetUtils.getWidgetDataTitle(app);
const subtitle = dataTitle && " - " + dataTitle;
@ -100,10 +101,10 @@ const AppRow: React.FC<IAppRowProps> = ({ app }) => {
});
};
const isPinned = WidgetStore.instance.isPinned(app.id);
const isPinned = WidgetStore.instance.isPinned(room.roomId, app.id);
const togglePin = isPinned
? () => { WidgetStore.instance.unpinWidget(app.id); }
: () => { WidgetStore.instance.pinWidget(app.id); };
? () => { WidgetStore.instance.unpinWidget(room.roomId, app.id); }
: () => { WidgetStore.instance.pinWidget(room.roomId, app.id); };
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
let contextMenu;
@ -118,7 +119,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app }) => {
/>;
}
const cannotPin = !isPinned && !WidgetStore.instance.canPin(app.id);
const cannotPin = !isPinned && !WidgetStore.instance.canPin(room.roomId, app.id);
let pinTitle: string;
if (cannotPin) {
@ -183,7 +184,7 @@ const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
};
return <Group className="mx_RoomSummaryCard_appsGroup" title={_t("Widgets")}>
{ apps.map(app => <AppRow key={app.id} app={app} />) }
{ apps.map(app => <AppRow key={app.id} app={app} room={room} />) }
<AccessibleButton kind="link" onClick={onManageIntegrations}>
{ apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") }

View file

@ -28,7 +28,7 @@ import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
import dis from '../../../dispatcher/dispatcher';
import Modal from '../../../Modal';
import {_t} from '../../../languageHandler';
import createRoom, {privateShouldBeEncrypted} from '../../../createRoom';
import createRoom, { findDMForUser, privateShouldBeEncrypted } from '../../../createRoom';
import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton';
import SdkConfig from '../../../SdkConfig';
@ -105,17 +105,7 @@ export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice
};
async function openDMForUser(matrixClient: MatrixClient, userId: string) {
const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
const room = matrixClient.getRoom(roomId);
if (!room || room.getMyMembership() === "leave") {
return lastActiveRoom;
}
if (!lastActiveRoom || lastActiveRoom.getLastActiveTimestamp() < room.getLastActiveTimestamp()) {
return room;
}
return lastActiveRoom;
}, null);
const lastActiveRoom = findDMForUser(matrixClient, userId);
if (lastActiveRoom) {
dis.dispatch({

View file

@ -42,7 +42,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
const apps = useWidgets(room);
const app = apps.find(a => a.id === widgetId);
const isPinned = app && WidgetStore.instance.isPinned(app.id);
const isPinned = app && WidgetStore.instance.isPinned(room.roomId, app.id);
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
@ -103,7 +103,6 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, room.roomId)}
/>
</BaseCard>;
};

View file

@ -210,8 +210,6 @@ export default class AppsDrawer extends React.Component {
if (!this.props.showApps) return <div />;
const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
return (<AppTile
key={app.id}
app={app}
@ -221,7 +219,6 @@ export default class AppsDrawer extends React.Component {
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
/>);
});

View file

@ -29,9 +29,10 @@ import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import classNames from 'classnames';
import {EventStatus} from 'matrix-js-sdk';
import BasicMessageComposer from "./BasicMessageComposer";
import {Key} from "../../../Keyboard";
import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import CountlyAnalytics from "../../../CountlyAnalytics";
function _isReply(mxEvent) {
@ -136,7 +137,10 @@ export default class EditMessageComposer extends React.Component {
if (event.metaKey || event.altKey || event.shiftKey) {
return;
}
if (event.key === Key.ENTER) {
const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend');
const send = ctrlEnterToSend ? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event)
: event.key === Key.ENTER;
if (send) {
this._sendEdit();
event.preventDefault();
} else if (event.key === Key.ESCAPE) {

View file

@ -745,13 +745,22 @@ export default class EventTile extends React.Component {
}
if (this.props.mxEvent.sender && avatarSize) {
let member;
// set member to receiver (target) if it is a 3PID invite
// so that the correct avatar is shown as the text is
// `$target accepted the invitation for $email`
if (this.props.mxEvent.getContent().third_party_invite) {
member = this.props.mxEvent.target;
} else {
member = this.props.mxEvent.sender;
}
avatar = (
<div className="mx_EventTile_avatar">
<MemberAvatar member={this.props.mxEvent.sender}
width={avatarSize} height={avatarSize}
viewUserOnClick={true}
/>
</div>
<div className="mx_EventTile_avatar">
<MemberAvatar member={member}
width={avatarSize} height={avatarSize}
viewUserOnClick={true}
/>
</div>
);
}

View file

@ -58,6 +58,7 @@ interface IProps {
interface IState {
sublists: ITagMap;
isNameFiltering: boolean;
}
const TAG_ORDER: TagID[] = [
@ -183,6 +184,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
this.state = {
sublists: {},
isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(),
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
@ -253,7 +255,8 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
return CustomRoomTagStore.getTags()[t];
});
let doUpdate = arrayHasDiff(previousListIds, newListIds);
const isNameFiltering = !!RoomListStore.instance.getFirstNameFilterCondition();
let doUpdate = this.state.isNameFiltering !== isNameFiltering || arrayHasDiff(previousListIds, newListIds);
if (!doUpdate) {
// so we didn't have the visible sublists change, but did the contents of those
// sublists change significantly enough to break the sticky headers? Probably, so
@ -275,14 +278,20 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const newSublists = objectWithOnly(newLists, newListIds);
const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v));
this.setState({sublists}, () => {
this.setState({sublists, isNameFiltering}, () => {
this.props.onResize();
});
}
};
private onStartChat = () => {
const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search;
dis.dispatch({ action: "view_create_chat", initialText });
};
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search;
dis.dispatch({ action: Action.ViewRoomDirectory, initialText });
};
private renderCommunityInvites(): TemporaryTile[] {
@ -332,8 +341,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
return p;
}, [] as TagID[]);
// show a skeleton UI if the user is in no rooms
const showSkeleton = Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length);
// show a skeleton UI if the user is in no rooms and they are not filtering
const showSkeleton = !this.state.isNameFiltering &&
Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length);
for (const orderedTagId of tagOrder) {
const orderedRooms = this.state.sublists[orderedTagId] || [];
@ -370,10 +380,21 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
public render() {
let explorePrompt: JSX.Element;
if (!this.props.isMinimized) {
if (RoomListStore.instance.getFirstNameFilterCondition()) {
if (this.state.isNameFiltering) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Can't see what youre looking for?")}</div>
<AccessibleButton kind="link" onClick={this.onExplore}>
<AccessibleButton
className="mx_RoomList_explorePrompt_startChat"
kind="link"
onClick={this.onStartChat}
>
{_t("Start a new chat")}
</AccessibleButton>
<AccessibleButton
className="mx_RoomList_explorePrompt_explore"
kind="link"
onClick={this.onExplore}
>
{_t("Explore all public rooms")}
</AccessibleButton>
</div>;
@ -385,7 +406,18 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Use the + to make a new room or explore existing ones below")}</div>
<AccessibleButton kind="link" onClick={this.onExplore}>
<AccessibleButton
className="mx_RoomList_explorePrompt_startChat"
kind="link"
onClick={this.onStartChat}
>
{_t("Start a new chat")}
</AccessibleButton>
<AccessibleButton
className="mx_RoomList_explorePrompt_explore"
kind="link"
onClick={this.onExplore}
>
{_t("Explore all public rooms")}
</AccessibleButton>
</div>;

View file

@ -422,7 +422,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
room = this.state.rooms && this.state.rooms[0];
} else {
// find the first room with a count of the same colour as the badge count
room = this.state.rooms.find((r: Room) => {
room = RoomListStore.instance.unfilteredLists[this.props.tagId].find((r: Room) => {
const notifState = this.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.color === this.notificationState.color;
});

View file

@ -38,10 +38,11 @@ import * as sdk from '../../../index';
import Modal from '../../../Modal';
import {_t, _td} from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
import {Key} from "../../../Keyboard";
import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import EMOJI_REGEX from 'emojibase-regex';
@ -142,7 +143,11 @@ export default class SendMessageComposer extends React.Component {
return;
}
const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
if (event.key === Key.ENTER && !hasModifier) {
const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend');
const send = ctrlEnterToSend
? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event)
: event.key === Key.ENTER && !hasModifier;
if (send) {
this._sendMessage();
event.preventDefault();
} else if (event.key === Key.ARROW_UP) {

View file

@ -280,7 +280,6 @@ export default class Stickerpicker extends React.Component {
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
whitelistCapabilities={['m.sticker', 'visibility']}
userWidget={true}
/>
</PersistedElement>

View file

@ -21,9 +21,18 @@ import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import AccessibleButton from '../elements/AccessibleButton';
import Spinner from '../elements/Spinner';
import withValidation from '../elements/Validation';
import { _t } from '../../../languageHandler';
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import PassphraseField from "../auth/PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics";
const FIELD_OLD_PASSWORD = 'field_old_password';
const FIELD_NEW_PASSWORD = 'field_new_password';
const FIELD_NEW_PASSWORD_CONFIRM = 'field_new_password_confirm';
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
export default class ChangePassword extends React.Component {
static propTypes = {
@ -63,6 +72,7 @@ export default class ChangePassword extends React.Component {
}
state = {
fieldValid: {},
phase: ChangePassword.Phases.Edit,
oldPassword: "",
newPassword: "",
@ -168,26 +178,84 @@ export default class ChangePassword extends React.Component {
);
};
markFieldValid(fieldID, valid) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
}
onChangeOldPassword = (ev) => {
this.setState({
oldPassword: ev.target.value,
});
};
onOldPasswordValidate = async fieldState => {
const result = await this.validateOldPasswordRules(fieldState);
this.markFieldValid(FIELD_OLD_PASSWORD, result.valid);
return result;
};
validateOldPasswordRules = withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Passwords can't be empty"),
},
],
});
onChangeNewPassword = (ev) => {
this.setState({
newPassword: ev.target.value,
});
};
onNewPasswordValidate = result => {
this.markFieldValid(FIELD_NEW_PASSWORD, result.valid);
};
onChangeNewPasswordConfirm = (ev) => {
this.setState({
newPasswordConfirm: ev.target.value,
});
};
onClickChange = (ev) => {
onNewPasswordConfirmValidate = async fieldState => {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result.valid);
return result;
};
validatePasswordConfirmRules = withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Confirm password"),
},
{
key: "match",
test({ value }) {
return !value || value === this.state.newPassword;
},
invalid: () => _t("Passwords don't match"),
},
],
});
onClickChange = async (ev) => {
ev.preventDefault();
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return;
}
const oldPassword = this.state.oldPassword;
const newPassword = this.state.newPassword;
const confirmPassword = this.state.newPasswordConfirm;
@ -201,9 +269,75 @@ export default class ChangePassword extends React.Component {
}
};
render() {
// TODO: Live validation on `new pw == confirm pw`
async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
FIELD_OLD_PASSWORD,
FIELD_NEW_PASSWORD,
FIELD_NEW_PASSWORD_CONFIRM,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
}
allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
}
findFirstInvalidField(fieldIDs) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
}
return null;
}
render() {
const rowClassName = this.props.rowClassName;
const buttonClassName = this.props.buttonClassName;
@ -213,28 +347,35 @@ export default class ChangePassword extends React.Component {
<form className={this.props.className} onSubmit={this.onClickChange}>
<div className={rowClassName}>
<Field
ref={field => this[FIELD_OLD_PASSWORD] = field}
type="password"
label={_t('Current password')}
value={this.state.oldPassword}
onChange={this.onChangeOldPassword}
onValidate={this.onOldPasswordValidate}
/>
</div>
<div className={rowClassName}>
<Field
<PassphraseField
fieldRef={field => this[FIELD_NEW_PASSWORD] = field}
type="password"
label={_t('New Password')}
label='New Password'
minScore={PASSWORD_MIN_SCORE}
value={this.state.newPassword}
autoFocus={this.props.autoFocusNewPasswordInput}
onChange={this.onChangeNewPassword}
onValidate={this.onNewPasswordValidate}
autoComplete="new-password"
/>
</div>
<div className={rowClassName}>
<Field
ref={field => this[FIELD_NEW_PASSWORD_CONFIRM] = field}
type="password"
label={_t("Confirm password")}
value={this.state.newPasswordConfirm}
onChange={this.onChangeNewPasswordConfirm}
onValidate={this.onNewPasswordConfirmValidate}
autoComplete="new-password"
/>
</div>

View file

@ -130,10 +130,13 @@ export default class EventIndexPanel extends React.Component {
<div>
<div className='mx_SettingsTab_subsectionText'>
{_t("Securely cache encrypted messages locally for them " +
"to appear in search results, using %(size)s to store messages from %(count)s rooms.",
"to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
{
size: formatBytes(this.state.eventIndexSize, 0),
count: formatCountLong(this.state.roomCount),
// This drives the singular / plural string
// selection for "room" / "rooms" only.
count: this.state.roomCount,
rooms: formatCountLong(this.state.roomCount),
},
)}
</div>

View file

@ -394,7 +394,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
className="mx_AppearanceUserSettingsTab_AdvancedToggle"
onClick={() => this.setState({showAdvanced: !this.state.showAdvanced})}
>
{this.state.showAdvanced ? "Hide advanced" : "Show advanced"}
{this.state.showAdvanced ? _t("Hide advanced") : _t("Show advanced")}
</div>;
let advanced: React.ReactNode;

View file

@ -33,6 +33,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
'MessageComposerInput.autoReplaceEmoji',
'MessageComposerInput.suggestEmoji',
'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend',
];
static TIMELINE_SETTINGS = [

View file

@ -26,6 +26,15 @@ import PersistentApp from "../elements/PersistentApp";
import SettingsStore from "../../../settings/SettingsStore";
import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
const SHOW_CALL_IN_STATES = [
CallState.Connected,
CallState.InviteSent,
CallState.Connecting,
CallState.CreateAnswer,
CallState.CreateOffer,
CallState.WaitLocalMedia,
];
interface IProps {
}
@ -94,14 +103,13 @@ export default class CallPreview extends React.Component<IProps, IState> {
const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId);
const showCall = (
this.state.activeCall &&
this.state.activeCall.state === CallState.Connected &&
SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) &&
!callForRoom
);
if (showCall) {
return (
<CallView
className="mx_CallPreview"
onClick={this.onCallViewClick}
showHangup={true}
/>

View file

@ -21,12 +21,13 @@ import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import VideoFeed, { VideoFeedType } from "./VideoFeed";
import RoomAvatar from "../avatars/RoomAvatar";
import PulsedAvatar from '../avatars/PulsedAvatar';
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
interface IProps {
// js-sdk room object. If set, we will only show calls for the given
@ -43,9 +44,6 @@ interface IProps {
// in a way that is likely to cause a resize.
onResize?: any;
// classname applied to view,
className?: string;
// Whether to show the hang up icon:W
showHangup?: boolean;
}
@ -53,6 +51,10 @@ interface IProps {
interface IState {
call: MatrixCall;
isLocalOnHold: boolean,
micMuted: boolean,
vidMuted: boolean,
callState: CallState,
controlsVisible: boolean,
}
function getFullScreenElement() {
@ -83,10 +85,15 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document);
}
const CONTROLS_HIDE_DELAY = 1000;
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const HEADER_HEIGHT = 44;
export default class CallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private container = createRef<HTMLDivElement>();
private contentRef = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
constructor(props: IProps) {
super(props);
@ -94,6 +101,10 @@ export default class CallView extends React.Component<IProps, IState> {
this.state = {
call,
isLocalOnHold: call ? call.isLocalOnHold() : null,
micMuted: call ? call.isMicrophoneMuted() : null,
vidMuted: call ? call.isLocalVideoMuted() : null,
callState: call ? call.state : null,
controlsVisible: true,
}
this.updateCallListeners(null, call);
@ -101,9 +112,11 @@ export default class CallView extends React.Component<IProps, IState> {
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener('keydown', this.onNativeKeyDown);
}
public componentWillUnmount() {
document.removeEventListener("keydown", this.onNativeKeyDown);
this.updateCallListeners(this.state.call, null);
dis.unregister(this.dispatcherRef);
}
@ -111,11 +124,11 @@ export default class CallView extends React.Component<IProps, IState> {
private onAction = (payload) => {
switch (payload.action) {
case 'video_fullscreen': {
if (!this.container.current) {
if (!this.contentRef.current) {
return;
}
if (payload.fullscreen) {
requestFullscreen(this.container.current);
requestFullscreen(this.contentRef.current);
} else if (getFullScreenElement()) {
exitFullscreen();
}
@ -125,9 +138,21 @@ export default class CallView extends React.Component<IProps, IState> {
const newCall = this.getCall();
if (newCall !== this.state.call) {
this.updateCallListeners(this.state.call, newCall);
let newControlsVisible = this.state.controlsVisible;
if (newCall && !this.state.call) {
newControlsVisible = true;
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
this.setState({
call: newCall,
isLocalOnHold: newCall ? newCall.isLocalOnHold() : null,
micMuted: newCall ? newCall.isMicrophoneMuted() : null,
vidMuted: newCall ? newCall.isLocalVideoMuted() : null,
callState: newCall ? newCall.state : null,
controlsVisible: newControlsVisible,
});
}
if (!newCall && getFullScreenElement()) {
@ -144,11 +169,6 @@ export default class CallView extends React.Component<IProps, IState> {
if (this.props.room) {
const roomId = this.props.room.roomId;
call = CallHandler.sharedInstance().getCallForRoom(roomId);
// We don't currently show voice calls in this view when in the room:
// they're represented in the room status bar at the bottom instead
// (but this will all change with the new designs)
if (call && call.type == CallType.Voice) call = null;
} else {
call = CallHandler.sharedInstance().getAnyActiveCall();
// Ignore calls if we can't get the room associated with them.
@ -160,7 +180,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
}
if (call && call.state == CallState.Ended) return null;
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call;
}
@ -177,67 +197,240 @@ export default class CallView extends React.Component<IProps, IState> {
});
};
public render() {
let view: React.ReactNode;
private onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
});
};
if (this.state.call) {
if (this.state.call.type === "voice") {
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.state.call.roomId);
private onExpandClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
});
};
let caption = _t("Active call");
if (this.state.isLocalOnHold) {
// we currently have no UI for holding / unholding a call (apart from slash
// commands) so we don't disintguish between when we've put the call on hold
// (ie. we'd show an unhold button) and when the other side has put us on hold
// (where obviously we would not show such a button).
caption = _t("Call Paused");
private onControlsHideTimer = () => {
this.controlsHideTimer = null;
this.setState({
controlsVisible: false,
});
}
private onMouseMove = () => {
this.showControls();
}
private showControls() {
if (!this.state.controlsVisible) {
this.setState({
controlsVisible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onMicMuteClick = () => {
if (!this.state.call) return;
const newVal = !this.state.micMuted;
this.state.call.setMicrophoneMuted(newVal);
this.setState({micMuted: newVal});
}
private onVidMuteClick = () => {
if (!this.state.call) return;
const newVal = !this.state.vidMuted;
this.state.call.setLocalVideoMuted(newVal);
this.setState({vidMuted: newVal});
}
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a callview on screen at any given time
// CallHandler would probably be a better place for this
private onNativeKeyDown = ev => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.key) {
case Key.D:
if (ctrlCmdOnly) {
this.onMicMuteClick();
// show the controls to give feedback
this.showControls();
handled = true;
}
break;
view = <AccessibleButton className="mx_CallView_voice" onClick={this.props.onClick}>
<PulsedAvatar>
<RoomAvatar
room={callRoom}
height={35}
width={35}
/>
</PulsedAvatar>
<div>
<h1>{callRoom.name}</h1>
<p>{ caption }</p>
</div>
</AccessibleButton>;
} else {
// For video calls, we currently ignore the call hold state altogether
// (the video will just go black)
// if we're fullscreen, we don't want to set a maxHeight on the video element.
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight;
view = <div className="mx_CallView_video" onClick={this.props.onClick}>
<VideoFeed type={VideoFeedType.Remote} call={this.state.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
</div>;
}
case Key.E:
if (ctrlCmdOnly) {
this.onVidMuteClick();
// show the controls to give feedback
this.showControls();
handled = true;
}
break;
}
let hangup: React.ReactNode;
if (this.props.showHangup) {
hangup = <div
className="mx_CallView_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: this.state.call.roomId,
});
}}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onRoomAvatarClick = () => {
dis.dispatch({
action: 'view_room',
room_id: this.state.call.roomId,
});
}
public render() {
if (!this.state.call) return null;
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.state.call.roomId);
let callControls;
if (this.props.room) {
const micClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: !this.state.micMuted,
mx_CallView_callControls_button_micOff: this.state.micMuted,
});
const vidClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
});
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: this.state.micMuted,
mx_CallView_callControls_button_micOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const vidCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: this.state.micMuted,
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const callControlsClasses = classNames({
mx_CallView_callControls: true,
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
const vidMuteButton = this.state.call.type === CallType.Video ? <div
className={vidClasses}
onClick={this.onVidMuteClick}
/> : null;
callControls = <div className={callControlsClasses}>
<div
className={micClasses}
onClick={this.onMicMuteClick}
/>
<div
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: this.state.call.roomId,
});
}}
/>
{vidMuteButton}
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
</div>;
}
// The 'content' for the call, ie. the videos for a video call and profile picture
// for voice calls (fills the bg)
let contentView: React.ReactNode;
if (this.state.call.type === CallType.Video) {
// if we're fullscreen, we don't want to set a maxHeight on the video element.
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight - HEADER_HEIGHT;
contentView = <div className="mx_CallView_video" ref={this.contentRef} onMouseMove={this.onMouseMove}>
<VideoFeed type={VideoFeedType.Remote} call={this.state.call} onResize={this.props.onResize}
maxHeight={maxVideoHeight}
/>
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
{callControls}
</div>;
} else {
const avatarSize = this.props.room ? 200 : 75;
contentView = <div className="mx_CallView_voice" onMouseMove={this.onMouseMove}>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
{callControls}
</div>;
}
const callTypeText = this.state.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
let myClassName;
let fullScreenButton;
if (this.state.call.type === CallType.Video && this.props.room) {
fullScreenButton = <div className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick} title={_t("Fill Screen")}
/>;
}
return <div className={this.props.className} ref={this.container}>
{view}
{hangup}
let expandButton;
if (!this.props.room) {
expandButton = <div className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick} title={_t("Return to call")}
/>;
}
const headerControls = <div className="mx_CallView_header_controls">
{fullScreenButton}
{expandButton}
</div>;
let header: React.ReactNode;
if (this.props.room) {
header = <div className="mx_CallView_header">
<div className="mx_CallView_header_phoneIcon"></div>
<span className="mx_CallView_header_callType">{callTypeText}</span>
{headerControls}
</div>;
myClassName = 'mx_CallView_large';
} else {
header = <div className="mx_CallView_header">
<AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div>
<div className="mx_CallView_header_roomName">{callRoom.name}</div>
<div className="mx_CallView_header_callTypeSmall">{callTypeText}</div>
</div>
{headerControls}
</div>;
myClassName = 'mx_CallView_pip';
}
return <div className={"mx_CallView " + myClassName}>
{header}
{contentView}
</div>;
}
}

View file

@ -22,7 +22,6 @@ import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { ActionPayload } from '../../../dispatcher/payloads';
import CallHandler from '../../../CallHandler';
import PulsedAvatar from '../avatars/PulsedAvatar';
import RoomAvatar from '../avatars/RoomAvatar';
import FormButton from '../elements/FormButton';
import { CallState } from 'matrix-js-sdk/lib/webrtc/call';
@ -108,13 +107,11 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
return <div className="mx_IncomingCallBox">
<div className="mx_IncomingCallBox_CallerInfo">
<PulsedAvatar>
<RoomAvatar
room={room}
height={32}
width={32}
/>
</PulsedAvatar>
<RoomAvatar
room={room}
height={32}
width={32}
/>
<div>
<h1>{caller}</h1>
<p>{incomingCallText}</p>

View file

@ -73,8 +73,6 @@ export default class VideoFeed extends React.Component<IProps> {
let videoStyle = {};
if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight };
return <div className={classnames(videoClasses)}>
<video ref={this.vid} style={videoStyle}></video>
</div>;
return <video className={classnames(videoClasses)} ref={this.vid} style={videoStyle} />;
}
}