Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Check profiles before starting a DM #10472

Merged
merged 5 commits into from
Apr 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions src/components/views/dialogs/AskInviteAnywayDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,31 @@ import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import BaseDialog from "./BaseDialog";

export interface UnknownProfile {
userId: string;
errorText: string;
}

export type UnknownProfiles = UnknownProfile[];

export interface AskInviteAnywayDialogProps {
unknownProfileUsers: Array<{
userId: string;
errorText: string;
}>;
unknownProfileUsers: UnknownProfiles;
onInviteAnyways: () => void;
onGiveUp: () => void;
onFinished: (success: boolean) => void;
description?: string;
inviteNeverWarnLabel?: string;
inviteLabel?: string;
}

export default function AskInviteAnywayDialog({
onFinished,
onGiveUp,
onInviteAnyways,
unknownProfileUsers,
description: descriptionProp,
inviteNeverWarnLabel,
inviteLabel,
}: AskInviteAnywayDialogProps): JSX.Element {
const onInviteClicked = useCallback((): void => {
onInviteAnyways();
Expand All @@ -60,6 +70,10 @@ export default function AskInviteAnywayDialog({
</li>
));

const description =
descriptionProp ??
_t("Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?");

return (
<BaseDialog
className="mx_RetryInvitesDialog"
Expand All @@ -68,20 +82,17 @@ export default function AskInviteAnywayDialog({
contentId="mx_Dialog_content"
>
<div id="mx_Dialog_content">
<p>
{_t(
"Unable to find profiles for the Matrix IDs listed below - " +
"would you like to invite them anyway?",
)}
</p>
<p>{description}</p>
<ul>{errorList}</ul>
</div>

<div className="mx_Dialog_buttons">
<button onClick={onGiveUpClicked}>{_t("Close")}</button>
<button onClick={onInviteNeverWarnClicked}>{_t("Invite anyway and never warn me again")}</button>
<button onClick={onInviteNeverWarnClicked}>
{inviteNeverWarnLabel ?? _t("Invite anyway and never warn me again")}
</button>
<button onClick={onInviteClicked} autoFocus={true}>
{_t("Invite anyway")}
{inviteLabel ?? _t("Invite anyway")}
</button>
</div>
</BaseDialog>
Expand Down
91 changes: 81 additions & 10 deletions src/components/views/dialogs/InviteDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2019 - 2022 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2023 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.
Expand All @@ -20,6 +20,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixError } from "matrix-js-sdk/src/matrix";

import { Icon as InfoIcon } from "../../../../res/img/element-icons/info.svg";
import { Icon as EmailPillAvatarIcon } from "../../../../res/img/icon-email-pill-avatar.svg";
Expand Down Expand Up @@ -75,10 +76,38 @@ import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
import { privateShouldBeEncrypted } from "../../../utils/rooms";
import { NonEmptyArray } from "../../../@types/common";
import { UNKNOWN_PROFILE_ERRORS } from "../../../utils/MultiInviter";
import AskInviteAnywayDialog, { UnknownProfiles } from "./AskInviteAnywayDialog";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { UserProfilesStore } from "../../../stores/UserProfilesStore";

// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */

const extractTargetUnknownProfiles = async (
targets: Member[],
profilesStores: UserProfilesStore,
): Promise<UnknownProfiles> => {
const directoryMembers = targets.filter((t): t is DirectoryMember => t instanceof DirectoryMember);
await Promise.all(directoryMembers.map((t) => profilesStores.getOrFetchProfile(t.userId)));
return directoryMembers.reduce<UnknownProfiles>((unknownProfiles: UnknownProfiles, target: DirectoryMember) => {
const lookupError = profilesStores.getProfileLookupError(target.userId);

if (
lookupError instanceof MatrixError &&
lookupError.errcode &&
UNKNOWN_PROFILE_ERRORS.includes(lookupError.errcode)
) {
unknownProfiles.push({
userId: target.userId,
errorText: lookupError.data.error || "",
});
}

return unknownProfiles;
}, []);
};

interface Result {
userId: string;
user: Member;
Expand Down Expand Up @@ -331,6 +360,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
private numberEntryFieldRef: React.RefObject<Field> = createRef();
private unmounted = false;
private encryptionByDefault = false;
private profilesStore: UserProfilesStore;

public constructor(props: Props) {
super(props);
Expand All @@ -341,6 +371,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
throw new Error("When using InviteKind.CallTransfer a call is required for an InviteDialog");
}

this.profilesStore = SdkContextClass.instance.userProfilesStore;

const alreadyInvited = new Set([MatrixClientPeg.get().getUserId()!]);
const welcomeUserId = SdkConfig.get("welcome_user_id");
if (welcomeUserId) alreadyInvited.add(welcomeUserId);
Expand Down Expand Up @@ -504,10 +536,28 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
return newTargets;
}

/**
* Check if there are unknown profiles if promptBeforeInviteUnknownUsers setting is enabled.
* If so show the "invite anyway?" dialog. Otherwise directly create the DM local room.
*/
private checkProfileAndStartDm = async (): Promise<void> => {
this.setBusy(true);
const targets = this.convertFilter();

if (SettingsStore.getValue("promptBeforeInviteUnknownUsers")) {
const unknownProfileUsers = await extractTargetUnknownProfiles(targets, this.profilesStore);

if (unknownProfileUsers.length) {
this.showAskInviteAnywayDialog(unknownProfileUsers);
return;
}
}

await this.startDm();
};

private startDm = async (): Promise<void> => {
this.setState({
busy: true,
});
this.setBusy(true);

try {
const cli = MatrixClientPeg.get();
Expand All @@ -523,6 +573,27 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
};

private setBusy(busy: boolean): void {
this.setState({
busy,
});
}

private showAskInviteAnywayDialog(unknownProfileUsers: { userId: string; errorText: string }[]): void {
Modal.createDialog(AskInviteAnywayDialog, {
unknownProfileUsers,
onInviteAnyways: () => this.startDm(),
onGiveUp: () => {
this.setBusy(false);
},
description: _t(
"Unable to find profiles for the Matrix IDs listed below - would you like to start a DM anyway?",
),
inviteNeverWarnLabel: _t("Start DM anyway and never warn me again"),
inviteLabel: _t("Start DM anyway"),
});
}

private inviteUsers = async (): Promise<void> => {
if (this.props.kind !== InviteKind.Invite) return;
this.setState({ busy: true });
Expand Down Expand Up @@ -639,7 +710,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
// if there's no matches (and the input looks like a mxid).
if (term[0] === "@" && term.indexOf(":") > 1) {
try {
const profile = await MatrixClientPeg.get().getProfileInfo(term);
const profile = await this.profilesStore.getOrFetchProfile(term, { shouldThrow: true });

if (profile) {
// If we have a profile, we have enough information to assume that
// the mxid can be invited - add it to the list. We stick it at the
Expand All @@ -651,8 +723,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
});
}
} catch (e) {
logger.warn("Non-fatal error trying to make an invite for a user ID");
logger.warn(e);
logger.warn("Non-fatal error trying to make an invite for a user ID", e);

// Reuse logic from Permalinks as a basic MXID validity check
const serverName = getServerName(term);
Expand Down Expand Up @@ -716,7 +787,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
// the email anyways, and so we don't cause things to jump around. In
// theory, the user would see the user pop up and think "ah yes, that
// person!"
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
const profile = await this.profilesStore.getOrFetchProfile(lookup.mxid);
if (term !== this.state.filterText || !profile) return; // abandon hope
this.setState({
threepidResultsMixin: [
Expand Down Expand Up @@ -861,7 +932,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}

try {
const profile = await MatrixClientPeg.get().getProfileInfo(address);
const profile = await this.profilesStore.getOrFetchProfile(address);
toAdd.push(
new DirectoryMember({
user_id: address,
Expand Down Expand Up @@ -1252,7 +1323,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}

buttonText = _t("Go");
goButtonFn = this.startDm;
goButtonFn = this.checkProfileAndStartDm;
extraSection = (
<div className="mx_InviteDialog_section_hidden_suggestions_disclaimer">
<span>{_t("Some suggestions may be hidden for privacy.")}</span>
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2711,8 +2711,8 @@
"Get it on F-Droid": "Get it on F-Droid",
"App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® and the Apple logo® are trademarks of Apple Inc.",
"Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play and the Google Play logo are trademarks of Google LLC.",
"The following users may not exist": "The following users may not exist",
"Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?",
"The following users may not exist": "The following users may not exist",
"Invite anyway and never warn me again": "Invite anyway and never warn me again",
"Invite anyway": "Invite anyway",
"Close dialog": "Close dialog",
Expand Down Expand Up @@ -2876,6 +2876,9 @@
"Click the button below to confirm your identity.": "Click the button below to confirm your identity.",
"Invite by email": "Invite by email",
"We couldn't create your DM.": "We couldn't create your DM.",
"Unable to find profiles for the Matrix IDs listed below - would you like to start a DM anyway?": "Unable to find profiles for the Matrix IDs listed below - would you like to start a DM anyway?",
"Start DM anyway and never warn me again": "Start DM anyway and never warn me again",
"Start DM anyway": "Start DM anyway",
"Something went wrong trying to invite the users.": "Something went wrong trying to invite the users.",
"We couldn't invite those users. Please check the users you want to invite and try again.": "We couldn't invite those users. Please check the users you want to invite and try again.",
"A call can only be transferred to a single user.": "A call can only be transferred to a single user.",
Expand Down
64 changes: 60 additions & 4 deletions src/stores/UserProfilesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,33 @@ limitations under the License.
*/

import { logger } from "matrix-js-sdk/src/logger";
import { IMatrixProfile, MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
import {
IMatrixProfile,
MatrixClient,
MatrixError,
MatrixEvent,
RoomMember,
RoomMemberEvent,
} from "matrix-js-sdk/src/matrix";

import { LruCache } from "../utils/LruCache";

const cacheSize = 500;

type StoreProfileValue = IMatrixProfile | undefined | null;

interface GetOptions {
/** Whether calling the function shouuld raise an Error. */
shouldThrow: boolean;
}

/**
* This store provides cached access to user profiles.
* Listens for membership events and invalidates the cache for a profile on update with different profile values.
*/
export class UserProfilesStore {
private profiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
private profileLookupErrors = new LruCache<string, MatrixError>(cacheSize);
private knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize);

public constructor(private client: MatrixClient) {
Expand All @@ -48,6 +61,32 @@ export class UserProfilesStore {
return this.profiles.get(userId);
}

/**
* Async shortcut function that returns the profile from cache or
* or fetches it on cache miss.
*
* @param userId - User Id of the profile to get or fetch
* @returns The profile, if cached by the store or fetched from the API.
* Null if the profile does not exist or an error occurred during fetch.
*/
public async getOrFetchProfile(userId: string, options?: GetOptions): Promise<IMatrixProfile | null> {
const cachedProfile = this.profiles.get(userId);

if (cachedProfile) return cachedProfile;

return this.fetchProfile(userId, options);
}

/**
* Get a profile lookup error.
*
* @param userId - User Id for which to get the lookup error
* @returns The lookup error or undefined if there was no error or the profile was not fetched.
*/
public getProfileLookupError(userId: string): MatrixError | undefined {
return this.profileLookupErrors.get(userId);
}

/**
* Synchronously get a profile from known users from the store cache.
* Known user means that at least one shared room with the user exists.
Expand All @@ -70,8 +109,8 @@ export class UserProfilesStore {
* @returns The profile, if found.
* Null if the profile does not exist or there was an error fetching it.
*/
public async fetchProfile(userId: string): Promise<IMatrixProfile | null> {
const profile = await this.fetchProfileFromApi(userId);
public async fetchProfile(userId: string, options?: GetOptions): Promise<IMatrixProfile | null> {
const profile = await this.fetchProfileFromApi(userId, options);
this.profiles.set(userId, profile);
return profile;
}
Expand All @@ -96,17 +135,34 @@ export class UserProfilesStore {
return profile;
}

public flush(): void {
this.profiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
this.profileLookupErrors = new LruCache<string, MatrixError>(cacheSize);
this.knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
}

/**
* Looks up a user profile via API.
*
* @param userId - User Id for which the profile should be fetched for
* @returns The profile information or null on errors
*/
private async fetchProfileFromApi(userId: string): Promise<IMatrixProfile | null> {
private async fetchProfileFromApi(userId: string, options?: GetOptions): Promise<IMatrixProfile | null> {
// invalidate cached profile errors
this.profileLookupErrors.delete(userId);

try {
return (await this.client.getProfileInfo(userId)) ?? null;
weeman1337 marked this conversation as resolved.
Show resolved Hide resolved
} catch (e) {
logger.warn(`Error retrieving profile for userId ${userId}`, e);

if (e instanceof MatrixError) {
this.profileLookupErrors.set(userId, e);
}

if (options?.shouldThrow) {
throw e;
}
}

return null;
Expand Down
7 changes: 6 additions & 1 deletion src/utils/MultiInviter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ interface IError {
errcode: string;
}

const UNKNOWN_PROFILE_ERRORS = ["M_NOT_FOUND", "M_USER_NOT_FOUND", "M_PROFILE_UNDISCLOSED", "M_PROFILE_NOT_FOUND"];
export const UNKNOWN_PROFILE_ERRORS = [
"M_NOT_FOUND",
"M_USER_NOT_FOUND",
"M_PROFILE_UNDISCLOSED",
"M_PROFILE_NOT_FOUND",
];

export type CompletionStates = Record<string, InviteState>;

Expand Down
Loading