Redesign to be MVVM-y, better presentation etc.

This commit is contained in:
Half-Shot 2025-08-28 11:28:27 +01:00
parent 63ceae52ed
commit ae323d1592
3 changed files with 233 additions and 62 deletions

View File

@ -1,8 +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 {
// TODO: FIX
min-width: 400px;
> li {
margin-bottom: 1em;
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

@ -559,7 +559,7 @@ class RoomPreviewBar extends React.Component<IProps, IState> {
/>
);
}
inviteContext = <RoomPreviewContext inviterMember={inviteMember} />
inviteContext = <RoomPreviewContext inviterMember={inviteMember} />;
primaryActionHandler = this.props.onJoinClick;
secondaryActionLabel = _t("action|decline");

View File

@ -1,17 +1,64 @@
import { JoinRule, RoomMember, Room, KnownMembership } from "matrix-js-sdk/src/matrix";
import React, { useEffect, useMemo, useState, type FC } from "react";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
/*
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 } 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 { Alert } from "@vector-im/compound-web";
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",
}
export const RoomPreviewContext: FC<{inviterMember: RoomMember|null}> = ({inviterMember}) => {
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;
};
} {
const client = useMatrixClientContext();
const [joinedTo, setJoinedTo] = useState<{title: string, description: string, type: "info"|"success"}|null>();
const [roomCount, setRoomCount] = useState<number|null>();
const [joinedTo, setJoinedTo] = useState<{ title: string; description: string; score: InviteScore }>();
const [roomCount, setRoomCount] = useState<number>();
useEffect(() => {
if (!inviterMember?.userId) {
@ -19,13 +66,16 @@ export const RoomPreviewContext: FC<{inviterMember: RoomMember|null}> = ({invite
}
(async () => {
let rooms;
let rooms: string[];
try {
rooms = await client._unstable_getSharedRooms(inviterMember.userId);
} catch (ex) {
// Could not fetch rooms.
// TODO: Handle error.
return;
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>();
@ -51,59 +101,90 @@ export const RoomPreviewContext: FC<{inviterMember: RoomMember|null}> = ({invite
}
}
for (const [roomSet, type] of ([[joinedToPrivateSpaces, "private spaces"], [joinedToPrivateRooms, "private rooms"], [joinedToPublicSpaces, "spaces"], [joinedToPublicRooms, "rooms"]] as [Map<string, number>, string][])) {
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(', ');
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}`, type: type === "private spaces" ? "success" : "info"});
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}`, type: type === "private spaces" ? "success" : "info"});
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);
setRoomCount(rooms.filter((r) => r !== inviterMember.roomId).length);
})();
return () => {
setRoomCount(null);
}
},[client, inviterMember]);
setRoomCount(undefined);
};
}, [client, inviterMember]);
const userBanned = useMemo(() => {
if (!inviterMember?.userId) {
return null;
return;
}
const bannedRooms = client.getRooms().map<[Room, RoomMember|null]>((r) => [r ,r.getMember(inviterMember?.userId)]).filter(([room, member]) => member?.membership === KnownMembership.Ban);
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(', ');
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 null;
return;
}, [client, inviterMember]);
const userKicked = useMemo(() => {
if (!inviterMember?.userId) {
return null;
return;
}
const kickedRooms = client.getRooms().map<[Room, RoomMember|null]>((r) => [r ,r.getMember(inviterMember?.userId)]).filter(([room, member]) => member?.isKicked());
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(', ');
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 null;
return;
}, [client, inviterMember]);
const userFirstSeen = useMemo<null|{text: string, type: "success" | "info" | "critical", description: string}>(() => {
const userFirstSeen = useMemo<{ title: string; score: InviteScore; description: string } | undefined>(() => {
if (!inviterMember?.userId) {
return null;
return;
}
const earliestMembershipTs = client
.getRooms()
@ -113,44 +194,95 @@ export const RoomPreviewContext: FC<{inviterMember: RoomMember|null}> = ({invite
.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 { text: `This user has been active for a while.`, description, type: "success" }
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 { text: `This user may have recently created their account.`, description, type: "critical" };
return {
title: `This user may have recently created their account.`,
description,
score: InviteScore.Unknown,
};
}
}
return null;
return;
}, [client, inviterMember]);
const score = useMemo<InviteScore | null>(() => {
if (!roomCount) {
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]);
if (!inviterMember) {
return null;
return {
score,
details: {
roomCount,
joinedTo,
userBanned,
userKicked,
userFirstSeen,
},
};
}
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>
);
}
return <ul className="mx_RoomPreviewContext">
{roomCount === 0 && <li>
<Alert type="critical" title={"You have no shared rooms"}></Alert>
</li>}
{joinedTo && <li>
<Alert type={joinedTo.type} title={joinedTo.title}>{joinedTo.description}</Alert>
</li>}
{userBanned && <li>
<Alert type="critical" title={"User has been banned from rooms in the past"}>{userBanned}</Alert>
</li>}
{userKicked && <li>
<Alert type="critical" title={"User has been kicked from rooms in the past"}>{userKicked}</Alert>
</li>}
{userFirstSeen && <li>
<Alert type={userFirstSeen.type} title={userFirstSeen.text}>{userFirstSeen.description}</Alert>
</li>}
{roomCount !== 0 && <li>
<Alert type="info" title={`You share ${roomCount} rooms.`}></Alert>
</li>}
</ul>;
}
const { roomCount, joinedTo, userBanned, userKicked, userFirstSeen } = details;
return (
<ul className="mx_RoomPreviewContext">
{roomCount === 0 && <SafetyDetailItem title="You have no shared rooms" 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>
);
};