diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 602885546ee..504a2c10315 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -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"; diff --git a/res/css/views/rooms/_RoomPreviewContext.pcss b/res/css/views/rooms/_RoomPreviewContext.pcss new file mode 100644 index 00000000000..9c4d12d9748 --- /dev/null +++ b/res/css/views/rooms/_RoomPreviewContext.pcss @@ -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; + } +} diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index b5c7e081542..9fb4310e6c0 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -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 { 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 { /> ); } + inviteContext = ; primaryActionHandler = this.props.onJoinClick; secondaryActionLabel = _t("action|decline"); @@ -736,6 +739,7 @@ class RoomPreviewBar extends React.Component { {subTitleElements} {reasonElement} + {inviteContext}
+ {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; + isLocalTrustedServer?: boolean; + }; +} { + const client = useMatrixClientContext(); + const [joinedTo, setJoinedTo] = useState<{ title: string; description: string; score: InviteScore }>(); + const [roomCount, setRoomCount] = useState(); + const [isLocalTrustedServer, setIsLocalTrustedServer] = useState(); + + + 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(); + const joinedToPrivateRooms = new Map(); + const joinedToPublicSpaces = new Map(); + const joinedToPublicRooms = new Map(); + 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][]) { + 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(() => { + 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(false); + + if (!score) { + return ( +
+ + Checking invite safety +
+ ); + } + + const { roomCount, joinedTo, userBanned, userKicked, userFirstSeen, isLocalTrustedServer } = details; + return ( +
    + {isLocalTrustedServer && } + {roomCount === 0 && } + {userBanned && ( + + )} + {userKicked && ( + + )} + {joinedTo && ( + + )} + {userFirstSeen && ( + + )} + {!learnMoreOpen && ( +
  • + +
  • + )} +
+ ); +};