From dc44b9ef59afc7bbef5c64547523d1bf6071d5a5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Sep 2020 19:49:48 -0600 Subject: [PATCH 1/8] Store and thread 3pid invite through the app This doesn't do anything with the stored value (yet), but enables us to do something with it in a future commit. --- src/components/structures/LoggedInView.tsx | 5 +- src/components/structures/MatrixChat.tsx | 44 +++++---- src/components/structures/RoomView.tsx | 26 ++--- src/stores/ThreepidInviteStore.ts | 107 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 41 deletions(-) create mode 100644 src/stores/ThreepidInviteStore.ts diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 1ac15caa4c..81b8da2cad 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -56,6 +56,7 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; 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) // 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 page_type: string; autoJoin: boolean; - thirdPartyInvite?: object; + threepidInvite?: IThreepidInvite; roomOobData?: object; currentRoomId: string; ConferenceHandler?: object; @@ -631,7 +632,7 @@ class LoggedInView extends React.Component { ref={this._roomView} autoJoin={this.props.autoJoin} onRegistered={this.props.onRegistered} - thirdPartyInvite={this.props.thirdPartyInvite} + threepidInvite={this.props.threepidInvite} oobData={this.props.roomOobData} viaServers={this.props.viaServers} key={this.props.currentRoomId || 'roomview'} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index d3d5835dae..13e0c21858 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -78,6 +78,7 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif import { SettingLevel } from "../../settings/SettingLevel"; import { leaveRoomBehaviour } from "../../utils/membership"; import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog"; +import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore"; /** constants for MatrixChat.state.view */ export enum Views { @@ -137,9 +138,9 @@ interface IRoomInfo { auto_join?: boolean; highlighted?: boolean; - third_party_invite?: object; oob_data?: object; via_servers?: string[]; + threepid_invite?: IThreepidInvite; } /* eslint-enable camelcase */ @@ -196,7 +197,7 @@ interface IState { resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; - thirdPartyInvite?: object; + threepidInvite?: IThreepidInvite, roomOobData?: object; viaServers?: string[]; pendingInitialSync?: boolean; @@ -260,6 +261,14 @@ export default class MatrixChat extends React.PureComponent { // outside this.state because updating it should never trigger a // rerender. 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.handleResize(); @@ -835,10 +844,8 @@ export default class MatrixChat extends React.PureComponent { // context of that particular event. // @param {boolean=} roomInfo.highlighted If true, add event_id to the hash of the URL // and alter the EventTile to appear highlighted. - // @param {Object=} roomInfo.third_party_invite Object containing data about the third party - // 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.threepid_invite Object containing data about the third party + // we received to join the room, if any. // @param {Object=} roomInfo.oob_data Object of additional data about the room // that has been passed out-of-band (eg. // room name and avatar from an invite email) @@ -896,7 +903,7 @@ export default class MatrixChat extends React.PureComponent { view: Views.LOGGED_IN, currentRoomId: roomInfo.room_id || null, page_type: PageTypes.RoomView, - thirdPartyInvite: roomInfo.third_party_invite, + threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, viaServers: roomInfo.via_servers, ready: true, @@ -1639,16 +1646,11 @@ export default class MatrixChat extends React.PureComponent { // TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149 - // FIXME: sort_out caseConsistency - const thirdPartyInvite = { - inviteSignUrl: params.signurl, - invitedEmail: params.email, - }; - const oobData = { - name: params.room_name, - avatarUrl: params.room_avatar_url, - inviterName: params.inviter_name, - }; + let threepidInvite: IThreepidInvite; + if (params.signurl && params.email) { + threepidInvite = ThreepidInviteStore.instance + .storeInvite(roomString, params as IThreepidInviteWireFormat); + } // 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 @@ -1669,8 +1671,12 @@ export default class MatrixChat extends React.PureComponent { // it as highlighted, which will propagate to RoomView and highlight the // associated EventTile. highlighted: Boolean(eventId), - third_party_invite: thirdPartyInvite, - oob_data: oobData, + threepid_invite: threepidInvite, + oob_data: { + name: threepidInvite?.roomName, + avatarUrl: threepidInvite?.roomAvatarUrl, + inviterName: threepidInvite?.inviterName, + }, room_alias: undefined, room_id: undefined, }; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 738042c680..039d36a8de 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -72,6 +72,7 @@ import RoomHeader from "../views/rooms/RoomHeader"; import TintableSvg from "../views/elements/TintableSvg"; import type * as ConferenceHandler from '../../VectorConferenceHandler'; import {XOR} from "../../@types/common"; +import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -86,15 +87,7 @@ if (DEBUG) { interface IProps { ConferenceHandler?: ConferenceHandler; - // An object representing a third party invite to join this room - // 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; - }; + threepidInvite: IThreepidInvite, // 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 @@ -1178,8 +1171,7 @@ export default class RoomView extends React.Component { // return; } else { Promise.resolve().then(() => { - const signUrl = this.props.thirdPartyInvite ? - this.props.thirdPartyInvite.inviteSignUrl : undefined; + const signUrl = this.props.threepidInvite?.signUrl; dis.dispatch({ action: 'join_room', opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, @@ -1752,10 +1744,7 @@ export default class RoomView extends React.Component { if (this.props.oobData) { inviterName = this.props.oobData.inviterName; } - let invitedEmail = undefined; - if (this.props.thirdPartyInvite) { - invitedEmail = this.props.thirdPartyInvite.invitedEmail; - } + const invitedEmail = this.props.threepidInvite?.toEmail; // 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. @@ -1773,7 +1762,7 @@ export default class RoomView extends React.Component { inviterName={inviterName} invitedEmail={invitedEmail} oobData={this.props.oobData} - signUrl={this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : null} + signUrl={this.props.threepidInvite?.signUrl} room={this.state.room} /> @@ -1907,10 +1896,7 @@ export default class RoomView extends React.Component { if (this.props.oobData) { inviterName = this.props.oobData.inviterName; } - let invitedEmail = undefined; - if (this.props.thirdPartyInvite) { - invitedEmail = this.props.thirdPartyInvite.invitedEmail; - } + const invitedEmail = this.props.threepidInvite?.toEmail; hideCancel = true; previewBar = ( {roomId, ...wireInvite}; + const id = this.generateIdOf(invite); + localStorage.setItem(`${STORAGE_PREFIX}${id}`, JSON.stringify(invite)); + return this.translateInvite(invite); + } + + public getInvites(): IThreepidInvite[] { + const result: IThreepidInvite[] = []; + for (let i = 0; i < localStorage.length; i++) { + const keyName = localStorage.key(i); + if (!keyName.startsWith(STORAGE_PREFIX)) continue; + + const persisted = JSON.parse(localStorage.getItem(keyName)) as IPersistedThreepidInvite; + result.push(this.translateInvite(persisted)); + } + return result; + } + + // 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, + }; + } +} From b1cdf1bc9a2745f4b75f81563153c361e319ade9 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Sep 2020 19:53:52 -0600 Subject: [PATCH 2/8] Redirect to the registration page if there's a 3PID invite Fixes https://github.com/vector-im/element-web/issues/15130 --- src/components/structures/MatrixChat.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 13e0c21858..787b1a44e3 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -413,8 +413,12 @@ export default class MatrixChat extends React.PureComponent { }); }).then((loadedSession) => { if (!loadedSession) { - // fall back to showing the welcome screen - dis.dispatch({action: "view_welcome_page"}); + // 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"}); + } } }); // Note we don't catch errors from this: we catch everything within From a5d7b24805a34fac1ec74466334e23288f06eb59 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Sep 2020 19:55:15 -0600 Subject: [PATCH 3/8] Add a note for why oob_data isn't threaded yet See https://github.com/vector-im/element-web/issues/15157 --- src/components/structures/MatrixChat.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 787b1a44e3..64524641e2 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1676,6 +1676,9 @@ export default class MatrixChat extends React.PureComponent { // associated EventTile. highlighted: Boolean(eventId), threepid_invite: threepidInvite, + // 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, From 803badba1bf0621774e36d5f48b69a859e56a041 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Sep 2020 20:20:33 -0600 Subject: [PATCH 4/8] Show the user's 3PID invite after they've reloaded the page This is a step towards https://github.com/vector-im/element-web/issues/13430 Since we've stored the invite, we can send the user to it once they reload the page or revisit Element. We currently only support one invite at a time, but this should be fine for most cases. We only do this restoration if the next screen isn't set to avoid breaking the user out of an expected flow. As an added touch, this also ensures that the email address is pre-filled on the registration page if needed, just in case the user refreshes before getting to the submit button. --- src/Lifecycle.js | 11 +++++++++++ src/components/structures/MatrixChat.tsx | 11 ++++++++++- src/stores/ThreepidInviteStore.ts | 25 +++++++++++++++++------- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Lifecycle.js b/src/Lifecycle.js index d2de31eb80..dc5718b378 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -42,6 +42,7 @@ import {Mjolnir} from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; import {Jitsi} from "./widgets/Jitsi"; 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 ID_SERVER_URL_KEY = "mx_is_url"; @@ -676,7 +677,17 @@ async function _clearStorage() { Analytics.disable(); if (window.localStorage) { + // try to save any 3pid invites from being obliterated + const pendingInvites = ThreepidInviteStore.instance.getWireInvites(); + window.localStorage.clear(); + + // now restore those invites + pendingInvites.forEach(i => { + const roomId = i.roomId; + delete i.roomId; // delete to avoid confusing the store + ThreepidInviteStore.instance.storeInvite(roomId, i); + }); } if (window.sessionStorage) { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 64524641e2..3a8a02c098 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1214,6 +1214,14 @@ export default class MatrixChat extends React.PureComponent { // the homepage. 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 { // The user has just logged in after registering, // so show the homepage. @@ -2015,12 +2023,13 @@ export default class MatrixChat extends React.PureComponent { view = ; } else if (this.state.view === Views.REGISTER) { const Registration = sdk.getComponent('structures.auth.Registration'); + const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail; view = ( {roomId, ...wireInvite}; const id = this.generateIdOf(invite); localStorage.setItem(`${STORAGE_PREFIX}${id}`, JSON.stringify(invite)); return this.translateInvite(invite); } - public getInvites(): IThreepidInvite[] { - const result: IThreepidInvite[] = []; + 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; - - const persisted = JSON.parse(localStorage.getItem(keyName)) as IPersistedThreepidInvite; - result.push(this.translateInvite(persisted)); + results.push(JSON.parse(localStorage.getItem(keyName)) as IPersistedThreepidInvite); } - return result; + 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 @@ -104,4 +105,14 @@ export default class ThreepidInviteStore extends EventEmitter { 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, + }; + } } From 525ec6b709f0af56803e8923ff5471ba1f0c1c18 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Sep 2020 20:24:51 -0600 Subject: [PATCH 5/8] Ensure we obliterate 3pid invites on logout --- src/Lifecycle.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Lifecycle.js b/src/Lifecycle.js index dc5718b378..b2aa32fe7b 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -667,13 +667,13 @@ export async function onLoggedOut() { // that can occur when components try to use a null client. dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); - await _clearStorage(); + await _clearStorage({deleteEverything: true}); } /** * @returns {Promise} promise which resolves once the stores have been cleared */ -async function _clearStorage() { +async function _clearStorage(opts: {deleteEverything: boolean}) { Analytics.disable(); if (window.localStorage) { @@ -683,11 +683,13 @@ async function _clearStorage() { window.localStorage.clear(); // now restore those invites - pendingInvites.forEach(i => { - const roomId = i.roomId; - delete i.roomId; // delete to avoid confusing the store - ThreepidInviteStore.instance.storeInvite(roomId, i); - }); + 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) { From 763309ae8046700f319df77f05a6c9f27657bc5c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Sep 2020 20:58:10 -0600 Subject: [PATCH 6/8] Show a spinner when processing a registration email confirmation See comment enclosed --- .../views/auth/InteractiveAuthEntryComponents.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 628c177d94..47263c1e21 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -25,6 +25,7 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; +import Spinner from "../elements/Spinner"; /* This file contains a collection of components which are used by the * 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, // assume that the link has been clicked and the server will realise when we poll. if (this.props.inputs.emailAddress === undefined) { - const Loader = sdk.getComponent("elements.Spinner"); - return ; + return ; + } 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 ; } else { return (
From fc2173a7899eb7acf737008501038a3c5288260f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Sep 2020 21:05:54 -0600 Subject: [PATCH 7/8] Appease the linter It's almost like I copy/pasted some stuff --- src/stores/ThreepidInviteStore.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/stores/ThreepidInviteStore.ts b/src/stores/ThreepidInviteStore.ts index 6ec2820539..06cfad2c6b 100644 --- a/src/stores/ThreepidInviteStore.ts +++ b/src/stores/ThreepidInviteStore.ts @@ -15,22 +15,20 @@ limitations under the License. */ import EventEmitter from "events"; -import { ComponentClass } from "../@types/common"; -import { UPDATE_EVENT } from "./AsyncStore"; -import { base32, base64 } from "rfc4648"; +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; - room_avatar_url: string; - inviter_name: 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; - guest_user_id?: string; + guest_access_token?: string; // eslint-disable-line camelcase + guest_user_id?: string; // eslint-disable-line camelcase } interface IPersistedThreepidInvite extends IThreepidInviteWireFormat { From ac300a293ea06b440262d4a0049ea2180cc5dae0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Sep 2020 21:09:42 -0600 Subject: [PATCH 8/8] Add jsdoc --- src/Lifecycle.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Lifecycle.js b/src/Lifecycle.js index b2aa32fe7b..3a48de5eef 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -671,6 +671,7 @@ export async function onLoggedOut() { } /** + * @param {object} opts Options for how to clear storage. * @returns {Promise} promise which resolves once the stores have been cleared */ async function _clearStorage(opts: {deleteEverything: boolean}) {