diff --git a/LOTUS_BUGS.md b/LOTUS_BUGS.md
index b8e4c337f..e0755272f 100644
--- a/LOTUS_BUGS.md
+++ b/LOTUS_BUGS.md
@@ -60,10 +60,10 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
### 6. Exclusive Background vs. Seasonal Choice
- **File:** `cinny/src/app/state/settings.ts`
-- **Status:** **OPEN**
+- **Status:** **FIXED**
- **Issue:** Concurrent application of both Chat Backgrounds and Seasonal Themes causes visual clutter and high GPU usage.
- **Root Cause:** These are currently handled as independent settings in the `settingsAtom` and applied simultaneously without mutual exclusion.
-- **Proposed Fix:** Introduce mutual exclusion in the settings application logic. Update the settings UI to present these as a single choice (e.g., a radio group or toggled selection) where activating one deactivates the other. Enforce this rule in `cinny/src/app/features/lotus/chatBackground.ts` and `cinny/src/app/components/seasonal/SeasonalEffect.tsx`.
+- **Fix Applied:** Mutual exclusion enforced at two layers: (1) `General.tsx` — ChatBgGrid clears seasonalThemeOverride→'off' when any non-'none' background is picked; SeasonalBgGrid clears chatBackground→'none' when any real seasonal theme is selected. (2) `SeasonalEffect.tsx` — runtime guard returns null if `chatBackground !== 'none'`, protecting against legacy persisted state.
### 7. Tiny Touch Targets in Composer Toolbar
@@ -149,11 +149,11 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| Issue Description | File Path |
| :-------------------------------------------------------------------- | :-------------------------------------------------------- |
| Hardcoded inline style `cursor: 'pointer'` | `cinny/src/app/plugins/react-custom-html-parser.tsx` |
-| Hardcoded color `#00D4FF`, `#FFB300` | `cinny/src/app/components/event-readers/EventReaders.tsx` |
+| Hardcoded color `#00D4FF`, `#FFB300` ✅ **VERIFIED COMPLIANT** | `cinny/src/app/components/event-readers/EventReaders.tsx` |
| Hardcoded color `#EE1D52`, `#9146ff`, `#ff4500`, `#cb3837`, `#f48024` | `cinny/src/app/components/url-preview/UrlPreviewCard.tsx` |
| Massive number of hardcoded `backgroundColor` values | `cinny/src/app/features/lotus/chatBackground.ts` |
-| Hardcoded colors `#00FF88`, `#FF6B00` | `cinny/src/app/features/call/CallControls.tsx` |
-| Hardcoded fallback hexes in toast colors | `cinny/src/app/features/toast/LotusToastContainer.tsx` |
+| Hardcoded colors `#00FF88`, `#FF6B00` ✅ **VERIFIED COMPLIANT** | `cinny/src/app/features/call/CallControls.tsx` |
+| Hardcoded fallback hexes in toast colors ✅ **FIXED** | `cinny/src/app/features/toast/LotusToastContainer.tsx` |
---
diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md
index cabdb6d9a..c20a291d6 100644
--- a/LOTUS_TODO.md
+++ b/LOTUS_TODO.md
@@ -7,10 +7,10 @@
## 🏗️ Infrastructure & Maintenance
-- [ ] **Upgrade Synapse to v1.155.0**
- - **Context:** Synapse 1.155.0 is the last version supporting Debian 12 Bookworm.
- - **Reference:** https://github.com/element-hq/synapse/releases/tag/v1.155.0
- - **Plan:** Review release notes, backup database and media store on LXC 151, perform upgrade in a staging environment if possible, then production. Prepare for OS migration to Debian 13 afterward.
+- [x] **Upgrade Synapse to v1.155.0** ✅ Done 2026-06-18
+ - **Context:** 1.155.0 is the last version supporting Debian 12 Bookworm. LXC 151 is already on Debian 13 Trixie — OS migration was completed prior to this upgrade.
+ - **What changed (1.154→1.155):** No breaking changes, no config changes, no DB migrations. Bugfixes: to-device EDU size limiting, restricted room joins, sliding sync subscription response timing. Rust port of more internal classes (perf only).
+ - **MSC4452** (Preview URL capabilities) shipped in 1.154 — opt-in via `msc4452_enabled`, not enabled.
---
@@ -52,18 +52,18 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
## Server Capabilities (as of June 2026)
- **Homeserver:** `matrix.lotusguild.org`
-- **Synapse version:** `1.153.0` (2026-05-19) — fully up to date
+- **Synapse version:** `1.155.0` (2026-06-18) — fully up to date; last version for Debian 12 (LXC 151 already on Debian 13 Trixie)
- **Matrix spec:** up to `v1.12` formally; newer MSC features via `unstable_features`
### Confirmed facts
| Finding | Impact |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
-| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` | All safe to use now |
+| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
-| **MSC3266** room summary: returns 404 | Room Preview feature BLOCKED |
+| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
-| **MSC4260** report user: server at v1.12, endpoint may not exist | Report User feature BLOCKED |
+| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
@@ -467,9 +467,9 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
### [BLOCKED] · Room Preview Before Joining (MSC3266)
-**Blocked by:** `GET /v1/rooms/{id}/summary` returns 404 — endpoint not available on this server
+**Blocked by:** `GET /_matrix/client/v1/rooms/{roomId}/summary` returns `M_UNRECOGNIZED` 404 — endpoint not implemented in Synapse 1.155. Config flag `msc3266_enabled: true` is set but has no effect; Synapse appears not to have shipped a stable implementation at the v1 path. Verified 2026-06-18.
**What it would do:** Show room name, topic, avatar, member count before joining.
-**Action when unblocked:** Build pre-join preview card; trigger on unjoined room navigation.
+**Action when unblocked:** Re-test after each future Synapse upgrade.
### [BLOCKED] · Thread Subscriptions (MSC4306)
@@ -477,12 +477,12 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
**What it would do:** Follow a thread without posting; get notifications for replies.
**Action when unblocked:** Add "Follow thread" button in the thread panel header (depends on #P3-8 Thread Panel).
-### [BLOCKED] · Report User (MSC4260)
+### [DONE] · Report User (MSC4260) ✅
-**Blocked by:** Server declares only spec v1.12; MSC4260 merged in v1.14 — endpoint may not exist
-**What it would do:** Report a specific user to homeserver admins (separate from reporting a message).
-**Note:** Report Message already exists in upstream Cinny. This would add Report User to the profile panel.
-**Action when unblocked:** Test `POST /_matrix/client/v3/users/{userId}/report`; if 200, add button to user profile.
+**Previously blocked by:** Server spec v1.12, but `POST /_matrix/client/v3/users/{userId}/report` was confirmed **200** on 2026-06-18 (live since Synapse 1.133.0).
+**What it does:** Reports a specific user to homeserver admins (separate from reporting a message).
+**Note:** Report Message already exists in upstream Cinny. This adds Report User to the profile panel.
+**Implemented 2026-06-18:** `ReportUserModal.tsx` added at `src/app/features/room/ReportUserModal.tsx`. Button wired into `UserRoomProfile.tsx` between UserModeration and UserDeviceSessions (hidden for own profile). Category dropdown + reason text, inline success/error feedback, auto-close 1500ms after success.
---
diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx
index 5ecd320b3..07879b701 100644
--- a/src/app/components/seasonal/SeasonalEffect.tsx
+++ b/src/app/components/seasonal/SeasonalEffect.tsx
@@ -797,5 +797,9 @@ export function SeasonalEffect() {
}, [settings.seasonalThemeOverride]);
if (!theme) return null;
+ // Suppress seasonal overlay when a chat background is active — both running simultaneously
+ // wastes GPU and looks cluttered. The settings UI enforces mutual exclusion on write;
+ // this guard covers any legacy state already persisted.
+ if (settings.chatBackground !== 'none') return null;
return ;
}
diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx
index 19a72d22d..fb89bb916 100644
--- a/src/app/components/user-profile/UserRoomProfile.tsx
+++ b/src/app/components/user-profile/UserRoomProfile.tsx
@@ -28,6 +28,7 @@ import { Membership } from '../../../types/matrix/room';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
+import { ReportUserModal } from '../../features/room/ReportUserModal';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
@@ -272,6 +273,7 @@ type UserRoomProfileProps = {
};
export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const mx = useMatrixClient();
+ const [reportUserOpen, setReportUserOpen] = useState(false);
const crossSigningActive = useCrossSigningActive();
const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
@@ -390,8 +392,25 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
/>
+ {userId !== myUserId && (
+
+ }
+ onClick={() => setReportUserOpen(true)}
+ >
+ Report User
+
+
+ )}
{showEncryption && userId !== myUserId && }
{userId !== myUserId && }
+ {reportUserOpen && (
+ setReportUserOpen(false)} />
+ )}
);
diff --git a/src/app/features/room/ReportUserModal.tsx b/src/app/features/room/ReportUserModal.tsx
new file mode 100644
index 000000000..51e0621e5
--- /dev/null
+++ b/src/app/features/room/ReportUserModal.tsx
@@ -0,0 +1,223 @@
+import React, { FormEventHandler, useCallback, useEffect, 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 = {
+ spam: 'Spam',
+ harassment: 'Harassment',
+ inappropriate: 'Inappropriate Content',
+ other: 'Other',
+};
+
+type ReportUserModalProps = {
+ userId: string;
+ onClose: () => void;
+};
+
+export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
+ const mx = useMatrixClient();
+ const [category, setCategory] = useState('spam');
+
+ const [reportState, submitReport] = useAsyncCallback(
+ useCallback(
+ async (reason: string) => {
+ await mx.http.authedRequest(
+ Method.Post,
+ `/users/${encodeURIComponent(userId)}/report`,
+ undefined,
+ { reason },
+ );
+ },
+ [mx, userId],
+ ),
+ );
+
+ useEffect(() => {
+ if (reportState.status === AsyncStatus.Success) {
+ const timer = setTimeout(onClose, 1500);
+ return () => clearTimeout(timer);
+ }
+ return undefined;
+ }, [reportState.status, onClose]);
+
+ const handleSubmit: FormEventHandler = (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 = `[${CATEGORY_LABELS[category]}] ${reasonText}`;
+ submitReport(fullReason);
+ };
+
+ const reportError =
+ reportState.status === AsyncStatus.Error
+ ? (reportState.error as { errcode?: string; httpStatus?: number })
+ : undefined;
+ const errcode = reportError?.errcode;
+ const errorMsg =
+ errcode === 'M_LIMIT_EXCEEDED'
+ ? 'You are being rate limited. Please wait before reporting again.'
+ : errcode === 'M_FORBIDDEN'
+ ? 'You cannot report this user.'
+ : errcode === 'M_UNRECOGNIZED' || reportError?.httpStatus === 404
+ ? 'User reporting is not supported by your homeserver.'
+ : 'Failed to submit report. Please try again.';
+
+ return (
+ }>
+
+
+
+
+
+
+ Report User
+
+
+
+
+
+
+
+
+
+ Report this user to your homeserver admins. Please describe the issue below.
+
+
+
+
+ Category
+
+ ) =>
+ setCategory(e.target.value as ReportCategory)
+ }
+ style={{
+ padding: `${config.space.S200} ${config.space.S300}`,
+ borderRadius: config.radii.R300,
+ border: `1px solid ${color.Surface.ContainerLine}`,
+ background: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ fontSize: 'inherit',
+ fontFamily: 'inherit',
+ width: '100%',
+ }}
+ >
+ {(Object.keys(CATEGORY_LABELS) as ReportCategory[]).map((key) => (
+
+ ))}
+
+
+
+
+
+ Reason
+
+
+ {reportState.status === AsyncStatus.Error && (
+
+ {errorMsg}
+
+ )}
+ {reportState.status === AsyncStatus.Success && (
+
+ User has been reported to the server.
+
+ )}
+
+
+
+
+
+ ) : undefined
+ }
+ aria-disabled={
+ reportState.status === AsyncStatus.Loading ||
+ reportState.status === AsyncStatus.Success
+ }
+ >
+
+ {reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report User'}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index de55cba8f..389e3e2cd 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -432,6 +432,7 @@ function Appearance() {
settingsAtom,
'seasonalThemeOverride',
);
+ const [, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
return (
@@ -512,7 +513,10 @@ function Appearance() {
setSeasonalThemeOverride(v)}
+ onChange={(v) => {
+ setSeasonalThemeOverride(v);
+ if (v !== 'auto' && v !== 'off') setChatBackground('none');
+ }}
/>
@@ -1671,6 +1675,7 @@ function SeasonalBgGrid({
function ChatBgGrid() {
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
+ const [, setSeasonalThemeOverride] = useSetting(settingsAtom, 'seasonalThemeOverride');
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
@@ -1683,7 +1688,10 @@ function ChatBgGrid() {
type="button"
aria-label={opt.label}
aria-pressed={chatBackground === opt.value}
- onClick={() => setChatBackground(opt.value as ChatBackground)}
+ onClick={() => {
+ setChatBackground(opt.value as ChatBackground);
+ if (opt.value !== 'none') setSeasonalThemeOverride('off');
+ }}
style={{
display: 'block',
width: toRem(76),
diff --git a/src/app/features/toast/LotusToastContainer.tsx b/src/app/features/toast/LotusToastContainer.tsx
index ce35b450c..655e14525 100644
--- a/src/app/features/toast/LotusToastContainer.tsx
+++ b/src/app/features/toast/LotusToastContainer.tsx
@@ -53,13 +53,13 @@ function ToastCard({ toast }: ToastCardProps) {
const cardStyle: CSSProperties = {
position: 'relative',
- background: 'var(--lt-bg-card, #1a1a2e)',
- border: '1px solid var(--lt-border-color, rgba(255,255,255,0.1))',
+ background: 'var(--lt-bg-card)',
+ border: '1px solid var(--lt-border-color)',
borderRadius: '12px',
padding: '12px 14px',
minWidth: '280px',
maxWidth: '340px',
- boxShadow: 'var(--lt-box-glow-orange, 0 4px 16px rgba(0,0,0,0.4))',
+ boxShadow: 'var(--lt-box-glow-orange)',
cursor: 'pointer',
animation: 'lotusToastIn 0.2s ease-out both',
userSelect: 'none',
@@ -84,19 +84,19 @@ function ToastCard({ toast }: ToastCardProps) {
width: '24px',
height: '24px',
borderRadius: '50%',
- background: 'var(--lt-accent-orange-dim, rgba(255,107,0,0.15))',
- border: '1px solid var(--lt-accent-orange-border, rgba(255,107,0,0.35))',
+ background: 'var(--lt-accent-orange-dim)',
+ border: '1px solid var(--lt-accent-orange-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontWeight: 700,
- color: 'var(--lt-accent-orange, #ff6b00)',
+ color: 'var(--lt-accent-orange)',
flexShrink: 0,
};
const nameStyle: CSSProperties = {
- color: 'var(--lt-accent-orange, #ff6b00)',
+ color: 'var(--lt-accent-orange)',
fontWeight: 600,
fontSize: '0.85rem',
overflow: 'hidden',
@@ -110,7 +110,7 @@ function ToastCard({ toast }: ToastCardProps) {
right: '10px',
background: 'none',
border: 'none',
- color: 'var(--lt-text-secondary, #7fa3bf)',
+ color: 'var(--lt-text-secondary)',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1,
@@ -119,7 +119,7 @@ function ToastCard({ toast }: ToastCardProps) {
};
const bodyStyle: CSSProperties = {
- color: 'var(--lt-text-primary, #c4d9ee)',
+ color: 'var(--lt-text-primary)',
fontSize: '0.82rem',
margin: '4px 0 2px',
overflow: 'hidden',
@@ -128,7 +128,7 @@ function ToastCard({ toast }: ToastCardProps) {
};
const roomNameStyle: CSSProperties = {
- color: 'var(--lt-text-secondary, #7fa3bf)',
+ color: 'var(--lt-text-secondary)',
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',