Convert MultiInviter, RoomInvite and UserAddress to Typescript

pull/21833/head
Michael Telatynski 2021-06-16 10:18:32 +01:00
parent 3162d144e2
commit 27e27b7a87
3 changed files with 127 additions and 95 deletions

View File

@ -1,7 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import {MatrixClientPeg} from './MatrixClientPeg'; import { Room } from "matrix-js-sdk/src/models/room";
import MultiInviter from './utils/MultiInviter'; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClientPeg } from './MatrixClientPeg';
import MultiInviter, { CompletionStates } from './utils/MultiInviter';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './'; import * as sdk from './';
import { _t } from './languageHandler'; 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 CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore";
/** /**
* Invites multiple addresses to a room * Invites multiple addresses to a room
@ -32,15 +33,18 @@ import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
* no option to cancel. * no option to cancel.
* *
* @param {string} roomId The ID of the room to invite to * @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 * @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); 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. // This dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog( 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. // This dialog handles the room creation internally - we don't need to worry about it.
Modal.createTrackedDialog( Modal.createTrackedDialog(
"Invite Users", "", InviteDialog, { "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( Modal.createTrackedDialog(
'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
); );
} }
export function showCommunityInviteDialog(communityId) { export function showCommunityInviteDialog(communityId: string): void {
const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId);
if (chat) { if (chat) {
const name = CommunityPrototypeStore.instance.getCommunityName(communityId); const name = CommunityPrototypeStore.instance.getCommunityName(communityId);
@ -83,7 +87,7 @@ export function showCommunityInviteDialog(communityId) {
* @param {MatrixEvent} event The event to check * @param {MatrixEvent} event The event to check
* @returns {boolean} True if valid, false otherwise * @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; 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 // any events without these keys are not valid 3pid invites, so we ignore them
@ -96,7 +100,7 @@ export function isValid3pidInvite(event) {
return true; return true;
} }
export function inviteUsersToRoom(roomId, userIds) { export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> {
return inviteMultipleToRoom(roomId, userIds).then((result) => { return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
showAnyInviteErrors(result.states, room, result.inviter); 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 // 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) { if (failedUsers.length === 1 && inviter.fatal) {
// Just get the first message because there was a fatal problem on the first // 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 // user. This usually means that no other users were attempted, making it
@ -126,7 +130,7 @@ export function showAnyInviteErrors(addrs, room, inviter) {
} else { } else {
const errorList = []; const errorList = [];
for (const addr of failedUsers) { for (const addr of failedUsers) {
if (addrs[addr] === "error") { if (states[addr] === "error") {
const reason = inviter.getErrorText(addr); const reason = inviter.getErrorText(addr);
errorList.push(addr + ": " + reason); errorList.push(addr + ": " + reason);
} }

View File

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
const emailRegex = /^\S+@\S+\.\S+$/; import PropTypes from "prop-types";
const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\S+:\S+$/; const mxUserIdRegex = /^@\S+:\S+$/;
const mxRoomIdRegex = /^!\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 // PropType definition for an object describing
// an address that can be invited to a room (which // an address that can be invited to a room (which
@ -40,18 +44,13 @@ export const UserAddressType = PropTypes.shape({
isKnown: PropTypes.bool, isKnown: PropTypes.bool,
}); });
export function getAddressType(inputText) { export function getAddressType(inputText: string): AddressType | null {
const isEmailAddress = emailRegex.test(inputText); if (emailRegex.test(inputText)) {
const isUserId = mxUserIdRegex.test(inputText); return AddressType.Email;
const isRoomId = mxRoomIdRegex.test(inputText); } else if (mxUserIdRegex.test(inputText)) {
return AddressType.MatrixUserId;
// sanity check the input for user IDs } else if (mxRoomIdRegex.test(inputText)) {
if (isEmailAddress) { return AddressType.MatrixRoomId;
return 'email';
} else if (isUserId) {
return 'mx-user-id';
} else if (isRoomId) {
return 'mx-room-id';
} else { } else {
return null; return null;
} }

View File

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import {MatrixClientPeg} from '../MatrixClientPeg'; import { MatrixError } from "matrix-js-sdk/src/http-api";
import {getAddressType} from '../UserAddress';
import { MatrixClientPeg } from '../MatrixClientPeg';
import { AddressType, getAddressType } from '../UserAddress';
import GroupStore from '../stores/GroupStore'; import GroupStore from '../stores/GroupStore';
import {_t} from "../languageHandler"; import { _t } from "../languageHandler";
import * as sdk from "../index";
import Modal from "../Modal"; import Modal from "../Modal";
import SettingsStore from "../settings/SettingsStore"; 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<string, InviteState>;
/** /**
* Invites multiple addresses to a room or group, handling rate limiting from the server * Invites multiple addresses to a room or group, handling rate limiting from the server
*/ */
export default class MultiInviter { 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<string, IError> = {}; // { address: {errorText, errcode} }
private deferred: IDeferred<CompletionStates> = null;
private reason: string = null;
/** /**
* @param {string} targetId The ID of the room or group to invite to * @param {string} targetId The ID of the room or group to invite to
*/ */
constructor(targetId) { constructor(targetId: string) {
if (targetId[0] === '+') { if (targetId[0] === '+') {
this.roomId = null; this.roomId = null;
this.groupId = targetId; this.groupId = targetId;
@ -39,41 +66,38 @@ export default class MultiInviter {
this.roomId = targetId; this.roomId = targetId;
this.groupId = null; this.groupId = null;
} }
}
this.canceled = false; public get fatal() {
this.addrs = []; return this._fatal;
this.busy = false;
this.completionStates = {}; // State of each address (invited or error)
this.errors = {}; // { address: {errorText, errcode} }
this.deferred = null;
} }
/** /**
* Invite users to this room. This may only be called once per * Invite users to this room. This may only be called once per
* instance of the class. * 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) * @param {string} reason Reason for inviting (optional)
* @returns {Promise} Resolved when all invitations in the queue are complete * @returns {Promise} Resolved when all invitations in the queue are complete
*/ */
invite(addrs, reason) { public invite(addresses, reason?: string): Promise<CompletionStates> {
if (this.addrs.length > 0) { if (this.addresses.length > 0) {
throw new Error("Already inviting/invited"); throw new Error("Already inviting/invited");
} }
this.addrs.push(...addrs); this.addresses.push(...addresses);
this.reason = reason; this.reason = reason;
for (const addr of this.addrs) { for (const addr of this.addresses) {
if (getAddressType(addr) === null) { if (getAddressType(addr) === null) {
this.completionStates[addr] = 'error'; this.completionStates[addr] = InviteState.Error;
this.errors[addr] = { this.errors[addr] = {
errcode: 'M_INVALID', errcode: 'M_INVALID',
errorText: _t('Unrecognised address'), errorText: _t('Unrecognised address'),
}; };
} }
} }
this.deferred = defer(); this.deferred = defer<CompletionStates>();
this._inviteMore(0); this.inviteMore(0);
return this.deferred.promise; return this.deferred.promise;
} }
@ -81,33 +105,36 @@ export default class MultiInviter {
/** /**
* Stops inviting. Causes promises returned by invite() to be rejected. * Stops inviting. Causes promises returned by invite() to be rejected.
*/ */
cancel() { public cancel(): void {
if (!this.busy) return; if (!this.busy) return;
this._canceled = true; this.canceled = true;
this.deferred.reject(new Error('canceled')); this.deferred.reject(new Error('canceled'));
} }
getCompletionState(addr) { public getCompletionState(addr: string): InviteState {
return this.completionStates[addr]; return this.completionStates[addr];
} }
getErrorText(addr) { public getErrorText(addr: string): string {
return this.errors[addr] ? this.errors[addr].errorText : null; 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); const addrType = getAddressType(addr);
if (addrType === 'email') { if (addrType === AddressType.Email) {
return MatrixClientPeg.get().inviteByEmail(roomId, addr); return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType === 'mx-user-id') { } else if (addrType === AddressType.MatrixUserId) {
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) throw new Error("Room not found"); if (!room) throw new Error("Room not found");
const member = room.getMember(addr); const member = room.getMember(addr);
if (member && ['join', 'invite'].includes(member.membership)) { 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)) { if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
@ -124,28 +151,28 @@ export default class MultiInviter {
} }
} }
_doInvite(address, ignoreProfile) { private doInvite(address: string, ignoreProfile = false): Promise<void> {
return new Promise((resolve, reject) => { return new Promise<void>((resolve, reject) => {
console.log(`Inviting ${address}`); console.log(`Inviting ${address}`);
let doInvite; let doInvite;
if (this.groupId !== null) { if (this.groupId !== null) {
doInvite = GroupStore.inviteUserToGroup(this.groupId, address); doInvite = GroupStore.inviteUserToGroup(this.groupId, address);
} else { } else {
doInvite = this._inviteToRoom(this.roomId, address, ignoreProfile); doInvite = this.inviteToRoom(this.roomId, address, ignoreProfile);
} }
doInvite.then(() => { doInvite.then(() => {
if (this._canceled) { if (this.canceled) {
return; return;
} }
this.completionStates[address] = 'invited'; this.completionStates[address] = InviteState.Invited;
delete this.errors[address]; delete this.errors[address];
resolve(); resolve();
}).catch((err) => { }).catch((err) => {
if (this._canceled) { if (this.canceled) {
return; return;
} }
@ -161,7 +188,7 @@ export default class MultiInviter {
} else if (err.errcode === 'M_LIMIT_EXCEEDED') { } else if (err.errcode === 'M_LIMIT_EXCEEDED') {
// we're being throttled so wait a bit & try again // we're being throttled so wait a bit & try again
setTimeout(() => { setTimeout(() => {
this._doInvite(address, ignoreProfile).then(resolve, reject); this.doInvite(address, ignoreProfile).then(resolve, reject);
}, 5000); }, 5000);
return; return;
} else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) { } 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) { } else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) {
// Invite without the profile check // Invite without the profile check
console.warn(`User ${address} does not have a profile - inviting anyways automatically`); 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") { } else if (err.errcode === "M_BAD_STATE") {
errorText = _t("The user must be unbanned before they can be invited."); errorText = _t("The user must be unbanned before they can be invited.");
} else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") { } else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") {
@ -180,14 +207,14 @@ export default class MultiInviter {
errorText = _t('Unknown server error'); errorText = _t('Unknown server error');
} }
this.completionStates[address] = 'error'; this.completionStates[address] = InviteState.Error;
this.errors[address] = {errorText, errcode: err.errcode}; this.errors[address] = { errorText, errcode: err.errcode };
this.busy = !fatal; this.busy = !fatal;
this.fatal = fatal; this._fatal = fatal;
if (fatal) { if (fatal) {
reject(); reject(err);
} else { } else {
resolve(); resolve();
} }
@ -195,22 +222,22 @@ export default class MultiInviter {
}); });
} }
_inviteMore(nextIndex, ignoreProfile) { private inviteMore(nextIndex: number, ignoreProfile = false): void {
if (this._canceled) { if (this.canceled) {
return; return;
} }
if (nextIndex === this.addrs.length) { if (nextIndex === this.addresses.length) {
this.busy = false; this.busy = false;
if (Object.keys(this.errors).length > 0 && !this.groupId) { if (Object.keys(this.errors).length > 0 && !this.groupId) {
// There were problems inviting some people - see if we can invite them // There were problems inviting some people - see if we can invite them
// without caring if they exist or not. // 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)
const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode)); .filter(a => UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode));
if (unknownProfileUsers.length > 0) { if (unknownProfileUsers.length > 0) {
const inviteUnknowns = () => { 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)); Promise.all(promises).then(() => this.deferred.resolve(this.completionStates));
}; };
@ -219,15 +246,17 @@ export default class MultiInviter {
return; return;
} }
const AskInviteAnywayDialog = sdk.getComponent("dialogs.AskInviteAnywayDialog");
console.log("Showing failed to invite dialog..."); console.log("Showing failed to invite dialog...");
Modal.createTrackedDialog('Failed to invite', '', AskInviteAnywayDialog, { 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(), onInviteAnyways: () => inviteUnknowns(),
onGiveUp: () => { onGiveUp: () => {
// Fake all the completion states because we already warned the user // Fake all the completion states because we already warned the user
for (const addr of unknownProfileUsers) { for (const addr of unknownProfileUsers) {
this.completionStates[addr] = 'invited'; this.completionStates[addr] = InviteState.Invited;
} }
this.deferred.resolve(this.completionStates); this.deferred.resolve(this.completionStates);
}, },
@ -239,25 +268,25 @@ export default class MultiInviter {
return; return;
} }
const addr = this.addrs[nextIndex]; const addr = this.addresses[nextIndex];
// don't try to invite it if it's an invalid address // don't try to invite it if it's an invalid address
// (it will already be marked as an error though, // (it will already be marked as an error though,
// so no need to do so again) // so no need to do so again)
if (getAddressType(addr) === null) { if (getAddressType(addr) === null) {
this._inviteMore(nextIndex + 1); this.inviteMore(nextIndex + 1);
return; return;
} }
// don't re-invite (there's no way in the UI to do this, but // don't re-invite (there's no way in the UI to do this, but
// for sanity's sake) // for sanity's sake)
if (this.completionStates[addr] === 'invited') { if (this.completionStates[addr] === InviteState.Invited) {
this._inviteMore(nextIndex + 1); this.inviteMore(nextIndex + 1);
return; return;
} }
this._doInvite(addr, ignoreProfile).then(() => { this.doInvite(addr, ignoreProfile).then(() => {
this._inviteMore(nextIndex + 1, ignoreProfile); this.inviteMore(nextIndex + 1, ignoreProfile);
}).catch(() => this.deferred.resolve(this.completionStates)); }).catch(() => this.deferred.resolve(this.completionStates));
} }
} }