diff --git a/src/RoomInvite.js b/src/RoomInvite.tsx similarity index 76% rename from src/RoomInvite.js rename to src/RoomInvite.tsx index aa758ecbdc..7c75b5d46b 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.tsx @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2021 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,15 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import {MatrixClientPeg} from './MatrixClientPeg'; -import MultiInviter from './utils/MultiInviter'; +import React from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import { MatrixClientPeg } from './MatrixClientPeg'; +import MultiInviter, { CompletionStates } from './utils/MultiInviter'; import Modal from './Modal'; import * as sdk from './'; import { _t } from './languageHandler'; -import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; +import InviteDialog, { KIND_DM, KIND_INVITE } from "./components/views/dialogs/InviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; -import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; +import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore"; /** * Invites multiple addresses to a room @@ -32,15 +33,18 @@ import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; * no option to cancel. * * @param {string} roomId The ID of the room to invite to - * @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. + * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. * @returns {Promise} Promise */ -export function inviteMultipleToRoom(roomId, addrs) { +export function inviteMultipleToRoom( + roomId: string, + addresses: string[], +): Promise<{ states: CompletionStates, inviter: MultiInviter }> { const inviter = new MultiInviter(roomId); - return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); + return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter })); } -export function showStartChatInviteDialog(initialText) { +export function showStartChatInviteDialog(initialText = ""): void { // This dialog handles the room creation internally - we don't need to worry about it. const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); Modal.createTrackedDialog( @@ -49,7 +53,7 @@ export function showStartChatInviteDialog(initialText) { ); } -export function showRoomInviteDialog(roomId, initialText = "") { +export function showRoomInviteDialog(roomId: string, initialText = ""): void { // This dialog handles the room creation internally - we don't need to worry about it. Modal.createTrackedDialog( "Invite Users", "", InviteDialog, { @@ -61,14 +65,14 @@ export function showRoomInviteDialog(roomId, initialText = "") { ); } -export function showCommunityRoomInviteDialog(roomId, communityName) { +export function showCommunityRoomInviteDialog(roomId: string, communityName: string): void { Modal.createTrackedDialog( 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } -export function showCommunityInviteDialog(communityId) { +export function showCommunityInviteDialog(communityId: string): void { const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); if (chat) { const name = CommunityPrototypeStore.instance.getCommunityName(communityId); @@ -83,7 +87,7 @@ export function showCommunityInviteDialog(communityId) { * @param {MatrixEvent} event The event to check * @returns {boolean} True if valid, false otherwise */ -export function isValid3pidInvite(event) { +export function isValid3pidInvite(event: MatrixEvent): boolean { if (!event || event.getType() !== "m.room.third_party_invite") return false; // any events without these keys are not valid 3pid invites, so we ignore them @@ -96,7 +100,7 @@ export function isValid3pidInvite(event) { return true; } -export function inviteUsersToRoom(roomId, userIds) { +export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise { return inviteMultipleToRoom(roomId, userIds).then((result) => { const room = MatrixClientPeg.get().getRoom(roomId); showAnyInviteErrors(result.states, room, result.inviter); @@ -110,9 +114,9 @@ export function inviteUsersToRoom(roomId, userIds) { }); } -export function showAnyInviteErrors(addrs, room, inviter) { +export function showAnyInviteErrors(states: CompletionStates, room: Room, inviter: MultiInviter): boolean { // Show user any errors - const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); + const failedUsers = Object.keys(states).filter(a => states[a] === 'error'); if (failedUsers.length === 1 && inviter.fatal) { // Just get the first message because there was a fatal problem on the first // user. This usually means that no other users were attempted, making it @@ -126,7 +130,7 @@ export function showAnyInviteErrors(addrs, room, inviter) { } else { const errorList = []; for (const addr of failedUsers) { - if (addrs[addr] === "error") { + if (states[addr] === "error") { const reason = inviter.getErrorText(addr); errorList.push(addr + ": " + reason); } diff --git a/src/UserAddress.js b/src/UserAddress.ts similarity index 69% rename from src/UserAddress.js rename to src/UserAddress.ts index e7501a0d91..a2c546deb7 100644 --- a/src/UserAddress.js +++ b/src/UserAddress.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017 - 2021 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. @@ -14,15 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -const emailRegex = /^\S+@\S+\.\S+$/; +import PropTypes from "prop-types"; +const emailRegex = /^\S+@\S+\.\S+$/; const mxUserIdRegex = /^@\S+:\S+$/; const mxRoomIdRegex = /^!\S+:\S+$/; -import PropTypes from 'prop-types'; -export const addressTypes = [ - 'mx-user-id', 'mx-room-id', 'email', -]; +export const addressTypes = ['mx-user-id', 'mx-room-id', 'email']; + +export enum AddressType { + Email = "email", + MatrixUserId = "mx-user-id", + MatrixRoomId = "mx-room-id", +} // PropType definition for an object describing // an address that can be invited to a room (which @@ -40,18 +44,13 @@ export const UserAddressType = PropTypes.shape({ isKnown: PropTypes.bool, }); -export function getAddressType(inputText) { - const isEmailAddress = emailRegex.test(inputText); - const isUserId = mxUserIdRegex.test(inputText); - const isRoomId = mxRoomIdRegex.test(inputText); - - // sanity check the input for user IDs - if (isEmailAddress) { - return 'email'; - } else if (isUserId) { - return 'mx-user-id'; - } else if (isRoomId) { - return 'mx-room-id'; +export function getAddressType(inputText: string): AddressType | null { + if (emailRegex.test(inputText)) { + return AddressType.Email; + } else if (mxUserIdRegex.test(inputText)) { + return AddressType.MatrixUserId; + } else if (mxRoomIdRegex.test(inputText)) { + return AddressType.MatrixRoomId; } else { return null; } diff --git a/src/utils/MultiInviter.js b/src/utils/MultiInviter.ts similarity index 66% rename from src/utils/MultiInviter.js rename to src/utils/MultiInviter.ts index 78f956b91b..f6a994484e 100644 --- a/src/utils/MultiInviter.js +++ b/src/utils/MultiInviter.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd +Copyright 2016 - 2021 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. @@ -15,23 +14,51 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from '../MatrixClientPeg'; -import {getAddressType} from '../UserAddress'; +import { MatrixError } from "matrix-js-sdk/src/http-api"; + +import { MatrixClientPeg } from '../MatrixClientPeg'; +import { AddressType, getAddressType } from '../UserAddress'; import GroupStore from '../stores/GroupStore'; -import {_t} from "../languageHandler"; -import * as sdk from "../index"; +import { _t } from "../languageHandler"; import Modal from "../Modal"; import SettingsStore from "../settings/SettingsStore"; -import {defer} from "./promise"; +import { defer, IDeferred } from "./promise"; +import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog"; + +export enum InviteState { + Invited = "invited", + Error = "error", +} + +interface IError { + errorText: string; + errcode: string; +} + +const UNKNOWN_PROFILE_ERRORS = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND']; + +export type CompletionStates = Record; /** * Invites multiple addresses to a room or group, handling rate limiting from the server */ export default class MultiInviter { + private readonly roomId?: string; + private readonly groupId?: string; + + private canceled = false; + private addresses: string[] = []; + private busy = false; + private _fatal = false; + private completionStates: CompletionStates = {}; // State of each address (invited or error) + private errors: Record = {}; // { address: {errorText, errcode} } + private deferred: IDeferred = null; + private reason: string = null; + /** * @param {string} targetId The ID of the room or group to invite to */ - constructor(targetId) { + constructor(targetId: string) { if (targetId[0] === '+') { this.roomId = null; this.groupId = targetId; @@ -39,41 +66,38 @@ export default class MultiInviter { this.roomId = targetId; this.groupId = null; } + } - this.canceled = false; - this.addrs = []; - this.busy = false; - this.completionStates = {}; // State of each address (invited or error) - this.errors = {}; // { address: {errorText, errcode} } - this.deferred = null; + public get fatal() { + return this._fatal; } /** * Invite users to this room. This may only be called once per * instance of the class. * - * @param {array} addrs Array of addresses to invite + * @param {array} addresses Array of addresses to invite * @param {string} reason Reason for inviting (optional) * @returns {Promise} Resolved when all invitations in the queue are complete */ - invite(addrs, reason) { - if (this.addrs.length > 0) { + public invite(addresses, reason?: string): Promise { + if (this.addresses.length > 0) { throw new Error("Already inviting/invited"); } - this.addrs.push(...addrs); + this.addresses.push(...addresses); this.reason = reason; - for (const addr of this.addrs) { + for (const addr of this.addresses) { if (getAddressType(addr) === null) { - this.completionStates[addr] = 'error'; + this.completionStates[addr] = InviteState.Error; this.errors[addr] = { errcode: 'M_INVALID', errorText: _t('Unrecognised address'), }; } } - this.deferred = defer(); - this._inviteMore(0); + this.deferred = defer(); + this.inviteMore(0); return this.deferred.promise; } @@ -81,33 +105,36 @@ export default class MultiInviter { /** * Stops inviting. Causes promises returned by invite() to be rejected. */ - cancel() { + public cancel(): void { if (!this.busy) return; - this._canceled = true; + this.canceled = true; this.deferred.reject(new Error('canceled')); } - getCompletionState(addr) { + public getCompletionState(addr: string): InviteState { return this.completionStates[addr]; } - getErrorText(addr) { + public getErrorText(addr: string): string { return this.errors[addr] ? this.errors[addr].errorText : null; } - async _inviteToRoom(roomId, addr, ignoreProfile) { + private async inviteToRoom(roomId: string, addr: string, ignoreProfile = false): Promise<{}> { const addrType = getAddressType(addr); - if (addrType === 'email') { + if (addrType === AddressType.Email) { return MatrixClientPeg.get().inviteByEmail(roomId, addr); - } else if (addrType === 'mx-user-id') { + } else if (addrType === AddressType.MatrixUserId) { const room = MatrixClientPeg.get().getRoom(roomId); if (!room) throw new Error("Room not found"); const member = room.getMember(addr); if (member && ['join', 'invite'].includes(member.membership)) { - throw {errcode: "RIOT.ALREADY_IN_ROOM", error: "Member already invited"}; + throw new new MatrixError({ + errcode: "RIOT.ALREADY_IN_ROOM", + error: "Member already invited", + }); } if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) { @@ -124,28 +151,28 @@ export default class MultiInviter { } } - _doInvite(address, ignoreProfile) { - return new Promise((resolve, reject) => { + private doInvite(address: string, ignoreProfile = false): Promise { + return new Promise((resolve, reject) => { console.log(`Inviting ${address}`); let doInvite; if (this.groupId !== null) { doInvite = GroupStore.inviteUserToGroup(this.groupId, address); } else { - doInvite = this._inviteToRoom(this.roomId, address, ignoreProfile); + doInvite = this.inviteToRoom(this.roomId, address, ignoreProfile); } doInvite.then(() => { - if (this._canceled) { + if (this.canceled) { return; } - this.completionStates[address] = 'invited'; + this.completionStates[address] = InviteState.Invited; delete this.errors[address]; resolve(); }).catch((err) => { - if (this._canceled) { + if (this.canceled) { return; } @@ -161,7 +188,7 @@ export default class MultiInviter { } else if (err.errcode === 'M_LIMIT_EXCEEDED') { // we're being throttled so wait a bit & try again setTimeout(() => { - this._doInvite(address, ignoreProfile).then(resolve, reject); + this.doInvite(address, ignoreProfile).then(resolve, reject); }, 5000); return; } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) { @@ -171,7 +198,7 @@ export default class MultiInviter { } else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) { // Invite without the profile check console.warn(`User ${address} does not have a profile - inviting anyways automatically`); - this._doInvite(address, true).then(resolve, reject); + this.doInvite(address, true).then(resolve, reject); } else if (err.errcode === "M_BAD_STATE") { errorText = _t("The user must be unbanned before they can be invited."); } else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") { @@ -180,14 +207,14 @@ export default class MultiInviter { errorText = _t('Unknown server error'); } - this.completionStates[address] = 'error'; - this.errors[address] = {errorText, errcode: err.errcode}; + this.completionStates[address] = InviteState.Error; + this.errors[address] = { errorText, errcode: err.errcode }; this.busy = !fatal; - this.fatal = fatal; + this._fatal = fatal; if (fatal) { - reject(); + reject(err); } else { resolve(); } @@ -195,22 +222,22 @@ export default class MultiInviter { }); } - _inviteMore(nextIndex, ignoreProfile) { - if (this._canceled) { + private inviteMore(nextIndex: number, ignoreProfile = false): void { + if (this.canceled) { return; } - if (nextIndex === this.addrs.length) { + if (nextIndex === this.addresses.length) { this.busy = false; if (Object.keys(this.errors).length > 0 && !this.groupId) { // There were problems inviting some people - see if we can invite them // without caring if they exist or not. - const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND']; - const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode)); + const unknownProfileUsers = Object.keys(this.errors) + .filter(a => UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode)); if (unknownProfileUsers.length > 0) { const inviteUnknowns = () => { - const promises = unknownProfileUsers.map(u => this._doInvite(u, true)); + const promises = unknownProfileUsers.map(u => this.doInvite(u, true)); Promise.all(promises).then(() => this.deferred.resolve(this.completionStates)); }; @@ -219,15 +246,17 @@ export default class MultiInviter { return; } - const AskInviteAnywayDialog = sdk.getComponent("dialogs.AskInviteAnywayDialog"); console.log("Showing failed to invite dialog..."); Modal.createTrackedDialog('Failed to invite', '', AskInviteAnywayDialog, { - unknownProfileUsers: unknownProfileUsers.map(u => {return {userId: u, errorText: this.errors[u].errorText};}), + unknownProfileUsers: unknownProfileUsers.map(u => ({ + userId: u, + errorText: this.errors[u].errorText, + })), onInviteAnyways: () => inviteUnknowns(), onGiveUp: () => { // Fake all the completion states because we already warned the user for (const addr of unknownProfileUsers) { - this.completionStates[addr] = 'invited'; + this.completionStates[addr] = InviteState.Invited; } this.deferred.resolve(this.completionStates); }, @@ -239,25 +268,25 @@ export default class MultiInviter { return; } - const addr = this.addrs[nextIndex]; + const addr = this.addresses[nextIndex]; // don't try to invite it if it's an invalid address // (it will already be marked as an error though, // so no need to do so again) if (getAddressType(addr) === null) { - this._inviteMore(nextIndex + 1); + this.inviteMore(nextIndex + 1); return; } // don't re-invite (there's no way in the UI to do this, but // for sanity's sake) - if (this.completionStates[addr] === 'invited') { - this._inviteMore(nextIndex + 1); + if (this.completionStates[addr] === InviteState.Invited) { + this.inviteMore(nextIndex + 1); return; } - this._doInvite(addr, ignoreProfile).then(() => { - this._inviteMore(nextIndex + 1, ignoreProfile); + this.doInvite(addr, ignoreProfile).then(() => { + this.inviteMore(nextIndex + 1, ignoreProfile); }).catch(() => this.deferred.resolve(this.completionStates)); } }