fixes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -16,7 +16,9 @@ export type ToastIcon =
|
||||
| "accent"
|
||||
| "unshare"
|
||||
| "edit"
|
||||
| "check";
|
||||
| "check"
|
||||
| "mail"
|
||||
| "inbox";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
|
||||
Reference in New Issue
Block a user