eleven / messenger Public

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>
Paul Campbell b778aac70a0a3129fd8376d89f3ae1d420a1e123 parent a1e2922
4 files changed, +156 −3
  • test/format.test.mjs +27 −1
  • web/static/chat.js +53 −2
  • web/static/format.js +37 −0
  • web/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;