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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -101,7 +101,7 @@ const defaultSettings: Settings = {
|
||||
hideNickAvatarEvents: true,
|
||||
mediaAutoLoad: true,
|
||||
urlPreview: true,
|
||||
encUrlPreview: false,
|
||||
encUrlPreview: true,
|
||||
showHiddenEvents: false,
|
||||
legacyUsernameColor: false,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user