From 15f2fcc4d11c4bf8ec42d3d44c11c2075f94039a Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:28:34 -0400 Subject: [PATCH] fix(moderation): policy viewer lists a stable rule and its legacy twin once (#321) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../room-settings/PolicyListViewer.tsx | 32 +---------------- .../room-settings/policyEntries.test.ts | 32 +++++++++++++++++ .../features/room-settings/policyEntries.ts | 35 +++++++++++++++++++ 3 files changed, 68 insertions(+), 31 deletions(-) create mode 100644 src/app/features/room-settings/policyEntries.test.ts create mode 100644 src/app/features/room-settings/policyEntries.ts diff --git a/src/app/features/room-settings/PolicyListViewer.tsx b/src/app/features/room-settings/PolicyListViewer.tsx index 5a120c8a3..453016a5c 100644 --- a/src/app/features/room-settings/PolicyListViewer.tsx +++ b/src/app/features/room-settings/PolicyListViewer.tsx @@ -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(); - 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 }) { diff --git a/src/app/features/room-settings/policyEntries.test.ts b/src/app/features/room-settings/policyEntries.test.ts new file mode 100644 index 000000000..0e88eba30 --- /dev/null +++ b/src/app/features/room-settings/policyEntries.test.ts @@ -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'); +}); diff --git a/src/app/features/room-settings/policyEntries.ts b/src/app/features/room-settings/policyEntries.ts new file mode 100644 index 000000000..3954731b6 --- /dev/null +++ b/src/app/features/room-settings/policyEntries.ts @@ -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(); + 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; +}