mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
youtube embeds
This commit is contained in:
@@ -15,11 +15,13 @@
|
||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||
import { extractYouTubeVideoIds } from '$lib/util/youtube'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
import RichText from '../common/RichText.svelte'
|
||||
import Attachments from './Attachments.svelte'
|
||||
import PollView from './PollView.svelte'
|
||||
import PreviewCardView from './PreviewCardView.svelte'
|
||||
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
|
||||
|
||||
interface Props {
|
||||
status: Status
|
||||
@@ -45,6 +47,7 @@
|
||||
const handle = $derived(fullHandle(author, session.host))
|
||||
const permalink = $derived(`#/blog/${entry.id}`)
|
||||
const isMine = $derived(session.me?.id === entry.account.id)
|
||||
const youtubeVideoIds = $derived(extractYouTubeVideoIds(entry.content))
|
||||
|
||||
let busy = $state(false)
|
||||
let actionError = $state<string | null>(null)
|
||||
@@ -188,6 +191,7 @@
|
||||
{#if entry.media_attachments.length > 0}
|
||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||
</details>
|
||||
{:else}
|
||||
<RichText
|
||||
@@ -200,13 +204,14 @@
|
||||
{#if entry.media_attachments.length > 0}
|
||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
|
||||
{#if entry.poll}
|
||||
<PollView poll={entry.poll} />
|
||||
{/if}
|
||||
|
||||
{#if entry.card && entry.media_attachments.length === 0}
|
||||
{#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
|
||||
<PreviewCardView card={entry.card} />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { fireEvent, render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { status, testServices } from '$test/fixtures'
|
||||
import BlogEntry from './BlogEntry.svelte'
|
||||
|
||||
function renderEntry(overrides: Parameters<typeof status>[0]) {
|
||||
return render(BlogEntry, {
|
||||
props: { status: status(overrides) },
|
||||
context: new Map([[APP_SERVICES, testServices()]]),
|
||||
})
|
||||
}
|
||||
|
||||
describe('BlogEntry YouTube embeds', () => {
|
||||
it('turns a YouTube note link into an attachment-style embed', () => {
|
||||
const view = renderEntry({
|
||||
content:
|
||||
'<p>Watch this: <a href="https://www.youtube.com/watch?v=pyNCigSulNs">https://www.youtube.com/watch?v=pyNCigSulNs</a></p>',
|
||||
card: {
|
||||
url: 'https://www.youtube.com/watch?v=pyNCigSulNs',
|
||||
title: 'Server-generated YouTube preview',
|
||||
description: 'This should be replaced by the player.',
|
||||
type: 'video',
|
||||
},
|
||||
})
|
||||
|
||||
const frame = view.getByTitle('YouTube video')
|
||||
expect(frame).toHaveAttribute(
|
||||
'src',
|
||||
'https://www.youtube-nocookie.com/embed/pyNCigSulNs',
|
||||
)
|
||||
expect(frame.closest('.attachment')).toHaveAttribute('data-type', 'youtube')
|
||||
expect(view.getByRole('link', { name: 'Watch on YouTube' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.youtube.com/watch?v=pyNCigSulNs',
|
||||
)
|
||||
expect(view.queryByText('Server-generated YouTube preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not load a sensitive player before the reader reveals it', async () => {
|
||||
const view = renderEntry({
|
||||
content: '<p><a href="https://youtu.be/pyNCigSulNs">video</a></p>',
|
||||
sensitive: true,
|
||||
})
|
||||
|
||||
expect(view.queryByTitle('YouTube video')).not.toBeInTheDocument()
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Show sensitive video' }))
|
||||
expect(view.getByTitle('YouTube video')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
/** Privacy-enhanced YouTube players presented in the normal attachment grid. */
|
||||
import { youtubeEmbedUrl, youtubeVideoId, youtubeWatchUrl } from '$lib/util/youtube'
|
||||
|
||||
interface Props {
|
||||
videoIds: string[]
|
||||
sensitive?: boolean
|
||||
}
|
||||
|
||||
let { videoIds, sensitive = false }: Props = $props()
|
||||
let revealed = $state<Record<string, boolean>>({})
|
||||
|
||||
const validVideoIds = $derived(
|
||||
[...new Set(videoIds.filter((id) => youtubeVideoId(youtubeWatchUrl(id)) === id))].slice(0, 4),
|
||||
)
|
||||
|
||||
function isRevealed(id: string): boolean {
|
||||
return !sensitive || revealed[id] === true
|
||||
}
|
||||
|
||||
function toggle(id: string): void {
|
||||
revealed = { ...revealed, [id]: !revealed[id] }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if validVideoIds.length > 0}
|
||||
<ul class="attachment-list youtube-attachment-list" aria-label="YouTube videos">
|
||||
{#each validVideoIds as videoId, index (videoId)}
|
||||
<li
|
||||
class="attachment youtube-attachment"
|
||||
data-type="youtube"
|
||||
data-sensitive={sensitive ? 'true' : 'false'}
|
||||
data-revealed={isRevealed(videoId) ? 'true' : 'false'}
|
||||
>
|
||||
<figure class="attachment-figure">
|
||||
<div class="youtube-embed">
|
||||
{#if isRevealed(videoId)}
|
||||
<iframe
|
||||
src={youtubeEmbedUrl(videoId)}
|
||||
title={validVideoIds.length === 1 ? 'YouTube video' : `YouTube video ${index + 1}`}
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
{:else}
|
||||
<div class="youtube-embed-placeholder">Sensitive YouTube video</div>
|
||||
{/if}
|
||||
</div>
|
||||
<figcaption class="attachment-caption">
|
||||
<a href={youtubeWatchUrl(videoId)} target="_blank" rel="noopener noreferrer">
|
||||
Watch on YouTube
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
{#if sensitive}
|
||||
<button type="button" class="button button--small attachment-reveal" onclick={() => toggle(videoId)}>
|
||||
{isRevealed(videoId) ? 'Hide' : 'Show'} sensitive video
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractYouTubeVideoIds, youtubeVideoId } from './youtube'
|
||||
|
||||
describe('youtubeVideoId', () => {
|
||||
it.each([
|
||||
['https://www.youtube.com/watch?v=pyNCigSulNs', 'pyNCigSulNs'],
|
||||
['https://youtu.be/pyNCigSulNs?t=42', 'pyNCigSulNs'],
|
||||
['https://m.youtube.com/shorts/pyNCigSulNs', 'pyNCigSulNs'],
|
||||
['https://www.youtube.com/live/pyNCigSulNs', 'pyNCigSulNs'],
|
||||
['https://www.youtube-nocookie.com/embed/pyNCigSulNs', 'pyNCigSulNs'],
|
||||
])('recognizes %s', (url, expected) => {
|
||||
expect(youtubeVideoId(url)).toBe(expected)
|
||||
})
|
||||
|
||||
it('rejects lookalike hosts and malformed IDs', () => {
|
||||
expect(youtubeVideoId('https://youtube.com.example.test/watch?v=pyNCigSulNs')).toBeNull()
|
||||
expect(youtubeVideoId('https://www.youtube.com/watch?v=too-short')).toBeNull()
|
||||
expect(youtubeVideoId('https://youtu.be/%invalid-id')).toBeNull()
|
||||
expect(youtubeVideoId('javascript:alert(1)')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractYouTubeVideoIds', () => {
|
||||
it('finds and de-duplicates linked and plain-text YouTube URLs', () => {
|
||||
const html = `
|
||||
<p>
|
||||
<a href="https://www.youtube.com/watch?v=pyNCigSulNs&t=12">first</a>
|
||||
https://youtu.be/pyNCigSulNs
|
||||
<a href="https://youtu.be/dQw4w9WgXcQ">second</a>
|
||||
</p>
|
||||
`
|
||||
|
||||
expect(extractYouTubeVideoIds(html)).toEqual(['pyNCigSulNs', 'dQw4w9WgXcQ'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* YouTube links in status HTML.
|
||||
*
|
||||
* Mastodon and Pleroma normally linkify URLs before returning a status, but
|
||||
* scanning visible text as well keeps this useful with older or unusual
|
||||
* servers. Only a validated eleven-character video ID leaves this module.
|
||||
*/
|
||||
|
||||
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
|
||||
const YOUTUBE_HOSTS = new Set(['youtube.com', 'm.youtube.com', 'music.youtube.com'])
|
||||
const EMBED_HOSTS = new Set(['youtube-nocookie.com'])
|
||||
|
||||
function decodeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/&#(?:39|x27);/gi, "'")
|
||||
}
|
||||
|
||||
function validVideoId(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
const decoded = decodeURIComponent(value)
|
||||
return VIDEO_ID_PATTERN.test(decoded) ? decoded : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function youtubeVideoId(value: string): string | null {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(decodeAttribute(value).replace(/[),.;!?]+$/, ''))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
||||
|
||||
const host = url.hostname.toLowerCase().replace(/^www\./, '')
|
||||
if (host === 'youtu.be') return validVideoId(url.pathname.split('/')[1])
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
if (YOUTUBE_HOSTS.has(host)) {
|
||||
if (segments[0] === 'watch') return validVideoId(url.searchParams.get('v'))
|
||||
if (['embed', 'live', 'shorts'].includes(segments[0])) return validVideoId(segments[1])
|
||||
}
|
||||
|
||||
if (EMBED_HOSTS.has(host) && segments[0] === 'embed') {
|
||||
return validVideoId(segments[1])
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractYouTubeVideoIds(html: string | null | undefined): string[] {
|
||||
if (!html) return []
|
||||
|
||||
const candidates: string[] = []
|
||||
const hrefPattern = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi
|
||||
const urlPattern =
|
||||
/https?:\/\/(?:(?:www|m|music)\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)\/[^\s<>"']+/gi
|
||||
|
||||
for (const match of html.matchAll(hrefPattern)) {
|
||||
candidates.push(match[1] ?? match[2] ?? match[3])
|
||||
}
|
||||
candidates.push(...html.match(urlPattern) ?? [])
|
||||
|
||||
const ids: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const candidate of candidates) {
|
||||
const id = youtubeVideoId(candidate)
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id)
|
||||
ids.push(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function youtubeWatchUrl(videoId: string): string {
|
||||
return `https://www.youtube.com/watch?v=${videoId}`
|
||||
}
|
||||
|
||||
export function youtubeEmbedUrl(videoId: string): string {
|
||||
return `https://www.youtube-nocookie.com/embed/${videoId}`
|
||||
}
|
||||
@@ -61,6 +61,7 @@
|
||||
['.blog-entry[data-mine="true"]', 'Entries you wrote'],
|
||||
['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
|
||||
['.blog-entry[data-boosted="true"]', 'Reposts'],
|
||||
['.youtube-attachment, .youtube-embed', 'YouTube embeds'],
|
||||
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons you’ve activated'],
|
||||
['.comment[data-depth="2"]', 'Comments by nesting depth'],
|
||||
],
|
||||
|
||||
@@ -173,6 +173,36 @@
|
||||
font-size: var(--ms-font-size-small);
|
||||
}
|
||||
|
||||
/* YouTube links become privacy-enhanced players in the attachment space. */
|
||||
.youtube-attachment-list {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
||||
}
|
||||
|
||||
.youtube-embed {
|
||||
display: grid;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.youtube-embed iframe,
|
||||
.youtube-embed-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
grid-area: 1 / 1;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.youtube-embed-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
color: #fff;
|
||||
background: #222;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- link preview */
|
||||
|
||||
.preview-card {
|
||||
|
||||
Reference in New Issue
Block a user