diff --git a/res/css/views/rooms/_RoomPreviewContext.pcss b/res/css/views/rooms/_RoomPreviewContext.pcss index 1067eec3ef..9c4d12d974 100644 --- a/res/css/views/rooms/_RoomPreviewContext.pcss +++ b/res/css/views/rooms/_RoomPreviewContext.pcss @@ -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); } -} \ No newline at end of file + 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; + } +} diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 599c53527a..9fb4310e6c 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -559,7 +559,7 @@ class RoomPreviewBar extends React.Component { /> ); } - inviteContext = + inviteContext = ; primaryActionHandler = this.props.onJoinClick; secondaryActionLabel = _t("action|decline"); diff --git a/src/components/views/rooms/RoomPreviewContext.tsx b/src/components/views/rooms/RoomPreviewContext.tsx index 7a4e80e3e6..9b79352e2a 100644 --- a/src/components/views/rooms/RoomPreviewContext.tsx +++ b/src/components/views/rooms/RoomPreviewContext.tsx @@ -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 ( +
  • + {score === InviteScore.Unknown && } + {score === InviteScore.Safe && } + {score === InviteScore.Unsafe && } +
    + {title &&

    {title}

    } + {description &&

    {description}

    } +
    +
  • + ); +} + +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(); + const [joinedTo, setJoinedTo] = useState<{ title: string; description: string; score: InviteScore }>(); + const [roomCount, setRoomCount] = useState(); 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(); const joinedToPrivateRooms = new Map(); @@ -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][])) { + for (const [roomSet, type] of [ + [joinedToPrivateSpaces, "private spaces"], + [joinedToPrivateRooms, "private rooms"], + [joinedToPublicSpaces, "spaces"], + [joinedToPublicRooms, "public rooms"], + ] as [Map, 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(() => { + 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(() => { + 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(false); + + if (!score) { + return ( +
    + + Checking invite safety +
    + ); } - return
      - {roomCount === 0 &&
    • - -
    • } - {joinedTo &&
    • - {joinedTo.description} -
    • } - {userBanned &&
    • - {userBanned} -
    • } - {userKicked &&
    • - {userKicked} -
    • } - {userFirstSeen &&
    • - {userFirstSeen.description} -
    • } - {roomCount !== 0 &&
    • - -
    • } -
    ; -} \ No newline at end of file + const { roomCount, joinedTo, userBanned, userKicked, userFirstSeen } = details; + return ( +
      + {roomCount === 0 && } + {userBanned && ( + + )} + {userKicked && ( + + )} + {joinedTo && ( + + )} + {userFirstSeen && ( + + )} + {!learnMoreOpen && ( +
    • + +
    • + )} +
    + ); +};