import { type ChangeEvent, type ReactNode, useCallback, useEffect, useMemo, useState, } from "react"; import { type AuthMode, ApiFailure, mutate, probeAuthMode, request, requestObjectUrl, } from "./api"; import type { FeedbackDetail, FeedbackStatus, FeedbackSummary, Report, ReportDetail as ReportDetailData, } from "./types"; import { useResource } from "./useResource"; function usePath() { const [path, setPath] = useState(location.pathname); useEffect(() => { const update = () => setPath(location.pathname); addEventListener("popstate", update); return () => removeEventListener("popstate", update); }, []); const navigate = useCallback((url: string) => { history.pushState(null, "", url); dispatchEvent(new PopStateEvent("popstate")); }, []); return { path, navigate }; } function Link({ href, className, activeWhenNested = false, children, }: { href: string; className?: string; activeWhenNested?: boolean; children: ReactNode; }) { const { path, navigate } = usePath(); const active = path === href || (activeWhenNested && path.startsWith(`${href}/`)); return ( { if (!event.metaKey && !event.ctrlKey) { event.preventDefault(); navigate(href); } }} > {children} ); } function StateView({ resource, children, }: { resource: ReturnType>; children: (data: T) => ReactNode; }) { if (resource.loading && !resource.data) return
Loading…
; if (resource.error && !resource.data) { const forbidden = resource.error instanceof ApiFailure && resource.error.status === 403; return (

{forbidden ? "Access denied" : "Could not load data"}

{resource.error.message}

); } return resource.data ? children(resource.data) : null; } function Reports({ authMode }: { authMode: AuthMode }) { const resource = useResource( () => request("/reports?status=open&limit=100", authMode), "reports", ); return ( {(reports) => reports.length ? (
{reports.map((report) => (
{report.reportType} {report.communityHost} {report.targetKind}: {short(report.target)}
Submitted
))}
) : ( ) }
); } function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) { const resource = useResource( () => request(`/reports/${id}`, authMode), id, ); return ( {(report) => (
{report.reportType}

{report.communityHost}

Status
{report.status}
Reporter
{report.reporterPubkey}
Target
{report.target}
{report.targetKind === "event" ? ( <>
Message
{report.message ? (
{report.message.deletedAt ? ( Deleted ) : null}

{report.message.content}

Author {report.message.authorPubkey} Created
) : (

Message content is unavailable. It may have expired or been removed from event storage.

)}
) : null}
Note
{report.note ?? "No note provided."}
)}
); } function FeedbackList({ authMode }: { authMode: AuthMode }) { const resource = useResource( () => request("/feedback", authMode), "feedback", ); const [query, setQuery] = useState(""); const [community, setCommunity] = useState("all"); const [timeRange, setTimeRange] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); // Successful PATCH responses override the server-loaded status so the row // reflects the new value without a full refetch. Keyed by feedback id. const [overrides, setOverrides] = useState>( {}, ); // Mutations require a named principal; disabled mode's server rejects them // (probe reports canAct: false), so the write control is hidden there rather // than offering an action that can only fail. const canWrite = authMode === "nip98"; const applyStatus = useCallback((id: string, status: FeedbackStatus) => { setOverrides((current) => ({ ...current, [id]: status })); }, []); return ( {(items) => { if (!items.length) return ; return ( {({ communities, filtered }) => ( <>

{filtered.length} of {items.length} submissions

