fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the five navbar views (Chat, RAG, Sources, Tuning, History) were separate HTML documents, so a navbar click was a REAL cross-document navigation — the chat page unloaded, the in-flight SSE fetch was aborted, and the phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled") stopped the model. Observed: send question -> click RAG mid-stream -> click Chat -> the answer never finished: no `query_log` row, and on return a dangling question with no brain record (the pre-token pagehide partial persist skips because `acc` is empty). Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06, flagged per AGENTS.md rule 3, not silently deviated): "real navigation cancels the fetch" now means LEAVING THE APP — tab close, external/other-document navigation, the Stop button. In-app navbar switches are client-side view switches and no longer cancel. Fix — Option A (SPA shell), chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume): - frontend/index.html is the shell: ONE `<main id="main">` holds the five `<section class="view">` blocks; hidden views carry BOTH `hidden` and `inert` (WCAG — no focus/keyboard traversal). The shared header, the single `doc-modal-*` skeleton, and the `#app-version` footer each exist exactly once; the per-view copies from the four folded pages are dropped. - New frontend/assets/router.js (vanilla module — no framework, no bundler, No-CDN rule intact): lazy-imports a view module on FIRST show only (mount-once, hide-forever — the chat view's in-flight SSE reader persists across switches; that persistence IS the fix); intercepts same-shell navbar links with preventDefault + history.pushState (never a document load); handles popstate; single writer of `.nav-link` active state (is-active + aria-current), document.title, and the per-view meta description (values carried over from the old pages' heads, brand-resolved at write time). - Each folded page's JS becomes `export async function mount(root)` — root-scoped queries; `initSharedHeader()` dropped (the header boots once in the shell via the chat module; the admin flag comes from the same cached `fetchIsAdmin()` promise — zero extra requests). - app/main.py: a small list-driven route factory serves the shell for /tuning.html, /sources.html, /git-sources.html, /history.html — registered AFTER the API routers and BEFORE the static catch-all (routes-first). The phase-33 caching middleware applies no-cache + `?v=` rewriting unchanged; app/core/caching.py needed NO change (the view paths did not change — pinned by the integration tests). - The four old view .html files are DELETED (one source of truth); deep links to the old URLs keep working (the router picks the view from the pathname); `/?chat=<id>` is unaffected; the Containerfile bundles router.js (inlining the lazy view modules) and drops the folded page files. - app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell keeps long saved answers in the chat, and the old cap (stricter than the 24_000-char total history budget) 422-rejected any second turn in such a chat (found by the phase-42 E2E suite on the shell). Boundaries: login.html, shared.html, doc-edit.html, document.html REMAIN separate documents (flow pages, not navbar tabs); a mid-stream navigation to doc-edit/document.html still cancels per phase 48 (follow-up candidate, out of scope). The SSE API is unchanged. Real departures still cancel the turn — phase 48 intact (pinned by tests/e2e/test_stop_generation.py, unchanged, and by the new suite's real-departure control). Tests: - Phase-20 suite REWRITTEN to the new semantics (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer cancels — the stream survives the switch and the FULL answer settles; the pagehide partial persist REMAINS for real departures (the partial's exact shape — first streamed chunk prefix, no done metadata — is still pinned there). - NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock LLM): the owner repro (send -> RAG mid-stream -> Chat: window sentinel survives = same document, FULL answer, exactly one brain turn in bor.chat.v1, exactly one settled query_log row, auto-saved row matches) + the same mid-stream switch against the other three views + the real-departure-still-cancels control + the no-switch baseline. - tests/unit/test_frontend_router.py: source-level pins of the router invariants (click interceptor targets ONLY same-shell view paths, pushState-only switches, mount-once guard, hidden+inert pair, single-writer active state/title); shell-route integration tests (each folded path serves the shell with no-cache + `?v=` body; a non-view path still 404s); the file-reading unit pins re-pointed at the shell (the four view files are gone — the shell is the source of truth). Verification (this commit): full suite green — 1565 unit+integration tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the phase's E2E suites green in isolation (house protocol, AGENTS.md rule 9). Owner repro verified in a real browser against the real LLM (dev server :8010, headful Chromium): "tell me about everquest" -> RAG mid-stream -> Chat — the answer completed with one brain bubble and no error banner, `query_log` gained exactly one settled row (deflected=True: the dev KB holds no EverQuest docs — the settle, not the topic, is the proof), zero "chat: turn cancelled" lines for that turn; the control (real navigation to /shared.html mid-stream) still cancelled (no settled row, the cancel line logged, the partial persisted on return). Screenshots: .agents/screenshots/76_manual_*. Phase 76 (76_spa_nav_shell) complete — moved to .agents/phases/complete/.
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
"""Unit: the phase-76 shell router contract (task 01).
|
||||
|
||||
The browser behavior is E2E-gated (the phase-76 story suite —
|
||||
``test_nav_switch_keeps_stream.py`` — plus the tuning-adjacent suites);
|
||||
here we pin the router.js / index.html source-level invariants the
|
||||
"views of one document" architecture depends on, so a silent
|
||||
regression is caught without a browser (house pattern:
|
||||
``tests/unit/test_frontend_hidden_tab.py`` reads JS source and
|
||||
asserts on its mechanisms).
|
||||
|
||||
Pinned design (phase 76 overview + task 01):
|
||||
* the VIEW map — pathname → view name — is the ONLY set of paths the
|
||||
click interceptor may swallow (every other link keeps its real,
|
||||
document-level navigation);
|
||||
* a switch is ``history.pushState`` + show/hide — NEVER a document
|
||||
load (no ``location.assign`` / ``location.href`` / ``location.replace``
|
||||
/ ``location.reload`` anywhere in the module);
|
||||
* mount-once per view: the lazy module is imported on FIRST show only,
|
||||
the guard runs before the import and is set only after ``mount``
|
||||
resolves;
|
||||
* hidden views carry BOTH ``hidden`` AND ``inert`` (WCAG — a hidden
|
||||
view must not receive focus or keyboard traversal);
|
||||
* the router is the SINGLE WRITER of the ``.nav-link`` active state
|
||||
(``is-active`` + ``aria-current="page"``), of ``document.title``, and
|
||||
of the per-view ``<meta name="description">``;
|
||||
* focus lands on the target view ONLY on user-initiated switches
|
||||
(navbar click / popstate) — never on initial boot (no focus steal);
|
||||
* the shell markup: ONE main holding the view sections, only the Chat
|
||||
link statically active, boot order brand.js → app.js → router.js,
|
||||
and the chat view needs no module import (app.js ran at shell boot).
|
||||
|
||||
Phase 76 task 04 (the header is shell-owned): the shell's header is the
|
||||
canonical one — the old per-page header copies (with their static
|
||||
active stamps) are gone with the four folded view documents, so
|
||||
``is-active`` occurs EXACTLY ONCE in the whole of index.html (on the
|
||||
Chat link), and header.js carries NO ``is-active`` write: the whoami
|
||||
auth gate + the mobile hamburger are its only nav responsibilities,
|
||||
and the router is the SINGLE runtime writer of the active state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
ROUTER_JS = ASSETS / "router.js"
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
assert ROUTER_JS.is_file(), f"missing {ROUTER_JS}"
|
||||
return ROUTER_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------- the VIEW map: the only interceptable paths ----------
|
||||
|
||||
|
||||
def test_view_map_covers_the_shell_paths() -> None:
|
||||
"""The VIEW map is pathname → view name: the shell's own two URLs
|
||||
("/" and "/index.html") are the chat view, plus one entry per
|
||||
folded view (tasks 01–03: tuning, rag, git-sources, history —
|
||||
all four non-chat navbar views are in)."""
|
||||
js = _js()
|
||||
view_start = js.find("const VIEW = {")
|
||||
assert view_start != -1, "the VIEW map must exist"
|
||||
view_body = js[view_start : js.find("\n}", view_start)]
|
||||
assert '"/": "chat"' in view_body, "the app root is the chat view"
|
||||
assert '"/index.html": "chat"' in view_body, (
|
||||
"the shell's alternate URL is the chat view too (HTML_PAGES)"
|
||||
)
|
||||
assert '"/tuning.html": "tuning"' in view_body, (
|
||||
"task 01 folds the Tuning view into the shell"
|
||||
)
|
||||
assert '"/history.html": "history"' in view_body, (
|
||||
"task 03 folds the History view into the shell"
|
||||
)
|
||||
# The view names are the #view-<name> section slugs in index.html.
|
||||
for name in ("chat", "tuning", "history"):
|
||||
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
|
||||
|
||||
|
||||
def test_interceptor_matches_only_view_map_paths() -> None:
|
||||
"""The delegated nav click handler intercepts ONLY a link whose href
|
||||
is in VIEW — the `in VIEW` guard runs BEFORE preventDefault, and a
|
||||
non-view link (login, the viewer, the not-yet-folded views) falls
|
||||
through to its real, document-level navigation."""
|
||||
js = _js()
|
||||
fn = js.find('nav.addEventListener("click"')
|
||||
assert fn != -1, "the delegated click handler on the nav must exist"
|
||||
body = js[fn : js.find("\n });", fn)]
|
||||
guard = body.find("in VIEW")
|
||||
prevent = body.find("e.preventDefault()")
|
||||
assert 0 <= guard < prevent, (
|
||||
"the VIEW membership guard must run BEFORE the preventDefault"
|
||||
)
|
||||
assert 'closest("a.nav-link")' in body, (
|
||||
"only the .nav-link family is considered (auth links are untouched)"
|
||||
)
|
||||
|
||||
|
||||
def test_switches_use_pushstate_not_document_navigation() -> None:
|
||||
"""A view switch is history.pushState (same-document) — the module
|
||||
must contain NO document-level navigation primitive: no
|
||||
location.assign, no location.href write, no location.replace, no
|
||||
location.reload (the phase-48 abort path lives in app.js, not here)."""
|
||||
js = _js()
|
||||
assert "history.pushState" in js, "the switch must pushState"
|
||||
for banned in ("location.assign", "location.href", "location.replace", "location.reload"):
|
||||
assert banned not in js, f"{banned} is a document load — the switch is same-document"
|
||||
# The pushState is the click handler's (the popstate path only READS
|
||||
# the location — it never writes it).
|
||||
fn = js.find('nav.addEventListener("click"')
|
||||
body = js[fn : js.find("\n });", fn)]
|
||||
assert "history.pushState" in body
|
||||
|
||||
|
||||
def test_popstate_switches_views() -> None:
|
||||
"""Back / forward re-runs the switch for the pathname in history —
|
||||
user-initiated (focus + top landing included)."""
|
||||
js = _js()
|
||||
fn = js.find('window.addEventListener("popstate"')
|
||||
assert fn != -1, "the popstate listener must exist"
|
||||
body = js[fn : js.find("});", fn)]
|
||||
assert "location.pathname" in body, "popstate resolves the view from the pathname"
|
||||
assert "userInitiated: true" in body, "back/forward is a user-initiated switch"
|
||||
|
||||
|
||||
# ---------- mount-once, hide-forever ----------
|
||||
|
||||
|
||||
def test_mount_once_guard_runs_before_import_and_after_mount() -> None:
|
||||
"""A non-chat view's module is imported on FIRST show only: the
|
||||
`mounted[name]` guard is checked BEFORE the lazy import, the import
|
||||
+ `await module.mount(root)` run once, and the guard is set only
|
||||
AFTER mount resolves (a failed mount may retry on the next show)."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
assert fn != -1, "switchTo must exist"
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
guard = body.find("if (!mounted[name])")
|
||||
load = body.find("await load()")
|
||||
mount = body.find("await mod.mount(root)")
|
||||
set_guard = body.find("mounted[name] = true")
|
||||
assert 0 <= guard < load < mount < set_guard, (
|
||||
"guard → lazy import → mount → set guard (in that order)"
|
||||
)
|
||||
# The guard map starts with chat mounted (app.js ran at shell boot).
|
||||
assert re.search(r"const mounted = \{ chat: true \}", js), (
|
||||
"chat starts mounted — it needs no module import"
|
||||
)
|
||||
|
||||
|
||||
def test_only_non_chat_views_have_lazy_modules() -> None:
|
||||
"""VIEW_MODULES lazy-imports the non-chat views only — the view
|
||||
modules are STATIC specifiers (so the Containerfile's esbuild
|
||||
stage can inline them into the router bundle) and there is NO
|
||||
import of app.js (the chat view needs no module — it ran at shell
|
||||
boot)."""
|
||||
js = _js()
|
||||
mods_start = js.find("const VIEW_MODULES = {")
|
||||
assert mods_start != -1, "the lazy module map must exist"
|
||||
mods_body = js[mods_start : js.find("\n}", mods_start)]
|
||||
assert 'tuning: () => import("./tuning.js")' in mods_body, (
|
||||
"the Tuning view module is lazy-imported on first show"
|
||||
)
|
||||
assert 'history: () => import("./history.js")' in mods_body, (
|
||||
"the History view module is lazy-imported on first show"
|
||||
)
|
||||
assert '"chat"' not in mods_body, "the chat view has no lazy module"
|
||||
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
|
||||
|
||||
|
||||
# ---------- show / hide: hidden AND inert ----------
|
||||
|
||||
|
||||
def test_hidden_views_get_both_hidden_and_inert() -> None:
|
||||
"""Show = drop hidden AND inert; hide = add BOTH — the same
|
||||
comparison drives both attributes in one loop, so a view can never
|
||||
be visible-but-inert or inert-but-visible (WCAG: a hidden view must
|
||||
not receive focus or keyboard traversal)."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
loop = body.find("Object.entries(viewEls)")
|
||||
assert loop != -1, "the show/hide loop must walk every view"
|
||||
loop_body = body[loop : body.find("\n }", loop)]
|
||||
hidden_i = loop_body.find("el.hidden =")
|
||||
inert_i = loop_body.find("el.inert =")
|
||||
assert 0 <= hidden_i < inert_i, "both attributes are set in the same loop"
|
||||
assert loop_body.count("viewName !== name") == 2, (
|
||||
"one comparison drives hidden AND inert (they can never drift)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the router is the single writer ----------
|
||||
|
||||
|
||||
def test_router_writes_active_state_title_and_meta() -> None:
|
||||
"""The router stamps the .nav-link active state (is-active +
|
||||
aria-current, removed on the inactive links), document.title, and
|
||||
the per-view meta description — values carried over from the old
|
||||
pages' <head>s (the tuning title/description survive the fold).
|
||||
Phase 76 (task 02): the title/meta are composed through
|
||||
titleFor()/descFor() — the per-view value with the brand literal
|
||||
replaced by window.BOR_BRAND (phase 39): the lazy view import
|
||||
defers switchTo past brand.js's one-time DOM pass, so a literal
|
||||
stamp would overwrite a configured deployment's name; composing
|
||||
at write time is a no-op for the default deployment."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert 'querySelectorAll("a.nav-link")' in body, "the writer walks the nav links"
|
||||
assert 'classList.toggle("is-active"' in body, "is-active is stamped + removed"
|
||||
assert 'setAttribute("aria-current", "page")' in body
|
||||
assert 'removeAttribute("aria-current")' in body
|
||||
assert "document.title = titleFor(name)" in body
|
||||
assert "metaDesc.content = descFor(name)" in body
|
||||
# The carried-over values (the old pages' <head>s — default form).
|
||||
assert 'chat: "Brain of Reese"' in js
|
||||
assert 'tuning: "Global Tuning · Brain of Reese"' in js
|
||||
assert "Manage the global tuning notes that steer every Brain of Reese answer." in js
|
||||
assert 'history: "Saved chats · Brain of Reese"' in js
|
||||
assert "Saved chats — every conversation is saved automatically, one click back." in js
|
||||
# The brand composition (phase 39's window.BOR_BRAND, read at
|
||||
# write time — never a hardcoded stamp).
|
||||
assert 'window.BOR_BRAND || "Brain of Reese"' in js
|
||||
assert 'TITLES[view].replaceAll("Brain of Reese", brandName())' in js
|
||||
assert 'DESCRIPTIONS[view].replaceAll("Brain of Reese", brandName())' in js
|
||||
|
||||
|
||||
def test_focus_only_on_user_initiated_switches() -> None:
|
||||
"""The target view is focused ONLY when the switch is
|
||||
user-initiated (navbar click / popstate) — the boot switch passes
|
||||
userInitiated:false, so a page load never steals focus. The top
|
||||
landing (scrollTo 0,0) rides the same flag."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
flag = body.rfind("if (userInitiated)")
|
||||
focus = body.find("root.focus(")
|
||||
scroll = body.find("window.scrollTo(0, 0)")
|
||||
assert 0 <= flag < scroll < focus, "focus + top landing sit inside the flag"
|
||||
# Boot is NOT user-initiated (no focus steal on load).
|
||||
boot = js.find("switchTo(bootName")
|
||||
assert boot != -1 and "userInitiated: false" in js[boot : boot + 60]
|
||||
# The click handler IS user-initiated.
|
||||
click = js.find('nav.addEventListener("click"')
|
||||
click_body = js[click : js.find("\n });", click)]
|
||||
assert "userInitiated: true" in click_body
|
||||
|
||||
|
||||
# ---------- the shell markup + boot order ----------
|
||||
|
||||
|
||||
def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
|
||||
"""index.html: ONE main#main holds the view sections; the Tuning
|
||||
section ships hidden AND inert (the a11y pair); ONLY the Chat link
|
||||
carries the static active stamp (the router is the single writer —
|
||||
no view other than chat may ship statically active)."""
|
||||
html = _html()
|
||||
assert html.count('id="main"') == 1, "the shell has exactly one main"
|
||||
main = html.find('<main id="main" class="app-main" tabindex="-1">')
|
||||
assert main != -1
|
||||
view_chat = html.find('<section class="view" id="view-chat"')
|
||||
view_tuning = html.find('<section class="view" id="view-tuning"')
|
||||
main_end = html.find("</main>", main)
|
||||
assert main < view_chat < view_tuning < main_end, (
|
||||
"both view sections live inside the single main (chat first)"
|
||||
)
|
||||
tuning_tag = html[view_tuning : html.find(">", view_tuning)]
|
||||
assert "hidden" in tuning_tag and "inert" in tuning_tag, (
|
||||
"the folded view ships hidden AND inert"
|
||||
)
|
||||
assert 'tabindex="-1"' in html[view_chat : html.find(">", view_chat)]
|
||||
assert 'tabindex="-1"' in tuning_tag, "the target view is focusable"
|
||||
# Only the Chat link is statically active (exactly one stamp, on Chat).
|
||||
assert html.count('class="nav-link is-active"') == 1, (
|
||||
"only ONE nav link may ship statically active"
|
||||
)
|
||||
active = html.find('<a href="/" class="nav-link is-active" aria-current="page">Chat</a>')
|
||||
assert active != -1, "the static active stamp is the Chat link"
|
||||
# The tuning nav link ships hidden (admin-only) and UNstamped.
|
||||
tuning_match = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', html)
|
||||
assert tuning_match, "the shell must carry the #nav-tuning nav link"
|
||||
tuning_link = tuning_match.group(0)
|
||||
assert "hidden" in tuning_link, "#nav-tuning ships hidden (admin-only)"
|
||||
assert "is-active" not in tuning_link, "no static active stamp on the Tuning link"
|
||||
|
||||
|
||||
# ---------- phase 76 task 04: the header is shell-owned ----------
|
||||
|
||||
|
||||
def test_shell_carries_exactly_one_static_is_active_on_chat() -> None:
|
||||
"""Phase 76 task 04: the shell's ONE header is the canonical header —
|
||||
the folded pages' header copies (which stamped is-active statically
|
||||
in their own markup) are gone, so ``is-active`` occurs EXACTLY ONCE
|
||||
in the whole of index.html, on the Chat link (the default view).
|
||||
A second stamp anywhere (a leaked page copy, a non-chat view
|
||||
shipping statically active) would break the router's single-writer
|
||||
contract the moment it disagrees with a switch."""
|
||||
html = _html()
|
||||
assert html.count("is-active") == 1, (
|
||||
"index.html must carry is-active exactly once (the Chat link's stamp)"
|
||||
)
|
||||
i = html.find("is-active")
|
||||
tag_start = html.rfind("<a ", 0, i)
|
||||
tag_end = html.find(">", tag_start)
|
||||
tag = html[tag_start:tag_end]
|
||||
assert tag.startswith('<a href="/"'), (
|
||||
"the single static active stamp must be the Chat link (href=\"/\")"
|
||||
)
|
||||
|
||||
|
||||
def test_header_js_never_writes_the_active_state() -> None:
|
||||
"""Phase 76 task 04: header.js is NOT a writer of the nav's active
|
||||
state — it never was (the old pages stamped is-active statically in
|
||||
their OWN markup; the shell's ONE header is the only header left)
|
||||
— and it must never become one: the whoami auth gate (the sign-in/
|
||||
sign-out pair + the admin-only nav links' hidden attributes) and the
|
||||
mobile hamburger are its only nav responsibilities, and neither
|
||||
touches the active state. The string is absent from the module
|
||||
entirely; the SINGLE runtime writer is the router (pinned in
|
||||
test_router_writes_active_state_title_and_meta)."""
|
||||
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
|
||||
assert "is-active" not in header_js, (
|
||||
"header.js must carry no is-active write — the router is the "
|
||||
"SINGLE WRITER of the active state (the shell markup ships the "
|
||||
"one static stamp on the Chat link)"
|
||||
)
|
||||
|
||||
|
||||
def test_boot_order_is_brand_app_router() -> None:
|
||||
"""The shell's script boot order: brand.js (classic) FIRST, then
|
||||
app.js (the chat view module — runs at shell boot exactly as
|
||||
today), then router.js (module) — the router may only see a
|
||||
fully-booted chat view."""
|
||||
html = _html()
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert "assets/brand.js" in srcs, "brand.js (classic) still ships"
|
||||
assert srcs.index("assets/brand.js") < srcs.index("/assets/app.js") < srcs.index(
|
||||
"/assets/router.js"
|
||||
), "boot order: brand.js → app.js → router.js"
|
||||
router_tag_match = re.search(r'<script[^>]*src="/assets/router\.js"[^>]*>', html)
|
||||
assert router_tag_match, "the shell must load the router module"
|
||||
router_tag = router_tag_match.group(0)
|
||||
assert 'type="module"' in router_tag, "router.js is an ES module"
|
||||
# No CDN: every asset reference is local (AGENTS.md rule 6).
|
||||
assert 'src="http' not in html and 'href="http' not in html
|
||||
Reference in New Issue
Block a user