import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as stylex from "@stylexjs/stylex"; import { formatTime } from "@/lib/format"; import InlineError from "@/lib/InlineError"; import { blocklistCreateMutation, blocklistDeleteMutation, blocklistUpdateMutation, blocklistsQuery, blocklistsUpdateNowMutation, } from "@/lib/queries"; import type { Blocklist, BlocklistInput } from "@/lib/types"; import ConfirmDialog from "@/ui/ConfirmDialog"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; import AuthorityGate from "./AuthorityGate"; import BlocklistForm from "./BlocklistForm"; import FileModeNote from "./FileModeNote"; import QueryPanel from "./QueryPanel"; import { styles as config } from "./styles"; const SKIPPED_NOTE = "Both “Skipped” columns count lines nxdns read and did not take. Skipped regex lines are patterns nxdns accepts " + "only from you — adopt one you trust as a regex rule. Skipped unsupported lines are syntax nxdns cannot translate " + "into a DNS decision: cosmetic element hiding, browser-only modifiers. A skipped unsupported count that dwarfs the " + "domain count usually means the list is written for a browser extension, and its DNS or hosts variant will block " + "more here."; const styles = stylex.create({ name: { fontWeight: 500, }, badge: { marginLeft: "0.5rem", borderRadius: "0.25rem", backgroundColor: colors.border, paddingInline: "0.375rem", paddingBlock: "0.125rem", fontSize: "0.75rem", lineHeight: "1rem", color: colors.text, }, url: { display: "block", maxWidth: "18rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", }, actions: { display: "flex", gap: "0.75rem", }, dimWhenDisabled: { opacity: { default: 1, ":disabled": 0.5 }, }, }); /** * A runtime action: it re-downloads the sources the running process already * knows about, so it stays enabled under file authority and while * `/api/config/status` is still answering. * * The feedback is the action's own, and it is transient: started while the * request is in flight, the failure verbatim if it fails, and nothing on * success — the refreshed counters are the success signal, and a standing * "counters refreshed" line would claim a refetch that may itself have failed. * Durable per-source outcomes live in Diagnostics. */ function UpdateNowAction() { const queryClient = useQueryClient(); const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient)); return (
{updateNow.isPending && (

Update started…

)}
); } export default function ProtectionSources() { const blocklists = useQuery(blocklistsQuery()); return (

The shared catalogue every group draws from. A group subscribes to sources on the Groups tab.

{(status) => status.authority === "managed_file" ? ( <> {(rows) => } ) : ( {(rows) => } ) }
); } /** * File mode is a different rendering of the same facts, not a smaller set of * them: the Suggested provenance and both skipped-line counters belong here * too. A list whose lines nxdns could not take is a failure the reader must * see under either authority. */ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) { return (

Blocklist sources blocklist_sources

{blocklists.length === 0 ? (

The file declares no blocklist sources.

) : (
{blocklists.map((b) => ( ))}
Name URL Enabled Domains Wildcards Exceptions Skipped regex Skipped unsupported Last updated
{b.name} {b.is_suggested && Suggested} {b.url} {String(b.enabled)} {b.domain_count} {b.wildcard_count} {b.exception_count} {b.skipped_regex_count} {b.skipped_unsupported_count} {b.last_updated === null ? "never" : formatTime(b.last_updated)}

{SKIPPED_NOTE}

)}
); } function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); const create = useMutation(blocklistCreateMutation(queryClient)); const save = useMutation(blocklistUpdateMutation(queryClient)); const toggle = useMutation(blocklistUpdateMutation(queryClient)); const remove = useMutation(blocklistDeleteMutation(queryClient)); async function submitForm(input: BlocklistInput) { if (editing === null) { await create.mutateAsync(input); } else { await save.mutateAsync({ id: editing.id, input: { ...input, is_suggested: editing.is_suggested } }); setEditing(null); } } function toggleEnabled(b: Blocklist) { toggle.mutate({ id: b.id, input: { url: b.url, name: b.name, enabled: !b.enabled, is_suggested: b.is_suggested }, }); } function confirmDelete() { if (pendingDelete === null) return; remove.mutate(pendingDelete.id); setPendingDelete(null); } const formError = editing === null ? create.error : save.error; const tableError = remove.error ?? toggle.error; return (
{blocklists.length === 0 ? (

No blocklist sources yet. Add one below.

) : (
{blocklists.map((b) => ( ))}
Name URL Enabled Domains Wildcards Exceptions Skipped regex Skipped unsupported Last updated Actions
{b.name} {b.is_suggested && Suggested} {b.url} toggleEnabled(b)} {...stylex.props(shared.focusRing)} /> {b.domain_count} {b.wildcard_count} {b.exception_count} {b.skipped_regex_count} {b.skipped_unsupported_count} {b.last_updated === null ? "never" : formatTime(b.last_updated)}

{SKIPPED_NOTE}

)} setEditing(null)} /> setPendingDelete(null)} />
); }