import { useCallback, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react' import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings' import { getDashboardStats } from '../../api/dashboard' import { loadQuickList, type QuickEntry } from '../../lib/quick-lists' import { nextUpdateText } from '../settings/panes/Blocking' import { SectionHeader } from '../../ui/SectionHeader' import { Panel, Body } from '../../ui/Panel' import { DataTable } from '../../ui/DataTable' import { RowAction } from '../../ui/Table' import { Field, Input, Select } from '../../ui/Field' import { Button } from '../../ui/Button' import { PermissionButton } from '../../ui/PermissionButton' import { Confirm } from '../../ui/Confirm' import { Notifier } from '../../ui/Notifier' import { Failure, Loading } from '../../ui/Empty' import { RouteLink } from '../../ui/RouteLink' import { Icon } from '../../ui/Icon' import { noticeFromFailure, type Notice } from '../../lib/notice' import { addHeading, addListTo, applyQuickTo, canMove, canToggle, changesOf, changesText, fromUrls, headingIndexes, headingName, headingProblem, listName, moveLine, removeHeading, renameHeading, saveBody, toggleLine, type ListLine, } from './list-lines' import { Locked } from './Locked' import { hostsOf, moreLists } from './extra-lists' import { missing, type Permissions } from './permissions' import shared from './Blocking.module.css' import styles from './BlockLists.module.css' import { UrlText } from '../../ui/UrlText' /* The Lists tab: the "Allow / Block List URLs" field of Settings > Blocking drawn as a table. OURS as a table; every line is the server's own grammar (`list-lines.ts`). Upstream's words here: `Quick Add` and its `None`, `Update Now`, the next-update value (`nextUpdateText`, shared with Settings), the update confirmation sentence and every alert of the update and the save. Everything else is OURS: - the figures `Block List Domains` and `Allow List Domains`, and `Next update · every N h` (upstream's label is "Block List Next Update On"); - the panel titles `Lists`, `Block Lists`, `Add a list`, and `Update Block Lists` with its `Update` button (upstream asks with a bare `confirm()`); - the field label `List URL` (upstream's is "Allow / Block List URLs"), its placeholder, the `file://` help line, the invalid-URL hint and `This list is already in the table.`; `Add block list` / `Add allow list`; - the table: the columns `Enabled`, `List` and `Type`, the types `Block` and `Allow`, `Remove`, `Move up` / `Move down`, `No lists`, and the row controls' aria-labels; - the groups (2026-10-15): a comment line drawn as a group heading (`list-lines.ts`, GROUPS) with its `N list(s)`, `No group` for the lists before the first heading, `Unnamed group` for a bare `#`, `Rename`, `Ungroup`, `Add group`, `Group name`, `Cancel`, the field `Add to group`, and `headingProblem`'s sentences; - the table's foot: `N list(s) · N disabled · N group(s)`; - the subtitle under the title (`LISTS_SUBTITLE`); - the unsaved-changes bar (2026-10-15): `Unsaved changes to the block list URLs:` and what changed, in `changesText`'s words (`N added`, `N removed`, `N switched on`, `N switched off`, `N changed`, `N moved`, `N group(s) added`, `renamed`, `removed`; `list-lines.ts`, WHAT CHANGED), `Discard`, and `Save`, shortened from upstream's "Save Settings" (index.html:2460); and the Lists tab's mark while there are unsaved changes, read as `unsaved changes` (`onDirty`, `Blocking.tsx`); - the two `Could not read …` sentences and the link to Settings › Blocking; - Quick Add's search (`Search lists`, `No lists match`) and its second catalogue, the console's own (`extra-lists.ts`), under `More lists` after Technitium's. Quick Add here lists Technitium's catalogue first, as Settings does, then ours. An entry of ours is chosen exactly as one of theirs (`applyQuick`), and its single-URL entries name their rows too. Its search matches the name and the URL's host. No node selector: `blockListUrls` is a CLUSTER-WIDE parameter (`nodeScope`, settings/model.ts:514), so it is read and saved on the cluster when there is one and on this server when there is not, which is what Settings does with the aggregate. Groups and moves are kept with everything else: a heading is a line like any other, so adding, renaming or removing one, and moving a list, only changes `lines`. Changes are KEPT until Save, as upstream's Save Settings keeps the textarea: `Quick Add > None` empties every list, and saving on each click would make that one click unrecoverable. One save also means one request, so quick changes cannot land out of order. */ /* The value of an entry of ours in Quick Add: by position, so a name shared with an entry of Technitium's still picks the right one. */ const MORE = 'more:' const KIND_LABEL = { block: 'Block', allow: 'Allow' } as const const KIND_CLASS = { block: styles.block, allow: styles.allow } as const /* An icon and a word, not only a colour, as the Rule column of Rules: the same two icons, so a block list and a blocked rule read as the same kind. */ const KIND_ICON = { block: 'blocked', allow: 'allowed' } as const /* The add field takes the bare URL; the buttons choose block or allow. Both sentences are OURS. `addList` only refuses an identical line, so the table checks the rest here: a `#` or `!` typed by hand would make the field say one thing and the button another, and the same URL switched off or on the other list would be a second row the server loads twice. */ const MSG_PREFIX = 'Enter the URL without # or !; use the buttons to choose block or allow.' const MSG_DUPLICATE = 'This list is already in the table.' /* OURS: `dashboard/stats/get` failed, which is not the same as zero domains. */ const COUNTS_FAILED = 'Could not read the counts.' /* After a Save or an Update Now the server reloads the lists in the background, and the counts change when it has finished, not at once. Until then both figures say "Updating…" (OURS). How the end is told, from the server (v15.5.1): `blockListNextUpdatedOn` is the last SUCCESSFUL update plus the interval (WebServiceSettingsApi.cs:384), and the last update only moves when a download ends well (BlockListZoneManager.cs:674-692). While it reloads, then, the server keeps answering the OLD date (a future one, so "Updating Now" is no signal). The value is captured when the action is taken (Save: from its own answer; Update Now: read just before the call) and the settings are read every POLL_MS until it differs; then the counts are read once more. POLL_LIMIT_MS bounds the wait (a download that fails never moves the date), and at the limit the counts are read anyway. A second Save or Update Now while waiting captures again and starts the limit again. */ const UPDATING = 'Updating…' const POLL_MS = 3000 const POLL_LIMIT_MS = 120_000 /** A reload being waited for: the date captured when it was asked for. Each one is a new object, so a second action restarts the wait even with the same date. */ type Reload = { from: string | null } /* Whether a Save makes the server reload. Only when the set of lines changed, by its `HasSameItems` (TechnitiumLibrary CollectionExtensions.cs: same count, every item of the one in the other, order aside; BlockListZoneManager.cs:512-523), and there is something to reload with a timer to do it: with no lines the server flushes the zones there and then (`Flush()`, :527), and with the interval at 0 it reloads nothing. */ function sameItems(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((x) => b.includes(x)) } /** What the tab is for, under its title. OURS (CONVENTIONS.md, deviation 2). */ export const LISTS_SUBTITLE = 'The block and allow lists the server downloads. Changes to them wait until you save.' /** A heading being written: an existing group by its ordinal, or a new one. */ type Editing = { group: number | 'new'; value: string; problem: string | null } const UNNAMED = 'Unnamed group' const nameOf = (line: ListLine) => headingName(line) || UNNAMED const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}` /* The heading editor: one field, its verb and `Cancel`. Enter takes it and Escape leaves it, as a field in a row should; the sentence that refuses a name goes under the field, once (the design rule). */ function HeadingEditor({ editing, verb, onChange, onSubmit, onCancel, }: { editing: Editing verb: string onChange: (value: string) => void onSubmit: () => void onCancel: () => void }) { const problemId = useId() return (
{ e.preventDefault() onSubmit() }} > onChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault() onCancel() } }} /> {editing.problem != null && (

{editing.problem}

)}
) } export function BlockLists({ tabs, token, permissions, clusterInitialised = false, onDirty, }: { tabs?: ReactNode token: string | null permissions: Permissions clusterInitialised?: boolean /** Told whether there are unsaved changes, for the tab's mark; false on leaving. */ onDirty?: (dirty: boolean) => void }) { const node = clusterInitialised ? 'cluster' : '' const viewNeed = missing(permissions, 'Settings.canView') const modifyNeed = missing(permissions, 'Settings.canModify') const statsNeed = missing(permissions, 'Dashboard.canView') const invalidId = useId() const [saved, setSaved] = useState(null) const [readFailed, setReadFailed] = useState(false) const [lines, setLines] = useState([]) const [next, setNext] = useState(null) const [interval, setIntervalHours] = useState(null) const [hasSaved, setHasSaved] = useState(false) const [counts, setCounts] = useState<{ block: number; allow: number } | null>(null) const [countsFailed, setCountsFailed] = useState(false) const [catalog, setCatalog] = useState([]) /** The console's own entries, once Technitium's are known to leave out repeats. */ const [more, setMore] = useState([]) const [field, setField] = useState('') const [invalid, setInvalid] = useState(null) const [notice, setNotice] = useState(null) const [busy, setBusy] = useState(false) const [askUpdate, setAskUpdate] = useState(false) /** The group "Add a list" and Quick Add put lists into: a heading's ordinal, -1 for none. */ const [group, setGroup] = useState(-1) const [editing, setEditing] = useState(null) /* Closing the editor removes the field that held the focus: it goes back to the button that opened it, found by its name once the table is drawn again. */ const table = useRef(null) const focusAfter = useRef(null) useEffect(() => { const name = focusAfter.current if (name == null || editing != null) return focusAfter.current = null const target = [...(table.current?.querySelectorAll('button') ?? [])].find( (b) => (b.getAttribute('aria-label') ?? b.textContent) === name, ) target?.focus() }, [editing, lines]) /* What would be saved, against what the server answered. */ const changes = saved == null ? '' : changesText(changesOf(saved, lines)) const dirty = changes !== '' useEffect(() => { onDirty?.(dirty) }, [dirty, onDirty]) useEffect(() => () => onDirty?.(false), [onDirty]) /** The server is reloading the lists: the counts on screen are not current yet. */ const [reloading, setReloading] = useState(null) /** The lines as the server last answered them, to tell whether a Save reloads. */ const serverUrls = useRef([]) /** `blockListNextUpdatedOn` as the server last answered it. */ const serverNext = useRef(null) const mounted = useRef(true) useEffect(() => { mounted.current = true return () => { mounted.current = false } }, []) useEffect(() => { if (viewNeed != null) return let live = true void readSettings(token, node).then((r) => { if (!live) return /* As every other tab: with nothing on screen, the notice carries the server's message, and the table slot says the read failed. */ if (r.kind !== 'ok') { setReadFailed(true) setNotice(noticeFromFailure(r)) return } const s = r.data const read = fromUrls(s.blockListUrls) setSaved(read) setLines(read) // `settings/get` OMITS this key when it is null (CONVENTIONS.md): absent is "Not Scheduled". setNext(s.blockListNextUpdatedOn ?? null) serverNext.current = s.blockListNextUpdatedOn ?? null serverUrls.current = s.blockListUrls ?? [] setIntervalHours(s.blockListUpdateIntervalHours) setHasSaved(s.blockListUrls != null) }) return () => { live = false } }, [token, node, viewNeed]) useEffect(() => { let live = true void loadQuickList('quick-block-lists').then((e) => { if (!live) return setCatalog(e) setMore(moreLists(e)) }) return () => { live = false } }, []) const readCounts = useCallback(async () => { if (statsNeed != null) return const r = await getDashboardStats(token, 'LastHour', undefined, node) if (!mounted.current) return if (r.kind !== 'ok') { setCountsFailed(true) return } setCountsFailed(false) setCounts({ block: r.data.stats.blockListZones, allow: r.data.stats.allowListZones }) }, [token, node, statsNeed]) useEffect(() => { void readCounts() }, [readCounts]) /* The wait described at POLL_MS: armed by a Save or an Update Now. Until it ends the next-update line keeps what the action left there ("Updating Now" after Update Now, main.js:2352-2356). */ useEffect(() => { if (reloading == null) return let live = true let timer: ReturnType | undefined const started = Date.now() const tick = async () => { const r = await readSettings(token, node) if (!live) return const now = r.kind !== 'ok' ? undefined : (r.data.blockListNextUpdatedOn ?? null) const finished = now !== undefined && now !== reloading.from if (!finished && Date.now() - started < POLL_LIMIT_MS) { timer = setTimeout(() => void tick(), POLL_MS) return } if (now !== undefined) { setNext(now) serverNext.current = now } await readCounts() if (live) setReloading(null) } timer = setTimeout(() => void tick(), POLL_MS) return () => { live = false clearTimeout(timer) } }, [reloading, token, node, readCounts]) if (viewNeed != null) { return ( <> ) } const off = modifyNeed != null const loading = saved == null const heads = headingIndexes(lines) /* What the chosen group still means: a Discard or a Quick Add that emptied the headings leaves an ordinal that no longer exists, and that is no group. */ const target = group < heads.length ? group : -1 const edit = editing != null && (editing.group === 'new' || editing.group < heads.length) ? editing : null function submitHeading() { if (edit == null) return const problem = headingProblem(edit.value) if (problem != null) { setEditing({ ...edit, problem }) return } if (edit.group === 'new') { setLines(addHeading(lines, edit.value)) // The new group is the one the next list goes into. setGroup(heads.length) focusAfter.current = 'Add group' } else { setLines(renameHeading(lines, heads[edit.group], edit.value)) focusAfter.current = `Rename group ${edit.value.trim()}` } setEditing(null) } function cancelEditing() { if (edit == null) return focusAfter.current = edit.group === 'new' ? 'Add group' : `Rename group ${nameOf(lines[heads[edit.group]])}` setEditing(null) } function ungroup(g: number) { setLines(removeHeading(lines, heads[g])) // Its lists joined the group above, and so does the choice of group. if (target >= g) setGroup(target - 1) if (edit != null && edit.group !== 'new' && edit.group >= g) setEditing(null) } function add(kind: 'block' | 'allow') { const url = field.trim() if (url === '') return if (url.startsWith('#') || url.startsWith('!')) { setInvalid(MSG_PREFIX) return } if (lines.some((l) => l.url === url)) { setInvalid(MSG_DUPLICATE) return } setLines(addListTo(lines, url, kind, target)) setField('') setInvalid(null) } async function save() { setBusy(true) const r = await setSettings(token, saveBody(lines, node)) setBusy(false) if (r.kind !== 'ok') { setNotice(noticeFromFailure(r)) return } const read = fromUrls(r.data.response.blockListUrls) setSaved(read) setLines(read) setHasSaved(r.data.response.blockListUrls != null) // The response carries the server's schedule, as Settings redraws from it: emptying // the lists stops the timer, and the key then comes back omitted ("Not Scheduled"). const answered = r.data.response.blockListNextUpdatedOn ?? null setNext(answered) setIntervalHours(r.data.response.blockListUpdateIntervalHours) // Settings.tsx:253-257, title and sentence. setNotice({ type: 'success', title: 'Settings Saved!', text: 'DNS Server settings were saved successfully.' }) // See `sameItems`: whether, and how, the counts change. const urls = r.data.response.blockListUrls ?? [] const changed = !sameItems(urls, serverUrls.current) serverUrls.current = urls serverNext.current = answered const interval = r.data.response.blockListUpdateIntervalHours ?? 0 if (!changed) return if (urls.length > 0 && interval > 0) setReloading({ from: answered }) else { setReloading(null) void readCounts() } } async function updateNow() { setAskUpdate(false) setBusy(true) // The date to wait on, read just before the call (see POLL_MS); the last answer // known if the read fails. const before = await readSettings(token, node) const from = before.kind !== 'ok' ? serverNext.current : (before.data.blockListNextUpdatedOn ?? null) const ok = await forceUpdateBlockLists(token) setBusy(false) if (!ok) return // main.js:2356. The label becomes "Updating Now" without reloading the settings. setNext(new Date(0).toISOString()) setNotice({ type: 'success', title: 'Updating Block List!', text: 'Block list update was triggered successfully.' }) setReloading({ from }) } /* What names a row: Technitium's entries first, so theirs wins a shared URL. */ const named = [...catalog, ...more] const listsCount = lines.filter((l) => l.kind !== 'comment').length const disabledCount = lines.filter((l) => l.kind !== 'comment' && !l.enabled).length const groupCount = heads.length /* The lists of each group, to say how many it holds. */ const groupSize = (g: number) => (heads[g + 1] ?? lines.length) - (g < 0 ? 0 : heads[g] + 1) /* What the table draws, one item per row: a group heading (its content spans the whole row) or a line. The grid draws cells, so a heading is a row whose first cell takes the width of all four and whose other cells are not drawn. */ type Entry = ListLine & { kind: 'block' | 'allow' } type Item = | { key: string; kind: 'heading'; node: ReactNode } | { key: string; kind: 'line'; l: Entry; i: number } function heading(key: string, name: string, count: number, controls: ReactNode): Item { return { key, kind: 'heading', node: (
{name} {plural(count, 'list', 'lists')} {controls != null && } {controls}
), } } /* One row per line. A key that follows the line and not its place, so a list that moves or switches keeps its row, and the focus stays on the button pressed. */ const seen = new Map() const items: Item[] = [] if (heads.length > 0 && heads[0] > 0) items.push(heading('none', 'No group', groupSize(-1), null)) lines.forEach((l, i) => { if (l.kind === 'comment') { const g = heads.indexOf(i) const name = nameOf(l) items.push( edit != null && edit.group === g ? { key: `H:${g}`, kind: 'heading', node: ( setEditing({ ...edit, value, problem: null })} onSubmit={submitHeading} onCancel={cancelEditing} /> ), } : heading(`H:${g}`, name, groupSize(g), ( <> setEditing({ group: g, value: headingName(l), problem: null })}> Rename ungroup(g)}> Ungroup )), ) return } const id = `${l.kind}:${l.url}` const n = seen.get(id) ?? 0 seen.set(id, n + 1) items.push({ key: `L:${id}:${n}`, kind: 'line', l: l as Entry, i }) }) if (edit != null && edit.group === 'new') { items.push({ key: 'H:new', kind: 'heading', node: ( setEditing({ ...edit, value, problem: null })} onSubmit={submitHeading} onCancel={cancelEditing} /> ), }) } /* What a line's cells share. */ const lineCells = (l: Entry) => ({ name: l.url == null ? null : listName(l.url, named), faded: !l.enabled ? styles.off : undefined, what: `${l.kind} list ${l.url ?? ''}`, }) return ( <> setNotice(null)} />
{statsNeed != null ? ( <> ) : ( <>
{countsFailed ? ( {COUNTS_FAILED} ) : ( {counts ? counts.block.toLocaleString() : '—'} )} {reloading != null ? ( {UPDATING} ) : ( {'\u00a0'} )} Block List Domains
{countsFailed ? ( {COUNTS_FAILED} ) : ( {counts ? counts.allow.toLocaleString() : '—'} )} {reloading != null ? ( {UPDATING} ) : ( {'\u00a0'} )} Allow List Domains
)}
{loading ? '—' : nextUpdateText(next)} {'\u00a0'} Next update{interval != null && interval > 0 ? ` · every ${interval} h` : ''}
setAskUpdate(true)} > Update Now
{/* Above both ways in, the URL and Quick Add, because it serves both. */}
{(id) => ( )}
{(id) => ( { setField(e.target.value) setInvalid(null) }} /> )} add('block')}> Add block list add('allow')}> Add allow list {/* The URL's own sentences stay under it, in the URL's column. */} {invalid != null && (

{invalid}

)}

Use file:// for a list stored on this server. Hosts, plain domain, wildcard or Adblock Plus files.

{/* Its own line under the URL, as wide as the URL, as upstream draws it (`index.html:2156-2157`, `width: 100%`): the catalogue's names run to 86 characters and a trigger beside the buttons cut them to one letter. */}
{(id) => ( )}
{readFailed ? ( Could not read the block list settings. ) : saved == null ? ( ) : ( data={items} getRowId={(item) => item.key} rowProps={(item) => (item.kind === 'heading' ? { className: styles.groupRow } : undefined)} emptyText={lines.length === 0 && edit?.group !== 'new' ? 'No lists' : undefined} className={shared.inPanel} columns={[ { id: 'enabled', header: 'Enabled', td: (item) => item.kind === 'heading' ? { colSpan: 4, scope: 'colgroup', role: 'columnheader' } : undefined, cell: (item) => { if (item.kind === 'heading') return item.node const { l, i } = item const { what } = lineCells(l) return ( canToggle(l) && ( ) ) }, }, { id: 'list', header: 'List', td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: lineCells(item.l).faded }), cell: (item) => { if (item.kind === 'heading') return null const { l } = item const { name } = lineCells(l) return ( <> {name != null &&
{name}
}
{/* The kind under the URL where the Type column is folded away. */} {KIND_LABEL[l.kind]} ) }, }, { id: 'kind', th: { className: styles.kindCell }, header: 'Type', td: (item) => item.kind === 'heading' ? { hidden: true } : { className: `${lineCells(item.l).faded ?? ''} ${styles.kindCell}` }, cell: (item) => item.kind === 'heading' ? null : ( {KIND_LABEL[item.l.kind]} ), }, { id: 'actions', td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: styles.actions }), cell: (item) => { if (item.kind === 'heading') return null const { l, i } = item const { what } = lineCells(l) return (
setLines((all) => moveLine(all, i, -1))} /> setLines((all) => moveLine(all, i, 1))} /> setLines((all) => all.filter((_, j) => j !== i))}> Remove
) }, }, ]} /> )}
setEditing({ group: 'new', value: '', problem: null })}> Add group {plural(listsCount, 'list', 'lists')} · {disabledCount} disabled · {plural(groupCount, 'group', 'groups')} More blocking settings in Settings › Blocking
{dirty && (
Unsaved changes to the block list URLs: {changes} void save()}> Save
)}
setAskUpdate(false)} title="Update Block Lists" label="Update" variant="primary" text="Are you sure to force download and update the block lists?" busy={busy} onConfirm={() => void updateNow()} /> ) }