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:
+1
-1
Submodule nobilis updated: a3897bab95...e78ae8dc3e
+18
-2
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user