Merge branch 'develop' into emoji_quick_shortcut
This commit is contained in:
commit
0c85cb587d
200 changed files with 10916 additions and 3783 deletions
|
@ -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>
|
||||
`);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
onBlur={this.onBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
placeholder={_t("Search")}
|
||||
placeholder={_t("Filter")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
|
@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
if (this.props.isMinimized) {
|
||||
icon = (
|
||||
<AccessibleButton
|
||||
title={_t("Search rooms")}
|
||||
title={_t("Filter rooms and people")}
|
||||
className="mx_RoomSearch_icon mx_RoomSearch_minimizedHandle"
|
||||
onClick={this.openSearch}
|
||||
/>
|
||||
|
|
|
@ -18,13 +18,11 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import { _t, _td } from '../../languageHandler';
|
||||
import * as sdk from '../../index';
|
||||
import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||
import Resend from '../../Resend';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
|
||||
export default class PostRegistration extends React.Component {
|
||||
static propTypes = {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
avatarUrl: null,
|
||||
errorString: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// There is some assymetry between ChangeDisplayName and ChangeAvatar,
|
||||
// as ChangeDisplayName will auto-get the name but ChangeAvatar expects
|
||||
// the URL to be passed to you (because it's also used for room avatars).
|
||||
const cli = MatrixClientPeg.get();
|
||||
this.setState({busy: true});
|
||||
const self = this;
|
||||
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
|
||||
self.setState({
|
||||
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
|
||||
busy: false,
|
||||
});
|
||||
}, function(error) {
|
||||
self.setState({
|
||||
errorString: _t("Failed to fetch avatar URL"),
|
||||
busy: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
|
||||
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
|
||||
const AuthHeader = sdk.getComponent('auth.AuthHeader');
|
||||
const AuthBody = sdk.getComponent("auth.AuthBody");
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<div className="mx_Login_profile">
|
||||
{ _t('Set a display name:') }
|
||||
<ChangeDisplayName />
|
||||
{ _t('Upload an avatar:') }
|
||||
<ChangeAvatar
|
||||
initialAvatarUrl={this.state.avatarUrl} />
|
||||
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
|
||||
{ this.state.errorString }
|
||||
</div>
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,109 +15,128 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import Matrix from 'matrix-js-sdk';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import classNames from "classnames";
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import Login from "../../../Login";
|
||||
import Login, {ISSOFlow} from "../../../Login";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ServerPicker from '../../views/elements/ServerPicker';
|
||||
|
||||
// Phases
|
||||
// Show controls to configure server details
|
||||
const PHASE_SERVER_DETAILS = 0;
|
||||
// Show the appropriate registration flow(s) for the server
|
||||
const PHASE_REGISTRATION = 1;
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
defaultDeviceDisplayName: string;
|
||||
email?: string;
|
||||
brand?: string;
|
||||
clientSecret?: string;
|
||||
sessionId?: string;
|
||||
idSid?: string;
|
||||
fragmentAfterLogin?: string;
|
||||
|
||||
// Enable phases for registration
|
||||
const PHASES_ENABLED = true;
|
||||
// Called when the user has logged in. Params:
|
||||
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
|
||||
// - The user's password, if available and applicable (may be cached in memory
|
||||
// for a short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn(params: {
|
||||
userId: string;
|
||||
deviceId: string
|
||||
homeserverUrl: string;
|
||||
identityServerUrl?: string;
|
||||
accessToken: string;
|
||||
}, password: string): void;
|
||||
makeRegistrationUrl(params: {
|
||||
/* eslint-disable camelcase */
|
||||
client_secret: string;
|
||||
hs_url: string;
|
||||
is_url?: string;
|
||||
session_id: string;
|
||||
/* eslint-enable camelcase */
|
||||
}): void;
|
||||
// registration shouldn't know or care how login is done.
|
||||
onLoginClick(): void;
|
||||
onServerConfigChange(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
export default class Registration extends React.Component {
|
||||
static propTypes = {
|
||||
// Called when the user has logged in. Params:
|
||||
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
|
||||
// - The user's password, if available and applicable (may be cached in memory
|
||||
// for a short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn: PropTypes.func.isRequired,
|
||||
interface IState {
|
||||
busy: boolean;
|
||||
errorText?: ReactNode;
|
||||
// true if we're waiting for the user to complete
|
||||
// We remember the values entered by the user because
|
||||
// the registration form will be unmounted during the
|
||||
// course of registration, but if there's an error we
|
||||
// want to bring back the registration form with the
|
||||
// values the user entered still in it. We can keep
|
||||
// them in this component's state since this component
|
||||
// persist for the duration of the registration process.
|
||||
formVals: Record<string, string>;
|
||||
// user-interactive auth
|
||||
// If we've been given a session ID, we're resuming
|
||||
// straight back into UI auth
|
||||
doingUIAuth: boolean;
|
||||
// If set, we've registered but are not going to log
|
||||
// the user in to their new account automatically.
|
||||
completedNoSignin: boolean;
|
||||
flows: {
|
||||
stages: string[];
|
||||
}[];
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError: string;
|
||||
|
||||
clientSecret: PropTypes.string,
|
||||
sessionId: PropTypes.string,
|
||||
makeRegistrationUrl: PropTypes.func.isRequired,
|
||||
idSid: PropTypes.string,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
brand: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
// registration shouldn't know or care how login is done.
|
||||
onLoginClick: PropTypes.func.isRequired,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
defaultDeviceDisplayName: PropTypes.string,
|
||||
};
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
matrixClient?: MatrixClient;
|
||||
// The user ID we've just registered
|
||||
registeredUsername?: string;
|
||||
// if a different user ID to the one we just registered is logged in,
|
||||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId?: string;
|
||||
// the SSO flow definition, this is fetched from /login as that's the only
|
||||
// place it is exposed.
|
||||
ssoFlow?: ISSOFlow;
|
||||
}
|
||||
|
||||
export default class Registration extends React.Component<IProps, IState> {
|
||||
loginLogic: Login;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
|
||||
this.state = {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
// We remember the values entered by the user because
|
||||
// the registration form will be unmounted during the
|
||||
// course of registration, but if there's an error we
|
||||
// want to bring back the registration form with the
|
||||
// values the user entered still in it. We can keep
|
||||
// them in this component's state since this component
|
||||
// persist for the duration of the registration process.
|
||||
formVals: {
|
||||
email: this.props.email,
|
||||
},
|
||||
// true if we're waiting for the user to complete
|
||||
// user-interactive auth
|
||||
// If we've been given a session ID, we're resuming
|
||||
// straight back into UI auth
|
||||
doingUIAuth: Boolean(this.props.sessionId),
|
||||
serverType,
|
||||
// Phase of the overall registration dialog.
|
||||
phase: PHASE_REGISTRATION,
|
||||
flows: null,
|
||||
// If set, we've registered but are not going to log
|
||||
// the user in to their new account automatically.
|
||||
completedNoSignin: false,
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
matrixClient: null,
|
||||
|
||||
// whether the HS requires an ID server to register with a threepid
|
||||
serverRequiresIdServer: null,
|
||||
|
||||
// The user ID we've just registered
|
||||
registeredUsername: null,
|
||||
|
||||
// if a different user ID to the one we just registered is logged in,
|
||||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId: null,
|
||||
};
|
||||
|
||||
const {hsUrl, isUrl} = this.props.serverConfig;
|
||||
this.loginLogic = new Login(hsUrl, isUrl, null, {
|
||||
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
this._replaceClient();
|
||||
this.replaceClient(this.props.serverConfig);
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
|
@ -129,63 +145,10 @@ export default class Registration extends React.Component {
|
|||
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
|
||||
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
|
||||
|
||||
this._replaceClient(newProps.serverConfig);
|
||||
|
||||
// Handle cases where the user enters "https://matrix.org" for their server
|
||||
// from the advanced option - we should default to FREE at that point.
|
||||
const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig);
|
||||
if (serverType !== this.state.serverType) {
|
||||
// Reset the phase to default phase for the server type.
|
||||
this.setState({
|
||||
serverType,
|
||||
phase: this.getDefaultPhaseForServerType(serverType),
|
||||
});
|
||||
}
|
||||
this.replaceClient(newProps.serverConfig);
|
||||
}
|
||||
|
||||
getDefaultPhaseForServerType(type) {
|
||||
switch (type) {
|
||||
case ServerType.FREE: {
|
||||
// Move directly to the registration phase since the server
|
||||
// details are fixed.
|
||||
return PHASE_REGISTRATION;
|
||||
}
|
||||
case ServerType.PREMIUM:
|
||||
case ServerType.ADVANCED:
|
||||
return PHASE_SERVER_DETAILS;
|
||||
}
|
||||
}
|
||||
|
||||
onServerTypeChange = type => {
|
||||
this.setState({
|
||||
serverType: type,
|
||||
});
|
||||
|
||||
// When changing server types, set the HS / IS URLs to reasonable defaults for the
|
||||
// the new type.
|
||||
switch (type) {
|
||||
case ServerType.FREE: {
|
||||
const { serverConfig } = ServerType.TYPES.FREE;
|
||||
this.props.onServerConfigChange(serverConfig);
|
||||
break;
|
||||
}
|
||||
case ServerType.PREMIUM:
|
||||
// We can accept whatever server config was the default here as this essentially
|
||||
// acts as a slightly different "custom server"/ADVANCED option.
|
||||
break;
|
||||
case ServerType.ADVANCED:
|
||||
// Use the default config from the config
|
||||
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Reset the phase to default phase for the server type.
|
||||
this.setState({
|
||||
phase: this.getDefaultPhaseForServerType(type),
|
||||
});
|
||||
};
|
||||
|
||||
async _replaceClient(serverConfig) {
|
||||
private async replaceClient(serverConfig: ValidatedServerConfig) {
|
||||
this.setState({
|
||||
errorText: null,
|
||||
serverDeadError: null,
|
||||
|
@ -194,7 +157,6 @@ export default class Registration extends React.Component {
|
|||
// the UI auth component while we don't have a matrix client)
|
||||
busy: true,
|
||||
});
|
||||
if (!serverConfig) serverConfig = this.props.serverConfig;
|
||||
|
||||
// Do a liveliness check on the URLs
|
||||
try {
|
||||
|
@ -222,16 +184,20 @@ export default class Registration extends React.Component {
|
|||
idBaseUrl: isUrl,
|
||||
});
|
||||
|
||||
let serverRequiresIdServer = true;
|
||||
this.loginLogic.setHomeserverUrl(hsUrl);
|
||||
this.loginLogic.setIdentityServerUrl(isUrl);
|
||||
|
||||
let ssoFlow: ISSOFlow;
|
||||
try {
|
||||
serverRequiresIdServer = await cli.doesServerRequireIdServerParam();
|
||||
const loginFlows = await this.loginLogic.getFlows();
|
||||
ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow;
|
||||
} catch (e) {
|
||||
console.log("Unable to determine is server needs id_server param", e);
|
||||
console.error("Failed to get login flows to check for SSO support", e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
matrixClient: cli,
|
||||
serverRequiresIdServer,
|
||||
ssoFlow,
|
||||
busy: false,
|
||||
});
|
||||
const showGenericError = (e) => {
|
||||
|
@ -246,7 +212,7 @@ export default class Registration extends React.Component {
|
|||
// do SSO instead. If we've already started the UI Auth process though, we don't
|
||||
// need to.
|
||||
if (!this.state.doingUIAuth) {
|
||||
await this._makeRegisterRequest(null);
|
||||
await this.makeRegisterRequest(null);
|
||||
// This should never succeed since we specified no auth object.
|
||||
console.log("Expecting 401 from register request but got success!");
|
||||
}
|
||||
|
@ -259,26 +225,16 @@ export default class Registration extends React.Component {
|
|||
// At this point registration is pretty much disabled, but before we do that let's
|
||||
// quickly check to see if the server supports SSO instead. If it does, we'll send
|
||||
// the user off to the login page to figure their account out.
|
||||
try {
|
||||
const loginLogic = new Login(hsUrl, isUrl, null, {
|
||||
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
|
||||
if (ssoFlow) {
|
||||
// Redirect to login page - server probably expects SSO only
|
||||
dis.dispatch({action: 'start_login'});
|
||||
} else {
|
||||
this.setState({
|
||||
serverErrorIsFatal: true, // fatal because user cannot continue on this server
|
||||
errorText: _t("Registration has been disabled on this homeserver."),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
const flows = await loginLogic.getFlows();
|
||||
const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas');
|
||||
if (hasSsoFlow) {
|
||||
// Redirect to login page - server probably expects SSO only
|
||||
dis.dispatch({action: 'start_login'});
|
||||
} else {
|
||||
this.setState({
|
||||
serverErrorIsFatal: true, // fatal because user cannot continue on this server
|
||||
errorText: _t("Registration has been disabled on this homeserver."),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get login flows to check for SSO support", e);
|
||||
showGenericError(e);
|
||||
}
|
||||
} else {
|
||||
console.log("Unable to query for supported registration methods.", e);
|
||||
|
@ -287,7 +243,7 @@ export default class Registration extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
onFormSubmit = formVals => {
|
||||
private onFormSubmit = formVals => {
|
||||
this.setState({
|
||||
errorText: "",
|
||||
busy: true,
|
||||
|
@ -296,7 +252,7 @@ export default class Registration extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
_requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
|
||||
private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
|
||||
return this.state.matrixClient.requestRegisterEmailToken(
|
||||
emailAddress,
|
||||
clientSecret,
|
||||
|
@ -310,28 +266,26 @@ export default class Registration extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
_onUIAuthFinished = async (success, response, extra) => {
|
||||
private onUIAuthFinished = async (success, response, extra) => {
|
||||
if (!success) {
|
||||
let msg = response.message || response.toString();
|
||||
// can we give a better error message?
|
||||
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact, {
|
||||
'monthly_active_user': _td(
|
||||
"This homeserver has hit its Monthly Active User limit.",
|
||||
),
|
||||
'': _td(
|
||||
"This homeserver has exceeded one of its resource limits.",
|
||||
),
|
||||
});
|
||||
response.data.admin_contact,
|
||||
{
|
||||
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
|
||||
'': _td("This homeserver has exceeded one of its resource limits."),
|
||||
},
|
||||
);
|
||||
const errorDetail = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact, {
|
||||
'': _td(
|
||||
"Please <a>contact your service administrator</a> to continue using this service.",
|
||||
),
|
||||
});
|
||||
response.data.admin_contact,
|
||||
{
|
||||
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
|
||||
},
|
||||
);
|
||||
msg = <div>
|
||||
<p>{errorTop}</p>
|
||||
<p>{errorDetail}</p>
|
||||
|
@ -339,11 +293,13 @@ export default class Registration extends React.Component {
|
|||
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
|
||||
let msisdnAvailable = false;
|
||||
for (const flow of response.available_flows) {
|
||||
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
|
||||
msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn');
|
||||
}
|
||||
if (!msisdnAvailable) {
|
||||
msg = _t('This server does not support authentication with a phone number.');
|
||||
}
|
||||
} else if (response.errcode === "M_USER_IN_USE") {
|
||||
msg = _t("That username already exists, please try another.");
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
|
@ -358,6 +314,10 @@ export default class Registration extends React.Component {
|
|||
const newState = {
|
||||
doingUIAuth: false,
|
||||
registeredUsername: response.user_id,
|
||||
differentLoggedInUserId: null,
|
||||
completedNoSignin: false,
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
busy: true,
|
||||
};
|
||||
|
||||
// The user came in through an email validation link. To avoid overwriting
|
||||
|
@ -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 }
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
|
||||
export default class CustomServerDialog extends React.Component {
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
return (
|
||||
<div className="mx_ErrorDialog">
|
||||
<div className="mx_Dialog_title">
|
||||
{ _t("Custom Server Options") }
|
||||
</div>
|
||||
<div className="mx_Dialog_content">
|
||||
<p>{_t(
|
||||
"You can use the custom server options to sign into other " +
|
||||
"Matrix servers by specifying a different homeserver URL. This " +
|
||||
"allows you to use %(brand)s with an existing Matrix account on a " +
|
||||
"different homeserver.",
|
||||
{ brand },
|
||||
)}</p>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.props.onFinished} autoFocus={true}>
|
||||
{ _t("Dismiss") }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -18,7 +18,6 @@ limitations under the License.
|
|||
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import url from 'url';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
|
@ -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
|
||||
|
|
|
@ -1,124 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import * as ServerType from '../../views/auth/ServerTypeSelector';
|
||||
import ServerConfig from "./ServerConfig";
|
||||
|
||||
const MODULAR_URL = 'https://element.io/matrix-services' +
|
||||
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
|
||||
|
||||
// TODO: TravisR - Can this extend ServerConfig for most things?
|
||||
|
||||
/*
|
||||
* Configure the Modular server name.
|
||||
*
|
||||
* This is a variant of ServerConfig with only the HS field and different body
|
||||
* text that is specific to the Modular case.
|
||||
*/
|
||||
export default class ModularServerConfig extends ServerConfig {
|
||||
static propTypes = ServerConfig.propTypes;
|
||||
|
||||
async validateAndApplyServer(hsUrl, isUrl) {
|
||||
// Always try and use the defaults first
|
||||
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
|
||||
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
hsUrl,
|
||||
isUrl,
|
||||
busy: true,
|
||||
errorText: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.props.onServerConfigChange(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
let message = _t("Unable to validate homeserver/identity server");
|
||||
if (e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: message,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async validateServer() {
|
||||
// TODO: Do we want to support .well-known lookups here?
|
||||
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
|
||||
// find their homeserver without demanding they use "https://matrix.org"
|
||||
return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl);
|
||||
}
|
||||
|
||||
render() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const submitButton = this.props.submitText
|
||||
? <AccessibleButton
|
||||
element="button"
|
||||
type="submit"
|
||||
className={this.props.submitClass}
|
||||
onClick={this.onSubmit}
|
||||
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx_ServerConfig">
|
||||
<h3>{_t("Your server")}</h3>
|
||||
{_t(
|
||||
"Enter the location of your Element Matrix Services homeserver. It may use your own " +
|
||||
"domain name or be a subdomain of <a>element.io</a>.",
|
||||
{}, {
|
||||
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
</a>,
|
||||
},
|
||||
)}
|
||||
<form onSubmit={this.onSubmit} autoComplete="off" action={null}>
|
||||
<div className="mx_ServerConfig_fields">
|
||||
<Field
|
||||
id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Server Name")}
|
||||
placeholder={this.props.serverConfig.hsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
/>
|
||||
</div>
|
||||
{submitButton}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
|
||||
import {_t, _td} from "../../../languageHandler";
|
||||
import Field from "../elements/Field";
|
||||
import Field, {IInputProps} from "../elements/Field";
|
||||
|
||||
interface IProps {
|
||||
interface IProps extends Omit<IInputProps, "onValidate"> {
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
|
|
|
@ -1,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>
|
||||
);
|
||||
}
|
||||
}
|
485
src/components/views/auth/PasswordLogin.tsx
Normal file
485
src/components/views/auth/PasswordLogin.tsx
Normal file
|
@ -0,0 +1,485 @@
|
|||
/*
|
||||
Copyright 2015, 2016, 2017, 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import withValidation from "../elements/Validation";
|
||||
import * as Email from "../../../email";
|
||||
import Field from "../elements/Field";
|
||||
import CountryDropdown from "./CountryDropdown";
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
||||
interface IProps {
|
||||
username: string; // also used for email address
|
||||
phoneCountry: string;
|
||||
phoneNumber: string;
|
||||
|
||||
serverConfig: ValidatedServerConfig;
|
||||
loginIncorrect?: boolean;
|
||||
disableSubmit?: boolean;
|
||||
busy?: boolean;
|
||||
|
||||
onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void;
|
||||
onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void;
|
||||
onUsernameChanged?(username: string): void;
|
||||
onUsernameBlur?(username: string): void;
|
||||
onPhoneCountryChanged?(phoneCountry: string): void;
|
||||
onPhoneNumberChanged?(phoneNumber: string): void;
|
||||
onForgotPasswordClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
fieldValid: Partial<Record<LoginField, boolean>>;
|
||||
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone,
|
||||
password: "",
|
||||
}
|
||||
|
||||
enum LoginField {
|
||||
Email = "login_field_email",
|
||||
MatrixId = "login_field_mxid",
|
||||
Phone = "login_field_phone",
|
||||
Password = "login_field_phone",
|
||||
}
|
||||
|
||||
/*
|
||||
* A pure UI component which displays a username/password form.
|
||||
* The email/username/phone fields are fully-controlled, the password field is not.
|
||||
*/
|
||||
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onUsernameChanged: function() {},
|
||||
onUsernameBlur: function() {},
|
||||
onPhoneCountryChanged: function() {},
|
||||
onPhoneNumberChanged: function() {},
|
||||
loginIncorrect: false,
|
||||
disableSubmit: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
// Field error codes by field ID
|
||||
fieldValid: {},
|
||||
loginType: LoginField.MatrixId,
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
private onForgotPasswordClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onForgotPasswordClick();
|
||||
};
|
||||
|
||||
private onSubmitForm = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let username = ''; // XXX: Synapse breaks if you send null here:
|
||||
let phoneCountry = null;
|
||||
let phoneNumber = null;
|
||||
|
||||
switch (this.state.loginType) {
|
||||
case LoginField.Email:
|
||||
case LoginField.MatrixId:
|
||||
username = this.props.username;
|
||||
break;
|
||||
case LoginField.Phone:
|
||||
phoneCountry = this.props.phoneCountry;
|
||||
phoneNumber = this.props.phoneNumber;
|
||||
break;
|
||||
}
|
||||
|
||||
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
|
||||
};
|
||||
|
||||
private onUsernameChanged = ev => {
|
||||
this.props.onUsernameChanged(ev.target.value);
|
||||
};
|
||||
|
||||
private onUsernameFocus = () => {
|
||||
if (this.state.loginType === LoginField.MatrixId) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_focus");
|
||||
}
|
||||
};
|
||||
|
||||
private onUsernameBlur = ev => {
|
||||
if (this.state.loginType === LoginField.MatrixId) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_blur");
|
||||
}
|
||||
this.props.onUsernameBlur(ev.target.value);
|
||||
};
|
||||
|
||||
private onLoginTypeChange = ev => {
|
||||
const loginType = ev.target.value;
|
||||
this.setState({ loginType });
|
||||
this.props.onUsernameChanged(""); // Reset because email and username use the same state
|
||||
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
|
||||
};
|
||||
|
||||
private onPhoneCountryChanged = country => {
|
||||
this.props.onPhoneCountryChanged(country.iso2);
|
||||
};
|
||||
|
||||
private onPhoneNumberChanged = ev => {
|
||||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
};
|
||||
|
||||
private onPhoneNumberFocus = () => {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
|
||||
};
|
||||
|
||||
private onPhoneNumberBlur = ev => {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
|
||||
};
|
||||
|
||||
private onPasswordChanged = ev => {
|
||||
this.setState({password: ev.target.value});
|
||||
};
|
||||
|
||||
private async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
this.state.loginType,
|
||||
LoginField.Password,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
// values for required fields.
|
||||
for (const fieldID of fieldIDsInDisplayOrder) {
|
||||
const field = this[fieldID];
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
// We must wait for these validations to finish before queueing
|
||||
// up the setState below so our setState goes in the queue after
|
||||
// all the setStates from these validate calls (that's how we
|
||||
// know they've finished).
|
||||
await field.validate({ allowEmpty: false });
|
||||
}
|
||||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise(resolve => this.setState({}, resolve));
|
||||
|
||||
if (this.allFieldsValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
|
||||
|
||||
if (!invalidField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus the first invalid field and show feedback in the stricter mode
|
||||
// that no longer allows empty values for required fields.
|
||||
invalidField.focus();
|
||||
invalidField.validate({ allowEmpty: false, focused: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
private allFieldsValid() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private findFirstInvalidField(fieldIDs: LoginField[]) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private markFieldValid(fieldID: LoginField, valid: boolean) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
fieldValid,
|
||||
});
|
||||
}
|
||||
|
||||
private validateUsernameRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter username"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onUsernameValidate = async (fieldState) => {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(LoginField.MatrixId, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validateEmailRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address"),
|
||||
}, {
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onEmailValidate = async (fieldState) => {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(LoginField.Email, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePhoneNumberRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number"),
|
||||
}, {
|
||||
key: "number",
|
||||
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
|
||||
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPhoneNumberValidate = async (fieldState) => {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(LoginField.Password, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePasswordRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter password"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPasswordValidate = async (fieldState) => {
|
||||
const result = await this.validatePasswordRules(fieldState);
|
||||
this.markFieldValid(LoginField.Password, result.valid);
|
||||
return result;
|
||||
}
|
||||
|
||||
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
|
||||
const classes = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
switch (loginType) {
|
||||
case LoginField.Email:
|
||||
classes.error = this.props.loginIncorrect && !this.props.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="email_input"
|
||||
type="text"
|
||||
label={_t("Email")}
|
||||
placeholder="joe@example.com"
|
||||
value={this.props.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onEmailValidate}
|
||||
ref={field => this[LoginField.Email] = field}
|
||||
/>;
|
||||
case LoginField.MatrixId:
|
||||
classes.error = this.props.loginIncorrect && !this.props.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={_t("Username")}
|
||||
placeholder={_t("Username").toLocaleLowerCase()}
|
||||
value={this.props.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onUsernameValidate}
|
||||
ref={field => this[LoginField.MatrixId] = field}
|
||||
/>;
|
||||
case LoginField.Phone: {
|
||||
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
|
||||
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.props.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChanged}
|
||||
/>;
|
||||
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="phoneNumber"
|
||||
key="phone_input"
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
value={this.props.phoneNumber}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onFocus={this.onPhoneNumberFocus}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
ref={field => this[LoginField.Password] = field}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isLoginEmpty() {
|
||||
switch (this.state.loginType) {
|
||||
case LoginField.Email:
|
||||
case LoginField.MatrixId:
|
||||
return !this.props.username;
|
||||
case LoginField.Phone:
|
||||
return !this.props.phoneCountry || !this.props.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let forgotPasswordJsx;
|
||||
|
||||
if (this.props.onForgotPasswordClick) {
|
||||
forgotPasswordJsx = <AccessibleButton
|
||||
className="mx_Login_forgot"
|
||||
disabled={this.props.busy}
|
||||
kind="link"
|
||||
onClick={this.onForgotPasswordClick}
|
||||
>
|
||||
{_t("Forgot password?")}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
const pwFieldClass = classNames({
|
||||
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
|
||||
});
|
||||
|
||||
// If login is empty, autoFocus login, otherwise autoFocus password.
|
||||
// this is for when auto server discovery remounts us when the user tries to tab from username to password
|
||||
const autoFocusPassword = !this.isLoginEmpty();
|
||||
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
|
||||
|
||||
let loginType;
|
||||
if (!SdkConfig.get().disable_3pid_login) {
|
||||
loginType = (
|
||||
<div className="mx_Login_type_container">
|
||||
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
|
||||
<Field
|
||||
element="select"
|
||||
value={this.state.loginType}
|
||||
onChange={this.onLoginTypeChange}
|
||||
disabled={this.props.disableSubmit}
|
||||
>
|
||||
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
|
||||
{_t('Username')}
|
||||
</option>
|
||||
<option
|
||||
key={LoginField.Email}
|
||||
value={LoginField.Email}
|
||||
>
|
||||
{_t('Email address')}
|
||||
</option>
|
||||
<option key={LoginField.Password} value={LoginField.Password}>
|
||||
{_t('Phone')}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
{loginType}
|
||||
{loginField}
|
||||
<Field
|
||||
className={pwFieldClass}
|
||||
type="password"
|
||||
name="password"
|
||||
label={_t('Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChanged}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocusPassword}
|
||||
onValidate={this.onPasswordValidate}
|
||||
ref={field => this[LoginField.Password] = field}
|
||||
/>
|
||||
{forgotPasswordJsx}
|
||||
{ !this.props.busy && <input className="mx_Login_submit"
|
||||
type="submit"
|
||||
value={_t('Sign in')}
|
||||
disabled={this.props.disableSubmit}
|
||||
/> }
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,7 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import * as Email from '../../../email';
|
||||
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
|
||||
|
@ -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>
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,153 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import classnames from 'classnames';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import {makeType} from "../../../utils/TypeUtils";
|
||||
|
||||
const MODULAR_URL = 'https://element.io/matrix-services' +
|
||||
'?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication';
|
||||
|
||||
export const FREE = 'Free';
|
||||
export const PREMIUM = 'Premium';
|
||||
export const ADVANCED = 'Advanced';
|
||||
|
||||
export const TYPES = {
|
||||
FREE: {
|
||||
id: FREE,
|
||||
label: () => _t('Free'),
|
||||
logo: () => <img src={require('../../../../res/img/matrix-org-bw-logo.svg')} />,
|
||||
description: () => _t('Join millions for free on the largest public server'),
|
||||
serverConfig: makeType(ValidatedServerConfig, {
|
||||
hsUrl: "https://matrix-client.matrix.org",
|
||||
hsName: "matrix.org",
|
||||
hsNameIsDifferent: false,
|
||||
isUrl: "https://vector.im",
|
||||
}),
|
||||
},
|
||||
PREMIUM: {
|
||||
id: PREMIUM,
|
||||
label: () => _t('Premium'),
|
||||
logo: () => <img src={require('../../../../res/img/ems-logo.svg')} height={16} />,
|
||||
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
|
||||
a: sub => <a href={MODULAR_URL} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
</a>,
|
||||
}),
|
||||
identityServerUrl: "https://vector.im",
|
||||
},
|
||||
ADVANCED: {
|
||||
id: ADVANCED,
|
||||
label: () => _t('Advanced'),
|
||||
logo: () => <div>
|
||||
<img src={require('../../../../res/img/feather-customised/globe.svg')} />
|
||||
{_t('Other')}
|
||||
</div>,
|
||||
description: () => _t('Find other public servers or use a custom server'),
|
||||
},
|
||||
};
|
||||
|
||||
export function getTypeFromServerConfig(config) {
|
||||
const {hsUrl} = config;
|
||||
if (!hsUrl) {
|
||||
return null;
|
||||
} else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) {
|
||||
return FREE;
|
||||
} else if (new URL(hsUrl).hostname.endsWith('.modular.im')) {
|
||||
// This is an unlikely case to reach, as Modular defaults to hiding the
|
||||
// server type selector.
|
||||
return PREMIUM;
|
||||
} else {
|
||||
return ADVANCED;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ServerTypeSelector extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// The default selected type.
|
||||
selected: PropTypes.string,
|
||||
// Handler called when the selected type changes.
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const {
|
||||
selected,
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
updateSelectedType(type) {
|
||||
if (this.state.selected === type) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
selected: type,
|
||||
});
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(type);
|
||||
}
|
||||
}
|
||||
|
||||
onClick = (e) => {
|
||||
e.stopPropagation();
|
||||
const type = e.currentTarget.dataset.id;
|
||||
this.updateSelectedType(type);
|
||||
};
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const serverTypes = [];
|
||||
for (const type of Object.values(TYPES)) {
|
||||
const { id, label, logo, description } = type;
|
||||
const classes = classnames(
|
||||
"mx_ServerTypeSelector_type",
|
||||
`mx_ServerTypeSelector_type_${id}`,
|
||||
{
|
||||
"mx_ServerTypeSelector_type_selected": id === this.state.selected,
|
||||
},
|
||||
);
|
||||
|
||||
serverTypes.push(<div className={classes} key={id} >
|
||||
<div className="mx_ServerTypeSelector_label">
|
||||
{label()}
|
||||
</div>
|
||||
<AccessibleButton onClick={this.onClick} data-id={id}>
|
||||
<div className="mx_ServerTypeSelector_logo">
|
||||
{logo()}
|
||||
</div>
|
||||
<div className="mx_ServerTypeSelector_description">
|
||||
{description()}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return <div className="mx_ServerTypeSelector">
|
||||
{serverTypes}
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import * as sdk from "../../../index";
|
||||
import PropTypes from "prop-types";
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
|
||||
export default class SignInToText extends React.PureComponent {
|
||||
static propTypes = {
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
};
|
||||
|
||||
render() {
|
||||
let signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
|
||||
serverName: this.props.serverConfig.hsName,
|
||||
});
|
||||
if (this.props.serverConfig.hsNameIsDifferent) {
|
||||
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
|
||||
|
||||
signInToText = _t('Sign in to your Matrix account on <underlinedServerName />', {}, {
|
||||
'underlinedServerName': () => {
|
||||
return <TextWithTooltip
|
||||
class="mx_Login_underlinedServerName"
|
||||
tooltip={this.props.serverConfig.hsUrl}
|
||||
>
|
||||
{this.props.serverConfig.hsName}
|
||||
</TextWithTooltip>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let editLink = null;
|
||||
if (this.props.onEditServerDetailsClick) {
|
||||
editLink = <a className="mx_AuthBody_editServerDetails"
|
||||
href="#" onClick={this.props.onEditServerDetailsClick}
|
||||
>
|
||||
{_t('Change')}
|
||||
</a>;
|
||||
}
|
||||
|
||||
return <h3>
|
||||
{signInToText}
|
||||
{editLink}
|
||||
</h3>;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
|
||||
|
|
|
@ -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>;
|
||||
});
|
||||
|
|
|
@ -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;
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
203
src/components/views/dialogs/ServerPickerDialog.tsx
Normal file
203
src/components/views/dialogs/ServerPickerDialog.tsx
Normal file
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import 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 it’s a good place for many.");
|
||||
}
|
||||
|
||||
let defaultServerName = this.defaultServer.hsName;
|
||||
if (this.defaultServer.hsNameIsDifferent) {
|
||||
defaultServerName = (
|
||||
<TextWithTooltip class="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>
|
||||
{this.defaultServer.hsName}
|
||||
</TextWithTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
title={this.props.title || _t("Sign into your homeserver")}
|
||||
className="mx_ServerPickerDialog"
|
||||
contentId="mx_ServerPickerDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
fixedWidth={false}
|
||||
hasCancel={true}
|
||||
>
|
||||
<form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}>
|
||||
<p>
|
||||
{_t("We call the places 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>;
|
||||
}
|
||||
}
|
147
src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
Normal file
147
src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
Normal file
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import {
|
||||
Capability,
|
||||
Widget,
|
||||
WidgetEventCapability,
|
||||
WidgetKind,
|
||||
} from "matrix-widget-api";
|
||||
import { objectShallowClone } from "../../../utils/objects";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import { CapabilityText } from "../../../widgets/CapabilityText";
|
||||
|
||||
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
|
||||
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
|
||||
}
|
||||
|
||||
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
|
||||
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
|
||||
}
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
requestedCapabilities: Set<Capability>;
|
||||
widget: Widget;
|
||||
widgetKind: WidgetKind; // TODO: Refactor into the Widget class
|
||||
}
|
||||
|
||||
interface IBooleanStates {
|
||||
// @ts-ignore - TS wants a string key, but we know better
|
||||
[capability: Capability]: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
booleanStates: IBooleanStates;
|
||||
rememberSelection: boolean;
|
||||
}
|
||||
|
||||
export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<IProps, IState> {
|
||||
private eventPermissionsMap = new Map<Capability, WidgetEventCapability>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const parsedEvents = WidgetEventCapability.findEventCapabilities(this.props.requestedCapabilities);
|
||||
parsedEvents.forEach(e => this.eventPermissionsMap.set(e.raw, e));
|
||||
|
||||
const states: IBooleanStates = {};
|
||||
this.props.requestedCapabilities.forEach(c => states[c] = true);
|
||||
|
||||
this.state = {
|
||||
booleanStates: states,
|
||||
rememberSelection: true,
|
||||
};
|
||||
}
|
||||
|
||||
private onToggle = (capability: Capability) => {
|
||||
const newStates = objectShallowClone(this.state.booleanStates);
|
||||
newStates[capability] = !newStates[capability];
|
||||
this.setState({booleanStates: newStates});
|
||||
};
|
||||
|
||||
private onRememberSelectionChange = (newVal: boolean) => {
|
||||
this.setState({rememberSelection: newVal});
|
||||
};
|
||||
|
||||
private onSubmit = async (ev) => {
|
||||
this.closeAndTryRemember(Object.entries(this.state.booleanStates)
|
||||
.filter(([_, isSelected]) => isSelected)
|
||||
.map(([cap]) => cap));
|
||||
};
|
||||
|
||||
private onReject = async (ev) => {
|
||||
this.closeAndTryRemember([]); // nothing was approved
|
||||
};
|
||||
|
||||
private closeAndTryRemember(approved: Capability[]) {
|
||||
if (this.state.rememberSelection) {
|
||||
setRememberedCapabilitiesForWidget(this.props.widget, approved);
|
||||
}
|
||||
this.props.onFinished({approved});
|
||||
}
|
||||
|
||||
public render() {
|
||||
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
|
||||
const text = CapabilityText.for(cap, this.props.widgetKind);
|
||||
const byline = text.byline
|
||||
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{text.byline}</span>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx_WidgetCapabilitiesPromptDialog_cap" key={cap + i}>
|
||||
<StyledCheckbox
|
||||
checked={isChecked}
|
||||
onChange={() => this.onToggle(cap)}
|
||||
>{text.primary}</StyledCheckbox>
|
||||
{byline}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_WidgetCapabilitiesPromptDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Approve widget permissions")}
|
||||
>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="text-muted">{_t("This widget would like to:")}</div>
|
||||
{checkboxRows}
|
||||
<DialogButtons
|
||||
primaryButton={_t("Approve")}
|
||||
cancelButton={_t("Decline All")}
|
||||
onPrimaryButtonClick={this.onSubmit}
|
||||
onCancel={this.onReject}
|
||||
additive={
|
||||
<LabelledToggleSwitch
|
||||
value={this.state.rememberSelection}
|
||||
toggleInFront={true}
|
||||
onChange={this.onRememberSelectionChange}
|
||||
label={_t("Remember my selection for this widget")} />}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -17,18 +17,17 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import * as sdk from "../../../index";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import {Widget} from "matrix-widget-api";
|
||||
import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore";
|
||||
|
||||
export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
widgetUrl: PropTypes.string.isRequired,
|
||||
widgetId: PropTypes.string.isRequired,
|
||||
isUserWidget: PropTypes.bool.isRequired,
|
||||
widget: PropTypes.objectOf(Widget).isRequired,
|
||||
widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api
|
||||
inRoomId: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -51,16 +50,10 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
|||
if (this.state.rememberSelection) {
|
||||
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
|
||||
|
||||
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
|
||||
if (!currentValues.allow) currentValues.allow = [];
|
||||
if (!currentValues.deny) currentValues.deny = [];
|
||||
|
||||
const securityKey = WidgetUtils.getWidgetSecurityKey(
|
||||
this.props.widgetId,
|
||||
this.props.widgetUrl,
|
||||
this.props.isUserWidget);
|
||||
(allowed ? currentValues.allow : currentValues.deny).push(securityKey);
|
||||
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
|
||||
WidgetPermissionStore.instance.setOIDCState(
|
||||
this.props.widget, this.props.widgetKind, this.props.inRoomId,
|
||||
allowed ? OIDCState.Allowed : OIDCState.Denied,
|
||||
);
|
||||
}
|
||||
|
||||
this.props.onFinished(allowed);
|
||||
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
/>;
|
||||
|
|
|
@ -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;
|
110
src/components/views/elements/SSOButtons.tsx
Normal file
110
src/components/views/elements/SSOButtons.tsx
Normal 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;
|
93
src/components/views/elements/ServerPicker.tsx
Normal file
93
src/components/views/elements/ServerPicker.tsx
Normal 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;
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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") }
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>);
|
||||
});
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 you’re 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>;
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -33,6 +33,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
|||
'MessageComposerInput.autoReplaceEmoji',
|
||||
'MessageComposerInput.suggestEmoji',
|
||||
'sendTypingNotifications',
|
||||
'MessageComposerInput.ctrlEnterToSend',
|
||||
];
|
||||
|
||||
static TIMELINE_SETTINGS = [
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue