mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-22 01:02:01 -07:00
Implement YouTube embed rendering in recent posts feed
By coder.
This commit is contained in:
@@ -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: '' })
|
||||
})
|
||||
Reference in New Issue
Block a user