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

Only allow to start a DM with one email if encryption by default is enabled #10253

Merged
merged 9 commits into from
Mar 6, 2023
Merged
Show file tree
Hide file tree
Changes from 7 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
5 changes: 5 additions & 0 deletions res/css/views/dialogs/_InviteDialog.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,8 @@ limitations under the License.
.mx_InviteDialog_identityServer {
margin-top: 1em; /* TODO: Use a spacing variable */
}

.mx_InviteDialog_oneThreepid {
font-size: $font-12px;
margin: $spacing-8 0;
}
165 changes: 142 additions & 23 deletions src/components/views/dialogs/InviteDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,14 @@ import {
import { InviteKind } from "./InviteDialogTypes";
import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
import { privateShouldBeEncrypted } from "../../../utils/rooms";

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

interface Result {
userId: string;
user: RoomMember | DirectoryMember | ThreepidMember;
user: Member;
lastActive?: number;
}

Expand Down Expand Up @@ -130,6 +131,20 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
}
}

/**
* Converts a RoomMember to a Member.
* Returns the Member if it is already a Member.
*/
const toMember = (member: RoomMember | Member): Member => {
return member instanceof RoomMember
? new DirectoryMember({
user_id: member.userId,
display_name: member.name,
avatar_url: member.getMxcAvatarUrl(),
})
: member;
};

