diff --git a/src/Lifecycle.js b/src/Lifecycle.js index d2de31eb80..3a48de5eef 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"; @@ -666,17 +667,30 @@ 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}); } /** + * @param {object} opts Options for how to clear storage. * @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) { + // try to save any 3pid invites from being obliterated + const pendingInvites = ThreepidInviteStore.instance.getWireInvites(); + 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) { 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 4cdb58014e..dde5dc6fb2 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(); @@ -404,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 @@ -835,10 +848,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 +907,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, @@ -1203,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. @@ -1639,16 +1658,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 +1683,15 @@ 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, + // 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_id: undefined, }; @@ -2002,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 = ( { // 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 = ( ; + 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 (
diff --git a/src/stores/ThreepidInviteStore.ts b/src/stores/ThreepidInviteStore.ts new file mode 100644 index 0000000000..06cfad2c6b --- /dev/null +++ b/src/stores/ThreepidInviteStore.ts @@ -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 = {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, + }; + } +}