Files
siftlode/frontend/src/features/player/descriptionLinks.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

173 lines
6.0 KiB
TypeScript

import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import { Pressable } from "../../components/ui/Pressable";
// Turn a video description into clickable nodes:
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
// - a YouTube link to the *current* video → seek (to its t= if any)
// - a YouTube link to *another* video → load it in the inline player
// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab
// Pure logic (no React state), factored out of PlayerModal so it's testable on its own.
const URL_RE = /(https?:\/\/[^\s<>]+)/g;
// One pass over plain text for the three inline token kinds (email | hashtag | timestamp).
const INLINE_RE =
/([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu;
function tsToSeconds(ts: string): number {
const parts = ts.split(":").map((n) => parseInt(n, 10));
const [a = 0, b = 0, c = 0] = parts; // hh:mm:ss or mm:ss — absent segments default to 0
return parts.length === 3 ? a * 3600 + b * 60 + c : a * 60 + b;
}
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
function parseStart(t: string | null): number | null {
if (!t) return null;
if (/^\d+$/.test(t)) return parseInt(t, 10) || null;
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!m) return null;
const total =
parseInt(m[1] || "0", 10) * 3600 + parseInt(m[2] || "0", 10) * 60 + parseInt(m[3] || "0", 10);
return total || null;
}
// Extract a video id (+ optional start) from a YouTube URL, else null.
function parseYouTube(url: string): { id: string; start: number | null } | null {
let u: URL;
try {
u = new URL(url);
} catch {
return null;
}
const host = u.hostname.replace(/^www\./, "");
let id: string | null = null;
if (host === "youtu.be") {
id = u.pathname.slice(1) || null;
} else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
if (u.pathname === "/watch") id = u.searchParams.get("v");
else {
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
if (m) id = m[1] ?? null;
}
}
if (!id) return null;
return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) };
}
/** The address inside a matched URL: sentence punctuation after it is not part of it, but a ")" that
* closes a "(" of its own is (a Wikipedia article's "Mercury_(planet)"). */
function trimUrl(raw: string): string {
let end = raw.length;
for (;;) {
const c = raw[end - 1];
if (c && ".,;:!?]".includes(c)) end--;
else if (c === ")") {
const body = raw.slice(0, end);
if (body.split(")").length > body.split("(").length) end--;
else break;
} else break;
}
return raw.slice(0, end);
}
export function renderDescription(
text: string,
opts: {
currentId: string;
onSeek: (seconds: number) => void;
onLoadVideo: (id: string, start: number | null) => void;
t: TFunction;
},
): ReactNode[] {
const { t } = opts;
const out: ReactNode[] = [];
let key = 0;
const linkCls = "text-accent hover:underline break-all";
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
// (they're just noise in the popover), keeping single line breaks.
const clean = text
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{2,}/g, "\n")
.trim();
for (const chunk of clean.split(URL_RE)) {
if (/^https?:\/\//.test(chunk)) {
const href = trimUrl(chunk);
// What was trimmed is the sentence's, not the link's: it stays in the text, after the link.
const tail = chunk.slice(href.length);
const yt = parseYouTube(href);
if (yt) {
const sameVideo = yt.id === opts.currentId;
out.push(
<Pressable
selectable
key={key++}
onClick={() =>
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
}
className={linkCls}
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
>
{href}
</Pressable>,
);
} else {
out.push(
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
{href}
</a>,
);
}
if (tail) out.push(<span key={key++}>{tail}</span>);
continue;
}
// Plain text: pull out emails, hashtags and bare timestamps.
INLINE_RE.lastIndex = 0;
let last = 0;
let m: RegExpExecArray | null;
while ((m = INLINE_RE.exec(chunk))) {
if (m.index > last) out.push(<span key={key++}>{chunk.slice(last, m.index)}</span>);
if (m[1]) {
// Email — trim trailing punctuation the domain rule may have swallowed.
const email = m[1].replace(/[.,;:]+$/, "");
out.push(
<a key={key++} href={`mailto:${email}`} className={linkCls}>
{email}
</a>,
);
if (m[1].length > email.length)
out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
} else if (m[2]) {
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
const tag = m[2].slice(1);
out.push(
<a
key={key++}
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
target="_blank"
rel="noreferrer"
className={linkCls}
>
{m[2]}
</a>,
);
} else {
// Reached only when neither m[1] (email) nor m[2] (hashtag) matched, so the third
// alternative — the timestamp group — is the one that fired.
const ts = m[3]!;
out.push(
<Pressable
selectable
key={key++}
onClick={() => opts.onSeek(tsToSeconds(ts))}
className="text-accent hover:underline"
>
{ts}
</Pressable>,
);
}
last = m.index + m[0].length;
}
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
}
return out;
}