Implement YouTube embed rendering in recent posts feed

By coder.
This commit is contained in:
Chris Troutner
2026-09-04 08:50:28 -07:00
parent 146685434e
commit 8e606aecb1
7 changed files with 362 additions and 2 deletions
@@ -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('<iframe'))
if (found) {
throw new Error('Feed unexpectedly shows an embedded video player.')
}
}
}
]
// Return the cached rendered feed HTML, computing it on first use.
function getRenderedFeed (world) {
if (!world.renderedFeed) {
world.renderedFeed = world.recentFeedPage.posts.map((post) => 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)
@@ -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 }
@@ -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
@@ -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}
<PostContent text={post.text} />
</p>
</div>
@@ -661,4 +661,30 @@
.like-tip-modal-error {
margin: 0;
font-size: 0.95rem;
}
}
.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;
}
@@ -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
}
@@ -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: '' })
})