perf(watch): a shared link loads the player, not the app
A /watch link is opened by whoever it was sent to — signed out, often on a phone on mobile data — and it booted through main.tsx: every app provider, the app's i18n (every locale of every area), the flag sprites, ~392 KB gzip before the player could start. It now has its own HTML entry, watch.html → watchMain.tsx, which imports WatchPage and what it renders; the server hands every /watch/ path that page (the OG block injected as before), and vite dev does the same. Measured on the build: /watch JS 290 → 87 KB gzip, CSS 104 → 19 KB; the app's own bytes unchanged. Two leaks had to go for that: lib/format imported the app's i18n for one "just now" string (the relative-time formatters now take `t`, like the file's other t-formatters), and the boundary's crash copy came from the same i18n (CrashBoundary takes its copy; ErrorBoundary is the app's wrapper, the watch entry gives it the page's own strings). The page's popover tree went too: it renders no popover. Held by: a contract test walking the entry's runtime imports, naming the path to anything only the app may load (red on a planted i18n import in lib/format), and holding watch.html's head equal to index.html's; a DB-lane test that every /watch/ path gets the player's page with the link's OG tags and app paths get the app (red on the old routing). Screenshots at 375 px, EN and HU, video / audio / password / gone / wrong password / unlocked: identical to the old entry's, bar the video's buffered bar.
This commit is contained in:
@@ -91,7 +91,7 @@ describe("the last sign-in column", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
|
||||
expect(await screen.findByText(relativeTime(at, "en", i18n.t))).toBeTruthy();
|
||||
expect(screen.getAllByText("—")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,7 +316,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
}
|
||||
>
|
||||
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
|
||||
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
|
||||
{u.last_login_at ? relativeTime(u.last_login_at, lang, t) : "—"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<MoreButton
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
export interface CrashCopy {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
reload: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Read at crash time, so the screen speaks the language active then. */
|
||||
copy: () => CrashCopy;
|
||||
/** What the entry does beyond the log line (the app keeps a fatal notice in its center). */
|
||||
onCrash?: () => void;
|
||||
}
|
||||
interface State {
|
||||
crashed: boolean;
|
||||
}
|
||||
|
||||
// Catches render-time crashes anywhere in the tree, logs them and offers a reload. Free of the app's
|
||||
// i18n and notification store, so the public /watch entry wraps its page in it without loading
|
||||
// either; `ErrorBoundary` is the app's wrapper around it.
|
||||
export class CrashBoundary extends Component<Props, State> {
|
||||
state: State = { crashed: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { crashed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not
|
||||
// load, not a crash — no fatal notice, which would outlive the reload.
|
||||
if (reloadingForNewBuild()) return;
|
||||
log.fatal("ui.crash", "the app crashed while rendering", {
|
||||
...errorAttrs(error),
|
||||
...(info.componentStack ? { component_stack: info.componentStack } : {}),
|
||||
});
|
||||
this.props.onCrash?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.crashed) {
|
||||
if (reloadingForNewBuild()) return null;
|
||||
const copy = this.props.copy();
|
||||
return (
|
||||
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-fg mb-1">{copy.title}</div>
|
||||
<div className="text-sm mb-3">{copy.subtitle}</div>
|
||||
<Button variant="link" onClick={() => location.reload()} className="text-sm">
|
||||
{copy.reload}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,30 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import i18n from "../i18n";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||
import { Button } from "./ui/Button";
|
||||
import { CrashBoundary, type CrashCopy } from "./CrashBoundary";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
crashed: boolean;
|
||||
}
|
||||
const copy = (): CrashCopy => ({
|
||||
title: i18n.t("errors.boundary.title"),
|
||||
subtitle: i18n.t("errors.boundary.subtitle"),
|
||||
reload: i18n.t("errors.boundary.reload"),
|
||||
});
|
||||
|
||||
// Catches render-time crashes anywhere in the tree, logs a fatal notification (kept
|
||||
// in the center across the reload) and offers a recovery action.
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { crashed: false };
|
||||
// A fatal notification, kept in the center across the reload.
|
||||
const onCrash = () =>
|
||||
notify({
|
||||
level: "fatal",
|
||||
title: i18n.t("errors.boundary.notifTitle"),
|
||||
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
|
||||
// is no message for the reader — the `ui.crash` log carries it for diagnosis.
|
||||
message: i18n.t("errors.boundary.notifMessage"),
|
||||
requiresInteraction: true,
|
||||
});
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { crashed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not
|
||||
// load, not a crash — no fatal notice, which would outlive the reload.
|
||||
if (reloadingForNewBuild()) return;
|
||||
log.fatal("ui.crash", "the app crashed while rendering", {
|
||||
...errorAttrs(error),
|
||||
...(info.componentStack ? { component_stack: info.componentStack } : {}),
|
||||
});
|
||||
notify({
|
||||
level: "fatal",
|
||||
title: i18n.t("errors.boundary.notifTitle"),
|
||||
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
|
||||
// is no message for the reader — the `ui.crash` log above carries it for diagnosis.
|
||||
message: i18n.t("errors.boundary.notifMessage"),
|
||||
requiresInteraction: true,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.crashed) {
|
||||
if (reloadingForNewBuild()) return null;
|
||||
return (
|
||||
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-fg mb-1">
|
||||
{i18n.t("errors.boundary.title")}
|
||||
</div>
|
||||
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
|
||||
<Button variant="link" onClick={() => location.reload()} className="text-sm">
|
||||
{i18n.t("errors.boundary.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
// The app's crash boundary: `CrashBoundary` in the app's language, plus the notice in the center.
|
||||
export default function ErrorBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<CrashBoundary copy={copy} onCrash={onCrash}>
|
||||
{children}
|
||||
</CrashBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -546,7 +546,7 @@ function Hero({
|
||||
? // The channel is the subtitle already.
|
||||
[
|
||||
video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`,
|
||||
relativeTime(video.published_at, lang),
|
||||
relativeTime(video.published_at, lang, t),
|
||||
]
|
||||
: [];
|
||||
const factLine = facts.filter(Boolean).join(" · ");
|
||||
|
||||
@@ -977,7 +977,7 @@ export default function PlayerModal({
|
||||
)}
|
||||
<span>
|
||||
{metaSep}
|
||||
{relativeTime(active.published_at, lang)}
|
||||
{relativeTime(active.published_at, lang, t)}
|
||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||
</span>
|
||||
{active.duration_seconds != null && (
|
||||
@@ -1009,7 +1009,7 @@ export default function PlayerModal({
|
||||
{detail.data?.published_at && (
|
||||
<span>
|
||||
{metaSep}
|
||||
{relativeTime(detail.data.published_at, lang)} ·{" "}
|
||||
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
|
||||
{fullDate(detail.data.published_at)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -254,7 +254,7 @@ function JobRow({
|
||||
{job.running
|
||||
? t("scheduler.runningNow")
|
||||
: job.last_finished
|
||||
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) })
|
||||
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang, t) })
|
||||
: t("scheduler.neverRun")}
|
||||
{job.last_error ? (
|
||||
<span className="text-danger">
|
||||
|
||||
@@ -420,7 +420,7 @@ function VideoCard({
|
||||
);
|
||||
const published = (
|
||||
<>
|
||||
{relativeTime(video.published_at, lang)}
|
||||
{relativeTime(video.published_at, lang, t)}
|
||||
{video.published_at && (
|
||||
<>
|
||||
{" · "}
|
||||
|
||||
@@ -9,10 +9,11 @@ import { send } from "../lib/http";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { LinkButton } from "./ui/LinkButton";
|
||||
import { T, lang } from "./watchStrings";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||
// self-contained i18n dict (the public viewer has no app language preference).
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Its own entry (watchMain.tsx,
|
||||
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
|
||||
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
|
||||
|
||||
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
|
||||
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
|
||||
@@ -75,72 +76,6 @@ function displayUrl(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const STR = {
|
||||
en: {
|
||||
loading: "Loading…",
|
||||
gone: "This link is no longer available.",
|
||||
goneHint: "It may have expired or been revoked by the owner.",
|
||||
passwordTitle: "This video is password-protected",
|
||||
passwordPlaceholder: "Enter password",
|
||||
watch: "Watch",
|
||||
wrong: "Wrong password.",
|
||||
download: "Download",
|
||||
file: "File",
|
||||
links: "Links",
|
||||
brand: "Shared via Siftlode",
|
||||
repeat: "Repeat",
|
||||
failed: "Couldn't load this link.",
|
||||
failedHint: "Check your connection and try again.",
|
||||
retry: "Try again",
|
||||
tooMany: "Too many attempts. Try again in a few minutes.",
|
||||
unlockFailed: "Couldn't check the password. Try again.",
|
||||
},
|
||||
hu: {
|
||||
loading: "Betöltés…",
|
||||
gone: "Ez a link már nem elérhető.",
|
||||
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
|
||||
passwordTitle: "Ez a videó jelszóval védett",
|
||||
passwordPlaceholder: "Add meg a jelszót",
|
||||
watch: "Megnézem",
|
||||
wrong: "Hibás jelszó.",
|
||||
download: "Letöltés",
|
||||
file: "Fájl",
|
||||
links: "Linkek",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
repeat: "Ismétlés",
|
||||
failed: "Nem sikerült betölteni a linket.",
|
||||
failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.",
|
||||
retry: "Újrapróbálás",
|
||||
tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.",
|
||||
unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.",
|
||||
},
|
||||
de: {
|
||||
loading: "Wird geladen…",
|
||||
gone: "Dieser Link ist nicht mehr verfügbar.",
|
||||
goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
|
||||
passwordTitle: "Dieses Video ist passwortgeschützt",
|
||||
passwordPlaceholder: "Passwort eingeben",
|
||||
watch: "Ansehen",
|
||||
wrong: "Falsches Passwort.",
|
||||
download: "Herunterladen",
|
||||
file: "Datei",
|
||||
links: "Links",
|
||||
brand: "Geteilt über Siftlode",
|
||||
repeat: "Wiederholen",
|
||||
failed: "Der Link konnte nicht geladen werden.",
|
||||
failedHint: "Prüfe deine Verbindung und versuche es erneut.",
|
||||
retry: "Erneut versuchen",
|
||||
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
|
||||
unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const lang =
|
||||
((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
|
||||
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
|
||||
: "en";
|
||||
const T = STR[lang];
|
||||
|
||||
export default function WatchPage() {
|
||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
||||
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
|
||||
|
||||
@@ -304,7 +304,7 @@ export function DownloadRow({
|
||||
<span className="text-muted" title={formatDateTime(job.created_at, lang)}>
|
||||
{" · "}
|
||||
<CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
|
||||
{relativeTime(job.created_at, lang)}
|
||||
{relativeTime(job.created_at, lang, t)}
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format";
|
||||
import "../../i18n"; // the real instance, in English: the component names time in the UI language
|
||||
import i18n from "../../i18n"; // the real instance, in English: the component names time in the UI language
|
||||
import { RelativeTime } from "./RelativeTime";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -15,7 +15,7 @@ describe("RelativeTime", () => {
|
||||
it("says how long ago, and names the exact minute on hover", () => {
|
||||
const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />);
|
||||
const el = container.querySelector("time")!;
|
||||
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en"));
|
||||
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en", i18n.t));
|
||||
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO, "en"));
|
||||
expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO);
|
||||
});
|
||||
|
||||
@@ -17,9 +17,10 @@ export function RelativeTime({
|
||||
empty?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const lang = useTranslation().i18n.language;
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN;
|
||||
const text = Number.isFinite(ms) ? relativeFromMs(ms, lang) : "";
|
||||
const text = Number.isFinite(ms) ? relativeFromMs(ms, lang, t) : "";
|
||||
if (!text) return empty ? <span className={className}>{empty}</span> : null;
|
||||
const iso = new Date(ms).toISOString();
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// The public /watch page's own strings. The page is opened by whoever got the link, signed out and
|
||||
// with no app language preference, so it reads the browser's language against this small dict
|
||||
// instead of loading the app's i18n (every locale of every area — half of what the page weighed).
|
||||
|
||||
const STR = {
|
||||
en: {
|
||||
loading: "Loading…",
|
||||
gone: "This link is no longer available.",
|
||||
goneHint: "It may have expired or been revoked by the owner.",
|
||||
passwordTitle: "This video is password-protected",
|
||||
passwordPlaceholder: "Enter password",
|
||||
watch: "Watch",
|
||||
wrong: "Wrong password.",
|
||||
download: "Download",
|
||||
file: "File",
|
||||
links: "Links",
|
||||
brand: "Shared via Siftlode",
|
||||
repeat: "Repeat",
|
||||
failed: "Couldn't load this link.",
|
||||
failedHint: "Check your connection and try again.",
|
||||
retry: "Try again",
|
||||
tooMany: "Too many attempts. Try again in a few minutes.",
|
||||
unlockFailed: "Couldn't check the password. Try again.",
|
||||
crashTitle: "Something went wrong.",
|
||||
crashHint: "Reload the page to try again.",
|
||||
reload: "Reload",
|
||||
},
|
||||
hu: {
|
||||
loading: "Betöltés…",
|
||||
gone: "Ez a link már nem elérhető.",
|
||||
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
|
||||
passwordTitle: "Ez a videó jelszóval védett",
|
||||
passwordPlaceholder: "Add meg a jelszót",
|
||||
watch: "Megnézem",
|
||||
wrong: "Hibás jelszó.",
|
||||
download: "Letöltés",
|
||||
file: "Fájl",
|
||||
links: "Linkek",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
repeat: "Ismétlés",
|
||||
failed: "Nem sikerült betölteni a linket.",
|
||||
failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.",
|
||||
retry: "Újrapróbálás",
|
||||
tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.",
|
||||
unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.",
|
||||
crashTitle: "Valami hiba történt.",
|
||||
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
|
||||
reload: "Újratöltés",
|
||||
},
|
||||
de: {
|
||||
loading: "Wird geladen…",
|
||||
gone: "Dieser Link ist nicht mehr verfügbar.",
|
||||
goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
|
||||
passwordTitle: "Dieses Video ist passwortgeschützt",
|
||||
passwordPlaceholder: "Passwort eingeben",
|
||||
watch: "Ansehen",
|
||||
wrong: "Falsches Passwort.",
|
||||
download: "Herunterladen",
|
||||
file: "Datei",
|
||||
links: "Links",
|
||||
brand: "Geteilt über Siftlode",
|
||||
repeat: "Wiederholen",
|
||||
failed: "Der Link konnte nicht geladen werden.",
|
||||
failedHint: "Prüfe deine Verbindung und versuche es erneut.",
|
||||
retry: "Erneut versuchen",
|
||||
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
|
||||
unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.",
|
||||
crashTitle: "Etwas ist schiefgelaufen.",
|
||||
crashHint: "Lade die Seite neu und versuche es erneut.",
|
||||
reload: "Neu laden",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const lang =
|
||||
((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
|
||||
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
|
||||
: "en";
|
||||
export const T = STR[lang];
|
||||
Reference in New Issue
Block a user