Compare commits
1 Commits
29d74eda8f
...
93f307cd63
| Author | SHA1 | Date | |
|---|---|---|---|
| 93f307cd63 |
@@ -197,6 +197,80 @@ export const MediaPlayButton = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inline video embed (YouTube / Vimeo) — media-forward click-to-play facade
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const EmbedMediaLandscape = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EmbedMediaPortrait = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
width: toRem(220),
|
||||||
|
maxWidth: '100%',
|
||||||
|
aspectRatio: '9 / 16',
|
||||||
|
margin: '0 auto',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fills the aspect box; used for both the <button> facade and the <a> fallback.
|
||||||
|
export const EmbedFacade = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'block',
|
||||||
|
padding: 0,
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
|
||||||
|
':hover': {
|
||||||
|
filter: 'brightness(0.85)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EmbedIframe = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 0,
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EmbedPlaceholder = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#111111',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: toRem(28),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon)
|
// Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ import { mxcUrlToHttp } from '../../utils/matrix';
|
|||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { ImageViewer } from '../image-viewer';
|
import { ImageViewer } from '../image-viewer';
|
||||||
import { onEnterOrSpace } from '../../utils/keyboard';
|
import { onEnterOrSpace } from '../../utils/keyboard';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import {
|
||||||
|
buildVideoEmbedUrl,
|
||||||
|
getVimeoVideoId,
|
||||||
|
getYouTubeVideoId,
|
||||||
|
getYoutubeShortsId,
|
||||||
|
isYouTubeShorts,
|
||||||
|
VideoEmbedProvider,
|
||||||
|
} from '../../utils/videoEmbed';
|
||||||
|
|
||||||
const linkStyles = { color: color.Success.Main };
|
const linkStyles = { color: color.Success.Main };
|
||||||
|
|
||||||
@@ -44,53 +54,6 @@ type CardVariant =
|
|||||||
| 'tenor'
|
| 'tenor'
|
||||||
| 'generic';
|
| 'generic';
|
||||||
|
|
||||||
function getYouTubeVideoId(url: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const { hostname, pathname, searchParams } = parsed;
|
|
||||||
|
|
||||||
// youtu.be/<id>
|
|
||||||
if (hostname === 'youtu.be') {
|
|
||||||
const id = pathname.slice(1).split('/')[0];
|
|
||||||
return id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// youtube.com/watch?v=<id>
|
|
||||||
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
|
|
||||||
if (pathname === '/watch') {
|
|
||||||
return searchParams.get('v');
|
|
||||||
}
|
|
||||||
// youtube.com/shorts/<id> — handled by isYouTubeShorts
|
|
||||||
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
|
||||||
if (shortsMatch) return shortsMatch[1];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore malformed URLs
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isYouTubeShorts(url: string): boolean {
|
|
||||||
try {
|
|
||||||
const { hostname, pathname } = new URL(url);
|
|
||||||
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
|
|
||||||
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getYoutubeShortsId(url: string): string | null {
|
|
||||||
try {
|
|
||||||
const { hostname, pathname } = new URL(url);
|
|
||||||
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
|
|
||||||
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTikTok(url: string): boolean {
|
function isTikTok(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname } = new URL(url);
|
const { hostname } = new URL(url);
|
||||||
@@ -111,18 +74,6 @@ function getTikTokUsername(url: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVimeoVideoId(url: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const { hostname, pathname } = parsed;
|
|
||||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
|
||||||
const m = pathname.match(/^\/(\d+)/);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGitHubRepo(url: string): boolean {
|
function isGitHubRepo(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
@@ -483,153 +434,21 @@ function SiteBadge({ label, colorClass }: { label: string; colorClass: string })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared VideoCard — used by YouTube (non-Shorts) and Vimeo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function VideoCard({
|
|
||||||
url,
|
|
||||||
prev,
|
|
||||||
thumbnailUrl,
|
|
||||||
siteBadgeLabel,
|
|
||||||
siteBadgeClass,
|
|
||||||
showPlayButton,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
prev: IPreviewUrlResponse;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
siteBadgeLabel: string;
|
|
||||||
siteBadgeClass: string;
|
|
||||||
showPlayButton?: boolean;
|
|
||||||
}) {
|
|
||||||
const title = prev['og:title'] ?? '';
|
|
||||||
const description = prev['og:description'] ?? '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={previewCss.MediaThumbnailWrapper}
|
|
||||||
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
className={previewCss.MediaThumbnailImg}
|
|
||||||
src={thumbnailUrl}
|
|
||||||
alt={title}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
{showPlayButton !== false && (
|
|
||||||
<div className={previewCss.MediaPlayOverlay}>
|
|
||||||
<div className={previewCss.MediaPlayButton}>
|
|
||||||
<Icon size="400" src={Icons.Play} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
<UrlPreviewContent>
|
|
||||||
<Text
|
|
||||||
style={linkStyles}
|
|
||||||
truncate
|
|
||||||
as="a"
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
size="T200"
|
|
||||||
priority="300"
|
|
||||||
>
|
|
||||||
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
|
|
||||||
</Text>
|
|
||||||
{title && (
|
|
||||||
<Text truncate priority="400">
|
|
||||||
<b>{title}</b>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{description && (
|
|
||||||
<Text size="T200" priority="300">
|
|
||||||
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</UrlPreviewContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Card 1: YouTube Shorts
|
// Card 1: YouTube Shorts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function YouTubeShortsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function YouTubeShortsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
const videoId = getYoutubeShortsId(url) ?? '';
|
|
||||||
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
|
|
||||||
const title = prev['og:title'] ?? '';
|
|
||||||
// YouTube Shorts og:description is often the channel name
|
|
||||||
const rawDescription = (prev['og:description'] as string | undefined) ?? '';
|
|
||||||
const channelName = rawDescription.length < 80 ? rawDescription : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<VideoEmbedCard
|
||||||
{/* Header bar with Shorts badge */}
|
url={url}
|
||||||
<div className={previewCss.ShortsHeader}>
|
prev={prev}
|
||||||
<SiteBadge label="Shorts" colorClass={previewCss.BadgeYouTubeShorts} />
|
provider="youtube"
|
||||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
videoId={getYoutubeShortsId(url) ?? ''}
|
||||||
YouTube
|
portrait
|
||||||
</Text>
|
siteBadgeLabel="Shorts"
|
||||||
</div>
|
siteBadgeClass={previewCss.BadgeYouTubeShorts}
|
||||||
{/* Side-by-side layout */}
|
|
||||||
<div className={previewCss.PortraitSideLayout}>
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={previewCss.PortraitThumbnail}
|
|
||||||
aria-label={`Watch Short: ${title}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
className={previewCss.PortraitThumbnailImg}
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={title}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
<div className={previewCss.PortraitPlayOverlay}>
|
|
||||||
<div className={previewCss.PortraitPlayButton}>▶</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
|
||||||
{title && (
|
|
||||||
<Text
|
|
||||||
priority="400"
|
|
||||||
style={{
|
|
||||||
fontWeight: 700,
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{channelName && (
|
|
||||||
<Text size="T200" priority="300" style={{ opacity: 0.65 }}>
|
|
||||||
{channelName}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
priority="300"
|
|
||||||
style={{ opacity: 0.5, marginTop: 'auto' }}
|
|
||||||
as="a"
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
youtube.com
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,15 +917,136 @@ function RedditCard({
|
|||||||
// Remaining card variants (unchanged from original)
|
// Remaining card variants (unchanged from original)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
// Media-forward video tile with a privacy-friendly click-to-play facade: shows
|
||||||
const videoId = getYouTubeVideoId(url)!;
|
// the homeserver's cached thumbnail + a play button; only on click does it swap
|
||||||
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
|
// in the (cookie-less) YouTube / Vimeo iframe, so nothing loads from the third
|
||||||
|
// party until the user presses play. Gated by the `inlineMediaEmbeds` setting —
|
||||||
|
// when off it falls back to a link that opens the video in a new tab.
|
||||||
|
function VideoEmbedCard({
|
||||||
|
url,
|
||||||
|
prev,
|
||||||
|
provider,
|
||||||
|
videoId,
|
||||||
|
portrait,
|
||||||
|
siteBadgeLabel,
|
||||||
|
siteBadgeClass,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
prev: IPreviewUrlResponse;
|
||||||
|
provider: VideoEmbedProvider;
|
||||||
|
videoId: string;
|
||||||
|
portrait?: boolean;
|
||||||
|
siteBadgeLabel: string;
|
||||||
|
siteBadgeClass: string;
|
||||||
|
}) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const thumbnailUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(
|
||||||
|
mx,
|
||||||
|
mxcImage,
|
||||||
|
useAuthentication,
|
||||||
|
portrait ? 320 : 480,
|
||||||
|
portrait ? 568 : 270,
|
||||||
|
'scale',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const mediaClass = portrait ? previewCss.EmbedMediaPortrait : previewCss.EmbedMediaLandscape;
|
||||||
|
|
||||||
|
const thumb = thumbnailUrl ? (
|
||||||
|
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span className={previewCss.EmbedPlaceholder}>
|
||||||
|
<Icon size="600" src={Icons.Play} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const playOverlay = (
|
||||||
|
<span className={previewCss.MediaPlayOverlay}>
|
||||||
|
<span className={previewCss.MediaPlayButton}>
|
||||||
|
<Icon size="400" src={Icons.Play} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoCard
|
<Box direction="Column">
|
||||||
|
<div className={mediaClass}>
|
||||||
|
{playing ? (
|
||||||
|
<iframe
|
||||||
|
className={previewCss.EmbedIframe}
|
||||||
|
src={buildVideoEmbedUrl(provider, videoId)}
|
||||||
|
title={title || siteBadgeLabel}
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : inlineMediaEmbeds ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={previewCss.EmbedFacade}
|
||||||
|
onClick={() => setPlaying(true)}
|
||||||
|
aria-label={`Play: ${title || siteBadgeLabel}`}
|
||||||
|
>
|
||||||
|
{thumb}
|
||||||
|
{playOverlay}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.EmbedFacade}
|
||||||
|
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
|
||||||
|
>
|
||||||
|
{thumb}
|
||||||
|
{playOverlay}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
|
||||||
|
</Text>
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
return (
|
||||||
|
<VideoEmbedCard
|
||||||
url={url}
|
url={url}
|
||||||
prev={prev}
|
prev={prev}
|
||||||
thumbnailUrl={thumbnailSrc}
|
provider="youtube"
|
||||||
|
videoId={getYouTubeVideoId(url) ?? ''}
|
||||||
siteBadgeLabel="YouTube"
|
siteBadgeLabel="YouTube"
|
||||||
siteBadgeClass=""
|
siteBadgeClass=""
|
||||||
/>
|
/>
|
||||||
@@ -1114,14 +1054,12 @@ function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
// Vimeo always includes og:image for video thumbnails
|
|
||||||
const thumbnailSrc = (prev['og:image'] as string | undefined) ?? '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoCard
|
<VideoEmbedCard
|
||||||
url={url}
|
url={url}
|
||||||
prev={prev}
|
prev={prev}
|
||||||
thumbnailUrl={thumbnailSrc}
|
provider="vimeo"
|
||||||
|
videoId={getVimeoVideoId(url) ?? ''}
|
||||||
siteBadgeLabel="Vimeo"
|
siteBadgeLabel="Vimeo"
|
||||||
siteBadgeClass={previewCss.BadgeVimeo}
|
siteBadgeClass={previewCss.BadgeVimeo}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2250,6 +2250,7 @@ function Messages() {
|
|||||||
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
|
const [inlineMediaEmbeds, setInlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||||
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
|
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
@@ -2344,6 +2345,15 @@ function Messages() {
|
|||||||
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Inline Media Players"
|
||||||
|
description="Play YouTube and Vimeo links right in the chat. Nothing loads from the video site until you press play."
|
||||||
|
after={
|
||||||
|
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Show Hidden Events"
|
title="Show Hidden Events"
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export interface Settings {
|
|||||||
mediaAutoLoad: boolean;
|
mediaAutoLoad: boolean;
|
||||||
urlPreview: boolean;
|
urlPreview: boolean;
|
||||||
encUrlPreview: boolean;
|
encUrlPreview: boolean;
|
||||||
|
inlineMediaEmbeds: boolean;
|
||||||
showHiddenEvents: boolean;
|
showHiddenEvents: boolean;
|
||||||
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
|
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
|
||||||
// retention window passes (default off — nothing auto-deletes by surprise).
|
// retention window passes (default off — nothing auto-deletes by surprise).
|
||||||
@@ -290,6 +291,7 @@ const defaultSettings: Settings = {
|
|||||||
mediaAutoLoad: true,
|
mediaAutoLoad: true,
|
||||||
urlPreview: true,
|
urlPreview: true,
|
||||||
encUrlPreview: true,
|
encUrlPreview: true,
|
||||||
|
inlineMediaEmbeds: true,
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
enforceRetentionLocally: false,
|
enforceRetentionLocally: false,
|
||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
getYouTubeVideoId,
|
||||||
|
getYoutubeShortsId,
|
||||||
|
isYouTubeShorts,
|
||||||
|
getVimeoVideoId,
|
||||||
|
parseVideoEmbed,
|
||||||
|
buildVideoEmbedUrl,
|
||||||
|
} from './videoEmbed';
|
||||||
|
|
||||||
|
test('getYouTubeVideoId: watch / youtu.be / embed / shorts', () => {
|
||||||
|
assert.equal(getYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
|
||||||
|
assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
|
||||||
|
assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ?t=42'), 'dQw4w9WgXcQ');
|
||||||
|
assert.equal(getYouTubeVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
|
||||||
|
assert.equal(getYouTubeVideoId('https://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getYouTubeVideoId: non-YouTube / malformed → null', () => {
|
||||||
|
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
|
||||||
|
assert.equal(getYouTubeVideoId('not a url'), null);
|
||||||
|
assert.equal(getYouTubeVideoId('https://www.youtube.com/'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Shorts detection', () => {
|
||||||
|
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
|
||||||
|
assert.equal(isYouTubeShorts('https://www.youtube.com/watch?v=abc123'), false);
|
||||||
|
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVimeoVideoId', () => {
|
||||||
|
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
|
||||||
|
assert.equal(getVimeoVideoId('https://vimeo.com/123456789/abcdef'), '123456789');
|
||||||
|
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
|
||||||
|
assert.equal(getVimeoVideoId('https://youtube.com/watch?v=x'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseVideoEmbed: routes provider + portrait for shorts', () => {
|
||||||
|
assert.deepEqual(parseVideoEmbed('https://youtube.com/shorts/abc'), {
|
||||||
|
provider: 'youtube',
|
||||||
|
id: 'abc',
|
||||||
|
portrait: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseVideoEmbed('https://www.youtube.com/watch?v=xyz'), {
|
||||||
|
provider: 'youtube',
|
||||||
|
id: 'xyz',
|
||||||
|
portrait: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseVideoEmbed('https://vimeo.com/42'), {
|
||||||
|
provider: 'vimeo',
|
||||||
|
id: '42',
|
||||||
|
portrait: false,
|
||||||
|
});
|
||||||
|
assert.equal(parseVideoEmbed('https://example.com/video'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo, id encoded', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'),
|
||||||
|
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildVideoEmbedUrl('vimeo', '123456789'),
|
||||||
|
'https://player.vimeo.com/video/123456789?autoplay=1',
|
||||||
|
);
|
||||||
|
// id is URL-encoded (defense-in-depth; ids are normally already safe)
|
||||||
|
assert.ok(buildVideoEmbedUrl('youtube', 'a/b').includes('a%2Fb'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Pure helpers for detecting embeddable video URLs (YouTube, Shorts, Vimeo) and
|
||||||
|
// building their privacy-friendly embed URLs. No React/DOM/CSS imports so this
|
||||||
|
// stays unit-testable in isolation.
|
||||||
|
|
||||||
|
export type VideoEmbedProvider = 'youtube' | 'vimeo';
|
||||||
|
|
||||||
|
export type VideoEmbed = {
|
||||||
|
provider: VideoEmbedProvider;
|
||||||
|
id: string;
|
||||||
|
portrait: boolean; // YouTube Shorts render 9:16
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getYouTubeVideoId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname, searchParams } = new URL(url);
|
||||||
|
// youtu.be/<id>
|
||||||
|
if (hostname === 'youtu.be') {
|
||||||
|
const id = pathname.slice(1).split('/')[0];
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
|
||||||
|
// youtube.com/watch?v=<id>
|
||||||
|
if (pathname === '/watch') return searchParams.get('v');
|
||||||
|
// youtube.com/embed/<id>
|
||||||
|
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/);
|
||||||
|
if (embedMatch) return embedMatch[1];
|
||||||
|
// youtube.com/shorts/<id> (also matched by getYoutubeShortsId)
|
||||||
|
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||||
|
if (shortsMatch) return shortsMatch[1];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed URLs
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYouTubeShorts(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
|
||||||
|
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYoutubeShortsId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
|
||||||
|
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVimeoVideoId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||||
|
const m = pathname.match(/^\/(\d+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect an embeddable video from any URL, or null if it isn't one. */
|
||||||
|
export function parseVideoEmbed(url: string): VideoEmbed | null {
|
||||||
|
const shortsId = getYoutubeShortsId(url);
|
||||||
|
if (shortsId) return { provider: 'youtube', id: shortsId, portrait: true };
|
||||||
|
const ytId = getYouTubeVideoId(url);
|
||||||
|
if (ytId) return { provider: 'youtube', id: ytId, portrait: false };
|
||||||
|
const vimeoId = getVimeoVideoId(url);
|
||||||
|
if (vimeoId) return { provider: 'vimeo', id: vimeoId, portrait: false };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the click-to-play embed URL. YouTube uses the cookie-less
|
||||||
|
* youtube-nocookie host so nothing is set until the user presses play.
|
||||||
|
*/
|
||||||
|
export function buildVideoEmbedUrl(provider: VideoEmbedProvider, id: string): string {
|
||||||
|
const safeId = encodeURIComponent(id);
|
||||||
|
if (provider === 'vimeo') {
|
||||||
|
return `https://player.vimeo.com/video/${safeId}?autoplay=1`;
|
||||||
|
}
|
||||||
|
return `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user