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 { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||||
import { renderDisplayName } from '$lib/util/html'
|
import { renderDisplayName } from '$lib/util/html'
|
||||||
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||||
|
import { extractYouTubeVideoIds } from '$lib/util/youtube'
|
||||||
import Avatar from '../common/Avatar.svelte'
|
import Avatar from '../common/Avatar.svelte'
|
||||||
import RichText from '../common/RichText.svelte'
|
import RichText from '../common/RichText.svelte'
|
||||||
import Attachments from './Attachments.svelte'
|
import Attachments from './Attachments.svelte'
|
||||||
import PollView from './PollView.svelte'
|
import PollView from './PollView.svelte'
|
||||||
import PreviewCardView from './PreviewCardView.svelte'
|
import PreviewCardView from './PreviewCardView.svelte'
|
||||||
|
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: Status
|
status: Status
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
const handle = $derived(fullHandle(author, session.host))
|
const handle = $derived(fullHandle(author, session.host))
|
||||||
const permalink = $derived(`#/blog/${entry.id}`)
|
const permalink = $derived(`#/blog/${entry.id}`)
|
||||||
const isMine = $derived(session.me?.id === entry.account.id)
|
const isMine = $derived(session.me?.id === entry.account.id)
|
||||||
|
const youtubeVideoIds = $derived(extractYouTubeVideoIds(entry.content))
|
||||||
|
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
let actionError = $state<string | null>(null)
|
let actionError = $state<string | null>(null)
|
||||||
@@ -188,6 +191,7 @@
|
|||||||
{#if entry.media_attachments.length > 0}
|
{#if entry.media_attachments.length > 0}
|
||||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||||
{/if}
|
{/if}
|
||||||
|
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||||
</details>
|
</details>
|
||||||
{:else}
|
{:else}
|
||||||
<RichText
|
<RichText
|
||||||
@@ -200,13 +204,14 @@
|
|||||||
{#if entry.media_attachments.length > 0}
|
{#if entry.media_attachments.length > 0}
|
||||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||||
{/if}
|
{/if}
|
||||||
|
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if entry.poll}
|
{#if entry.poll}
|
||||||
<PollView poll={entry.poll} />
|
<PollView poll={entry.poll} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if entry.card && entry.media_attachments.length === 0}
|
{#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
|
||||||
<PreviewCardView card={entry.card} />
|
<PreviewCardView card={entry.card} />
|
||||||
{/if}
|
{/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-mine="true"]', 'Entries you wrote'],
|
||||||
['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
|
['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
|
||||||
['.blog-entry[data-boosted="true"]', 'Reposts'],
|
['.blog-entry[data-boosted="true"]', 'Reposts'],
|
||||||
|
['.youtube-attachment, .youtube-embed', 'YouTube embeds'],
|
||||||
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons you’ve activated'],
|
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons you’ve activated'],
|
||||||
['.comment[data-depth="2"]', 'Comments by nesting depth'],
|
['.comment[data-depth="2"]', 'Comments by nesting depth'],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -173,6 +173,36 @@
|
|||||||
font-size: var(--ms-font-size-small);
|
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 */
|
/* ----------------------------------------------------------- link preview */
|
||||||
|
|
||||||
.preview-card {
|
.preview-card {
|
||||||
|
|||||||
Reference in New Issue
Block a user