This commit is contained in:
k
2026-09-24 10:33:43 +02:00
parent cf4480743e
commit 4005ce0631
17 changed files with 625 additions and 98 deletions
+77 -33
View File
@@ -3,6 +3,7 @@ import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } fr
import { api, getToken, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
import { pushToast } from "../lib/toasts.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
@@ -16,6 +17,22 @@ const STATUS_STYLE: Record<AccessStatus, { cls: string }> = {
type Filter = "all" | AccessStatus;
const FILTERS: Filter[] = ["all", "pending", "reviewing", "accepted", "refused"];
/**
* Message ids already applied (WS echo + HTTP response race). Shared by the
* list (count bumps) and the open thread (appends) so each message lands
* exactly once no matter which arrives first.
*/
const seenAccessMsgIds = new Set<number>();
function markAccessMsgSeen(msg: AccessMessage): boolean {
if (seenAccessMsgIds.has(msg.id)) return false;
seenAccessMsgIds.add(msg.id);
if (seenAccessMsgIds.size > 1000) {
const oldest = seenAccessMsgIds.values().next().value;
if (oldest !== undefined) seenAccessMsgIds.delete(oldest);
}
return true;
}
/** Admin triage for the access-request wishlist: accept / refuse / review. */
export function AccessRequestsPanel() {
const [requests, setRequests] = useState<(AccessRequest & { message_count: number })[]>([]);
@@ -24,11 +41,23 @@ export function AccessRequestsPanel() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const { t } = useT();
// State mirrors for the WS handler (no side effects inside updaters).
const rowsRef = useRef(requests);
const selectedRef = useRef(selectedId);
rowsRef.current = requests;
selectedRef.current = selectedId;
function setRows(fn: (prev: (AccessRequest & { message_count: number })[]) => (AccessRequest & { message_count: number })[]): void {
setRequests((prev) => {
const next = fn(prev);
rowsRef.current = next;
return next;
});
}
async function refresh(select?: string) {
try {
const res = await api.adminAccessList();
setRequests(res.requests);
setRows(() => res.requests);
if (select) setSelectedId(select);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
@@ -39,14 +68,40 @@ export function AccessRequestsPanel() {
useEffect(() => {
refresh();
// Live list: the admin firehose pushes every access event (shares its
// socket with the AdminCenter badge via the pool). Resync on
// (re)connect covers events missed while offline.
// Live list: the admin firehose pushes full payloads, so rows update
// incrementally with zero HTTP (shares its socket with the AdminCenter
// badge via the pool). Full resync only on (re)connect (offline gap).
const token = getToken();
if (!token) return;
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
onEvent: (evt) => {
if (evt.type !== "pong") refresh();
if (evt.type === "pong") return;
if (evt.type === "access_created") {
markAccessMsgSeen(evt.message);
setRows((prev) =>
prev.some((r) => r.id === evt.request_id)
? prev
: [{ ...evt.request, message_count: evt.message_count }, ...prev],
);
} else if (evt.type === "access_message") {
if (!markAccessMsgSeen(evt.message)) return;
const row = rowsRef.current.find((r) => r.id === evt.request_id);
if (row && evt.message.author === "user" && selectedRef.current !== evt.request_id) {
pushToast(t("live.newMessage", { user: row.username }), { icon: "mail" });
}
setRows((prev) =>
prev.map((r) =>
r.id === evt.request_id ? { ...r, message_count: r.message_count + 1 } : r,
),
);
} else if (evt.type === "access_status") {
setRows((prev) =>
prev.map((r) =>
r.id === evt.request_id ? { ...evt.request, message_count: r.message_count } : r,
),
);
}
// Report events: the ReportsPanel owns them; badges own the counts.
},
onSync: () => {
refresh();
@@ -121,7 +176,7 @@ export function AccessRequestsPanel() {
{t(`access.status.${r.status}` as StringKey)}
</span>
</button>
{selectedId === r.id && <TicketDetail request={r} onChanged={() => refresh(r.id)} />}
{selectedId === r.id && <TicketDetail request={r} onUpdated={(updated) => setRows((prev) => prev.map((x) => x.id === updated.id ? { ...updated, message_count: x.message_count } : x))} />}
</li>
))}
</ul>
@@ -129,7 +184,7 @@ export function AccessRequestsPanel() {
);
}
function TicketDetail({ request, onChanged }: { request: AccessRequest; onChanged: () => void }) {
function TicketDetail({ request, onUpdated }: { request: AccessRequest; onUpdated: (r: AccessRequest) => void }) {
const [messages, setMessages] = useState<AccessMessage[]>([]);
const [reason, setReason] = useState(request.reason);
const [draft, setDraft] = useState("");
@@ -137,29 +192,19 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
const [error, setError] = useState("");
const { t } = useT();
const [freshKey, setFreshKey] = useState<string | null>(null);
// Latest callbacks / server state without restarting the poll loop.
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
const known = useRef({ status: request.status, reason: request.reason });
useEffect(() => {
setReason(request.reason);
setFreshKey(null);
known.current = { status: request.status, reason: request.reason };
// Live thread: requester replies arrive instantly, and a status change
// made elsewhere syncs the list (badges, filters). Resync on
// (re)connect covers events missed while offline.
// Live thread: requester replies arrive instantly with their full
// payload (no refetch); status changes made elsewhere arrive on the
// parent's firehose and flow back down as props. Full reload only on
// (re)connect (offline gap).
async function fetchOnce() {
try {
const res = await api.adminAccessGet(request.id);
for (const m of res.messages) markAccessMsgSeen(m);
setMessages(res.messages);
if (
res.request.status !== known.current.status ||
res.request.reason !== known.current.reason
) {
known.current = { status: res.request.status, reason: res.request.reason };
onChangedRef.current();
}
} catch {
// Transient failure: keep last state.
}
@@ -167,15 +212,14 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
fetchOnce();
return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), {
onEvent: (evt) => {
if (evt.type === "pong" || evt.request_id !== request.id) return;
// Report events only travel the admin firehose — never ticket sockets.
if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return;
if (evt.request_id !== request.id) return;
if (evt.type === "access_message") {
const msg = evt.message;
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
onChangedRef.current();
} else if (evt.type === "access_status") {
known.current = { status: evt.request.status, reason: evt.request.reason };
onChangedRef.current();
if (!markAccessMsgSeen(evt.message)) return;
setMessages((prev) => [...prev, evt.message]);
}
// access_status flows via the parent firehose -> onUpdated -> props.
},
onSync: () => {
fetchOnce();
@@ -192,8 +236,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
try {
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
if (res.key) setFreshKey(res.key);
known.current = { status: res.request.status, reason: res.request.reason };
onChanged();
onUpdated(res.request);
} catch (err) {
setError(err instanceof Error ? err.message : t("access.updateFailed"));
} finally {
@@ -209,9 +252,10 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
setError("");
try {
const res = await api.adminAccessReply(request.id, body);
setMessages((prev) => [...prev, res.message]);
// The broadcast echo of our own message may already be applied —
// the shared seen-set keeps exactly one copy either way.
if (markAccessMsgSeen(res.message)) setMessages((prev) => [...prev, res.message]);
setDraft("");
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t("access.sendFailed"));
} finally {
+58 -18
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { api, getToken } from "../lib/api.ts";
import { api, getToken, type AccessStatus, type ReportStatus } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { ReportsPanel } from "./ReportsPanel.tsx";
import { ModelsPanel } from "./ModelsPanel.tsx";
import { Toasts } from "./Toasts.tsx";
import { pushToast } from "../lib/toasts.ts";
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
@@ -29,6 +31,9 @@ export function AdminCenter() {
const [saving, setSaving] = useState(false);
const [openAccessCount, setOpenAccessCount] = useState(0);
const [openReportCount, setOpenReportCount] = useState(0);
// Full badge resync (HTTP) — only for mount, reconnect gaps and rare
// actions with no WS event (reporter shadow-ban). Hot path is incremental.
const resyncBadgesRef = useRef<() => void>(() => {});
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
@@ -64,40 +69,74 @@ export function AdminCenter() {
return () => window.clearTimeout(timer);
}, []);
// Live badges on the Access/Reports tabs: the admin firehose pushes every access
// event, so the open counts stay fresh with zero polling (reports re-fetch
// alongside). Resync on (re)connect covers events missed while offline.
// Live badges on the Access/Reports tabs: the admin firehose pushes full
// payloads, so the open counts update incrementally with zero HTTP.
// Resync on (re)connect covers events missed while offline.
useEffect(() => {
if (!allowed) return;
async function fetchCount() {
const accessById = new Map<string, AccessStatus>();
const reportById = new Map<string, { status: ReportStatus; shadowbanned: number }>();
function recount() {
let access = 0;
for (const s of accessById.values()) if (s === "pending" || s === "reviewing") access++;
let reports = 0;
for (const r of reportById.values()) {
if ((r.status === "open" || r.status === "reviewing") && r.shadowbanned !== 1) reports++;
}
setOpenAccessCount(access);
setOpenReportCount(reports);
}
async function resync() {
try {
const res = await api.adminAccessList();
setOpenAccessCount(
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
);
accessById.clear();
for (const r of res.requests) accessById.set(r.id, r.status);
} catch {
// Transient failure: keep last count.
// Transient failure: keep last counts.
}
try {
const res = await api.adminReportList();
setOpenReportCount(
res.reports.filter((r) => (r.status === "open" || r.status === "reviewing") && r.reporter_shadowbanned !== 1).length,
);
reportById.clear();
for (const r of res.reports) {
reportById.set(r.id, { status: r.status, shadowbanned: r.reporter_shadowbanned });
}
} catch {
// Transient failure: keep last count.
// Transient failure: keep last counts.
}
recount();
}
fetchCount();
resyncBadgesRef.current = resync;
resync();
const token = getToken();
if (!token) return;
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
onEvent: (evt) => {
if (evt.type !== "pong") fetchCount();
if (evt.type === "pong" || evt.type === "access_message") return;
if (evt.type === "access_created") {
accessById.set(evt.request_id, evt.request.status);
recount();
pushToast(t("live.newRequest", { user: evt.request.username }), { icon: "inbox" });
} else if (evt.type === "access_status") {
accessById.set(evt.request_id, evt.request.status);
recount();
} else if (evt.type === "report_created") {
reportById.set(evt.report.id, { status: evt.report.status, shadowbanned: evt.report.reporter_shadowbanned });
recount();
pushToast(t("live.newReport", { reason: evt.report.reason }), { icon: "mail" });
} else if (evt.type === "report_status") {
const prev = reportById.get(evt.report.id);
reportById.set(evt.report.id, {
status: evt.report.status,
shadowbanned: prev?.shadowbanned ?? evt.report.reporter_shadowbanned,
});
recount();
}
},
onSync: () => {
fetchCount();
resync();
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowed]);
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) {
@@ -199,7 +238,7 @@ export function AdminCenter() {
{tab === "reports" && (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<ReportsPanel />
<ReportsPanel onShadowbanChanged={() => resyncBadgesRef.current()} />
</section>
)}
@@ -265,6 +304,7 @@ export function AdminCenter() {
{tab === "mail" && <MailPanel />}
</main>
<Toasts />
</div>
);
}
+4 -1
View File
@@ -1,8 +1,11 @@
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "./ui.tsx";
import { highlightCode } from "../lib/highlight.tsx";
function CodeBlock({ code, lang }: { code: string; lang: string }) {
const nodes = useMemo(() => highlightCode(code, lang), [code, lang]);
return (
<div className="relative">
{lang && (
@@ -12,7 +15,7 @@ function CodeBlock({ code, lang }: { code: string; lang: string }) {
<CopyButton text={code} />
</div>
<pre className={lang ? "pt-9" : ""}>
<code>{code}</code>
<code>{nodes}</code>
</pre>
</div>
);
+41 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { CheckCircle2, Clock, Eye, EyeOff, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
import { api, type Report, type ReportStatus } from "../lib/api.ts";
import { api, getToken, type Report, type ReportStatus } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { Button, ConfirmDialog, Field, Input, Spinner } from "./ui.tsx";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
@@ -16,7 +17,7 @@ type Filter = "all" | ReportStatus;
const FILTERS: Filter[] = ["all", "open", "reviewing", "resolved", "dismissed"];
/** Admin triage for user-flagged AI responses: review / resolve / dismiss. */
export function ReportsPanel() {
export function ReportsPanel({ onShadowbanChanged }: { onShadowbanChanged?: () => void }) {
const [reports, setReports] = useState<Report[]>([]);
const [filter, setFilter] = useState<Filter>("open");
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -42,11 +43,24 @@ export function ReportsPanel() {
useEffect(() => {
refresh();
// Live list: new reports pop without manual refresh.
const id = setInterval(() => {
if (!document.hidden) refresh();
}, 15000);
return () => clearInterval(id);
// Live list: the admin firehose pushes full report payloads, so rows
// update incrementally with zero HTTP (shares its socket with the
// AdminCenter badge via the pool). Full resync only on (re)connect.
const token = getToken();
if (!token) return;
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
onEvent: (evt) => {
if (evt.type === "report_created") {
setReports((prev) => (prev.some((r) => r.id === evt.report.id) ? prev : [evt.report, ...prev]));
} else if (evt.type === "report_status") {
setReports((prev) => prev.map((r) => (r.id === evt.report.id ? evt.report : r)));
}
// Access events: the AccessRequestsPanel owns them.
},
onSync: () => {
refresh();
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -124,7 +138,18 @@ export function ReportsPanel() {
</span>
)}
</button>
{selectedId === r.id && <ReportDetail report={r} onChanged={() => refresh(r.id)} />}
{selectedId === r.id && (
<ReportDetail
report={r}
onPatched={(updated) => setReports((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))}
onBanned={(reporterId, shadowbanned) => {
setReports((prev) =>
prev.map((x) => (x.reporter_id === reporterId ? { ...x, reporter_shadowbanned: shadowbanned ? 1 : 0 } : x)),
);
onShadowbanChanged?.();
}}
/>
)}
</li>
))}
</ul>
@@ -132,7 +157,7 @@ export function ReportsPanel() {
);
}
function ReportDetail({ report, onChanged }: { report: Report; onChanged: () => void }) {
function ReportDetail({ report, onPatched, onBanned }: { report: Report; onPatched: (r: Report) => void; onBanned: (reporterId: string, shadowbanned: boolean) => void }) {
const [note, setNote] = useState(report.admin_note);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -149,8 +174,10 @@ function ReportDetail({ report, onChanged }: { report: Report; onChanged: () =>
setBusy(true);
setError("");
try {
await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
onChanged();
// The report_status broadcast echoes the same payload to every admin —
// applying the response directly keeps this view instant and idempotent.
const res = await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
onPatched(res.report);
} catch (err) {
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
} finally {
@@ -163,7 +190,9 @@ function ReportDetail({ report, onChanged }: { report: Report; onChanged: () =>
setError("");
try {
await api.adminShadowban(report.reporter_id, shadowbanned);
onChanged();
// Shadow-ban has no WS event (per-user, not per-report): patch every
// row of this reporter locally and resync the badges over HTTP.
onBanned(report.reporter_id, shadowbanned);
} catch (err) {
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
} finally {
+35 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
CheckCircle2,
Clock,
@@ -19,6 +19,8 @@ import {
Modal,
Spinner,
} from "./ui.tsx";
import { Toasts } from "./Toasts.tsx";
import { pushToast } from "../lib/toasts.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
@@ -70,10 +72,28 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
const { t } = useT();
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const threadRef = useRef<HTMLDivElement>(null);
// Message ids already applied (WS echo + HTTP response race): each lands once.
const seenIds = useRef(new Set<number>());
function appendMessage(msg: AccessMessage): boolean {
if (seenIds.current.has(msg.id)) return false;
seenIds.current.add(msg.id);
setMessages((prev) => [...prev, msg]);
return true;
}
// Follow live traffic: stick to the bottom while the user already is.
useEffect(() => {
const el = threadRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 96;
if (nearBottom) el.scrollTop = el.scrollHeight;
}, [messages]);
async function load() {
try {
const res = await api.accessTicket(ticketId);
for (const m of res.messages) seenIds.current.add(m.id);
setRequest(res.request);
setMessages(res.messages);
} catch {
@@ -87,6 +107,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
async function refreshSilent() {
try {
const res = await api.accessTicket(ticketId);
for (const m of res.messages) seenIds.current.add(m.id);
setRequest(res.request);
setMessages(res.messages);
} catch {
@@ -96,16 +117,21 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
useEffect(() => {
load();
// Live updates over WebSocket: admin replies and the decision arrive
// instantly; resync on (re)connect covers events missed while offline.
// Live updates over WebSocket with full payloads: admin replies and the
// decision apply straight to state (no refetch); resync on (re)connect
// covers events missed while offline.
const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), {
onEvent: (evt) => {
if (evt.type === "pong" || evt.request_id !== ticketId) return;
// Report events only travel the admin firehose — never ticket sockets.
if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return;
if (evt.request_id !== ticketId) return;
if (evt.type === "access_message") {
const msg = evt.message;
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
if (appendMessage(evt.message) && evt.message.author === "admin") {
pushToast(t("live.adminReply"), { icon: "mail" });
}
} else if (evt.type === "access_status") {
setRequest(evt.request);
pushToast(t("live.ticketStatus", { status: t(`review.status.${evt.request.status}` as StringKey) }), { icon: "inbox" });
}
},
onSync: () => {
@@ -124,7 +150,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
setError("");
try {
const res = await api.accessReply(ticketId, body);
setMessages((prev) => [...prev, res.message]);
appendMessage(res.message);
setDraft("");
} catch (err) {
setError(err instanceof Error ? err.message : t("access.sendFailed"));
@@ -190,7 +216,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
</>
)}
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
<div ref={threadRef} className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
{messages.map((m) => (
<div
key={m.id}
@@ -259,6 +285,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
</div>
)}
</div>
<Toasts />
</div>
);
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Archive, ArchiveRestore, Check, Cpu, Palette, Pencil, SunMoon, Trash2, Unplug, type LucideIcon } from "lucide-react";
import { Archive, ArchiveRestore, Check, Cpu, Inbox, Mail, Palette, Pencil, SunMoon, Trash2, Unplug, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
import { removeToast, TOAST_DISPLAY_MS, useToasts, type Toast, type ToastIcon } from "../lib/toasts.ts";
@@ -13,6 +13,8 @@ const ICONS: Record<ToastIcon, LucideIcon> = {
unshare: Unplug,
edit: Pencil,
check: Check,
mail: Mail,
inbox: Inbox,
};
/** Must match the .toast-exit animation duration in index.css. */
+67 -7
View File
@@ -1,4 +1,4 @@
import type { AccessMessage, AccessRequest } from "./api.ts";
import type { AccessMessage, AccessRequest, Report } from "./api.ts";
/**
* Shared WebSocket pool for live access-request updates.
@@ -7,13 +7,21 @@ import type { AccessMessage, AccessRequest } from "./api.ts";
* AdminCenter badge and the triage list share the admin firehose).
* Auto-reconnects with backoff; every (re)connect triggers onSync so
* components resync over HTTP and never miss an event sent while offline.
* A 25s client ping keeps idle connections alive through proxies.
* A 25s client ping keeps idle connections alive through proxies, and a
* pong watchdog reconnects half-dead sockets (ping answered by nobody)
* that would otherwise silently stop notifying.
*
* Events carry their full payload — handlers apply them straight to state.
* HTTP resync (onSync) is only the offline-gap fallback, never the
* per-event path.
*/
export type AccessLiveEvent =
| { type: "access_message"; request_id: string; message: AccessMessage }
| { type: "access_status"; request_id: string; request: AccessRequest }
| { type: "access_created"; request_id: string; request: AccessRequest }
| { type: "access_created"; request_id: string; request: AccessRequest; message: AccessMessage; message_count: number }
| { type: "report_created"; report: Report }
| { type: "report_status"; report: Report }
| { type: "pong" };
export interface AccessLiveListener {
@@ -28,15 +36,51 @@ interface Pool {
backoff: number;
retryTimer: ReturnType<typeof setTimeout> | null;
pingTimer: ReturnType<typeof setInterval> | null;
/** Last time the server proved liveness (open or pong). 0 = unknown. */
lastLive: number;
}
let globalHooksInstalled = false;
const pools = new Map<string, Pool>();
/** Ping period; the watchdog tolerates one missed pong plus slack. */
const PING_MS = 25_000;
const WATCHDOG_MS = PING_MS + 10_000;
export function wsUrl(path: string): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}${path}`;
}
/** Reconnect every pooled socket now (tab visible again / back online). */
function reconnectAll(): void {
for (const [url, pool] of pools) {
if (pool.listeners.size === 0) continue;
if (pool.retryTimer) {
clearTimeout(pool.retryTimer);
pool.retryTimer = null;
}
try {
pool.ws?.close();
} catch {
// already gone — connect() below redials
}
pool.ws = null;
pool.backoff = 0;
connect(url, pool);
}
}
function installGlobalHooks(): void {
if (globalHooksInstalled || typeof document === "undefined") return;
globalHooksInstalled = true;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") reconnectAll();
});
window.addEventListener("online", () => reconnectAll());
}
function connect(url: string, pool: Pool): void {
let ws: WebSocket;
try {
@@ -49,15 +93,27 @@ function connect(url: string, pool: Pool): void {
ws.onopen = () => {
pool.backoff = 0;
pool.lastLive = Date.now();
pool.pingTimer = setInterval(() => {
// Half-dead socket: OPEN but the server hasn't proved liveness for
// over a ping period + grace (proxy killed it, server restarted…).
// Close it so onclose schedules a fresh dial.
if (ws.readyState === WebSocket.OPEN) {
if (Date.now() - pool.lastLive > WATCHDOG_MS) {
try {
ws.close();
} catch {
// close handler reconnects
}
return;
}
try {
ws.send(JSON.stringify({ type: "ping" }));
} catch {
// send failed — the close handler will schedule a retry
}
}
}, 25_000);
}, PING_MS);
for (const l of [...pool.listeners]) {
try {
l.onSync?.();
@@ -75,6 +131,8 @@ function connect(url: string, pool: Pool): void {
return;
}
if (!parsed || typeof parsed.type !== "string") return;
// Any frame (event or pong) proves the socket is alive.
if (pool.ws === ws) pool.lastLive = Date.now();
for (const l of [...pool.listeners]) {
try {
l.onEvent(parsed);
@@ -110,10 +168,12 @@ function scheduleRetry(url: string, pool: Pool): void {
}
export function subscribeAccessLive(url: string, listener: AccessLiveListener): () => void {
let pool = pools.get(url);
let pool: Pool | undefined = pools.get(url);
if (!pool) {
pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null };
pools.set(url, pool);
const fresh: Pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null, lastLive: 0 };
pools.set(url, fresh);
pool = fresh;
installGlobalHooks();
connect(url, pool);
}
pool.listeners.add(listener);
+219
View File
@@ -0,0 +1,219 @@
import type { ReactNode } from "react";
/**
* Dependency-free syntax highlighting for chat code blocks.
*
* No highlight.js / prism (keeps the bundle small and the Nix offline build
* lockfile-stable): a small regex tokenizer per language family that emits
* <span> tokens colored for the always-dark <pre> bubble (see index.css).
* Unknown languages fall back to generic strings/comments/numbers so every
* block still gets some coloration.
*/
const CLS = {
comment: "text-stone-400 italic",
string: "text-amber-200",
keyword: "text-violet-300",
number: "text-sky-300",
function: "text-emerald-300",
type: "text-rose-300",
decorator: "text-orange-300",
diffAdd: "text-emerald-300",
diffDel: "text-red-300",
} as const;
type TokKind = keyof typeof CLS | "plain";
interface Token {
kind: TokKind;
text: string;
}
const ALIASES: Record<string, string> = {
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
tsx: "typescript",
mts: "typescript",
py: "python",
py3: "python",
sh: "bash",
shell: "bash",
zsh: "bash",
console: "bash",
terminal: "bash",
yml: "yaml",
json5: "json",
jsonc: "json",
cxx: "cpp",
"c++": "cpp",
hpp: "cpp",
cs: "csharp",
rs: "rust",
golang: "go",
dockerfile: "docker",
makefile: "make",
md: "markdown",
html: "xml",
vue: "xml",
svelte: "xml",
};
const KEYWORDS: Record<string, string[]> = {
javascript: "break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield async await of static get set".split(" "),
typescript: "break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield async await of static get set interface type enum implements private public protected readonly abstract namespace declare".split(" "),
python: "and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case".split(" "),
bash: "if then else elif fi for while in do done case esac function select until echo exit return local export alias cd ls".split(" "),
rust: "as break const continue crate else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while async await dyn".split(" "),
go: "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var".split(" "),
java: "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while var".split(" "),
c: "auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while".split(" "),
cpp: "auto break case char const continue default do double else enum extern float for goto if inline int long namespace new private protected public return short signed sizeof static struct switch template this throw try typedef typename union unsigned using virtual void volatile while class constexpr decltype noexcept nullptr static_assert".split(" "),
csharp: "abstract as base bool break byte case catch char checked class const continue decimal default do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while var".split(" "),
php: "abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile extends final finally fn for foreach function global if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public require require_once return static switch throw trait try unset use var while xor yield".split(" "),
ruby: "alias and begin BEGIN break case class def defined do else elsif end END ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield".split(" "),
swift: "associatedtype class deinit enum extension fileprivate func if import in init inout internal let open operator private protocol public static struct subscript typealias var break case continue default defer do else fallthrough for guard repeat return switch where while as catch throw throws try await async".split(" "),
kotlin: "as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while by catch constructor delegate dynamic field finally get import init out set value where".split(" "),
sql: "select from where join left right inner outer on group by order having limit offset insert into values update set delete create table alter drop index view as and or not null primary key foreign references distinct union all exists in between like is asc desc".split(" "),
json: "true false null".split(" "),
yaml: "true false null yes no on off".split(" "),
css: "import media supports keyframes font-face".split(" "),
xml: [],
docker: "from run cmd label maintainer expose env add copy entrypoint volume user workdir arg onbuild stopsignal healthcheck shell".split(" "),
make: "ifeq ifneq else endif include override export unexport define endef".split(" "),
markdown: [],
};
function langKey(lang: string): string {
const l = lang.trim().toLowerCase();
return ALIASES[l] ?? l;
}
function escapeRe(words: string[]): string {
return words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
}
interface Grammar {
lineComment?: string;
blockComment?: boolean;
strings: ("sq" | "dq" | "bq" | "py3")[];
keywords: string[];
}
function grammarFor(lang: string): Grammar | null {
switch (lang) {
case "javascript":
case "typescript":
return { lineComment: "//", blockComment: true, strings: ["sq", "dq", "bq"], keywords: KEYWORDS[lang]! };
case "json":
return { strings: ["dq"], keywords: KEYWORDS.json! };
case "python":
return { lineComment: "#", strings: ["sq", "dq", "py3"], keywords: KEYWORDS.python! };
case "bash":
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.bash! };
case "rust":
case "go":
case "java":
case "c":
case "cpp":
case "csharp":
case "php":
case "swift":
case "kotlin":
return { lineComment: "//", blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS[lang]! };
case "ruby":
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.ruby! };
case "sql":
return { lineComment: "--", blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS.sql! };
case "yaml":
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.yaml! };
case "css":
return { blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS.css! };
case "xml":
return { strings: ["sq", "dq"], keywords: [] };
case "docker":
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.docker! };
case "make":
return { lineComment: "#", strings: [], keywords: KEYWORDS.make! };
default:
return null;
}
}
const STRING_RE: Record<string, string> = {
sq: "'(?:[^'\\\\\\n]|\\\\.)*'",
dq: '"(?:[^"\\\\\\n]|\\\\.)*"',
bq: "`(?:[^`\\\\]|\\\\.)*`",
py3: '"""[\\s\\S]*?"""|\'\'\'[\\s\\S]*?\'\'\'',
};
function tokenize(code: string, lang: string): Token[] {
const lk = langKey(lang);
// Diffs color whole lines — handled line by line, no grammar needed.
if (lk === "diff" || lk === "patch") {
return code.split("\n").flatMap((line, i, arr) => {
const kind: TokKind =
line.startsWith("+") && !line.startsWith("+++") ? "diffAdd" : line.startsWith("-") && !line.startsWith("---") ? "diffDel" : line.startsWith("@") ? "function" : "plain";
const toks: Token[] = [{ kind, text: line }];
if (i < arr.length - 1) toks.push({ kind: "plain", text: "\n" });
return toks;
});
}
const g = grammarFor(lk);
// Unknown language: generic strings + comments + numbers.
const grammar: Grammar = g ?? { lineComment: "//", blockComment: true, strings: ["sq", "dq"], keywords: [] };
const commentAlts: string[] = [];
if (grammar.blockComment) commentAlts.push("/\\*[\\s\\S]*?(?:\\*/|$)");
if (lk === "xml") commentAlts.push("<!--[\\s\\S]*?(?:-->|$)");
if (grammar.lineComment) {
const lc = grammar.lineComment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// `//` must not match the `://` of URLs (https://…).
commentAlts.push(grammar.lineComment === "//" ? `(?<!:)//[^\\n]*` : `${lc}[^\\n]*`);
}
const stringAlts = grammar.strings.map((s) => STRING_RE[s]!);
const named: [TokKind, string][] = [];
if (commentAlts.length > 0) named.push(["comment", commentAlts.join("|")]);
if (stringAlts.length > 0) named.push(["string", stringAlts.join("|")]);
if (grammar.keywords.length > 0) named.push(["keyword", `\\b(?:${escapeRe(grammar.keywords)})\\b`]);
named.push(["number", "\\b(?:0x[0-9a-fA-F]+|\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\\b"]);
named.push(["decorator", "@[A-Za-z_][\\w$]*"]);
named.push(["function", "[A-Za-z_][\\w$]*(?=\\s*\\()"]);
named.push(["type", "\\b[A-Z][A-Za-z0-9_]*\\b"]);
const re = new RegExp(named.map(([kind, p]) => `(?<${kind}>${p})`).join("|"), "g");
const tokens: Token[] = [];
let last = 0;
let m: RegExpExecArray | null;
// Guard against pathological inputs: cap tokens, dump the rest as plain.
const MAX_TOKENS = 5000;
while ((m = re.exec(code)) !== null && tokens.length < MAX_TOKENS) {
if (m.index > last) tokens.push({ kind: "plain", text: code.slice(last, m.index) });
const text = m[0];
const groups = m.groups ?? {};
const kind: TokKind = (named.find(([k]) => groups[k] !== undefined)?.[0] ?? "plain") as TokKind;
tokens.push({ kind, text });
last = m.index + text.length;
if (text.length === 0) re.lastIndex++;
}
if (last < code.length) tokens.push({ kind: "plain", text: code.slice(last) });
return tokens;
}
export function highlightCode(code: string, lang: string): ReactNode[] {
const tokens = tokenize(code, lang);
return tokens.map((t, i) =>
t.kind === "plain" ? (
<span key={i}>{t.text}</span>
) : (
<span key={i} className={CLS[t.kind]}>
{t.text}
</span>
),
);
}
+30
View File
@@ -485,6 +485,11 @@ const en = {
"access.youAdmin": "You (admin)",
"access.sendFailed": "Send failed",
"access.updateFailed": "Update failed",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copy message",
"msg.search": "Search the web",
@@ -1056,6 +1061,11 @@ const es: Dict = {
"access.youAdmin": "Tú (admin)",
"access.sendFailed": "No se pudo enviar",
"access.updateFailed": "No se pudo actualizar",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copiar mensaje",
"msg.search": "Buscar en la web",
@@ -1622,6 +1632,11 @@ const fr: Dict = {
"access.youAdmin": "Vous (admin)",
"access.sendFailed": "Envoi impossible",
"access.updateFailed": "Mise à jour impossible",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copier le message",
"msg.search": "Rechercher sur le web",
@@ -2176,6 +2191,11 @@ const ru: Dict = {
"access.youAdmin": "Вы (админ)",
"access.sendFailed": "Не отправилось",
"access.updateFailed": "Не обновилось",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Скопировать сообщение",
"msg.search": "Искать в интернете",
@@ -2735,6 +2755,11 @@ const it: Dict = {
"access.youAdmin": "Tu (admin)",
"access.sendFailed": "Invio fallito",
"access.updateFailed": "Aggiornamento fallito",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copia messaggio",
"msg.search": "Cerca nel web",
@@ -3285,6 +3310,11 @@ const pt: Dict = {
"access.youAdmin": "Tu (admin)",
"access.sendFailed": "Falha ao enviar",
"access.updateFailed": "Falha ao atualizar",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copiar mensagem",
"msg.search": "Pesquisar na web",
+3 -1
View File
@@ -16,7 +16,9 @@ export type ToastIcon =
| "accent"
| "unshare"
| "edit"
| "check";
| "check"
| "mail"
| "inbox";
export interface Toast {
id: number;