{filtered.length ? (
{filtered.map((item) => (
{item.bodySummary} {short(item.submitterPubkey)}
Received
Open feedback from {hostLabel(item.communityHost)}
))}
) : (
No matching feedback.
)} )}
); }}
); } const STATUS_LABELS: Record = { new: "New", reviewed: "Reviewed", archived: "Archived", }; const STATUS_OPTIONS: FeedbackStatus[] = ["new", "reviewed", "archived"]; /// The per-row lifecycle control. In writable mode it PATCHes the relay and /// adopts the status from the PATCH response (never optimistically — a failed /// write leaves the prior status and surfaces an error). In read-only mode it /// shows the authoritative status as a static badge, since disabled-mode /// mutations are rejected server-side. function FeedbackStatusControl({ id, status, authMode, canWrite, onApplied, }: { id: string; status: FeedbackStatus; authMode: AuthMode; canWrite: boolean; onApplied: (id: string, status: FeedbackStatus) => void; }) { const [pending, setPending] = useState(false); const [error, setError] = useState(false); if (!canWrite) { return ( {STATUS_LABELS[status]} ); } const onChange = async (event: ChangeEvent) => { const next = event.target.value as FeedbackStatus; setPending(true); setError(false); try { const updated = await mutate<{ status: FeedbackStatus }>( `/feedback/${encodeURIComponent(id)}`, "PATCH", { status: next }, authMode, ); onApplied(id, updated.status); } catch { setError(true); } finally { setPending(false); } }; return ( ); } /// Feedback whose source community was purged carries no host. Render an /// explicit provenance-unavailable marker rather than an empty slot. function Provenance({ host }: { host: string | null }) { if (host) return host; return Community unavailable; } function hostLabel(host: string | null): string { return host ?? "an unavailable community"; } function FeedbackResults({ items, query, community, timeRange, statusFilter, overrides, children, }: { items: FeedbackSummary[]; query: string; community: string; timeRange: string; statusFilter: string; overrides: Record; children: (results: { communities: string[]; filtered: FeedbackSummary[]; }) => ReactNode; }) { const results = useMemo(() => { const communities = [...new Set(items.map((item) => item.communityHost))] .filter((host): host is string => Boolean(host)) .sort((left, right) => left.localeCompare(right)); const normalizedQuery = query.trim().toLocaleLowerCase(); const after = timeRangeStart(timeRange); const filtered = items.filter((item) => { const status = overrides[item.id] ?? item.status; if (community !== "all" && item.communityHost !== community) return false; if (statusFilter !== "all" && status !== statusFilter) return false; if (after !== undefined) { const receivedAt = new Date(item.receivedAt).valueOf(); if (Number.isNaN(receivedAt) || receivedAt < after) return false; } if (!normalizedQuery) return true; return [ item.bodySummary, item.communityHost ?? "", item.category ?? "uncategorized", item.submitterPubkey, ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)); }); return { communities, filtered }; }, [items, query, community, timeRange, statusFilter, overrides]); return children(results); } function FeedbackDetailView({ id, authMode, }: { id: string; authMode: AuthMode; }) { const resource = useResource( () => request(`/feedback/${id}`, authMode), id, ); return ( {(feedback) => { // Without an authoritative host we cannot validate attachment URLs or // derive their fetch paths safely, so we render none and mark the // provenance unavailable rather than guessing an origin. const attachments = feedback.communityHost ? feedbackAttachments( feedback.id, feedback.tags, feedback.communityHost, ) : []; const body = stripAttachmentMarkdown(feedback.body, attachments); return (

