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
17 changed files with 91 additions and 111 deletions
+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>
+1 -12
View File
@@ -44,9 +44,6 @@ export function ConsentRequiredPrompt() {
const [opened, setOpened] = useState(false);
const [blockedCount, setBlockedCount] = useState(0);
const snoozedUntil = useRef(0);
// [Gitea #311] Closing via Retry also deactivates the focus trap; that must
// not snooze, or a retry that fails again (terms not accepted yet) is silent.
const closingForRetry = useRef(false);
const blocked = useRef(new Map<MatrixEvent, Room>());
const modalStyle = useModalStyle(440);
const server = mx.getDomain() ?? 'Your homeserver';
@@ -77,8 +74,6 @@ export function ConsentRequiredPrompt() {
}, [consentUri]);
const retry = useCallback(() => {
closingForRetry.current = true;
snoozedUntil.current = 0;
setOpen(false);
setOpened(false);
const pending = Array.from(blocked.current.entries());
@@ -102,13 +97,7 @@ export function ConsentRequiredPrompt() {
focusTrapOptions={{
initialFocus: '#consent-review',
fallbackFocus: '#consent-later',
onDeactivate: () => {
if (closingForRetry.current) {
closingForRetry.current = false;
return;
}
later();
},
onDeactivate: later,
clickOutsideDeactivates: true,
escapeDeactivates: true,
}}
@@ -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.