feat: add Report Room (MSC4151) and fix URL preview default for encrypted rooms

- Report Room: new ReportRoomModal with reason + category, POST /rooms/{id}/report
- URL preview: encUrlPreview default changed to true; security note added to settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:11:21 -04:00
parent a893b13f97
commit dc5570f5f7
5 changed files with 228 additions and 4 deletions
+186
View File
@@ -0,0 +1,186 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Text,
Input,
Button,
IconButton,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Header,
config,
color,
Spinner,
} from 'folds';
import { Method } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
type ReportCategory = 'spam' | 'harassment' | 'inappropriate' | 'other';
const CATEGORY_LABELS: Record<ReportCategory, string> = {
spam: 'Spam',
harassment: 'Harassment',
inappropriate: 'Inappropriate Content',
other: 'Other',
};
type ReportRoomModalProps = {
roomId: string;
onClose: () => void;
};
export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
const mx = useMatrixClient();
const [category, setCategory] = useState<ReportCategory>('spam');
const [reportState, submitReport] = useAsyncCallback(
useCallback(
async (reason: string) => {
await mx.http.authedRequest(
Method.Post,
`/rooms/${encodeURIComponent(roomId)}/report`,
undefined,
{ reason },
);
},
[mx, roomId],
),
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (reportState.status === AsyncStatus.Loading || reportState.status === AsyncStatus.Success) {
return;
}
const target = evt.target as HTMLFormElement;
const reasonInput = target.elements.namedItem('reasonInput') as HTMLInputElement | null;
const reasonText = reasonInput?.value.trim() ?? '';
const fullReason = reasonText
? `[${CATEGORY_LABELS[category]}] ${reasonText}`
: `[${CATEGORY_LABELS[category]}]`;
submitReport(fullReason);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Box
as="form"
onSubmit={handleSubmit}
direction="Column"
style={{
background: 'var(--mx-surface)',
borderRadius: config.radii.R400,
boxShadow: '0 8px 32px rgba(0,0,0,0.55)',
width: '100%',
maxWidth: 420,
overflow: 'hidden',
}}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Report Room</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text priority="400">
Report this room to your homeserver admins. Please describe the issue below.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Category</Text>
<Box
as="select"
value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setCategory(e.target.value as ReportCategory)
}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: '1px solid var(--mx-border)',
background: 'var(--mx-bg-surface)',
color: 'var(--mx-c-surface-on)',
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
}}
>
{(Object.keys(CATEGORY_LABELS) as ReportCategory[]).map((key) => (
<option key={key} value={key}>
{CATEGORY_LABELS[key]}
</option>
))}
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Reason</Text>
<Input name="reasonInput" variant="Background" required />
{reportState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to submit report. Please try again.
</Text>
)}
{reportState.status === AsyncStatus.Success && (
<Text style={{ color: color.Success.Main }} size="T300">
Room has been reported to the server.
</Text>
)}
</Box>
<Box gap="200" justifyContent="End">
<Button type="button" variant="Secondary" fill="None" radii="300" onClick={onClose}>
<Text size="B400">Cancel</Text>
</Button>
<Button
type="submit"
variant="Critical"
radii="300"
before={
reportState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
aria-disabled={
reportState.status === AsyncStatus.Loading ||
reportState.status === AsyncStatus.Success
}
>
<Text size="B400">
{reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report Room'}
</Text>
</Button>
</Box>
</Box>
</Box>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+35 -3
View File
@@ -20,6 +20,7 @@ import {
PopOut,
RectCords,
Badge,
Chip,
Spinner,
Button,
} from 'folds';
@@ -67,6 +68,7 @@ import { JumpToTime } from './jump-to-time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { ReportRoomModal } from './ReportRoomModal';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings';
@@ -92,6 +94,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
const [reportRoomOpen, setReportRoomOpen] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
@@ -127,6 +130,15 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
}}
/>
)}
{reportRoomOpen && (
<ReportRoomModal
roomId={room.roomId}
onClose={() => {
setReportRoomOpen(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
@@ -227,6 +239,19 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={() => setReportRoomOpen(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Warning} />}
radii="300"
aria-pressed={reportRoomOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Report Room
</Text>
</MenuItem>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
@@ -478,9 +503,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</Avatar>
)}
<Box direction="Column">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
</Text>
<Box alignItems="Center" gap="200">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
</Text>
{room.getType() === 'm.server_notice' && (
<Chip size="400" variant="Warning" radii="Pill" outlined>
<Text size="T200">Server Notice</Text>
</Chip>
)}
</Box>
{topic && (
<UseStateProvider initial={false}>
{(viewTopic, setViewTopic) => (
@@ -1202,6 +1202,7 @@ function Messages() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Url Preview in Encrypted Room"
description="URL previews in encrypted rooms are fetched by your homeserver, which sees the URL but not the message content."
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/>
</SequenceCard>
+1 -1
View File
@@ -101,7 +101,7 @@ const defaultSettings: Settings = {
hideNickAvatarEvents: true,
mediaAutoLoad: true,
urlPreview: true,
encUrlPreview: false,
encUrlPreview: true,
showHiddenEvents: false,
legacyUsernameColor: false,