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 4223ac85d3
commit 572bc6a4c0
5 changed files with 228 additions and 4 deletions
+5
View File
@@ -67,6 +67,10 @@ A full custom theme engine layered on top of Cinny's vanilla-extract theming:
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
### Moderation
- **Report Room**: A "Report Room" option in the room header menu (⋮) allows users to report a room to homeserver admins with a reason and abuse category (Spam / Harassment / Inappropriate Content / Other). Calls `POST /_matrix/client/v3/rooms/{roomId}/report` (MSC4151, confirmed supported on matrix.lotusguild.org). Implemented in `ReportRoomModal.tsx` with loading/success/error states.
### Messaging Enhancements
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
@@ -131,6 +135,7 @@ Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]`
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
- **URL preview default in encrypted rooms**: `encUrlPreview` default changed from `false` to `true` in `src/app/state/settings.ts`. A security note is shown next to the toggle in Settings → General explaining that the homeserver fetches the URL (and sees it) but not the message content.
---
+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,