Merge pull request #5201 from matrix-org/travis/3pid-invites

Tactical improvements to 3PID invites
This commit is contained in:
Travis Ralston 2020-09-15 10:42:55 -06:00 committed by GitHub
commit 3d9c520af8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 192 additions and 48 deletions

View file

@ -42,6 +42,7 @@ import {Mjolnir} from "./mjolnir/Mjolnir";
import DeviceListener from "./DeviceListener"; import DeviceListener from "./DeviceListener";
import {Jitsi} from "./widgets/Jitsi"; import {Jitsi} from "./widgets/Jitsi";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform";
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
const HOMESERVER_URL_KEY = "mx_hs_url"; const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url"; const ID_SERVER_URL_KEY = "mx_is_url";
@ -666,17 +667,30 @@ export async function onLoggedOut() {
// that can occur when components try to use a null client. // that can occur when components try to use a null client.
dis.dispatch({action: 'on_logged_out'}, true); dis.dispatch({action: 'on_logged_out'}, true);
stopMatrixClient(); stopMatrixClient();
await _clearStorage(); await _clearStorage({deleteEverything: true});
} }
/** /**
* @param {object} opts Options for how to clear storage.
* @returns {Promise} promise which resolves once the stores have been cleared * @returns {Promise} promise which resolves once the stores have been cleared
*/ */
async function _clearStorage() { async function _clearStorage(opts: {deleteEverything: boolean}) {
Analytics.disable(); Analytics.disable();
if (window.localStorage) { if (window.localStorage) {
// try to save any 3pid invites from being obliterated
const pendingInvites = ThreepidInviteStore.instance.getWireInvites();
window.localStorage.clear(); window.localStorage.clear();
// now restore those invites
if (!opts?.deleteEverything) {
pendingInvites.forEach(i => {
const roomId = i.roomId;
delete i.roomId; // delete to avoid confusing the store
ThreepidInviteStore.instance.storeInvite(roomId, i);
});
}
} }
if (window.sessionStorage) { if (window.sessionStorage) {

View file

@ -56,6 +56,7 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay
import RoomListStore from "../../stores/room-list/RoomListStore"; import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer"; import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -81,7 +82,7 @@ interface IProps {
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
page_type: string; page_type: string;
autoJoin: boolean; autoJoin: boolean;
thirdPartyInvite?: object; threepidInvite?: IThreepidInvite;
roomOobData?: object; roomOobData?: object;
currentRoomId: string; currentRoomId: string;
ConferenceHandler?: object; ConferenceHandler?: object;
@ -631,7 +632,7 @@ class LoggedInView extends React.Component<IProps, IState> {
ref={this._roomView} ref={this._roomView}
autoJoin={this.props.autoJoin} autoJoin={this.props.autoJoin}
onRegistered={this.props.onRegistered} onRegistered={this.props.onRegistered}
thirdPartyInvite={this.props.thirdPartyInvite} threepidInvite={this.props.threepidInvite}
oobData={this.props.roomOobData} oobData={this.props.roomOobData}
viaServers={this.props.viaServers} viaServers={this.props.viaServers}
key={this.props.currentRoomId || 'roomview'} key={this.props.currentRoomId || 'roomview'}

View file

@ -78,6 +78,7 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif
import { SettingLevel } from "../../settings/SettingLevel"; import { SettingLevel } from "../../settings/SettingLevel";
import { leaveRoomBehaviour } from "../../utils/membership"; import { leaveRoomBehaviour } from "../../utils/membership";
import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog"; import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog";
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
export enum Views { export enum Views {
@ -137,9 +138,9 @@ interface IRoomInfo {
auto_join?: boolean; auto_join?: boolean;
highlighted?: boolean; highlighted?: boolean;
third_party_invite?: object;
oob_data?: object; oob_data?: object;
via_servers?: string[]; via_servers?: string[];
threepid_invite?: IThreepidInvite;
} }
/* eslint-enable camelcase */ /* eslint-enable camelcase */
@ -196,7 +197,7 @@ interface IState {
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
serverConfig?: ValidatedServerConfig; serverConfig?: ValidatedServerConfig;
ready: boolean; ready: boolean;
thirdPartyInvite?: object; threepidInvite?: IThreepidInvite,
roomOobData?: object; roomOobData?: object;
viaServers?: string[]; viaServers?: string[];
pendingInitialSync?: boolean; pendingInitialSync?: boolean;
@ -260,6 +261,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// outside this.state because updating it should never trigger a // outside this.state because updating it should never trigger a
// rerender. // rerender.
this.screenAfterLogin = this.props.initialScreenAfterLogin; this.screenAfterLogin = this.props.initialScreenAfterLogin;
if (this.screenAfterLogin) {
const params = this.screenAfterLogin.params || {};
if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) {
// probably a threepid invite - try to store it
const roomId = this.screenAfterLogin.screen.substring("room/".length);
ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat);
}
}
this.windowWidth = 10000; this.windowWidth = 10000;
this.handleResize(); this.handleResize();
@ -404,9 +413,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
}).then((loadedSession) => { }).then((loadedSession) => {
if (!loadedSession) { if (!loadedSession) {
// fall back to showing the welcome screen // fall back to showing the welcome screen... unless we have a 3pid invite pending
if (ThreepidInviteStore.instance.pickBestInvite()) {
dis.dispatch({action: 'start_registration'});
} else {
dis.dispatch({action: "view_welcome_page"}); dis.dispatch({action: "view_welcome_page"});
} }
}
}); });
// Note we don't catch errors from this: we catch everything within // Note we don't catch errors from this: we catch everything within
// loadSession as there's logic there to ask the user if they want // loadSession as there's logic there to ask the user if they want
@ -835,10 +848,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// context of that particular event. // context of that particular event.
// @param {boolean=} roomInfo.highlighted If true, add event_id to the hash of the URL // @param {boolean=} roomInfo.highlighted If true, add event_id to the hash of the URL
// and alter the EventTile to appear highlighted. // and alter the EventTile to appear highlighted.
// @param {Object=} roomInfo.third_party_invite Object containing data about the third party // @param {Object=} roomInfo.threepid_invite Object containing data about the third party
// we received to join the room, if any. // we received to join the room, if any.
// @param {string=} roomInfo.third_party_invite.inviteSignUrl 3pid invite sign URL
// @param {string=} roomInfo.third_party_invite.invitedEmail The email address the invite was sent to
// @param {Object=} roomInfo.oob_data Object of additional data about the room // @param {Object=} roomInfo.oob_data Object of additional data about the room
// that has been passed out-of-band (eg. // that has been passed out-of-band (eg.
// room name and avatar from an invite email) // room name and avatar from an invite email)
@ -896,7 +907,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
view: Views.LOGGED_IN, view: Views.LOGGED_IN,
currentRoomId: roomInfo.room_id || null, currentRoomId: roomInfo.room_id || null,
page_type: PageTypes.RoomView, page_type: PageTypes.RoomView,
thirdPartyInvite: roomInfo.third_party_invite, threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data, roomOobData: roomInfo.oob_data,
viaServers: roomInfo.via_servers, viaServers: roomInfo.via_servers,
ready: true, ready: true,
@ -1203,6 +1214,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// the homepage. // the homepage.
dis.dispatch({action: 'view_home_page'}); dis.dispatch({action: 'view_home_page'});
} }
} else if (ThreepidInviteStore.instance.pickBestInvite()) {
// The user has a 3pid invite pending - show them that
const threepidInvite = ThreepidInviteStore.instance.pickBestInvite();
// HACK: This is a pretty brutal way of threading the invite back through
// our systems, but it's the safest we have for now.
const params = ThreepidInviteStore.instance.translateToWireFormat(threepidInvite);
this.showScreen(`room/${threepidInvite.roomId}`, params)
} else { } else {
// The user has just logged in after registering, // The user has just logged in after registering,
// so show the homepage. // so show the homepage.
@ -1639,16 +1658,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149 // TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149
// FIXME: sort_out caseConsistency let threepidInvite: IThreepidInvite;
const thirdPartyInvite = { if (params.signurl && params.email) {
inviteSignUrl: params.signurl, threepidInvite = ThreepidInviteStore.instance
invitedEmail: params.email, .storeInvite(roomString, params as IThreepidInviteWireFormat);
}; }
const oobData = {
name: params.room_name,
avatarUrl: params.room_avatar_url,
inviterName: params.inviter_name,
};
// on our URLs there might be a ?via=matrix.org or similar to help // on our URLs there might be a ?via=matrix.org or similar to help
// joins to the room succeed. We'll pass these through as an array // joins to the room succeed. We'll pass these through as an array
@ -1669,8 +1683,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// it as highlighted, which will propagate to RoomView and highlight the // it as highlighted, which will propagate to RoomView and highlight the
// associated EventTile. // associated EventTile.
highlighted: Boolean(eventId), highlighted: Boolean(eventId),
third_party_invite: thirdPartyInvite, threepid_invite: threepidInvite,
oob_data: oobData, // TODO: Replace oob_data with the threepidInvite (which has the same info).
// This isn't done yet because it's threaded through so many more places.
// See https://github.com/vector-im/element-web/issues/15157
oob_data: {
name: threepidInvite?.roomName,
avatarUrl: threepidInvite?.roomAvatarUrl,
inviterName: threepidInvite?.inviterName,
},
room_alias: undefined, room_alias: undefined,
room_id: undefined, room_id: undefined,
}; };
@ -2002,12 +2023,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
view = <Welcome />; view = <Welcome />;
} else if (this.state.view === Views.REGISTER) { } else if (this.state.view === Views.REGISTER) {
const Registration = sdk.getComponent('structures.auth.Registration'); const Registration = sdk.getComponent('structures.auth.Registration');
const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail;
view = ( view = (
<Registration <Registration
clientSecret={this.state.register_client_secret} clientSecret={this.state.register_client_secret}
sessionId={this.state.register_session_id} sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid} idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email} email={email}
brand={this.props.config.brand} brand={this.props.config.brand}
makeRegistrationUrl={this.makeRegistrationUrl} makeRegistrationUrl={this.makeRegistrationUrl}
onLoggedIn={this.onRegisterFlowComplete} onLoggedIn={this.onRegisterFlowComplete}

View file

@ -72,6 +72,7 @@ import RoomHeader from "../views/rooms/RoomHeader";
import TintableSvg from "../views/elements/TintableSvg"; import TintableSvg from "../views/elements/TintableSvg";
import type * as ConferenceHandler from '../../VectorConferenceHandler'; import type * as ConferenceHandler from '../../VectorConferenceHandler';
import {XOR} from "../../@types/common"; import {XOR} from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -86,15 +87,7 @@ if (DEBUG) {
interface IProps { interface IProps {
ConferenceHandler?: ConferenceHandler; ConferenceHandler?: ConferenceHandler;
// An object representing a third party invite to join this room threepidInvite: IThreepidInvite,
// Fields:
// * inviteSignUrl (string) The URL used to join this room from an email invite
// (given as part of the link in the invite email)
// * invitedEmail (string) The email address that was invited to this room
thirdPartyInvite?: {
inviteSignUrl: string;
invitedEmail: string;
};
// Any data about the room that would normally come from the homeserver // Any data about the room that would normally come from the homeserver
// but has been passed out-of-band, eg. the room name and avatar URL // but has been passed out-of-band, eg. the room name and avatar URL
@ -1178,8 +1171,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// return; // return;
} else { } else {
Promise.resolve().then(() => { Promise.resolve().then(() => {
const signUrl = this.props.thirdPartyInvite ? const signUrl = this.props.threepidInvite?.signUrl;
this.props.thirdPartyInvite.inviteSignUrl : undefined;
dis.dispatch({ dis.dispatch({
action: 'join_room', action: 'join_room',
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
@ -1752,10 +1744,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (this.props.oobData) { if (this.props.oobData) {
inviterName = this.props.oobData.inviterName; inviterName = this.props.oobData.inviterName;
} }
let invitedEmail = undefined; const invitedEmail = this.props.threepidInvite?.toEmail;
if (this.props.thirdPartyInvite) {
invitedEmail = this.props.thirdPartyInvite.invitedEmail;
}
// We have no room object for this room, only the ID. // We have no room object for this room, only the ID.
// We've got to this room by following a link, possibly a third party invite. // We've got to this room by following a link, possibly a third party invite.
@ -1773,7 +1762,7 @@ export default class RoomView extends React.Component<IProps, IState> {
inviterName={inviterName} inviterName={inviterName}
invitedEmail={invitedEmail} invitedEmail={invitedEmail}
oobData={this.props.oobData} oobData={this.props.oobData}
signUrl={this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : null} signUrl={this.props.threepidInvite?.signUrl}
room={this.state.room} room={this.state.room}
/> />
</ErrorBoundary> </ErrorBoundary>
@ -1907,10 +1896,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (this.props.oobData) { if (this.props.oobData) {
inviterName = this.props.oobData.inviterName; inviterName = this.props.oobData.inviterName;
} }
let invitedEmail = undefined; const invitedEmail = this.props.threepidInvite?.toEmail;
if (this.props.thirdPartyInvite) {
invitedEmail = this.props.thirdPartyInvite.invitedEmail;
}
hideCancel = true; hideCancel = true;
previewBar = ( previewBar = (
<RoomPreviewBar <RoomPreviewBar

View file

@ -25,6 +25,7 @@ import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
/* This file contains a collection of components which are used by the /* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed * InteractiveAuth to prompt the user to enter the information needed
@ -404,8 +405,12 @@ export class EmailIdentityAuthEntry extends React.Component {
// the validation link, we won't know the email address, so if we don't have it, // the validation link, we won't know the email address, so if we don't have it,
// assume that the link has been clicked and the server will realise when we poll. // assume that the link has been clicked and the server will realise when we poll.
if (this.props.inputs.emailAddress === undefined) { if (this.props.inputs.emailAddress === undefined) {
const Loader = sdk.getComponent("elements.Spinner"); return <Spinner />;
return <Loader />; } else if (this.props.stageState?.emailSid) {
// we only have a session ID if the user has clicked the link in their email,
// so show a loading state instead of "an email has been sent to..." because
// that's confusing when you've already read that email.
return <Spinner />;
} else { } else {
return ( return (
<div> <div>

View file

@ -0,0 +1,116 @@
/*
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 EventEmitter from "events";
import { base32 } from "rfc4648";
// Dev note: the interface is split in two so we don't have to disable the
// linter across the whole project.
export interface IThreepidInviteWireFormat {
email: string;
signurl: string;
room_name: string; // eslint-disable-line camelcase
room_avatar_url: string; // eslint-disable-line camelcase
inviter_name: string; // eslint-disable-line camelcase
// TODO: Figure out if these are ever populated
guest_access_token?: string; // eslint-disable-line camelcase
guest_user_id?: string; // eslint-disable-line camelcase
}
interface IPersistedThreepidInvite extends IThreepidInviteWireFormat {
roomId: string;
}
export interface IThreepidInvite {
id: string; // generated by us
roomId: string;
toEmail: string;
signUrl: string;
roomName: string;
roomAvatarUrl: string;
inviterName: string;
}
const STORAGE_PREFIX = "mx_threepid_invite_";
export default class ThreepidInviteStore extends EventEmitter {
private static _instance: ThreepidInviteStore;
public static get instance(): ThreepidInviteStore {
if (!ThreepidInviteStore._instance) {
ThreepidInviteStore._instance = new ThreepidInviteStore();
}
return ThreepidInviteStore._instance;
}
public storeInvite(roomId: string, wireInvite: IThreepidInviteWireFormat): IThreepidInvite {
const invite = <IPersistedThreepidInvite>{roomId, ...wireInvite};
const id = this.generateIdOf(invite);
localStorage.setItem(`${STORAGE_PREFIX}${id}`, JSON.stringify(invite));
return this.translateInvite(invite);
}
public getWireInvites(): IPersistedThreepidInvite[] {
const results: IPersistedThreepidInvite[] = [];
for (let i = 0; i < localStorage.length; i++) {
const keyName = localStorage.key(i);
if (!keyName.startsWith(STORAGE_PREFIX)) continue;
results.push(JSON.parse(localStorage.getItem(keyName)) as IPersistedThreepidInvite);
}
return results;
}
public getInvites(): IThreepidInvite[] {
return this.getWireInvites().map(i => this.translateInvite(i));
}
// Currently Element can only handle one invite at a time, so handle that
public pickBestInvite(): IThreepidInvite {
return this.getInvites()[0];
}
public resolveInvite(invite: IThreepidInvite) {
localStorage.removeItem(`${STORAGE_PREFIX}${invite.id}`);
}
private generateIdOf(persisted: IPersistedThreepidInvite): string {
// Use a consistent "hash" to form an ID.
return base32.stringify(Buffer.from(JSON.stringify(persisted)));
}
private translateInvite(persisted: IPersistedThreepidInvite): IThreepidInvite {
return {
id: this.generateIdOf(persisted),
roomId: persisted.roomId,
toEmail: persisted.email,
signUrl: persisted.signurl,
roomName: persisted.room_name,
roomAvatarUrl: persisted.room_avatar_url,
inviterName: persisted.inviter_name,
};
}
public translateToWireFormat(invite: IThreepidInvite): IThreepidInviteWireFormat {
return {
email: invite.toEmail,
signurl: invite.signUrl,
room_name: invite.roomName,
room_avatar_url: invite.roomAvatarUrl,
inviter_name: invite.inviterName,
};
}
}