363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
|
|
import React, { useCallback, useRef, useState } from 'react';
|
||
|
|
import { Badge, Box, Button, Icon, IconButton, Icons, 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"
|
||
|
|
style={{
|
||
|
|
flexGrow: 1,
|
||
|
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||
|
|
borderRadius: config.radii.R300,
|
||
|
|
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
|
||
|
|
background: color.Surface.Container,
|
||
|
|
color: color.Surface.OnContainer,
|
||
|
|
fontSize: 'inherit',
|
||
|
|
fontFamily: 'inherit',
|
||
|
|
outline: 'none',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
}
|