- Playlists: a link over a playlist the account does not hold plays alone; the rail's own fallback no longer counts as a pick that gives the player up. - The linked opener opens or clears nothing once the address names another player, and stops looking (and fetching its row) once it settled. - Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a notice of its own (EN/HU). - History: an in-place view rewrite (search refine, count, channel to channel, sub-view) drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a search keeps the search; the Feed press on a search opens the plain feed. - Files/Playlists: an emptied search is written at once. - Client log: plf/cf (column text filters) are typed keys; the shell has none of its own. - Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark net reads page tags. - Feed defaults in one constant; one live-search queue helper; Scheduler writes through the Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
217 lines
8.7 KiB
TypeScript
217 lines
8.7 KiB
TypeScript
// The client log's app-wide hooks, installed once from main.tsx before anything renders:
|
|
// the start snapshot, uncaught errors and rejections, navigation, connectivity, and — only while the
|
|
// user's consented level is raised — which control was used and what the tab itself went
|
|
// through (hidden/shown, restored from the back-forward cache, resized).
|
|
//
|
|
// Navigation has no single choke point in this router-free app — `setPage`, the channel and search
|
|
// overlays, sub-views, the overlay stack and the URL mirrors each call `history.pushState` /
|
|
// `replaceState` themselves (lib/history.ts, NavigationProvider, ChannelsProvider, …). Instrumenting
|
|
// each would be one more thing a new call site can forget, so the History methods themselves are
|
|
// wrapped here, and `popstate` covers Back/Forward. A replaceState that only saves a scroll position
|
|
// changes nothing this logs, so it is not an event.
|
|
import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
|
|
import { enabled, errorAttrs, log } from "./log";
|
|
import { pathTemplate } from "./http";
|
|
|
|
// URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at
|
|
// TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its
|
|
// value at DEBUG. The shell has none; each page's join at install (ModuleDef.typedUrlKeys).
|
|
const TYPED_PARAMS = new Set<string>();
|
|
|
|
type Via = "start" | "push" | "replace" | "pop";
|
|
|
|
/** What a navigation event records of where the tab is now. Built from `history.state`'s own view
|
|
* keys and the query, never the full URL (a `#reset=` fragment, a `?token=` link). */
|
|
function viewAttrs(): Record<string, unknown> {
|
|
const st = (window.history.state ?? {}) as Record<string, unknown>;
|
|
const params = new URLSearchParams(window.location.search);
|
|
const view: Record<string, unknown> = {
|
|
path: pathTemplate(window.location.pathname),
|
|
page: typeof st.sfPage === "string" ? st.sfPage : null,
|
|
params: [...new Set(params.keys())].sort(),
|
|
overlay: st._ov != null,
|
|
channel: st._chan != null,
|
|
search: st._yt != null,
|
|
};
|
|
if (enabled("DEBUG")) {
|
|
const values: Record<string, string> = {};
|
|
for (const [k, v] of params) {
|
|
if (!TYPED_PARAMS.has(k) || enabled("TRACE")) values[k] = v;
|
|
}
|
|
view.values = values;
|
|
if (typeof st._sub === "string") view.sub = st._sub;
|
|
}
|
|
if (enabled("TRACE") && typeof st._yt === "string") view.query = st._yt;
|
|
return view;
|
|
}
|
|
|
|
/** A script's path (`/assets/index-B1x9.js`): the bundle name is what finds the fault, and a script
|
|
* URL carries no user data — but a malformed one is dropped rather than logged raw. */
|
|
function scriptPath(filename: string): string {
|
|
try {
|
|
return new URL(filename, window.location.href).pathname;
|
|
} catch {
|
|
return "?";
|
|
}
|
|
}
|
|
|
|
let lastView = "";
|
|
function recordView(via: Via): void {
|
|
const view = viewAttrs();
|
|
const signature = JSON.stringify(view);
|
|
if (via !== "start" && signature === lastView) return;
|
|
lastView = signature;
|
|
log.info("nav.view", `${via} ${String(view.page ?? view.path)}`, { via, ...view });
|
|
}
|
|
|
|
// What counts as a control for click capture: anything a user operates.
|
|
const CONTROL = [
|
|
"button",
|
|
"a[href]",
|
|
"input",
|
|
"select",
|
|
"textarea",
|
|
"summary",
|
|
...[
|
|
"button",
|
|
"link",
|
|
"tab",
|
|
"menuitem",
|
|
"menuitemradio",
|
|
"menuitemcheckbox",
|
|
"option",
|
|
"switch",
|
|
"checkbox",
|
|
"radio",
|
|
].map((role) => `[role="${role}"]`),
|
|
].join(", ");
|
|
const ARIA_STATE = ["aria-pressed", "aria-checked", "aria-expanded", "aria-selected"] as const;
|
|
|
|
type AppCopy = (text: string | null) => string | null;
|
|
|
|
/** Which control this is — never what it says, unless what it says is the app's own copy.
|
|
* Recorded: its test id (its own, else the nearest enclosing one as `within`), its lucide icon, the
|
|
* English wording of its aria-label/text/title when that is exactly app copy (`appCopy`), its tag,
|
|
* role and input type, its aria state, whether a checkbox is checked, and for a link the path
|
|
* TEMPLATE or just "external". Never: an input's value, free label text, an href's query. */
|
|
function controlAttrs(el: Element, appCopy: AppCopy): Record<string, unknown> {
|
|
const attrs: Record<string, unknown> = { tag: el.tagName.toLowerCase() };
|
|
const role = el.getAttribute("role");
|
|
if (role) attrs.role = role;
|
|
const testId = el.getAttribute("data-testid");
|
|
if (testId) attrs.control = testId;
|
|
else {
|
|
const within = el.parentElement?.closest("[data-testid]")?.getAttribute("data-testid");
|
|
if (within) attrs.within = within;
|
|
}
|
|
const icon = [...(el.querySelector("svg.lucide")?.classList ?? [])].find(
|
|
(c) => c.startsWith("lucide-") && c !== "lucide-icon",
|
|
);
|
|
if (icon) attrs.icon = icon.slice("lucide-".length);
|
|
const label =
|
|
appCopy(el.getAttribute("aria-label")) ??
|
|
appCopy(el.textContent) ??
|
|
appCopy(el.getAttribute("title"));
|
|
if (label) attrs.label = label;
|
|
if (el instanceof HTMLInputElement) {
|
|
attrs.type = el.type;
|
|
if (el.type === "checkbox" || el.type === "radio") attrs.checked = el.checked;
|
|
}
|
|
for (const name of ARIA_STATE) {
|
|
const value = el.getAttribute(name);
|
|
if (value !== null) attrs[name.slice("aria-".length)] = value;
|
|
}
|
|
if (el instanceof HTMLAnchorElement) {
|
|
attrs.href = el.origin === window.location.origin ? pathTemplate(el.pathname) : "external";
|
|
}
|
|
const page = (window.history.state as Record<string, unknown> | null)?.sfPage;
|
|
if (typeof page === "string") attrs.page = page;
|
|
return attrs;
|
|
}
|
|
|
|
function installDetailHooks(appCopy: AppCopy): void {
|
|
// Capture phase, so a handler that stops propagation cannot hide the click from the log.
|
|
document.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
if (!enabled("DEBUG") || !(e.target instanceof Element)) return;
|
|
const control = e.target.closest(CONTROL);
|
|
if (!control) return;
|
|
const attrs = controlAttrs(control, appCopy);
|
|
const name = attrs.control ?? attrs.label ?? attrs.icon ?? attrs.within ?? attrs.tag;
|
|
log.debug("ui.click", `click ${String(name)}`, attrs);
|
|
},
|
|
true,
|
|
);
|
|
document.addEventListener("visibilitychange", () => {
|
|
log.debug("app.visibility", document.visibilityState, { state: document.visibilityState });
|
|
});
|
|
window.addEventListener("pageshow", (e) => {
|
|
if (e.persisted) log.debug("app.restored", "restored from the back-forward cache");
|
|
});
|
|
let resizeTimer: number | null = null;
|
|
window.addEventListener("resize", () => {
|
|
if (!enabled("DEBUG")) return;
|
|
if (resizeTimer !== null) window.clearTimeout(resizeTimer);
|
|
resizeTimer = window.setTimeout(() => {
|
|
resizeTimer = null;
|
|
const viewport = `${window.innerWidth}x${window.innerHeight}`;
|
|
log.debug("app.resize", viewport, { viewport });
|
|
}, 500);
|
|
});
|
|
// Connectivity is worth a line at the base level: it explains a burst of failed requests.
|
|
window.addEventListener("offline", () => log.warn("app.network", "offline", { online: false }));
|
|
window.addEventListener("online", () => log.info("app.network", "online", { online: true }));
|
|
}
|
|
|
|
let installed = false;
|
|
|
|
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice.
|
|
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
|
|
* module stays free of the i18n runtime. */
|
|
export function installClientLog(
|
|
appCopy: AppCopy = () => null,
|
|
typedParams: readonly string[] = [],
|
|
): void {
|
|
if (installed) return;
|
|
installed = true;
|
|
for (const k of typedParams) TYPED_PARAMS.add(k);
|
|
|
|
log.info("app.start", "app loaded", {
|
|
version: FRONTEND_VERSION,
|
|
sha: FRONTEND_SHA,
|
|
user_agent: navigator.userAgent,
|
|
language: navigator.language,
|
|
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
|
dpr: window.devicePixelRatio,
|
|
});
|
|
|
|
window.addEventListener("error", (e) => {
|
|
// A failed <img>/<script> load also dispatches `error` on window (capture only); those carry no
|
|
// `error` object and are not script faults.
|
|
if (!(e instanceof ErrorEvent)) return;
|
|
log.error("app.uncaught", e.message || "uncaught error", {
|
|
...errorAttrs(e.error ?? e.message),
|
|
where: e.filename ? `${scriptPath(e.filename)}:${e.lineno}:${e.colno}` : null,
|
|
});
|
|
});
|
|
window.addEventListener("unhandledrejection", (e) => {
|
|
log.error("app.unhandled_rejection", "unhandled promise rejection", errorAttrs(e.reason));
|
|
});
|
|
|
|
const history = window.history;
|
|
for (const [method, via] of [
|
|
["pushState", "push"],
|
|
["replaceState", "replace"],
|
|
] as const) {
|
|
const original = history[method];
|
|
history[method] = function (this: History, ...args: Parameters<History["pushState"]>) {
|
|
original.apply(this, args);
|
|
recordView(via);
|
|
};
|
|
}
|
|
window.addEventListener("popstate", () => recordView("pop"));
|
|
recordView("start");
|
|
installDetailHooks(appCopy);
|
|
}
|