Files
cinny/src/app/features/room-settings/PolicyListViewer.tsx
T
jared b361d43088 fix(ui): native inputs/checkboxes, QR fallback, focus + report modal cleanup
- N23 RoomServerACL: raw text input -> folds Input; raw checkbox -> folds Checkbox
- N24 PolicyListViewer: raw room-id input -> folds Input (Critical variant on error)
- N25 ExportRoomHistory: raw <input type="date"> x2 -> folds Input
- N26 RoomShareInvite: QR <img> gets loading="lazy" + onError fallback card
  ("QR code unavailable") instead of a broken-image icon
- N27 GifPicker: FocusTrap returnFocusOnDeactivate:false (matches EmojiBoard)
- N76 Report modals: drop redundant Cancel button (dismiss via header x /
  click-outside, like MessageReportItem)
- N5 ReadReceiptAvatars: hover/focus moved to co-located css :hover/:focus-visible
  (removed JS onMouseEnter/Leave .style mutation)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:12:25 -04:00

368 lines
12 KiB
TypeScript

import React, { useCallback, useRef, useState } from 'react';
import {
Badge,
Box,
Button,
Icon,
IconButton,
Icons,
Input,
Scroll,
Text,
color,
config,
} from 'folds';
import { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
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';
// ── Policy event types ────────────────────────────────────────────────────────
const POLICY_USER_EVENT = 'm.policy.rule.user';
const POLICY_ROOM_EVENT = 'm.policy.rule.room';
const POLICY_SERVER_EVENT = 'm.policy.rule.server';
type PolicyRuleContent = {
entity?: string;
reason?: string;
recommendation?: string;
};
type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
type PolicyTab = 'users' | 'rooms' | 'servers';
// ── Helpers ───────────────────────────────────────────────────────────────────
function isGlob(entity: string): boolean {
return entity.includes('*') || entity.includes('?');
}
function recommendationLabel(rec: string): string {
if (rec === 'm.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.
*/
function getRoomPolicyEvents(room: Room, eventType: string): MatrixEvent[] {
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!state) return [];
return state.getStateEvents(eventType);
}
function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
return events
.map((e) => {
const content = e.getContent<PolicyRuleContent>();
return {
entity: content.entity ?? '',
reason: content.reason ?? '',
recommendation: content.recommendation ?? '',
stateKey: e.getStateKey() ?? '',
};
})
.filter((entry) => entry.entity !== '');
}
// ── Entry row ─────────────────────────────────────────────────────────────────
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
const glob = isGlob(entry.entity);
return (
<Box
direction="Column"
gap="100"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Box gap="200" alignItems="Center" wrap="Wrap">
<Text
size="T300"
style={{
fontFamily: 'monospace',
color: glob ? color.Warning.Main : undefined,
wordBreak: 'break-all',
}}
>
{entry.entity}
</Text>
{glob && (
<Badge variant="Warning" fill="Soft" radii="Pill">
<Text size="T200">glob</Text>
</Badge>
)}
<Badge variant="Critical" fill="Soft" radii="Pill">
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
</Badge>
</Box>
{entry.reason && (
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
{entry.reason}
</Text>
)}
</Box>
);
}
// ── Tab button ────────────────────────────────────────────────────────────────
function TabButton({
label,
count,
active,
onClick,
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
}) {
return (
<Button
onClick={onClick}
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Solid' : 'Soft'}
size="300"
radii="300"
>
<Text size="B300">
{label} ({count})
</Text>
</Button>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type PolicyListViewerProps = {
requestClose: () => void;
};
export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
const mx = useMatrixClient();
const inputRef = useRef<HTMLInputElement>(null);
const [roomIdInput, setRoomIdInput] = useState('');
const [activeTab, setActiveTab] = useState<PolicyTab>('users');
const [error, setError] = useState<string | undefined>();
const [userEntries, setUserEntries] = useState<PolicyEntry[]>([]);
const [roomEntries, setRoomEntries] = useState<PolicyEntry[]>([]);
const [serverEntries, setServerEntries] = useState<PolicyEntry[]>([]);
const [loadedRoomId, setLoadedRoomId] = useState<string | undefined>();
const handleLoad = useCallback(() => {
const rawInput = (inputRef.current?.value ?? roomIdInput).trim();
if (!rawInput) {
setError('Please enter a room ID or alias.');
return;
}
// Resolve alias to room ID using local cache
let roomId = rawInput;
if (rawInput.startsWith('#')) {
const cachedId = mx.getRooms().find((r) => {
const aliases = r.getAltAliases();
const canonical = r.getCanonicalAlias();
return aliases.includes(rawInput) || canonical === rawInput;
})?.roomId;
if (cachedId) {
roomId = cachedId;
} else {
setError(`Cannot resolve alias "${rawInput}". Make sure you have joined that room.`);
return;
}
}
const room = mx.getRoom(roomId);
if (!room) {
setError(`Not joined to room "${roomId}". Join the policy list room first.`);
setUserEntries([]);
setRoomEntries([]);
setServerEntries([]);
setLoadedRoomId(undefined);
return;
}
setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)));
setLoadedRoomId(roomId);
setError(undefined);
}, [mx, roomIdInput]);
const activeEntries =
activeTab === 'users' ? userEntries : activeTab === 'rooms' ? roomEntries : serverEntries;
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text as="h2" size="H3" truncate>
Policy Lists
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{/* Description */}
<Box direction="Column" gap="100">
<Text size="L400">About</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<Text size="T300">
Policy lists are Matrix rooms containing ban rules managed by moderation bots
(e.g. Draupnir). Enter a policy list room ID below to inspect its current rules.
This is a read-only viewer rule enforcement is handled by your moderation bot.
</Text>
</SequenceCard>
</Box>
{/* Room ID input */}
<Box direction="Column" gap="100">
<Text size="L400">Policy List Room</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="300"
>
<Box gap="200" alignItems="Center">
<Input
ref={inputRef}
value={roomIdInput}
onChange={(e) => setRoomIdInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleLoad();
}}
placeholder="!roomId:server or #alias:server"
variant={error ? 'Critical' : 'Secondary'}
size="400"
radii="300"
style={{ flexGrow: 1 }}
/>
<Button
onClick={handleLoad}
variant="Primary"
fill="Solid"
size="300"
radii="300"
before={<Icon src={Icons.Search} size="100" />}
>
<Text size="B300">Load</Text>
</Button>
</Box>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
{loadedRoomId && (
<Text size="T200" priority="300">
Showing rules from:{' '}
<span style={{ fontFamily: 'monospace' }}>{loadedRoomId}</span>
</Text>
)}
</SequenceCard>
</Box>
{/* Rules viewer */}
{loadedRoomId && (
<Box direction="Column" gap="100">
<Text size="L400">Rules</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="300"
>
{/* Tabs */}
<Box gap="200">
<TabButton
label="Users"
count={userEntries.length}
active={activeTab === 'users'}
onClick={() => setActiveTab('users')}
/>
<TabButton
label="Rooms"
count={roomEntries.length}
active={activeTab === 'rooms'}
onClick={() => setActiveTab('rooms')}
/>
<TabButton
label="Servers"
count={serverEntries.length}
active={activeTab === 'servers'}
onClick={() => setActiveTab('servers')}
/>
</Box>
{/* Entry list */}
<Box
direction="Column"
style={{
border: `1px solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
overflow: 'hidden',
}}
>
{activeEntries.length === 0 ? (
<Box
alignItems="Center"
justifyContent="Center"
style={{ padding: config.space.S500 }}
>
<Text size="T300" priority="300">
No{' '}
{activeTab === 'users'
? 'user'
: activeTab === 'rooms'
? 'room'
: 'server'}{' '}
ban rules found.
</Text>
</Box>
) : (
activeEntries.map((entry) => (
<PolicyEntryRow key={entry.stateKey || entry.entity} entry={entry} />
))
)}
</Box>
</SequenceCard>
</Box>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}