YouTube cards like Discord's, and the video plays in place (#237)

The card now leads with its provider and the video's channel, then the
title, with the thumbnail inside it - Discord's layout. Pressing the
thumbnail swaps in YouTube's player from youtube-nocookie.com, in the
card; a corner button opens the video outside instead.

YouTube refuses to play for an embedder it cannot name (error 153), and a
window loaded from a file sends no Referer, so the player's own requests
carry the app id as one - YouTube's documented answer for apps. CSP gains
frame-src for that one host only.

On an account that routes strictly the thumbnail opens the video in the
browser and says why: a player is a whole site, which the strict route
cannot carry, and playing it in place would reach YouTube from the
reader's own address.

With a third party's frame now in the page, window.open from either the
main window or a popout opens only http(s) and mailto, as the window's own
openExternal already did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-03 00:41:27 -04:00
co-authored by Claude Opus 5.5
parent 0ea63f5d89
commit 2d30650735
7 changed files with 154 additions and 16 deletions
+1 -1
Submodule nobilis updated: a3897bab95...e78ae8dc3e
+18 -2
View File
@@ -301,7 +301,11 @@ function createWindow(): void {
}
}
}
shell.openExternal(url)
// The same schemes the window's own openExternal allows. The page now
// holds a frame of somebody else's (a YouTube player), and a frame can
// call window.open with anything - a file: URL or another program's
// scheme is not something it gets to have opened.
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url)
return { action: 'deny' }
})
@@ -459,7 +463,8 @@ function openPopout(bufferId: string, title?: string): void {
publishPopouts()
})
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
// As the main window's: web addresses only, whatever a frame asks for.
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url)
return { action: 'deny' }
})
@@ -967,6 +972,17 @@ app.whenReady().then(() => {
allowRoot(path.join(app.getPath('userData'), 'group-icons'))
installMediaHandler()
installRoutedHandler((method, params) => client.request(method, params))
// YouTube's player refuses to play (error 153) for an embedder it cannot
// name, and a window loaded from a file sends no Referer at all. YouTube's
// own answer for an app with no web address is to send its app id as one.
// Only on the player's own pages: nothing else hears from moho this way.
session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: ['https://www.youtube-nocookie.com/embed/*'] },
(details, callback) => {
details.requestHeaders['Referer'] = 'https://com.salastil.moho/'
callback({ requestHeaders: details.requestHeaders })
}
)
prefs = new Prefs()
nobilis = new NobilisProcess()
+1 -1
View File
@@ -12,7 +12,7 @@
<title>moho</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data: blob: https: moho-media: moho-routed:; media-src 'self' blob: https: moho-media: moho-routed:; font-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' moho-media: moho-routed: https: wss: ws://localhost:* http://localhost:*"
content="default-src 'self'; img-src 'self' data: blob: https: moho-media: moho-routed:; media-src 'self' blob: https: moho-media: moho-routed:; font-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' moho-media: moho-routed: https: wss: ws://localhost:* http://localhost:*; frame-src https://www.youtube-nocookie.com"
/>
</head>
<body>
+55
View File
@@ -2689,6 +2689,15 @@ img.smilie {
margin-bottom: 2px;
}
.rich-embed-provider {
margin-bottom: 2px;
}
.rich-embed-author {
font-weight: 600;
margin-bottom: 2px;
}
.rich-embed-desc {
color: var(--surface-variant-text);
white-space: pre-wrap;
@@ -2732,11 +2741,21 @@ img.smilie {
.media-embed.youtube {
position: relative;
display: inline-block;
padding: 0;
background: none;
border: none;
}
.youtube-thumb {
display: block;
position: relative;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
.media-embed.youtube img {
max-width: 420px;
max-height: 240px;
@@ -2744,6 +2763,42 @@ img.smilie {
display: block;
}
/* The player, at the thumbnail's width and YouTube's shape. */
.media-embed.youtube.playing iframe {
display: block;
width: min(420px, 100%);
aspect-ratio: 16 / 9;
border: none;
border-radius: var(--corner-radius-small);
background: #000;
}
.media-embed.youtube.playing {
display: block;
width: min(420px, 100%);
}
/* Out to YouTube itself, over the thumbnail's corner, on hover. */
.youtube-outside {
position: absolute;
top: 6px;
right: 6px;
display: flex;
padding: 4px;
border: none;
border-radius: var(--corner-radius-small);
background: rgba(0, 0, 0, 0.6);
color: #fff;
cursor: pointer;
opacity: 0;
transition: opacity 120ms;
}
.media-embed.youtube:hover .youtube-outside,
.youtube-outside:focus-visible {
opacity: 1;
}
.youtube-play {
position: absolute;
inset: 0;
+73 -12
View File
@@ -4,7 +4,7 @@ import { Lightbox } from './Lightbox'
import { VoiceMessage } from './VoiceMessage'
import { LottieSticker, lottieStickerId } from './LottieSticker'
import { fullImageFor, knownFullImage } from '../lib/fullimage'
import { useMediaUrl } from '../lib/route'
import { useMediaUrl, useStrictRoute } from '../lib/route'
import type { MediaItem } from '../lib/format'
import type { Attachment } from '../../../shared/wire'
@@ -446,17 +446,78 @@ export function MediaEmbed({
}
// youtube - a detected link, never an attachment
return <YouTubeEmbed id={item?.youtubeId ?? ''} link={fullSrc} media={media} onFailed={() => setFailed(true)} />
}
/**
* A YouTube video: its thumbnail until pressed, then the player, in place.
*
* The player is YouTube's own page in a frame, from youtube-nocookie.com -
* the address YouTube offers for embedding that sets no tracking cookie until
* the video is played. A page is not something the strict route can carry:
* pictures are fetched for the window one at a time, but a player is a whole
* site loading what it likes. So on an account that routes strictly the video
* opens in the browser instead, and says so, rather than reaching YouTube
* from the reader's own address behind their back.
*/
function YouTubeEmbed({
id,
link,
media,
onFailed
}: {
id: string
link: string
media: (url: string) => string
onFailed: () => void
}): JSX.Element {
const strict = useStrictRoute()
const [playing, setPlaying] = useState(false)
const outside = (): void => void window.moho.openExternal(link)
// No button of ours over the player: its corners are its own controls, and
// its YouTube mark already opens the video outside.
if (playing) {
return (
<div className="media-embed youtube playing">
<iframe
src={`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&rel=0`}
title="YouTube video"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
/>
</div>
)
}
return (
<button type="button" className="media-embed youtube" onClick={open} title={fullSrc}>
<img
src={media(`https://i.ytimg.com/vi/${item?.youtubeId}/hqdefault.jpg`)}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
<span className="youtube-play">
<Icon name="play_arrow" size={28} fill />
</span>
</button>
<span className="media-embed youtube">
<button
type="button"
className="youtube-thumb"
onClick={strict ? outside : () => setPlaying(true)}
title={
strict
? 'Opens in your browser: this account routes strictly, and a video player cannot go through its route'
: 'Play'
}
>
<img
src={media(`https://i.ytimg.com/vi/${id}/hqdefault.jpg`)}
alt=""
loading="lazy"
onError={onFailed}
/>
<span className="youtube-play">
<Icon name={strict ? 'open_in_new' : 'play_arrow'} size={28} fill />
</span>
</button>
{!strict && (
<button type="button" className="youtube-outside" title="Open on YouTube" onClick={outside}>
<Icon name="open_in_new" size={16} />
</button>
)}
</span>
)
}
@@ -866,6 +866,8 @@ function MessageRowBody({
className="rich-embed selectable"
style={{ borderLeftColor: embedColor(embed.color) || 'var(--outline-strong)' }}
>
{embed.provider && <div className="rich-embed-provider small muted">{embed.provider}</div>}
{embed.author && <div className="rich-embed-author small">{embed.author}</div>}
{embed.title &&
(embed.url ? (
<a
+4
View File
@@ -451,6 +451,10 @@ export interface Embed {
* sniffing and are paired with the card separately.
*/
imageUrl?: string
/** Who serves what the card is about - "YouTube" - shown small, first. */
provider?: string
/** Who made it: a video's channel. */
author?: string
}
/**