Feedback
{body}
{attachments.length ? ( <>
Attachments
{attachments.map((attachment) => ( ))}
) : null}
Submitted by
{feedback.submitterPubkey}
Event
{feedback.eventId}
Created
{date(feedback.eventCreatedAt)}
Received
{date(feedback.receivedAt)}
); }}
); } interface FeedbackAttachment { path: string; sourceUrl: string; mimeType: string; hash: string; size?: number; dimensions?: string; filename?: string; } function timeRangeStart(range: string) { const durations: Record = { day: 24 * 60 * 60 * 1000, week: 7 * 24 * 60 * 60 * 1000, month: 30 * 24 * 60 * 60 * 1000, }; const duration = durations[range]; return duration ? Date.now() - duration : undefined; } function feedbackAttachments( feedbackId: string, tags: string[][], communityHost: string, ): FeedbackAttachment[] { return tags.flatMap((tag) => { if (tag[0] !== "imeta") return []; const values = new Map(); for (const entry of tag.slice(1)) { const separator = entry.indexOf(" "); if (separator > 0) { values.set(entry.slice(0, separator), entry.slice(separator + 1)); } } const url = values.get("url"); const mimeType = values.get("m"); const hash = values.get("x"); const safeUrl = url && safeAttachmentUrl(url, communityHost); if (!safeUrl || !mimeType || !hash) return []; const parsedSize = Number(values.get("size")); return [ { path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`, sourceUrl: safeUrl, mimeType, hash, size: Number.isFinite(parsedSize) && parsedSize > 0 ? parsedSize : undefined, dimensions: values.get("dim"), filename: values.get("filename"), }, ]; }); } function safeAttachmentUrl(value: string, communityHost: string) { try { const url = new URL(value, `${location.protocol}//${communityHost}`); return ["http:", "https:"].includes(url.protocol) && url.host.toLocaleLowerCase() === communityHost.toLocaleLowerCase() && url.pathname.startsWith("/media/") ? url.href : undefined; } catch { return undefined; } } function stripAttachmentMarkdown( body: string, attachments?: FeedbackAttachment[], ) { const knownUrls = attachments ? new Set(attachments.map((attachment) => attachment.sourceUrl)) : undefined; return body .replace(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, url) => { const isMedia = knownUrls ? knownUrls.has(url) : url.includes("/media/"); return isMedia ? "" : match; }) .replace(/\n{3,}/g, "\n\n") .trim(); } function Attachment({ attachment, authMode, }: { attachment: FeedbackAttachment; authMode: AuthMode; }) { const [objectUrl, setObjectUrl] = useState(); const [objectType, setObjectType] = useState(); const [failed, setFailed] = useState(false); const path = attachment.path; const name = attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`; const metadata = [ attachment.mimeType, attachment.dimensions, attachment.size ? formatBytes(attachment.size) : undefined, ] .filter(Boolean) .join(" · "); useEffect(() => { // The API requires an Authorization header, so the bytes are fetched here // and handed to the DOM as an object URL revoked on replacement/unmount. let url: string | undefined; let active = true; setObjectUrl(undefined); setObjectType(undefined); setFailed(false); requestObjectUrl(path, authMode) .then((created) => { if (!active) { URL.revokeObjectURL(created.url); return; } url = created.url; setObjectUrl(created.url); setObjectType(created.type); }) .catch(() => { if (active) setFailed(true); }); return () => { active = false; if (url) URL.revokeObjectURL(url); }; }, [path, authMode]); const detail = failed ? "Could not load attachment" : metadata; // Inline rendering is gated on the server-VERIFIED blob type, never the // reporter-supplied `attachment.mimeType`: the relay only labels sniffed // passive raster images as `image/*` and forces everything else to // `application/octet-stream`, so a hostile payload can never render inline. if (objectUrl && objectType?.startsWith("image/")) { return (
{name}
{name} {detail}
); } const label = ( <> {name} {detail} ); // Until the bytes are fetched there is nothing a link could point at: the // API path itself would open unauthenticated in a new tab. if (!objectUrl) return
{label}
; // Non-image (or not-yet-typed) payloads are download-only. The object URL // already wraps `application/octet-stream` bytes, and the `download` // attribute with no `target="_blank"` means clicking saves the file rather // than navigating to a typed document on the admin origin. return ( {label} ); } function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function Page({ eyebrow, title, description, back, backLabel, children, }: { eyebrow: string; title: string; description: string; back?: string; backLabel?: string; children: ReactNode; }) { return (
{back ? ( {backLabel ?? "Back to reports"} ) : null}

{eyebrow}

{title}

{description}
{children}
); } function Empty() { return
No records.
; } function short(value: string) { return value.length > 20 ? `${value.slice(0, 10)}…${value.slice(-8)}` : value; } function date(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.valueOf()) ? "Unknown date" : parsed.toLocaleString(); } function BuzzMark() { return ( ); } function ReportIcon() { return ( ); } function FeedbackIcon() { return ( ); } function CategoryTag({ category }: { category?: string }) { const labels: Record = { bug: "Bug", praise: "Praise", "needs-work": "Needs work", }; return ( {category ? (labels[category] ?? category) : "Uncategorized"} ); } function CategoryIcon({ category }: { category?: string }) { if (category === "bug") return ; if (category === "praise") return ; if (category === "needs-work") return ; return ; } function BugIcon() { return ( ); } function ThumbsUpIcon() { return ( ); } function WrenchIcon() { return ( ); } function SearchIcon() { return ( ); } function FileIcon() { return ( ); } function ArrowIcon() { return ( ); } /// Shown in nip98 mode when no NIP-07 extension is available. Instructs the /// operator to install nos2x or Alby before continuing. function Nip07Screen() { return (

Nostr extension required

This relay uses NIP-98 HTTP Auth. Install a NIP-07 browser extension such as{" "} nos2x {" "} or{" "} Alby , then reload this page. Your Nostr key will be used to sign each request.

); } export function App() { const { path } = usePath(); // Probe the relay once to discover the auth mode. `null` means the probe is // still in flight. Once resolved, the mode is stable for the session. const [authMode, setAuthMode] = useState(null); useEffect(() => { let active = true; probeAuthMode().then((mode) => { if (active) setAuthMode(mode); }); return () => { active = false; }; }, []); const report = path.match(/^\/reports\/([^/]+)$/); const feedback = path.match(/^\/feedback\/([^/]+)$/); // Probe still in flight — render nothing to avoid a visible flash. if (authMode === null) return null; // NIP-98 mode: require a NIP-07 extension. if (authMode === "nip98" && !(window as Window & { nostr?: unknown }).nostr) return ; const content = report ? ( ) : feedback ? ( ) : path === "/feedback" ? ( ) : ( ); return (
Buzz Admin
{content}
); }