web: click-to-play YouTube embeds (#51)
A YouTube link keeps its place in the bubble, but now grows a 16:9 click-to-play card below: thumbnail + play button, swapped for the real player only on tap. The embed uses youtube-nocookie.com (reduced tracking) with rel=0; nothing loads from YouTube beyond the thumbnail unless someone chooses to play. Supports watch/youtu.be/shorts/live/ embed URLs and t=/start= offsets (1h2m3s forms included). Privacy note: these cards are built purely from the URL client-side and skip /api/unfurl, so the server never sees YouTube URLs at all — a small improvement over the OG-card path. A dead/private video (YouTube's grey placeholder thumbnail) collapses back to just the link. youtubeId() is a pure, unit-tested helper in format.js; the id/start are validated (11-char alphabet, integer seconds) so the embed URL is safe by construction.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+156
−3
test/format.test.mjs+27 −1web/static/chat.js+53 −2web/static/format.js+37 −0web/static/style.css+39 −0
diff --git a/test/format.test.mjs b/test/format.test.mjs| index f639bfa..c8b8eaa 100644 |
| --- a/test/format.test.mjs |
| +++ b/test/format.test.mjs |
| @@ -1,6 +1,6 @@ |
| // Tests for text formatting (link detection, file sizes). node test/format.test.mjs |
| import assert from "node:assert/strict"; |
| -import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle } from "../web/static/format.js"; |
| +import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, youtubeId } from "../web/static/format.js"; |
| import { emojify, emojiMatches } from "../web/static/emoji.js"; |
| let failures = 0; |
| @@ -328,5 +328,31 @@ await test("emojiMatches de-dupes aliases to the same glyph", () => { |
| assert.equal(new Set(glyphs).size, glyphs.length); // no repeated emoji |
| }); |
| +await test("youtubeId parses the common URL shapes", () => { |
| + assert.deepEqual(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| + assert.deepEqual(youtubeId("https://youtu.be/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| + assert.deepEqual(youtubeId("https://m.youtube.com/watch?v=dQw4w9WgXcQ&pp=xyz"), { id: "dQw4w9WgXcQ", start: 0 }); |
| + assert.deepEqual(youtubeId("https://www.youtube.com/shorts/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| + assert.deepEqual(youtubeId("https://www.youtube.com/live/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| + assert.deepEqual(youtubeId("https://www.youtube.com/embed/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| +}); |
| + |
| +await test("youtubeId parses start offsets", () => { |
| + assert.equal(youtubeId("https://youtu.be/dQw4w9WgXcQ?t=90").start, 90); |
| + assert.equal(youtubeId("https://youtu.be/dQw4w9WgXcQ?t=90s").start, 90); |
| + assert.equal(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s").start, 3723); |
| + assert.equal(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45").start, 45); |
| +}); |
| + |
| +await test("youtubeId rejects non-video and lookalike URLs", () => { |
| + assert.equal(youtubeId("https://www.youtube.com/@somechannel"), null); |
| + assert.equal(youtubeId("https://www.youtube.com/playlist?list=PLx"), null); |
| + assert.equal(youtubeId("https://notyoutube.com/watch?v=dQw4w9WgXcQ"), null); |
| + assert.equal(youtubeId("https://evil.com/youtube.com/watch?v=dQw4w9WgXcQ"), null); |
| + assert.equal(youtubeId("https://www.youtube.com/watch?v=too-short"), null); |
| + assert.equal(youtubeId("not a url"), null); |
| + assert.equal(youtubeId("ftp://youtu.be/dQw4w9WgXcQ"), null); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/chat.js b/web/static/chat.js| index ca93325..39efa4f 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -29,7 +29,7 @@ import { |
| colorScheme, |
| composerToken, |
| } from "/static/ui.js"; |
| -import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount } from "/static/format.js"; |
| +import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| import { MESSAGE_TYPE_TYPING } from "/static/message.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| @@ -227,6 +227,10 @@ const ICON_TRASH = |
| 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + |
| '<polyline points="3 6 5 6 21 6"></polyline>' + |
| '<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>'; |
| +// Filled play triangle, for the click-to-play YouTube card. |
| +const ICON_PLAY = |
| + '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + |
| + '<path d="M8 5.5v13a1 1 0 0 0 1.53.85l10.2-6.5a1 1 0 0 0 0-1.7L9.53 4.65A1 1 0 0 0 8 5.5z"></path></svg>'; |
| // Overlapping pages, for the "Copy" action in the mobile long-press menu. |
| const ICON_COPY = |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' + |
| @@ -1427,7 +1431,11 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache |
| renderText(bubble, data.text); |
| content.appendChild(bubble); |
| const link = firstLink(data.text); |
| - if (link) addLinkPreview(content, link); |
| + // A YouTube link gets a click-to-play video card instead of the OG unfurl — |
| + // built purely from the URL, so the server never sees it (unlike unfurl). |
| + const yt = link && youtubeId(link); |
| + if (yt) content.appendChild(buildYouTubeCard(yt)); |
| + else if (link) addLinkPreview(content, link); |
| } |
| // Reply/edit affordances: desktop hover icons (reply on any message; edit on |
| // your own in-window ones), touch swipe-right to reply. The press-hold / |
| @@ -1495,6 +1503,49 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache |
| } |
| } |
| +// A click-to-play YouTube card: thumbnail + play button, swapped for the real |
| +// player only on tap — so nothing loads from YouTube (beyond the thumbnail |
| +// image) unless someone chooses to play. The embed uses youtube-nocookie.com |
| +// (YouTube's reduced-tracking domain) with rel=0 (related videos limited to the |
| +// same channel). yt is youtubeId()'s validated { id, start } — an 11-char id |
| +// and integer seconds — so the URL is safe by construction. |
| +function buildYouTubeCard(yt) { |
| + const card = document.createElement("div"); |
| + card.className = "yt-card"; |
| + const btn = document.createElement("button"); |
| + btn.type = "button"; |
| + btn.className = "yt-poster"; |
| + btn.setAttribute("aria-label", "Play video"); |
| + const img = document.createElement("img"); |
| + img.className = "yt-thumb"; |
| + img.alt = ""; |
| + img.loading = "lazy"; |
| + img.src = `https://i.ytimg.com/vi/${yt.id}/hqdefault.jpg`; |
| + // A dead/private video serves a grey 120px placeholder; drop to a plain card. |
| + img.onload = () => { |
| + if (img.naturalWidth <= 120) card.classList.add("yt-bare"); |
| + }; |
| + img.onerror = () => card.classList.add("yt-bare"); |
| + const play = document.createElement("span"); |
| + play.className = "yt-play"; |
| + play.innerHTML = ICON_PLAY; // static in-code SVG (safe) |
| + btn.appendChild(img); |
| + btn.appendChild(play); |
| + btn.onclick = () => { |
| + const frame = document.createElement("iframe"); |
| + frame.className = "yt-frame"; |
| + frame.src = |
| + `https://www.youtube-nocookie.com/embed/${yt.id}?autoplay=1&rel=0` + |
| + (yt.start ? `&start=${yt.start}` : ""); |
| + frame.allow = "autoplay; encrypted-media; fullscreen; picture-in-picture"; |
| + frame.allowFullscreen = true; |
| + frame.title = "YouTube video"; |
| + btn.replaceWith(frame); |
| + }; |
| + card.appendChild(btn); |
| + return card; |
| +} |
| + |
| // Fetch an OpenGraph preview for a URL and append a card below the bubble. The |
| // server briefly sees the URL to build the card (the one documented exception); |
| // the card image loads from its origin (no proxy). |
diff --git a/web/static/format.js b/web/static/format.js| index 06867ae..3a07e94 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -177,6 +177,43 @@ export function firstLink(text) { |
| return null; |
| } |
| +// Parse a YouTube URL into { id, start } (start in seconds, 0 if none), or null |
| +// for anything that isn't a YouTube video link. Covers the shapes people paste: |
| +// watch?v=, youtu.be/, /shorts/, /live/, /embed/, with t= / start= offsets |
| +// (plain seconds, "90s", or "1h2m3s"). Pure so chat.js's click-to-play embed |
| +// card stays unit-testable; the 11-char id alphabet is YouTube's own. |
| +const YT_ID = /^[A-Za-z0-9_-]{11}$/; |
| +export function youtubeId(url) { |
| + let u; |
| + try { |
| + u = new URL(url); |
| + } catch { |
| + return null; |
| + } |
| + if (!/^https?:$/.test(u.protocol)) return null; |
| + const host = u.hostname.replace(/^www\.|^m\./, ""); |
| + let id = ""; |
| + if (host === "youtu.be") { |
| + id = u.pathname.split("/")[1] || ""; |
| + } else if (host === "youtube.com" || host === "youtube-nocookie.com" || host === "music.youtube.com") { |
| + const seg = u.pathname.split("/").filter(Boolean); |
| + if (u.pathname === "/watch") id = u.searchParams.get("v") || ""; |
| + else if (["shorts", "live", "embed", "v"].includes(seg[0])) id = seg[1] || ""; |
| + } else { |
| + return null; |
| + } |
| + if (!YT_ID.test(id)) return null; |
| + // t=90, t=90s, t=1h2m3s, start=90 — normalise to whole seconds. |
| + const t = u.searchParams.get("t") || u.searchParams.get("start") || ""; |
| + let start = 0; |
| + if (/^\d+s?$/.test(t)) start = parseInt(t, 10); |
| + else { |
| + const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(t); |
| + if (m && (m[1] || m[2] || m[3])) start = (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0); |
| + } |
| + return { id, start }; |
| +} |
| + |
| // Build a short, plain-text preview of a message for a reply quote. Collapses |
| // whitespace and truncates long text; when a message has no text (e.g. a photo |
| // only), falls back to a label for its first attachment so the quote still says |
diff --git a/web/static/style.css b/web/static/style.css| index bc442d1..b1e6ed9 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -517,6 +517,45 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .file-chip .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .file-chip .file-sub { font-size: 12px; opacity: 0.7; } |
| +/* click-to-play YouTube card (see buildYouTubeCard) — 16:9, bubble-styled */ |
| +.yt-card { |
| + width: min(420px, 100%); |
| + aspect-ratio: 16 / 9; |
| + border-radius: var(--radius); |
| + overflow: hidden; |
| + background: #000; |
| + border: 1px solid var(--border); |
| +} |
| +.yt-card.yt-bare { display: none; } /* dead/private video → no card, just the link */ |
| +.yt-poster { |
| + position: relative; |
| + display: block; |
| + width: 100%; |
| + height: 100%; |
| + padding: 0; |
| + border-radius: 0; |
| + background: #000; |
| +} |
| +.yt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; } |
| +.yt-play { |
| + position: absolute; |
| + inset: 0; |
| + display: grid; |
| + place-items: center; |
| + color: #fff; |
| +} |
| +.yt-play svg { |
| + width: 56px; |
| + height: 56px; |
| + padding: 10px 8px 10px 12px; |
| + box-sizing: border-box; |
| + border-radius: 50%; |
| + background: rgba(0, 0, 0, 0.55); |
| + transition: transform 0.15s ease, background 0.15s ease; |
| +} |
| +.yt-poster:hover .yt-play svg { background: rgba(0, 0, 0, 0.75); transform: scale(1.06); } |
| +.yt-frame { width: 100%; height: 100%; border: 0; display: block; } |
| + |
| /* link (OpenGraph) preview card */ |
| .link-preview { |
| display: flex; |