interface IDMRoomTileProps {
member: Member;
lastActiveTs?: number;
Expand Down Expand Up @@ -232,7 +247,7 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {

const caption = (this.props.member as ThreepidMember).isEmail
? _t("Invite by email")
: this.highlightName(userIdentifier);
: this.highlightName(userIdentifier || this.props.member.userId);

return (
<div className="mx_InviteDialog_tile mx_InviteDialog_tile--room" onClick={this.onClick}>
Expand Down Expand Up @@ -314,6 +329,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
private editorRef = createRef<HTMLInputElement>();
private numberEntryFieldRef: React.RefObject<Field> = createRef();
private unmounted = false;
private encryptionByDefault = false;

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

const alreadyInvited = new Set([MatrixClientPeg.get().getUserId()!, SdkConfig.get("welcome_user_id")]);
const alreadyInvited = new Set([MatrixClientPeg.get().getUserId()!]);
const welcomeUserId = SdkConfig.get("welcome_user_id");
if (welcomeUserId) alreadyInvited.add(welcomeUserId);

if (isRoomInvite(props)) {
const room = MatrixClientPeg.get().getRoom(props.roomId);
if (!room) throw new Error("Room ID given to InviteDialog does not look like a room");
Expand Down Expand Up @@ -355,6 +374,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}

public componentDidMount(): void {
this.encryptionByDefault = privateShouldBeEncrypted();

if (this.props.initialText) {
this.updateSuggestions(this.props.initialText);
}
Expand Down Expand Up @@ -387,9 +408,10 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial

const recents: {
userId: string;
user: RoomMember;
user: Member;
lastActive: number;
}[] = [];

for (const userId in rooms) {
// Filter out user IDs that are already in the room / should be excluded
if (excludedTargetIds.has(userId)) {
Expand All @@ -398,8 +420,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}

const room = rooms[userId];
const member = room.getMember(userId);
if (!member) {
const roomMember = room.getMember(userId);
if (!roomMember) {
// just skip people who don't have memberships for some reason
logger.warn(`[Invite:Recents] ${userId} is missing a member object in their own DM (${room.roomId})`);
continue;
Expand All @@ -425,7 +447,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
continue;
}

recents.push({ userId, user: member, lastActive: lastEventTs });
recents.push({ userId, user: toMember(roomMember), lastActive: lastEventTs });
}
if (!recents) logger.warn("[Invite:Recents] No recents to suggest!");

Expand All @@ -435,17 +457,18 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
return recents;
}

private buildSuggestions(excludedTargetIds: Set<string>): { userId: string; user: RoomMember }[] {
private buildSuggestions(excludedTargetIds: Set<string>): { userId: string; user: Member }[] {
const cli = MatrixClientPeg.get();
const activityScores = buildActivityScores(cli);
const memberScores = buildMemberScores(cli);

const memberComparator = compareMembers(activityScores, memberScores);

return Object.values(memberScores)
.map(({ member }) => member)
.filter((member) => !excludedTargetIds.has(member.userId))
.sort(memberComparator)
.map((member) => ({ userId: member.userId, user: member }));
.map((member) => ({ userId: member.userId, user: toMember(member) }));
}

private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
Expand All @@ -458,13 +481,20 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
// Check to see if there's anything to convert first
if (!this.state.filterText || !this.state.filterText.includes("@")) return this.state.targets || [];

if (!this.canInviteMore()) {
// There should only be one third-party invite → do not allow more targets
return this.state.targets;
}

let newMember: Member | undefined;
if (this.state.filterText.startsWith("@")) {
// Assume mxid
newMember = new DirectoryMember({ user_id: this.state.filterText });
} else if (SettingsStore.getValue(UIFeature.IdentityServer)) {
// Assume email
newMember = new ThreepidMember(this.state.filterText);
if (this.canInviteThirdParty()) {
newMember = new ThreepidMember(this.state.filterText);
}
}
if (!newMember) return this.state.targets;

Expand Down Expand Up @@ -657,7 +687,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
this.setState({ tryingIdentityServer: true });
return;
}
if (Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) {
if (Email.looksValid(term) && this.canInviteThirdParty() && SettingsStore.getValue(UIFeature.IdentityServer)) {
// Start off by suggesting the plain email while we try and resolve it
// to a real account.
this.setState({
Expand All @@ -667,6 +697,9 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
try {
const authClient = new IdentityAuthClient();
const token = await authClient.getAccessToken();
// No token → unable to try a lookup
if (!token) return;

if (term !== this.state.filterText) return; // abandon hope

const lookup = await MatrixClientPeg.get().lookupThreePid("email", term, token);
Expand Down Expand Up @@ -764,6 +797,13 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
};

private parseFilter(filter: string): string[] {
return filter
.split(/[\s,]+/)
.map((p) => p.trim())
.filter((p) => !!p); // filter empty strings
}

private onPaste = async (e: React.ClipboardEvent): Promise<void> => {
if (this.state.filterText) {
// if the user has already typed something, just let them
Expand All @@ -785,19 +825,32 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
];
const toAdd: Member[] = [];
const failed: string[] = [];
const potentialAddresses = text
.split(/[\s,]+/)
.map((p) => p.trim())
.filter((p) => !!p); // filter empty strings

// Addresses that could not be added.
// Will be displayed as filter text to provide feedback.
const unableToAddMore: string[] = [];

const potentialAddresses = this.parseFilter(text);

for (const address of potentialAddresses) {
const member = possibleMembers.find((m) => m.userId === address);
if (member) {
toAdd.push(member.user);
if (this.canInviteMore([...this.state.targets, ...toAdd])) {
toAdd.push(member.user);
} else {
// Invite not possible for current targets and pasted targets.
unableToAddMore.push(address);
}
continue;
}

if (Email.looksValid(address)) {
toAdd.push(new ThreepidMember(address));
if (this.canInviteThirdParty([...this.state.targets, ...toAdd])) {
toAdd.push(new ThreepidMember(address));
} else {
// Third-party invite not possible for current targets and pasted targets.
unableToAddMore.push(address);
}
continue;
}

Expand Down Expand Up @@ -834,7 +887,16 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
});
}

this.setState({ targets: [...this.state.targets, ...toAdd] });
if (unableToAddMore) {
this.setState({
filterText: unableToAddMore.join(" "),
targets: [...this.state.targets, ...toAdd],
});
} else {
this.setState({
targets: [...this.state.targets, ...toAdd],
});
}
};

private onClickInputArea = (e: React.MouseEvent): void => {
Expand Down Expand Up @@ -898,6 +960,11 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
// Hide the section if there's nothing to filter by
if (sourceMembers.length === 0 && !hasAdditionalMembers) return null;

if (!this.canInviteThirdParty()) {
// It is currently not allowed to add more third-party invites. Filter them out.
priorityAdditionalMembers = priorityAdditionalMembers.filter((s) => s instanceof ThreepidMember);
}

// Do some simple filtering on the input before going much further. If we get no results, say so.
if (this.state.filterText) {
const filterBy = this.state.filterText.toLowerCase();
Expand Down Expand Up @@ -1092,6 +1159,42 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
}

/**
* If encryption by default is enabled, third-party invites should be encrypted as well.
* For encryption to work, the other side requires a device.
* To achieve this Element implements a waiting room until all have joined.
* Waiting for many users degrades the UX → only one email invite is allowed at a time.
*
* @param targets - Optional member list to check. Uses targets from state if not provided.
*/
private canInviteMore(targets?: (Member | RoomMember)[]): boolean {
targets = targets || this.state.targets;
return this.canInviteThirdParty(targets) || !targets.some((t) => t instanceof ThreepidMember);
}

/**
* A third-party invite is possible if
* - this is a non-DM dialog or
* - there are no invites yet or
* - encryption by default is not enabled
*
* Also see {@link InviteDialog#canInviteMore}.
*
* @param targets - Optional member list to check. Uses targets from state if not provided.
*/
private canInviteThirdParty(targets?: (Member | RoomMember)[]): boolean {
targets = targets || this.state.targets;
return this.props.kind !== InviteKind.Dm || targets.length === 0 || !this.encryptionByDefault;
}

private hasFilterAtLeastOneEmail(): boolean {
if (!this.state.filterText) return false;

return this.parseFilter(this.state.filterText).some((address: string) => {
return Email.looksValid(address);
});
}

public render(): React.ReactNode {
let spinner: JSX.Element | undefined;
if (this.state.busy) {
Expand Down Expand Up @@ -1277,6 +1380,25 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
</AccessibleButton>
);

let results: React.ReactNode | null = null;
let onlyOneThreepidNote: React.ReactNode | null = null;

if (!this.canInviteMore() || (this.hasFilterAtLeastOneEmail() && !this.canInviteThirdParty())) {
onlyOneThreepidNote = (
<div className="mx_InviteDialog_oneThreepid">
{_t("Invites by email can only be sent one at a time")}
weeman1337 marked this conversation as resolved.
Show resolved Hide resolved
</div>
);
} else {
results = (
<div className="mx_InviteDialog_userSections">
{this.renderSection("recents")}
{this.renderSection("suggestions")}
{extraSection}
</div>
);
}

const usersSection = (
<React.Fragment>
<p className="mx_InviteDialog_helpText">{helpText}</p>
Expand All @@ -1290,11 +1412,8 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
{keySharingWarning}
{this.renderIdentityServerWarning()}
<div className="error">{this.state.errorText}</div>
<div className="mx_InviteDialog_userSections">
{this.renderSection("recents")}
{this.renderSection("suggestions")}
{extraSection}
</div>
{onlyOneThreepidNote}
{results}
{footer}
</React.Fragment>
);
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2862,6 +2862,7 @@
"Invited people will be able to read old messages.": "Invited people will be able to read old messages.",
"Transfer": "Transfer",
"Consult first": "Consult first",
"Invites by email can only be sent one at a time": "Invites by email can only be sent one at a time",
"User Directory": "User Directory",
"Dial pad": "Dial pad",
"a new master key signature": "a new master key signature",
Expand Down
Loading