From 8e606aecb11523af07d7eea2955a5aee356aec97 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 08:50:28 -0700 Subject: [PATCH] Implement YouTube embed rendering in recent posts feed By coder. --- psf-memo-client/acceptance/lib/handlers.js | 69 +++++++++++ psf-memo-client/acceptance/lib/render-post.js | 20 ++++ .../src/components/post-feed/post-content.js | 41 +++++++ .../components/post-feed/post-feed-item.js | 3 +- .../src/components/post-feed/post-feed.css | 28 ++++- psf-memo-client/src/services/youtube-embed.js | 90 ++++++++++++++ .../test/unit/youtube-embed.test.js | 113 ++++++++++++++++++ 7 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 psf-memo-client/acceptance/lib/render-post.js create mode 100644 psf-memo-client/src/components/post-feed/post-content.js create mode 100644 psf-memo-client/src/services/youtube-embed.js create mode 100644 psf-memo-client/test/unit/youtube-embed.test.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 6661b23..b0dc458 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -51,6 +51,8 @@ const MemoPollOption = require('../../src/services/memo-poll-option') const PollOptionPage = require('../../src/services/poll-option-page') const MemoPollVote = require('../../src/services/memo-poll-vote') const PollVotePage = require('../../src/services/poll-vote-page') +const { renderPostText } = require('./render-post') +const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX @@ -1419,6 +1421,7 @@ const handlers = [ async run (m, example, world) { await world.recentFeedPage.load() world.currentPath = RecentFeedPage.RECENT_FEED_PATH + world.renderedFeed = world.recentFeedPage.posts.map((post) => renderPostText(post.text)) } }, { @@ -2627,9 +2630,75 @@ const handlers = [ throw new Error('Expected notifications page to show the no-notifications message.') } } + }, + { + name: 'API serves post with address and text', + pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with text (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const addr = resolveParam(m[2], example) + const text = resolveText(m[3], example) + world.memoDb.addPost({ txid, addr, text, blockHeight: 100 }) + } + }, + { + name: 'feed shows embedded YouTube player', + pattern: /^the feed shows an embedded YouTube player for the video (.+)$/, + run (m, example, world) { + const videoId = resolveParam(m[1], example) + const rendered = getRenderedFeed(world) + const needle = `${YOUTUBE_EMBED_BASE_URL}/${videoId}` + const found = rendered.some((html) => html.includes(needle)) + if (!found) { + throw new Error(`Feed does not show an embedded YouTube player for ${videoId}.`) + } + } + }, + { + name: 'feed does not show raw URL', + pattern: /^the feed does not show the raw URL (.+)$/, + run (m, example, world) { + const url = resolveText(m[1], example) + const rendered = getRenderedFeed(world) + const found = rendered.some((html) => html.includes(url)) + if (found) { + throw new Error(`Feed unexpectedly shows the raw URL ${url}.`) + } + } + }, + { + name: 'feed shows text', + pattern: /^the feed shows the text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const rendered = getRenderedFeed(world) + const found = rendered.some((html) => html.replace(/<[^\u003e]+>/g, '').includes(expected)) + if (!found) { + throw new Error(`Feed does not show the text "${expected}".`) + } + } + }, + { + name: 'feed does not show embedded video player', + pattern: /^the feed does not show an embedded video player$/, + run (m, example, world) { + const rendered = getRenderedFeed(world) + const found = rendered.some((html) => html.includes(' renderPostText(post.text)) + } + return world.renderedFeed +} + // Decode a raw create-poll payload into poll_type, option_count, and question. function decodeCreatePollPayload (raw) { const buf = Buffer.from(raw) diff --git a/psf-memo-client/acceptance/lib/render-post.js b/psf-memo-client/acceptance/lib/render-post.js new file mode 100644 index 0000000..7949633 --- /dev/null +++ b/psf-memo-client/acceptance/lib/render-post.js @@ -0,0 +1,20 @@ +/* + Acceptance rendering adapter for post text. + + Renders the same PostContent component the browser uses to a static HTML + string, so acceptance assertions can inspect the resulting embed/player + markup without running a browser. +*/ + +'use strict' + +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const PostContent = require('../../src/components/post-feed/post-content') + +function renderPostText (text) { + const element = React.createElement(PostContent, { text }) + return ReactDOMServer.renderToStaticMarkup(element) +} + +module.exports = { renderPostText } diff --git a/psf-memo-client/src/components/post-feed/post-content.js b/psf-memo-client/src/components/post-feed/post-content.js new file mode 100644 index 0000000..2c881d5 --- /dev/null +++ b/psf-memo-client/src/components/post-feed/post-content.js @@ -0,0 +1,41 @@ +/* + Render Memo post text, embedding YouTube videos inline when present. + + Written in plain React.createElement style so the same module can be used + both by the JSX components in the browser build and by the acceptance + adapter that renders HTML under Node. +*/ + +const React = require('react') +const { + parsePostText, + YOUTUBE_EMBED_BASE_URL +} = require('../../services/youtube-embed') + +function PostContent ({ text = '' }) { + const segments = parsePostText(text) + const children = segments.map((segment, index) => { + if (segment.type === 'youtube') { + return React.createElement( + 'div', + { + key: index, + className: 'posts-feed-item-youtube' + }, + React.createElement('iframe', { + src: `${YOUTUBE_EMBED_BASE_URL}/${segment.videoId}`, + title: `YouTube video ${segment.videoId}`, + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', + referrerPolicy: 'strict-origin-when-cross-origin', + allowFullScreen: true, + frameBorder: '0' + }) + ) + } + return React.createElement('span', { key: index }, segment.text) + }) + + return React.createElement(React.Fragment, null, ...children) +} + +module.exports = PostContent diff --git a/psf-memo-client/src/components/post-feed/post-feed-item.js b/psf-memo-client/src/components/post-feed/post-feed-item.js index 32be55d..7e5670c 100644 --- a/psf-memo-client/src/components/post-feed/post-feed-item.js +++ b/psf-memo-client/src/components/post-feed/post-feed-item.js @@ -15,6 +15,7 @@ import { getDisplayName, truncateTxid } from './post-display' +import PostContent from './post-content' import './post-feed.css' const appUtil = new AppUtil() @@ -137,7 +138,7 @@ function PostFeedItem ({ {' '} - {post.text} +

diff --git a/psf-memo-client/src/components/post-feed/post-feed.css b/psf-memo-client/src/components/post-feed/post-feed.css index 96009ea..438c319 100644 --- a/psf-memo-client/src/components/post-feed/post-feed.css +++ b/psf-memo-client/src/components/post-feed/post-feed.css @@ -661,4 +661,30 @@ .like-tip-modal-error { margin: 0; font-size: 0.95rem; -} \ No newline at end of file +} + +.posts-feed-item-youtube { + position: relative; + width: 100%; + max-width: 100%; + margin-top: 12px; + padding-bottom: 56.25%; + height: 0; + overflow: hidden; + border-radius: var(--ig-radius); + background: #000; +} + +.posts-feed-item-youtube iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; + border-radius: var(--ig-radius); +} + +.posts-feed-item-embedded .posts-feed-item-youtube { + margin-top: 10px; +} diff --git a/psf-memo-client/src/services/youtube-embed.js b/psf-memo-client/src/services/youtube-embed.js new file mode 100644 index 0000000..60162b3 --- /dev/null +++ b/psf-memo-client/src/services/youtube-embed.js @@ -0,0 +1,90 @@ +/* + Pure helpers for turning YouTube URLs in Memo post text into embed segments. + + These functions have no React or network dependencies, so they can be unit + tested directly and reused by the UI and acceptance adapters. +*/ + +const YOUTUBE_EMBED_BASE_URL = 'https://www.youtube.com/embed' + +// Strip trailing punctuation that is never part of a YouTube video id. +const TRAILING_PUNCTUATION_RE = /[.,;:!?)\]]+$/ + +/** + * Extract a YouTube video id from a URL string, or return null if the URL is + * not a recognisable, complete YouTube watch or short link. + */ +function extractYouTubeVideoId (url) { + if (typeof url !== 'string') return null + const candidate = url.trim().replace(TRAILING_PUNCTUATION_RE, '') + + let parsed + try { + parsed = new URL(candidate) + } catch { + return null + } + + const host = parsed.hostname.replace(/^www\./, '') + + if (host === 'youtube.com' && parsed.pathname === '/watch') { + const id = parsed.searchParams.get('v') + if (id && /^[A-Za-z0-9_-]+$/.test(id)) return id + } + + if (host === 'youtu.be') { + const id = parsed.pathname.slice(1) + if (id && /^[A-Za-z0-9_-]+$/.test(id)) return id + } + + return null +} + +const URL_RE = /(https?:\/\/[^\s]+)/g + +/** + * Split a post's text into segments. Each segment is either a plain text + * fragment ({ type: 'text', text }) or a YouTube embed reference + * ({ type: 'youtube', videoId, url }). + */ +function parsePostText (text) { + const input = String(text ?? '') + const segments = [] + let lastIndex = 0 + let match + + while ((match = URL_RE.exec(input)) !== null) { + const matchedUrl = match[1] + const url = matchedUrl.replace(TRAILING_PUNCTUATION_RE, '') + const trailing = matchedUrl.slice(url.length) + + const leading = input.slice(lastIndex, match.index) + if (leading) segments.push({ type: 'text', text: leading }) + + const videoId = extractYouTubeVideoId(url) + if (videoId) { + segments.push({ type: 'youtube', videoId, url }) + } else { + segments.push({ type: 'text', text: url }) + } + + if (trailing) segments.push({ type: 'text', text: trailing }) + + lastIndex = match.index + matchedUrl.length + } + + const trailing = input.slice(lastIndex) + if (trailing) segments.push({ type: 'text', text: trailing }) + + if (segments.length === 0) { + segments.push({ type: 'text', text: input }) + } + + return segments +} + +module.exports = { + YOUTUBE_EMBED_BASE_URL, + extractYouTubeVideoId, + parsePostText +} diff --git a/psf-memo-client/test/unit/youtube-embed.test.js b/psf-memo-client/test/unit/youtube-embed.test.js new file mode 100644 index 0000000..94358de --- /dev/null +++ b/psf-memo-client/test/unit/youtube-embed.test.js @@ -0,0 +1,113 @@ +/* + Unit tests for the YouTube embed parser. + + The parser turns a post's raw text into text segments and YouTube embed + segments. It must recognize watch and short YouTube URLs, ignore other + URLs and malformed YouTube URLs, and preserve the surrounding text. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { + extractYouTubeVideoId, + parsePostText +} = require('../../src/services/youtube-embed') + +const WATCH_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' +const SHORT_URL = 'https://youtu.be/dQw4w9WgXcQ' +const VIDEO_ID = 'dQw4w9WgXcQ' + +test('extractYouTubeVideoId returns the video id for a watch URL', () => { + assert.equal(extractYouTubeVideoId(WATCH_URL), VIDEO_ID) +}) + +test('extractYouTubeVideoId returns the video id for a short URL', () => { + assert.equal(extractYouTubeVideoId(SHORT_URL), VIDEO_ID) +}) + +test('extractYouTubeVideoId returns null for a non-YouTube URL', () => { + assert.equal(extractYouTubeVideoId('https://example.com/video'), null) +}) + +test('extractYouTubeVideoId returns null when the watch URL has no v value', () => { + assert.equal(extractYouTubeVideoId('https://www.youtube.com/watch?v='), null) +}) + +test('extractYouTubeVideoId returns null when the short URL has no path id', () => { + assert.equal(extractYouTubeVideoId('https://youtu.be/'), null) +}) + +test('parsePostText returns a single youtube segment for a bare watch URL', () => { + const segments = parsePostText(WATCH_URL) + assert.equal(segments.length, 1) + assert.deepEqual(segments[0], { type: 'youtube', videoId: VIDEO_ID, url: WATCH_URL }) +}) + +test('parsePostText returns a single youtube segment for a bare short URL', () => { + const segments = parsePostText(SHORT_URL) + assert.equal(segments.length, 1) + assert.deepEqual(segments[0], { type: 'youtube', videoId: VIDEO_ID, url: SHORT_URL }) +}) + +test('parsePostText preserves surrounding text and replaces the URL with an embed segment', () => { + const segments = parsePostText(`check this out ${WATCH_URL}`) + assert.equal(segments.length, 2) + assert.deepEqual(segments[0], { type: 'text', text: 'check this out ' }) + assert.deepEqual(segments[1], { type: 'youtube', videoId: VIDEO_ID, url: WATCH_URL }) +}) + +test('parsePostText returns plain text when no embeddable YouTube link is present', () => { + const text = 'just a normal memo' + const segments = parsePostText(text) + assert.equal(segments.length, 1) + assert.deepEqual(segments[0], { type: 'text', text }) +}) + +test('parsePostText keeps a non-YouTube URL as plain text', () => { + const text = 'visit https://example.com for details' + const segments = parsePostText(text) + assert.equal(segments.length, 3) + assert.deepEqual(segments[0], { type: 'text', text: 'visit ' }) + assert.deepEqual(segments[1], { type: 'text', text: 'https://example.com' }) + assert.deepEqual(segments[2], { type: 'text', text: ' for details' }) +}) + +test('parsePostText keeps an invalid YouTube URL as plain text', () => { + const text = 'broken https://www.youtube.com/watch?v= link' + const segments = parsePostText(text) + assert.equal(segments.length, 3) + assert.deepEqual(segments[0], { type: 'text', text: 'broken ' }) + assert.deepEqual(segments[1], { type: 'text', text: 'https://www.youtube.com/watch?v=' }) + assert.deepEqual(segments[2], { type: 'text', text: ' link' }) +}) + +test('parsePostText preserves text that follows an embedded URL', () => { + const segments = parsePostText(`${SHORT_URL} enjoy`) + assert.equal(segments.length, 2) + assert.deepEqual(segments[0], { type: 'youtube', videoId: VIDEO_ID, url: SHORT_URL }) + assert.deepEqual(segments[1], { type: 'text', text: ' enjoy' }) +}) + +test('parsePostText handles multiple YouTube links in one post', () => { + const segments = parsePostText(`${WATCH_URL} and ${SHORT_URL}`) + assert.equal(segments.length, 3) + assert.deepEqual(segments[0], { type: 'youtube', videoId: VIDEO_ID, url: WATCH_URL }) + assert.deepEqual(segments[1], { type: 'text', text: ' and ' }) + assert.deepEqual(segments[2], { type: 'youtube', videoId: VIDEO_ID, url: SHORT_URL }) +}) + +test('parsePostText drops trailing punctuation from the URL when extracting the id', () => { + const segments = parsePostText(`watch ${WATCH_URL}.`) + assert.equal(segments.length, 3) + assert.deepEqual(segments[0], { type: 'text', text: 'watch ' }) + assert.deepEqual(segments[1], { type: 'youtube', videoId: VIDEO_ID, url: WATCH_URL }) + assert.deepEqual(segments[2], { type: 'text', text: '.' }) +}) + +test('parsePostText treats an empty string as a single empty text segment', () => { + const segments = parsePostText('') + assert.equal(segments.length, 1) + assert.deepEqual(segments[0], { type: 'text', text: '' }) +})