diff --git a/src/components/blog/BlogEntry.svelte b/src/components/blog/BlogEntry.svelte index 499253d..db6cf72 100644 --- a/src/components/blog/BlogEntry.svelte +++ b/src/components/blog/BlogEntry.svelte @@ -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(null) @@ -188,6 +191,7 @@ {#if entry.media_attachments.length > 0} {/if} + {:else} 0} {/if} + {/if} {#if entry.poll} {/if} - {#if entry.card && entry.media_attachments.length === 0} + {#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0} {/if} diff --git a/src/components/blog/BlogEntry.test.ts b/src/components/blog/BlogEntry.test.ts new file mode 100644 index 0000000..aad0eac --- /dev/null +++ b/src/components/blog/BlogEntry.test.ts @@ -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[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: + '

Watch this: https://www.youtube.com/watch?v=pyNCigSulNs

', + 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: '

video

', + 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() + }) +}) diff --git a/src/components/blog/YouTubeEmbeds.svelte b/src/components/blog/YouTubeEmbeds.svelte new file mode 100644 index 0000000..e6e09d9 --- /dev/null +++ b/src/components/blog/YouTubeEmbeds.svelte @@ -0,0 +1,65 @@ + + +{#if validVideoIds.length > 0} +
    + {#each validVideoIds as videoId, index (videoId)} +
  • +
    +
    + {#if isRevealed(videoId)} + + {:else} +
    Sensitive YouTube video
    + {/if} +
    +
    + + Watch on YouTube + +
    +
    + + {#if sensitive} + + {/if} +
  • + {/each} +
+{/if} diff --git a/src/lib/util/youtube.test.ts b/src/lib/util/youtube.test.ts new file mode 100644 index 0000000..81af46f --- /dev/null +++ b/src/lib/util/youtube.test.ts @@ -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 = ` +

+ first + https://youtu.be/pyNCigSulNs + second +

+ ` + + expect(extractYouTubeVideoIds(html)).toEqual(['pyNCigSulNs', 'dQw4w9WgXcQ']) + }) +}) diff --git a/src/lib/util/youtube.ts b/src/lib/util/youtube.ts new file mode 100644 index 0000000..0dc5e25 --- /dev/null +++ b/src/lib/util/youtube.ts @@ -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() + 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}` +} diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte index 4483499..ed3799f 100644 --- a/src/routes/Settings.svelte +++ b/src/routes/Settings.svelte @@ -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'], ], diff --git a/src/styles/blog.css b/src/styles/blog.css index 876a1a0..5c1ed9e 100644 --- a/src/styles/blog.css +++ b/src/styles/blog.css @@ -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 {