Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 15f2fcc4d1 fix(moderation): policy viewer lists a stable rule and its legacy twin once (#321)
CI / Build & Quality Checks (pull_request) Successful in 3m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m30s
dedupePolicyEntries keyed on the raw recommendation, so m.ban and
org.matrix.mjolnir.ban for the same entity never matched and the rule
showed twice (both labelled "Ban"). Key on the normalised value; move
the pure helpers to policyEntries.ts with unit tests.

Fixes #321

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:28:34 -04:00
18 changed files with 93 additions and 195 deletions
+2 -95
View File
@@ -1,7 +1,7 @@
# Lotus Chat — Feature Reference
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
Last updated: October 2026.
Last updated: July 2026.
---
@@ -28,8 +28,7 @@ Last updated: October 2026.
19. [Infrastructure](#infrastructure)
20. [Localization](#localization)
21. [Desktop App Features](#desktop-app-features)
22. [June–October 2026 Additions](#juneoctober-2026-additions)
23. [Key Custom Files](#key-custom-files)
22. [Key Custom Files](#key-custom-files)
---
@@ -1523,98 +1522,6 @@ Checks for a new desktop release every 12h and offers a one-click update. `Tauri
---
## June–October 2026 Additions
Features shipped since the sections above were last updated (Gitea #294). Each
line names the commit that introduced it.
### Calls
- **Selectable ringtone + volume:** pick the incoming-call ringtone (`4a875884`).
- **In-call incoming-call banner:** a small banner for a second call instead of a full ring (`c67aed01`).
- **Denoise tester:** Settings → Calls lets you audition the ML models and calibrate the noise gate (`14cfa021`).
- **Mic level meter** on the mute button (`3449338b`, #146).
- **"You're muted" nudge** when you talk into a muted mic (`73b1d1e3`, #117).
- **Undeafen catch-up toast:** what you missed while deafened (`33e16e85`, #128).
- **Admin turned off screen share / camera:** a toast explains why your share or camera stopped (`34574178`, #223).
- **Voice-limit / audio-only chip** in the room header (`7f17940d`, #134).
- **"N Live" pill names** who is in the call on hover / focus (`a475531b`, #157).
- **Live-call dot on the space rail** (`4fe9c870`, #148).
- **Missed-call line** in the timeline for DMs and small groups (`c921f115`, #162).
- **Answer with camera or audio-only:** answering says when it turns the camera on (`d2f56817`, #135).
- **Audio output quick-switch** in the call bar (`98c80f36`, #119).
- **Call-ended summary toast:** "Call ended · 41 min · connection was good" (`af1c0ee1`, #143).
- **No WebRTC on desktop:** offers "Open in browser" instead of a dead end (`fff583fc`).
- **Call page on its own origin** (desktop, opt-in) (`7d7a379c`, #43).
### Messaging & composer
- **Reactions collapse** to one row with a +N chip (`bf05751e`, #138).
- **Consecutive photos / videos** render as one gallery grid (`bd8c79e0`, #137).
- **Inline audio playback** for `m.file` audio attachments (`eb34b047`).
- **Recently used GIFs** in the GIF picker (`5b58e5fe`).
- **Pasted image → caption field:** a single pasted or dropped image lands in its caption (`0c45bde8`, #129).
- **Offer to format pasted code** as a code block (`75d55e86`, #107).
- **Tap to preview stickers / GIFs** on touch, tap again to send (`8d11a62e`, #147).
- **Link-mismatch confirmation** before opening a link whose text names another site (`74d8e311`, #122).
- **Embed fallback** for hung embeds and deleted X posts (`ba5b1ffe`, #200).
- **"Back to where you were"** after jumping in the timeline (`7da91bd8`, #127).
- **Offline outbox:** unsent messages survive a reload and retry (`02d86cae`, #112).
- **Terms-consent explanation** when "Failed to send" is the server wanting its terms accepted (`c96c47dd`).
### Search & navigation
- **Quick switcher (Ctrl/⌘+K)**, with a configurable key (`9d4679d2`).
- **Search filters:** `has:image/file/video`, recent searches, pinned only (`1ee0f0b5`).
- **Search operators:** `in:`, `before:` / `after:`, `has:`, `is:pinned` (`75951f90`, #106).
- **Explore:** featured Lotus Guild space and the matrixrooms.info directory (`b1ecb0c4`).
### Rooms & threads
- **Disappearing messages** (MSC1763 `m.room.retention`) (`82e52e1b`).
- **Mark as Unread** (MSC2867) and **Low Priority** rooms (`d46b91b1`).
- **"Who can see this"** line under the room name in Room Settings (`ad1cbcf7`, #133).
- **Threads: Mark all read** in the threads list (`fdec3ed7`, #165).
### Media viewer
- **Swipe** between items in the full-screen viewer (`cf80729a`, #164).
- **Two-finger pinch zoom** (`39589b40`, #164).
### Profile, privacy & safety
- **Report User** (MSC4260) (`26f90087`).
- **Active now / Last active** from presence (`dea1f7af`, #150).
- **Delete all my messages** in a room (`edb46247`, #169).
- **Self-hosted VT323 font** instead of Google Fonts (`6f250353`, #214).
- **Device-security nudge:** verify this device / set up key backup (`6a7627fa`, #110, #123).
- **Homeserver status banner** from Uptime Kuma (`6ae00872`, #124).
### Appearance
- **Custom accent colour** for non-TDS themes (`3c4842df`, P5-1).
- **Theme presets:** Cyberpunk, Ocean, Blood Red, Matrix, Midnight (`65e24bd4`, P5-2).
### Mobile & PWA
- **Saved Messages** from the room header's More Options menu (`8c711f5f`).
- **Long-press bottom sheet** of message actions (`53a80adb`, #166).
- **Haptics** on PTT press / release and on reactions (`492b57c6`, #125).
- **Android share target:** share into Lotus Chat from other apps (`96a97a2f`, #155).
### Notifications
- **Inline reply** from a browser notification (unencrypted rooms) (`d4420905`, #203).
### Desktop app
- **Tray icon mirrors call state** (`25fa0f6e`, cinny-desktop #4).
- **Start minimized** under Launch on login (`4a982389`, cinny-desktop #3).
- **First-close choice:** asks once what closing the window should do (`359c79a4`, cinny-desktop #5).
- **Keychain mirror:** login tokens also kept in the OS keychain (`9b9f33b2`, #105).
---
## Key Custom Files
| File | Purpose |
+1 -1
View File
@@ -34,7 +34,7 @@ This file keeps only what a contributor needs to have open while working: the tw
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.162.0 (2026-10-18, Gitea #294):** live location (`msc3489`/`msc3672`) and relation redaction (`msc3892`) are still unadvertised; thread subscriptions (`msc4306`), sticky events (`msc4354`) and QR login (`msc4108`) are still `false`. New: `org.matrix.msc3026.busy_presence` is advertised as `false`, and enabling it would give Do Not Disturb a real presence state (#283). `msc3266` and `msc4222` are no longer listed, but room preview still works (`im.nheko.summary` and `v1/room_summary` both answer 200). (Previous check: 1.157.1 on 2026-07-23, no change then.)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.23",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
"version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+1 -1
View File
@@ -297,7 +297,7 @@ export function AccountDataEditor({
</Chip>
</Box>
<Box shrink="No">
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
Setup Device Verification
</Text>
</Box>
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
Reset Device Verification
</Text>
</Box>
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+2 -5
View File
@@ -139,11 +139,8 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
data.allowFederation,
data.additionalCreators,
),
// [Gitea #328] Every non-space room gets call.member at user level, not just
// voice rooms: the call button is shown in private group rooms too, and with
// Synapse's state_default (50) only admins could start or join a call there
// ("You don't have permission to join!").
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
power_level_content_override:
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
initial_state: initialState,
};
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
</Chip>
</Box>
<Box shrink="No">
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -17,6 +17,7 @@ import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries';
// ── Policy event types ────────────────────────────────────────────────────────
@@ -38,13 +39,6 @@ type PolicyRuleContent = {
recommendation?: string;
};
type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
type PolicyTab = 'users' | 'rooms' | 'servers';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -53,13 +47,6 @@ function isGlob(entity: string): boolean {
return entity.includes('*') || entity.includes('?');
}
function recommendationLabel(rec: string): string {
// `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
// (pre-stabilization) recommendation still emitted by older bots.
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
return rec;
}
/**
* Fetch all state events of a given type from a room's live forward state.
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
@@ -84,23 +71,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
.filter((entry) => entry.entity !== '');
}
/**
* Merge policy entries from the stable and legacy event types for a rule
* kind, de-duplicating by entity+recommendation so a room that emits both a
* stable and a legacy rule for the same target isn't double-listed.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${entry.recommendation}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
// ── Entry row ─────────────────────────────────────────────────────────────────
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dedupePolicyEntries, recommendationLabel } from './policyEntries';
const e = (entity: string, recommendation: string, stateKey = entity) => ({
entity,
reason: 'spam waves',
recommendation,
stateKey,
});
test('dedupePolicyEntries: a stable rule and its legacy twin show once (#321)', () => {
const out = dedupePolicyEntries([
e('@spammer:evil.example', 'm.ban'),
e('@spammer:evil.example', 'org.matrix.mjolnir.ban'),
e('@*:spam.example', 'm.ban'),
]);
assert.deepEqual(
out.map((x) => x.entity),
['@spammer:evil.example', '@*:spam.example'],
);
});
test('dedupePolicyEntries: different recommendations stay separate', () => {
assert.equal(dedupePolicyEntries([e('@a:x', 'm.ban'), e('@a:x', 'org.example.mute')]).length, 2);
});
test('recommendationLabel', () => {
assert.equal(recommendationLabel('m.ban'), 'Ban');
assert.equal(recommendationLabel('org.matrix.mjolnir.ban'), 'Ban');
assert.equal(recommendationLabel('org.example.mute'), 'org.example.mute');
});
@@ -0,0 +1,35 @@
export type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
/**
* `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
* (pre-stabilization) recommendation still emitted by older bots.
*/
export const normalizeRecommendation = (rec: string): string =>
rec === 'org.matrix.mjolnir.ban' ? 'm.ban' : rec;
export function recommendationLabel(rec: string): string {
if (normalizeRecommendation(rec) === 'm.ban') return 'Ban';
return rec;
}
/**
* Merge stable and legacy rule lists, one entry per entity + recommendation.
* [Gitea #321] Keyed on the normalised recommendation: a bot publishing both
* `m.ban` and its `org.matrix.mjolnir.ban` twin showed the rule twice.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${normalizeRecommendation(entry.recommendation)}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
+1 -1
View File
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
Create Poll
</Text>
</Box>
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+1 -1
View File
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
Report Room
</Text>
</Box>
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+1 -1
View File
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
Report User
</Text>
</Box>
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
{title}
</Text>
</Box>
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+2 -14
View File
@@ -725,13 +725,7 @@ export const MessageDeleteItem = as<
Delete Message
</Text>
</Box>
<IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -871,13 +865,7 @@ export const MessageReportItem = as<
Report Message
</Text>
</Box>
<IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+4 -24
View File
@@ -69,15 +69,8 @@ export const Reactions = as<'div', ReactionsProps>(
const measure = useCallback(() => {
const container = containerRef.current;
if (!container) return;
// [Gitea #305] The width the chips actually get (content box). The border
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
const cs = getComputedStyle(container);
const rowWidth =
container.clientWidth -
(parseFloat(cs.paddingLeft) || 0) -
(parseFloat(cs.paddingRight) || 0);
const gap = parseFloat(cs.columnGap) || 8;
const rowWidth = container.getBoundingClientRect().width;
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
@@ -117,21 +110,8 @@ export const Reactions = as<'div', ReactionsProps>(
style={{
...props.style,
...(collapsed && rowHeight
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
// margins so the timeline's marginTop survives (Gitea #305: the
// `margin` shorthand overrode it and, once removed, cleared it).
{
maxHeight: rowHeight + 4,
overflow: 'hidden',
padding: 2,
marginLeft: -2,
marginRight: -2,
marginBottom: -2,
marginTop:
props.style?.marginTop !== undefined
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
: -2,
}
? // 2px breathing room so row-1 focus outlines are not clipped.
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
: {}),
}}
ref={(el) => {
+1 -12
View File
@@ -31,7 +31,6 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
import { isRingableRoom } from '../../utils/callRing';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
@@ -252,18 +251,8 @@ export class CallEmbed {
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
}
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
// (DMs, invite-only groups outside a space). In a voice channel or space
// room the notification carries an @room mention, so every member got a
// highlighted ping (and phone push) whenever a moderator hopped in, and
// other clients showed it as a missed call.
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
if (CallEmbed.startingCall(intent)) {
let notify = 'none';
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
}
params.append('sendNotificationType', notify);
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
}
// [Gitea #43] On its own origin when config.json sets elementCallUrl.