Compare commits

...

3 Commits

Author SHA1 Message Date
Half-Shot
66cd837e9d More room preview context stuff 2025-09-01 07:54:31 +01:00
Half-Shot
ae323d1592 Redesign to be MVVM-y, better presentation etc. 2025-08-28 11:28:27 +01:00
Half-Shot
63ceae52ed Add context to invites. 2025-08-26 15:25:54 +01:00
4 changed files with 381 additions and 0 deletions

View File

@@ -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";

View 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;
}
}

View File

@@ -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,

View 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>
);
};