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.
|
- **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.
|
- **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
|
### 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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,
|
PopOut,
|
||||||
RectCords,
|
RectCords,
|
||||||
Badge,
|
Badge,
|
||||||
|
Chip,
|
||||||
Spinner,
|
Spinner,
|
||||||
Button,
|
Button,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
@@ -67,6 +68,7 @@ import { JumpToTime } from './jump-to-time';
|
|||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { ReportRoomModal } from './ReportRoomModal';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
@@ -92,6 +94,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
|
const [reportRoomOpen, setReportRoomOpen] = useState(false);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
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 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
@@ -227,6 +239,19 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<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}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(promptLeave, setPromptLeave) => (
|
||||||
<>
|
<>
|
||||||
@@ -478,9 +503,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
<Text size={topic ? 'H5' : 'H3'} truncate>
|
<Text size={topic ? 'H5' : 'H3'} truncate>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
|
{room.getType() === 'm.server_notice' && (
|
||||||
|
<Chip size="400" variant="Warning" radii="Pill" outlined>
|
||||||
|
<Text size="T200">Server Notice</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
{topic && (
|
{topic && (
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(viewTopic, setViewTopic) => (
|
{(viewTopic, setViewTopic) => (
|
||||||
|
|||||||
@@ -1202,6 +1202,7 @@ function Messages() {
|
|||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Url Preview in Encrypted Room"
|
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} />}
|
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const defaultSettings: Settings = {
|
|||||||
hideNickAvatarEvents: true,
|
hideNickAvatarEvents: true,
|
||||||
mediaAutoLoad: true,
|
mediaAutoLoad: true,
|
||||||
urlPreview: true,
|
urlPreview: true,
|
||||||
encUrlPreview: false,
|
encUrlPreview: true,
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user