mirror of
https://github.com/vector-im/element-web.git
synced 2025-12-25 11:11:31 +01:00
Redesign to be MVVM-y, better presentation etc.
This commit is contained in:
parent
63ceae52ed
commit
ae323d1592
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user