mirror of
https://github.com/element-hq/element-web.git
synced 2025-12-09 01:30:57 +00:00
Compare commits
3 Commits
langleyd/e
...
hs/invite-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66cd837e9d | ||
|
|
ae323d1592 | ||
|
|
63ceae52ed |
@@ -323,6 +323,7 @@
|
||||
@import "./views/rooms/_RoomKnocksBar.pcss";
|
||||
@import "./views/rooms/_RoomPreviewBar.pcss";
|
||||
@import "./views/rooms/_RoomPreviewCard.pcss";
|
||||
@import "./views/rooms/_RoomPreviewContext.pcss";
|
||||
@import "./views/rooms/_RoomSearchAuxPanel.pcss";
|
||||
@import "./views/rooms/_RoomSublist.pcss";
|
||||
@import "./views/rooms/_RoomTile.pcss";
|
||||
|
||||
47
res/css/views/rooms/_RoomPreviewContext.pcss
Normal file
47
res/css/views/rooms/_RoomPreviewContext.pcss
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_RoomPreviewContext {
|
||||
> li {
|
||||
list-style: none;
|
||||
margin-bottom: var(--cpd-space-2x);
|
||||
}
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mx_RoomPreviewContext_detailsItem {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-1x);
|
||||
|
||||
svg {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
&.safe {
|
||||
color: var(--cpd-color-text-success-primary);
|
||||
}
|
||||
|
||||
&.unknown {
|
||||
color: var(--cpd-color-text-info-primary);
|
||||
}
|
||||
|
||||
&.unsafe {
|
||||
color: var(--cpd-color-text-critical-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--cpd-font-size-body-md);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { ModuleRunner } from "../../../modules/ModuleRunner";
|
||||
import { Icon as AskToJoinIcon } from "../../../../res/img/element-icons/ask-to-join.svg";
|
||||
import Field from "../elements/Field";
|
||||
import ModuleApi from "../../../modules/Api.ts";
|
||||
import { RoomPreviewContext } from "./RoomPreviewContext.tsx";
|
||||
|
||||
const MemberEventHtmlReasonField = "io.element.html_reason";
|
||||
|
||||
@@ -317,6 +318,7 @@ class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
let title: string | undefined;
|
||||
let subTitle: string | ReactNode[] | undefined;
|
||||
let reasonElement: JSX.Element | undefined;
|
||||
let inviteContext: JSX.Element | undefined;
|
||||
let primaryActionHandler: (() => void) | undefined;
|
||||
let primaryActionLabel: string | undefined;
|
||||
let secondaryActionHandler: (() => void) | undefined;
|
||||
@@ -557,6 +559,7 @@ class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
/>
|
||||
);
|
||||
}
|
||||
inviteContext = <RoomPreviewContext inviterMember={inviteMember} />;
|
||||
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
secondaryActionLabel = _t("action|decline");
|
||||
@@ -736,6 +739,7 @@ class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
{subTitleElements}
|
||||
</div>
|
||||
{reasonElement}
|
||||
{inviteContext}
|
||||
<div
|
||||
className={classNames("mx_RoomPreviewBar_actions", {
|
||||
mx_RoomPreviewBar_fullWidth: messageCase === MessageCase.PromptAskToJoin,
|
||||
|
||||
329
src/components/views/rooms/RoomPreviewContext.tsx
Normal file
329
src/components/views/rooms/RoomPreviewContext.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { JoinRule, type RoomMember, type Room, KnownMembership, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import React, { type JSX, useEffect, useMemo, useState, type FC } from "react";
|
||||
import { Button, InlineSpinner } from "@vector-im/compound-web";
|
||||
import { CheckCircleIcon, InfoIcon, WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { formatDuration } from "../../../DateUtils";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
|
||||
const PRIVATE_JOIN_RULES: JoinRule[] = [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted];
|
||||
const LONG_TERM_USER_MS = 28 * 24 * 60 * 60 * 1000; // ~a month ago.
|
||||
|
||||
enum InviteScore {
|
||||
Unknown = "unknown",
|
||||
Safe = "safe",
|
||||
Unsafe = "unsafe",
|
||||
}
|
||||
|
||||
function SafetyDetailItem({
|
||||
title,
|
||||
description,
|
||||
score,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
score?: InviteScore;
|
||||
}): JSX.Element {
|
||||
score = score ?? InviteScore.Unknown;
|
||||
return (
|
||||
<li className={classNames("mx_RoomPreviewContext_detailsItem", score)}>
|
||||
{score === InviteScore.Unknown && <InfoIcon />}
|
||||
{score === InviteScore.Safe && <CheckCircleIcon />}
|
||||
{score === InviteScore.Unsafe && <WarningIcon />}
|
||||
<div>
|
||||
{title && <h1>{title}</h1>}
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function useGetUserSafety(inviterMember: RoomMember | null): {
|
||||
score: InviteScore | null;
|
||||
details: {
|
||||
roomCount?: number;
|
||||
joinedTo?: { title: string; description: string; score: InviteScore };
|
||||
userFirstSeen?: { title: string; description: string; score: InviteScore };
|
||||
userBanned?: string;
|
||||
userKicked?: string;
|
||||
isLocalTrustedServer?: boolean;
|
||||
};
|
||||
} {
|
||||
const client = useMatrixClientContext();
|
||||
const [joinedTo, setJoinedTo] = useState<{ title: string; description: string; score: InviteScore }>();
|
||||
const [roomCount, setRoomCount] = useState<number>();
|
||||
const [isLocalTrustedServer, setIsLocalTrustedServer] = useState<boolean>();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!inviterMember?.userId) {
|
||||
return;
|
||||
}
|
||||
const inviterDomain = inviterMember.userId.replace(/^.*?:/, "");
|
||||
if (inviterDomain !== client.getDomain()) {
|
||||
setIsLocalTrustedServer(false);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Try auth metadata first for OIDC
|
||||
try {
|
||||
const metadata = await client.getAuthMetadata();
|
||||
const openReg = metadata.prompt_values_supported?.includes("create")
|
||||
setIsLocalTrustedServer(!openReg);
|
||||
} catch {
|
||||
// OIDC not configured, fall through.
|
||||
}
|
||||
try {
|
||||
await client.registerRequest({});
|
||||
setIsLocalTrustedServer(false);
|
||||
} catch (ex) {
|
||||
if (ex instanceof MatrixError && ex.errcode === "M_FORBIDDEN") {
|
||||
// We only accept M_FORBIDDEN for checking if the server is closed, for safety.
|
||||
setIsLocalTrustedServer(true);
|
||||
return;
|
||||
}
|
||||
setIsLocalTrustedServer(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
setIsLocalTrustedServer(false);
|
||||
};
|
||||
}, [client, inviterMember]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inviterMember?.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let rooms: string[];
|
||||
try {
|
||||
rooms = await client._unstable_getSharedRooms(inviterMember.userId);
|
||||
} catch (ex) {
|
||||
console.warn("getSharedRooms not supported, using slow path", ex);
|
||||
// Could not fetch rooms. We should fallback to the slow path.
|
||||
rooms = client
|
||||
.getRooms()
|
||||
.filter((r) => r.getJoinedMembers().some((m) => m.userId === inviterMember.userId))
|
||||
.map((r) => r.roomId);
|
||||
}
|
||||
const joinedToPrivateSpaces = new Map<string, number>();
|
||||
const joinedToPrivateRooms = new Map<string, number>();
|
||||
const joinedToPublicSpaces = new Map<string, number>();
|
||||
const joinedToPublicRooms = new Map<string, number>();
|
||||
for (const roomId of rooms) {
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
continue;
|
||||
}
|
||||
if (room.isSpaceRoom()) {
|
||||
if (PRIVATE_JOIN_RULES.includes(room.getJoinRule())) {
|
||||
joinedToPrivateSpaces.set(room.name, room.getMembers().length);
|
||||
} else {
|
||||
joinedToPublicSpaces.set(room.name, room.getMembers().length);
|
||||
}
|
||||
} else {
|
||||
if (PRIVATE_JOIN_RULES.includes(room.getJoinRule())) {
|
||||
joinedToPrivateRooms.set(room.name, room.getMembers().length);
|
||||
} else {
|
||||
joinedToPublicRooms.set(room.name, room.getMembers().length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [roomSet, type] of [
|
||||
[joinedToPrivateSpaces, "private spaces"],
|
||||
[joinedToPrivateRooms, "private rooms"],
|
||||
[joinedToPublicSpaces, "spaces"],
|
||||
[joinedToPublicRooms, "public rooms"],
|
||||
] as [Map<string, number>, string][]) {
|
||||
if (roomSet.size === 0) {
|
||||
continue;
|
||||
}
|
||||
const roomNames = [...roomSet]
|
||||
.sort(([, memberCountA], [, memberCountB]) => memberCountB - memberCountA)
|
||||
.slice(0, 3)
|
||||
.map(([name]) => name)
|
||||
.join(", ");
|
||||
if (roomNames) {
|
||||
setJoinedTo({
|
||||
description: `You share ${roomSet.size} ${type}, including ${roomNames}`,
|
||||
title: `You share ${type}`,
|
||||
score: type === "private spaces" ? InviteScore.Safe : InviteScore.Unknown,
|
||||
});
|
||||
} else {
|
||||
setJoinedTo({
|
||||
description: `You share ${roomSet.size} ${type}`,
|
||||
title: `You share ${type}`,
|
||||
score: type === "private spaces" ? InviteScore.Safe : InviteScore.Unknown,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
setRoomCount(rooms.filter((r) => r !== inviterMember.roomId).length);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
setRoomCount(undefined);
|
||||
};
|
||||
}, [client, inviterMember]);
|
||||
|
||||
const userBanned = useMemo(() => {
|
||||
if (!inviterMember?.userId) {
|
||||
return;
|
||||
}
|
||||
const bannedRooms = client
|
||||
.getRooms()
|
||||
.map<[Room, RoomMember | null]>((r) => [r, r.getMember(inviterMember?.userId)])
|
||||
.filter(([room, member]) => member?.membership === KnownMembership.Ban);
|
||||
if (bannedRooms.length) {
|
||||
const exampleNames = bannedRooms
|
||||
.filter(([room]) => room.normalizedName && room.normalizedName !== room.roomId)
|
||||
.slice(0, 3)
|
||||
.map(([room]) => room.normalizedName)
|
||||
.join(", ");
|
||||
if (exampleNames) {
|
||||
return `User has been banned from ${bannedRooms.length} rooms, including ${exampleNames}`;
|
||||
}
|
||||
return `User has been banned from ${bannedRooms.length} rooms`;
|
||||
}
|
||||
return;
|
||||
}, [client, inviterMember]);
|
||||
|
||||
const userKicked = useMemo(() => {
|
||||
if (!inviterMember?.userId) {
|
||||
return;
|
||||
}
|
||||
const kickedRooms = client
|
||||
.getRooms()
|
||||
.map<[Room, RoomMember | null]>((r) => [r, r.getMember(inviterMember?.userId)])
|
||||
.filter(([room, member]) => member?.isKicked());
|
||||
if (kickedRooms.length) {
|
||||
const exampleNames = kickedRooms
|
||||
.filter(([room]) => room.normalizedName && room.normalizedName !== room.roomId)
|
||||
.slice(0, 3)
|
||||
.map(([room]) => room.normalizedName)
|
||||
.join(", ");
|
||||
if (exampleNames) {
|
||||
return `User has been kicked from ${kickedRooms.length} rooms, including ${exampleNames}`;
|
||||
}
|
||||
return `User has been kicked from ${kickedRooms.length} rooms`;
|
||||
}
|
||||
return;
|
||||
}, [client, inviterMember]);
|
||||
|
||||
const userFirstSeen = useMemo<{ title: string; score: InviteScore; description: string } | undefined>(() => {
|
||||
if (!inviterMember?.userId) {
|
||||
return;
|
||||
}
|
||||
const earliestMembershipTs = client
|
||||
.getRooms()
|
||||
.map((r) => r.getMember(inviterMember?.userId))
|
||||
.filter((member) => member?.membership === KnownMembership.Join)
|
||||
.map((member) => member?.events.member?.getTs())
|
||||
.filter((ts) => ts !== undefined)
|
||||
.sort((tsA, tsB) => tsA - tsB)[0];
|
||||
|
||||
if (earliestMembershipTs) {
|
||||
const userDuration = Date.now() - earliestMembershipTs;
|
||||
if (userDuration > LONG_TERM_USER_MS) {
|
||||
const description = `You first saw activity from this user ${formatDuration(userDuration)} ago.`;
|
||||
return { title: `This user has been active for a while.`, description, score: InviteScore.Safe };
|
||||
} else {
|
||||
const description = `The earliest activity you have seen from this user was ${formatDuration(userDuration)} ago.`;
|
||||
return {
|
||||
title: `This user may have recently created their account.`,
|
||||
description,
|
||||
score: InviteScore.Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [client, inviterMember]);
|
||||
|
||||
const score = useMemo<InviteScore | null>(() => {
|
||||
if (roomCount === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (roomCount === 0 || userBanned || userKicked) {
|
||||
return InviteScore.Unsafe;
|
||||
}
|
||||
if (userFirstSeen?.score === InviteScore.Unknown || joinedTo?.score === InviteScore.Unknown) {
|
||||
return InviteScore.Unknown;
|
||||
}
|
||||
return InviteScore.Safe;
|
||||
}, [roomCount, userBanned, userKicked, joinedTo, userFirstSeen]);
|
||||
|
||||
return {
|
||||
score,
|
||||
details: {
|
||||
roomCount,
|
||||
joinedTo,
|
||||
userBanned,
|
||||
userKicked,
|
||||
userFirstSeen,
|
||||
isLocalTrustedServer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const RoomPreviewContext: FC<{ inviterMember: RoomMember | null }> = ({ inviterMember }) => {
|
||||
const { score, details } = useGetUserSafety(inviterMember);
|
||||
const [learnMoreOpen, setLearnMoreOpen] = useState<boolean>(false);
|
||||
|
||||
if (!score) {
|
||||
return (
|
||||
<div className="mx_RoomPreviewContext_Badge">
|
||||
<InlineSpinner />
|
||||
<span>Checking invite safety</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { roomCount, joinedTo, userBanned, userKicked, userFirstSeen, isLocalTrustedServer } = details;
|
||||
return (
|
||||
<ul className="mx_RoomPreviewContext">
|
||||
{isLocalTrustedServer && <SafetyDetailItem title="You are on the same server" description={learnMoreOpen ? "You share the same server as this user, and registration is disabled." : undefined} score={InviteScore.Safe} />}
|
||||
{roomCount === 0 && <SafetyDetailItem title="You have no shared rooms" description={learnMoreOpen ? "You have no rooms in common with this user. This may be a spam invite." : undefined} score={InviteScore.Unsafe} />}
|
||||
{userBanned && (
|
||||
<SafetyDetailItem
|
||||
score={InviteScore.Unsafe}
|
||||
title="User has been banned from rooms in the past"
|
||||
description={learnMoreOpen ? userBanned : undefined}
|
||||
/>
|
||||
)}
|
||||
{userKicked && (
|
||||
<SafetyDetailItem
|
||||
score={InviteScore.Unsafe}
|
||||
title="User has been kicked from rooms in the past"
|
||||
description={learnMoreOpen ? userKicked : undefined}
|
||||
/>
|
||||
)}
|
||||
{joinedTo && (
|
||||
<SafetyDetailItem {...joinedTo} description={learnMoreOpen ? joinedTo.description : undefined} />
|
||||
)}
|
||||
{userFirstSeen && (
|
||||
<SafetyDetailItem
|
||||
{...userFirstSeen}
|
||||
description={learnMoreOpen ? userFirstSeen.description : undefined}
|
||||
/>
|
||||
)}
|
||||
{!learnMoreOpen && (
|
||||
<li>
|
||||
<Button kind="tertiary" size="sm" onClick={() => setLearnMoreOpen(true)}>
|
||||
Explain safety information
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user