Compare commits
11 Commits
29d74eda8f
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| 013f113bc2 | |||
| a52c9e12a4 | |||
| a28c305835 | |||
| 3694a3612d | |||
| 4de2d233ef | |||
| b247a0447c | |||
| 6f15f7f56e | |||
| fbaa921f83 | |||
| 791b1cb5ea | |||
| 6c903fe3e5 | |||
| 93f307cd63 |
@@ -86,7 +86,9 @@ export function RenderMessageContent({
|
|||||||
eventId,
|
eventId,
|
||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||||
|
// fetches + iframes at once.
|
||||||
|
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
|
||||||
if (filteredUrls.length === 0) return undefined;
|
if (filteredUrls.length === 0) return undefined;
|
||||||
return (
|
return (
|
||||||
<UrlPreviewHolder>
|
<UrlPreviewHolder>
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export const UrlPreview = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Wider, responsive card for interactive embeds (video players, tweets, posts)
|
||||||
|
// so the player chrome / tweet content isn't cramped or clipped. Defined after
|
||||||
|
// UrlPreview so its width wins the cascade.
|
||||||
|
export const UrlPreviewWide = style({
|
||||||
|
width: 'min(38rem, 94vw)',
|
||||||
|
});
|
||||||
|
|
||||||
export const UrlPreviewImg = style([
|
export const UrlPreviewImg = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
@@ -197,6 +204,116 @@ 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,
|
||||||
|
selectors: {
|
||||||
|
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EmbedMediaPortrait = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
width: toRem(300),
|
||||||
|
maxWidth: '100%',
|
||||||
|
aspectRatio: '9 / 16',
|
||||||
|
margin: '0 auto',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
selectors: {
|
||||||
|
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto', margin: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 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)',
|
||||||
|
},
|
||||||
|
selectors: {
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: `2px solid ${color.Primary.Main}`,
|
||||||
|
outlineOffset: '-2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EmbedIframe = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 0,
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Variable-height iframe that sizes itself (X/Twitter, Instagram, Reddit posts —
|
||||||
|
// height set inline). Capped ~550px (X/IG native max) + centered in the wide card.
|
||||||
|
export const EmbedIframeStatic = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: toRem(550),
|
||||||
|
margin: '0 auto',
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Audio embeds (Spotify / SoundCloud) — full-width, short fixed height (set inline).
|
||||||
|
export const EmbedMediaAudio = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon)
|
// Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -315,6 +432,51 @@ export const BadgeTikTok = style({
|
|||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BadgeSoundCloud = style({
|
||||||
|
backgroundColor: '#ff5500',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeDailymotion = style({
|
||||||
|
backgroundColor: '#0066dc',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeStreamable = style({
|
||||||
|
backgroundColor: '#0f90fa',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeAppleMusic = style({
|
||||||
|
backgroundColor: '#fa243c',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeInstagram = style({
|
||||||
|
backgroundColor: '#e1306c',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeBluesky = style({
|
||||||
|
backgroundColor: '#0085ff',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeLoom = style({
|
||||||
|
backgroundColor: '#625df5',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeKick = style({
|
||||||
|
backgroundColor: '#53fc18',
|
||||||
|
color: '#000000',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeTidal = style({
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Twitch LIVE badge
|
// Twitch LIVE badge
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
import { Box, Chip, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||||
import { ImageOverlay } from '../ImageOverlay';
|
import { ImageOverlay } from '../ImageOverlay';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -17,6 +17,19 @@ 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 {
|
||||||
|
extractEmbedHeight,
|
||||||
|
getTikTokVideoId,
|
||||||
|
getTweetId,
|
||||||
|
isTikTokLink,
|
||||||
|
MediaEmbed,
|
||||||
|
parseMediaEmbed,
|
||||||
|
tiktokIdFromOembed,
|
||||||
|
tiktokOembedUrl,
|
||||||
|
tiktokPlayerEmbedUrl,
|
||||||
|
} from '../../utils/videoEmbed';
|
||||||
|
|
||||||
const linkStyles = { color: color.Success.Main };
|
const linkStyles = { color: color.Success.Main };
|
||||||
|
|
||||||
@@ -25,10 +38,7 @@ const linkStyles = { color: color.Success.Main };
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type CardVariant =
|
type CardVariant =
|
||||||
| 'youtube-shorts'
|
|
||||||
| 'youtube'
|
|
||||||
| 'tiktok'
|
| 'tiktok'
|
||||||
| 'vimeo'
|
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'twitter'
|
| 'twitter'
|
||||||
| 'reddit'
|
| 'reddit'
|
||||||
@@ -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);
|
||||||
@@ -332,11 +283,10 @@ function isTenor(url: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCardVariant(url: string): CardVariant {
|
function getCardVariant(url: string): CardVariant {
|
||||||
// Shorts must be detected before generic youtube
|
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
||||||
if (isYouTubeShorts(url)) return 'youtube-shorts';
|
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
||||||
if (getYouTubeVideoId(url) !== null) return 'youtube';
|
// non-embed fallbacks (incl. TikTok short links with no resolvable id).
|
||||||
if (isTikTok(url)) return 'tiktok';
|
if (isTikTok(url)) return 'tiktok';
|
||||||
if (getVimeoVideoId(url) !== null) return 'vimeo';
|
|
||||||
if (isGitHubRepo(url)) return 'github';
|
if (isGitHubRepo(url)) return 'github';
|
||||||
if (isTwitter(url)) return 'twitter';
|
if (isTwitter(url)) return 'twitter';
|
||||||
if (getRedditSubreddit(url) !== null) return 'reddit';
|
if (getRedditSubreddit(url) !== null) return 'reddit';
|
||||||
@@ -484,157 +434,7 @@ function SiteBadge({ label, colorClass }: { label: string; colorClass: string })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared VideoCard — used by YouTube (non-Shorts) and Vimeo
|
// Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id)
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
{/* Header bar with Shorts badge */}
|
|
||||||
<div className={previewCss.ShortsHeader}>
|
|
||||||
<SiteBadge label="Shorts" colorClass={previewCss.BadgeYouTubeShorts} />
|
|
||||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
|
||||||
YouTube
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
{/* 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Card 2: TikTok
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function TikTokCard({
|
function TikTokCard({
|
||||||
@@ -754,6 +554,76 @@ function TikTokCard({
|
|||||||
// Card 3: Twitter / X
|
// Card 3: Twitter / X
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Origins allowed to drive a self-resizing embed (stable refs → effect deps).
|
||||||
|
const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com'];
|
||||||
|
const INSTAGRAM_ORIGINS = ['https://www.instagram.com'];
|
||||||
|
const REDDIT_ORIGINS = ['https://embed.reddit.com'];
|
||||||
|
const BLUESKY_ORIGINS = ['https://embed.bsky.app'];
|
||||||
|
const NO_RESIZE_ORIGINS: string[] = [];
|
||||||
|
const EMBED_MAX_HEIGHT = 1400;
|
||||||
|
|
||||||
|
// Defense-in-depth on top of the CSP frame-src allowlist. Critically OMITS
|
||||||
|
// allow-top-navigation, so a compromised embed can't redirect the whole app
|
||||||
|
// (phishing). Fullscreen still works — it's gated by allow=, not a sandbox token.
|
||||||
|
const EMBED_SANDBOX =
|
||||||
|
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation';
|
||||||
|
|
||||||
|
// Variable-height iframe embeds (Twitter/Instagram/Reddit) report their content
|
||||||
|
// height via postMessage; listen — scoped to the allowed origins + OUR iframe —
|
||||||
|
// and grow to fit. `origins` must be a stable reference.
|
||||||
|
function useIframeAutoHeight(origins: string[], initial: number) {
|
||||||
|
const ref = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const [height, setHeight] = useState(initial);
|
||||||
|
useEffect(() => {
|
||||||
|
if (origins.length === 0) return undefined;
|
||||||
|
const onMessage = (e: MessageEvent) => {
|
||||||
|
if (!origins.includes(e.origin)) return;
|
||||||
|
if (ref.current && e.source !== ref.current.contentWindow) return;
|
||||||
|
let data: unknown = e.data;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const h = extractEmbedHeight(data);
|
||||||
|
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), EMBED_MAX_HEIGHT));
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
return () => window.removeEventListener('message', onMessage);
|
||||||
|
}, [origins]);
|
||||||
|
return { ref, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets.
|
||||||
|
// Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade).
|
||||||
|
function TweetEmbed({ id }: { id: string }) {
|
||||||
|
const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320);
|
||||||
|
const theme =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: light)').matches
|
||||||
|
? 'light'
|
||||||
|
: 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
ref={ref}
|
||||||
|
className={previewCss.EmbedIframeStatic}
|
||||||
|
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
|
||||||
|
id,
|
||||||
|
)}&theme=${theme}&dnt=true`}
|
||||||
|
title="Post on X"
|
||||||
|
style={{ height: `${height}px` }}
|
||||||
|
sandbox={EMBED_SANDBOX}
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
||||||
|
loading="lazy"
|
||||||
|
scrolling="no"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TwitterCard({
|
function TwitterCard({
|
||||||
url,
|
url,
|
||||||
prev,
|
prev,
|
||||||
@@ -765,12 +635,16 @@ function TwitterCard({
|
|||||||
mx: ReturnType<typeof useMatrixClient>;
|
mx: ReturnType<typeof useMatrixClient>;
|
||||||
useAuthentication: boolean;
|
useAuthentication: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const rawTitle = (prev['og:title'] as string | undefined) ?? '';
|
const rawTitle = (prev['og:title'] as string | undefined) ?? '';
|
||||||
const rawDescription = (prev['og:description'] as string | undefined) ?? '';
|
const rawDescription = (prev['og:description'] as string | undefined) ?? '';
|
||||||
const mxcImage = prev['og:image'] as string | undefined;
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
const imageWidth = prev['og:image:width'] as number | undefined;
|
const imageWidth = prev['og:image:width'] as number | undefined;
|
||||||
|
|
||||||
const isTweet = isTwitterTweet(url);
|
const isTweet = isTwitterTweet(url);
|
||||||
|
const tweetId = getTweetId(url);
|
||||||
const { name, handle, text } = parseTweetTitle(rawTitle);
|
const { name, handle, text } = parseTweetTitle(rawTitle);
|
||||||
|
|
||||||
const tweetText = text ?? (isTweet ? rawDescription : undefined);
|
const tweetText = text ?? (isTweet ? rawDescription : undefined);
|
||||||
@@ -781,9 +655,9 @@ function TwitterCard({
|
|||||||
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
|
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;
|
||||||
<>
|
|
||||||
{/* Header row */}
|
const header = (
|
||||||
<div className={previewCss.TwitterHeader}>
|
<div className={previewCss.TwitterHeader}>
|
||||||
<XLogoIcon size={18} />
|
<XLogoIcon size={18} />
|
||||||
<Text priority="400" style={{ fontWeight: 600 }} truncate>
|
<Text priority="400" style={{ fontWeight: 600 }} truncate>
|
||||||
@@ -797,6 +671,43 @@ function TwitterCard({
|
|||||||
<Box grow="Yes" />
|
<Box grow="Yes" />
|
||||||
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
|
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expanded: the interactive, self-sizing post embed (playable video, gallery…).
|
||||||
|
if (canEmbed && expanded && tweetId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={previewCss.TwitterHeader}>
|
||||||
|
<XLogoIcon size={18} />
|
||||||
|
<Text priority="400" style={{ fontWeight: 600 }} truncate>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{handle && (
|
||||||
|
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
|
||||||
|
@{handle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" />
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
aria-label="Collapse post"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<TweetEmbed id={tweetId} />
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
|
||||||
<UrlPreviewContent>
|
<UrlPreviewContent>
|
||||||
{isTweet && tweetText ? (
|
{isTweet && tweetText ? (
|
||||||
@@ -819,6 +730,7 @@ function TwitterCard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
priority="300"
|
priority="300"
|
||||||
@@ -830,6 +742,17 @@ function TwitterCard({
|
|||||||
>
|
>
|
||||||
{getDomain(url)}
|
{getDomain(url)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{canEmbed && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
before={<Icon size="50" src={Icons.Play} />}
|
||||||
|
>
|
||||||
|
<Text size="T200">View post</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</UrlPreviewContent>
|
</UrlPreviewContent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1098,33 +1021,385 @@ 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.
|
||||||
|
const EMBED_BADGE: Record<string, { label: string; class: string }> = {
|
||||||
|
youtube: { label: 'YouTube', class: '' },
|
||||||
|
vimeo: { label: 'Vimeo', class: previewCss.BadgeVimeo },
|
||||||
|
tiktok: { label: 'TikTok', class: previewCss.BadgeTikTok },
|
||||||
|
dailymotion: { label: 'Dailymotion', class: previewCss.BadgeDailymotion },
|
||||||
|
streamable: { label: 'Streamable', class: previewCss.BadgeStreamable },
|
||||||
|
twitch: { label: 'Twitch', class: previewCss.BadgeTwitchPurple },
|
||||||
|
spotify: { label: 'Spotify', class: previewCss.BadgeSpotify },
|
||||||
|
soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud },
|
||||||
|
applemusic: { label: 'Apple Music', class: previewCss.BadgeAppleMusic },
|
||||||
|
tidal: { label: 'TIDAL', class: previewCss.BadgeTidal },
|
||||||
|
instagram: { label: 'Instagram', class: previewCss.BadgeInstagram },
|
||||||
|
reddit: { label: 'Reddit', class: previewCss.BadgeReddit },
|
||||||
|
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
||||||
|
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
||||||
|
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
||||||
|
};
|
||||||
|
|
||||||
|
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
||||||
|
// "please wait for verification" page; don't surface that as the caption.
|
||||||
|
const looksLikeBotWall = (s: string): boolean =>
|
||||||
|
/please wait|are you a (human|robot)|verifying|verification|just a moment|attention required/i.test(
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// One media-forward tile for every embeddable provider (video / portrait / audio).
|
||||||
|
// Privacy-friendly facade: shows the homeserver's cached thumbnail + a play
|
||||||
|
// button; only on click does it swap in the provider iframe, so nothing loads
|
||||||
|
// from the third party until the user presses play. Gated by `inlineMediaEmbeds`
|
||||||
|
// — when off it falls back to a link that opens the media in a new tab.
|
||||||
|
function MediaEmbedCard({
|
||||||
|
url,
|
||||||
|
prev,
|
||||||
|
embed,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
prev: IPreviewUrlResponse;
|
||||||
|
embed: MediaEmbed;
|
||||||
|
}) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const mediaRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const rawTitle = prev['og:title'] ?? '';
|
||||||
|
const rawDescription = prev['og:description'] ?? '';
|
||||||
|
const portrait = embed.kind === 'portrait';
|
||||||
|
const video = embed.kind === 'landscape' || embed.kind === 'portrait';
|
||||||
|
const rich = embed.kind === 'rich';
|
||||||
|
const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
|
||||||
|
// Instagram/Reddit report their height via postMessage → self-resize.
|
||||||
|
const resizeOrigins = !rich
|
||||||
|
? NO_RESIZE_ORIGINS
|
||||||
|
: embed.provider === 'instagram'
|
||||||
|
? INSTAGRAM_ORIGINS
|
||||||
|
: embed.provider === 'bluesky'
|
||||||
|
? BLUESKY_ORIGINS
|
||||||
|
: REDDIT_ORIGINS;
|
||||||
|
// Only subscribe to resize messages while the iframe is actually mounted.
|
||||||
|
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
|
||||||
|
playing ? resizeOrigins : NO_RESIZE_ORIGINS,
|
||||||
|
embed.height ?? 480,
|
||||||
|
);
|
||||||
|
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
|
||||||
|
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
|
||||||
|
const description = looksLikeBotWall(rawDescription) ? '' : rawDescription;
|
||||||
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const thumbnailUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(
|
||||||
|
mx,
|
||||||
|
mxcImage,
|
||||||
|
useAuthentication,
|
||||||
|
portrait ? 320 : 480,
|
||||||
|
portrait ? 568 : 270,
|
||||||
|
'scale',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Shorts share the youtube provider but get their own badge.
|
||||||
|
const badge =
|
||||||
|
embed.provider === 'youtube' && portrait
|
||||||
|
? { label: 'Shorts', class: previewCss.BadgeYouTubeShorts }
|
||||||
|
: (EMBED_BADGE[embed.provider] ?? { label: embed.provider, class: '' });
|
||||||
|
|
||||||
|
const mediaClass = fixedHeight
|
||||||
|
? previewCss.EmbedMediaAudio
|
||||||
|
: portrait
|
||||||
|
? previewCss.EmbedMediaPortrait
|
||||||
|
: previewCss.EmbedMediaLandscape;
|
||||||
|
const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
|
||||||
|
|
||||||
|
const thumb = thumbnailUrl ? (
|
||||||
|
// alt="" — the parent button/link already carries the accessible name.
|
||||||
|
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const enterFullscreen = () => {
|
||||||
|
mediaRef.current?.requestFullscreen?.().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoCard
|
<Box direction="Column">
|
||||||
url={url}
|
{playing && rich ? (
|
||||||
prev={prev}
|
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
|
||||||
thumbnailUrl={thumbnailSrc}
|
<iframe
|
||||||
siteBadgeLabel="YouTube"
|
ref={resizeRef}
|
||||||
siteBadgeClass=""
|
className={previewCss.EmbedIframeStatic}
|
||||||
|
src={embed.embedUrl}
|
||||||
|
title={title || badge.label}
|
||||||
|
style={{ height: `${resizeHeight}px` }}
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
|
sandbox={EMBED_SANDBOX}
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
scrolling="no"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
|
||||||
|
{playing ? (
|
||||||
|
<iframe
|
||||||
|
className={previewCss.EmbedIframe}
|
||||||
|
src={embed.embedUrl}
|
||||||
|
title={title || badge.label}
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
|
sandbox={EMBED_SANDBOX}
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : inlineMediaEmbeds ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={previewCss.EmbedFacade}
|
||||||
|
onClick={() => setPlaying(true)}
|
||||||
|
aria-label={`Play: ${title || badge.label}`}
|
||||||
|
>
|
||||||
|
{thumb}
|
||||||
|
{playOverlay}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.EmbedFacade}
|
||||||
|
aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`}
|
||||||
|
>
|
||||||
|
{thumb}
|
||||||
|
{playOverlay}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label={badge.label} colorClass={badge.class} />
|
||||||
|
</Text>
|
||||||
|
{playing && (
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No">
|
||||||
|
{video && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={enterFullscreen}
|
||||||
|
aria-label="Fullscreen"
|
||||||
|
>
|
||||||
|
<Text size="T200">⛶ Fullscreen</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={() => setPlaying(false)}
|
||||||
|
aria-label="Close player"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{/* While a video plays, drop the title/description so the player isn't
|
||||||
|
squeezed by text below it. */}
|
||||||
|
{!(playing && video) && title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!(playing && video) && description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
// TikTok needs its own card: short "copy-link" URLs (vm.tiktok.com, tiktok.com/t/…)
|
||||||
// Vimeo always includes og:image for video thumbnails
|
// don't carry the video id, and the homeserver's link preview is bot-walled. So we
|
||||||
const thumbnailSrc = (prev['og:image'] as string | undefined) ?? '';
|
// resolve the id client-side via TikTok's CORS-enabled oEmbed API (on click, to
|
||||||
|
// keep the facade privacy model), then play the player/v1 embed.
|
||||||
|
function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||||
|
const [videoId, setVideoId] = useState<string | null>(() => getTikTokVideoId(url));
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const mediaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
|
||||||
|
useEffect(() => () => abortRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const rawTitle = prev['og:title'] ?? '';
|
||||||
|
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
|
||||||
|
const thumbnailUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 320, 568, 'scale', false)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
if (videoId) {
|
||||||
|
setPlaying(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResolving(true);
|
||||||
|
setFailed(false);
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
try {
|
||||||
|
const res = await fetch(tiktokOembedUrl(url), { signal: controller.signal });
|
||||||
|
const id = tiktokIdFromOembed(await res.json());
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
if (id) {
|
||||||
|
setVideoId(id);
|
||||||
|
setPlaying(true);
|
||||||
|
} else {
|
||||||
|
setFailed(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!controller.signal.aborted) setFailed(true);
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) setResolving(false);
|
||||||
|
}
|
||||||
|
}, [url, videoId]);
|
||||||
|
|
||||||
|
const enterFullscreen = () => mediaRef.current?.requestFullscreen?.().catch(() => undefined);
|
||||||
|
|
||||||
|
const facadeInner = (
|
||||||
|
<>
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span className={previewCss.EmbedPlaceholder} />
|
||||||
|
)}
|
||||||
|
<span className={previewCss.MediaPlayOverlay}>
|
||||||
|
<span className={previewCss.MediaPlayButton}>
|
||||||
|
{resolving ? <Spinner size="100" /> : <Icon size="400" src={Icons.Play} />}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoCard
|
<Box direction="Column">
|
||||||
url={url}
|
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
|
||||||
prev={prev}
|
{playing && videoId ? (
|
||||||
thumbnailUrl={thumbnailSrc}
|
<iframe
|
||||||
siteBadgeLabel="Vimeo"
|
className={previewCss.EmbedIframe}
|
||||||
siteBadgeClass={previewCss.BadgeVimeo}
|
src={tiktokPlayerEmbedUrl(videoId)}
|
||||||
|
title={title || 'TikTok'}
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
|
sandbox={EMBED_SANDBOX}
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
) : inlineMediaEmbeds ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={previewCss.EmbedFacade}
|
||||||
|
onClick={start}
|
||||||
|
disabled={resolving}
|
||||||
|
aria-busy={resolving}
|
||||||
|
aria-label={
|
||||||
|
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{facadeInner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.EmbedFacade}
|
||||||
|
aria-label={`Open on TikTok${title ? `: ${title}` : ''}`}
|
||||||
|
>
|
||||||
|
{facadeInner}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
<SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
|
||||||
|
</Text>
|
||||||
|
{playing && videoId && (
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No">
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={enterFullscreen}
|
||||||
|
aria-label="Fullscreen"
|
||||||
|
>
|
||||||
|
<Text size="T200">⛶ Fullscreen</Text>
|
||||||
|
</Chip>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={() => setPlaying(false)}
|
||||||
|
aria-label="Close player"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{failed && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
Couldn’t load this TikTok — open it on TikTok.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!(playing && videoId) && title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1733,18 +2008,38 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
|
|
||||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||||
|
|
||||||
|
// Interactive embeds (players, tweets) render in a wider, responsive card so
|
||||||
|
// player chrome / tweet content isn't cramped or clipped.
|
||||||
|
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||||
|
const wide = !!embed || isTwitterTweet(url);
|
||||||
|
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
|
||||||
|
|
||||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||||
|
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||||
|
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
||||||
|
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
|
||||||
|
// redirects) don't carry the id, so fall back to the canonical og:url that
|
||||||
|
// the homeserver already resolved when fetching the preview.
|
||||||
|
const ogUrl = prev['og:url'];
|
||||||
|
const resolvedEmbed =
|
||||||
|
embed ??
|
||||||
|
(typeof ogUrl === 'string' && ogUrl !== url
|
||||||
|
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||||
|
: null);
|
||||||
|
if (resolvedEmbed) {
|
||||||
|
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TikTok video links (incl. short share links) get their own oEmbed-resolving card.
|
||||||
|
if (isTikTokLink(url)) {
|
||||||
|
return <TikTokEmbedCard url={url} prev={prev} />;
|
||||||
|
}
|
||||||
|
|
||||||
const variant = getCardVariant(url);
|
const variant = getCardVariant(url);
|
||||||
|
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'youtube-shorts':
|
|
||||||
return <YouTubeShortsCard url={url} prev={prev} />;
|
|
||||||
case 'youtube':
|
|
||||||
return <YouTubeCard url={url} prev={prev} />;
|
|
||||||
case 'tiktok':
|
case 'tiktok':
|
||||||
return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
|
return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
|
||||||
case 'vimeo':
|
|
||||||
return <VimeoCard url={url} prev={prev} />;
|
|
||||||
case 'github':
|
case 'github':
|
||||||
return <GitHubCard url={url} prev={prev} />;
|
return <GitHubCard url={url} prev={prev} />;
|
||||||
case 'twitter':
|
case 'twitter':
|
||||||
@@ -1826,14 +2121,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
const content = renderContent(previewStatus.data);
|
const content = renderContent(previewStatus.data);
|
||||||
if (content === null) return null;
|
if (content === null) return null;
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref}>
|
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||||
{content}
|
{content}
|
||||||
</UrlPreview>
|
</UrlPreview>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref}>
|
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" size="400" />
|
<Spinner variant="Secondary" size="400" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party 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,267 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
getYouTubeVideoId,
|
||||||
|
getYoutubeShortsId,
|
||||||
|
isYouTubeShorts,
|
||||||
|
getVimeoVideoId,
|
||||||
|
getVimeoParts,
|
||||||
|
extractEmbedHeight,
|
||||||
|
getTikTokVideoId,
|
||||||
|
isTikTokLink,
|
||||||
|
tiktokIdFromOembed,
|
||||||
|
tiktokPlayerEmbedUrl,
|
||||||
|
getDailymotionId,
|
||||||
|
getStreamableId,
|
||||||
|
getTwitchTarget,
|
||||||
|
getSpotifyEmbedTarget,
|
||||||
|
isSoundCloudTrack,
|
||||||
|
getAppleMusicEmbed,
|
||||||
|
getTweetId,
|
||||||
|
getTidalEmbed,
|
||||||
|
getInstagramEmbed,
|
||||||
|
getRedditPostEmbed,
|
||||||
|
getBlueskyEmbed,
|
||||||
|
getLoomId,
|
||||||
|
getKickChannel,
|
||||||
|
buildVideoEmbedUrl,
|
||||||
|
spotifyEmbedHeight,
|
||||||
|
parseMediaEmbed,
|
||||||
|
} from './videoEmbed';
|
||||||
|
|
||||||
|
const HOST = 'chat.lotusguild.org';
|
||||||
|
|
||||||
|
test('YouTube: watch / youtu.be / embed / shorts', () => {
|
||||||
|
assert.equal(getYouTubeVideoId('https://www.youtube.com/watch?v=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_-');
|
||||||
|
assert.equal(getYouTubeVideoId('https://www.youtube.com/live/abcLIVE123'), 'abcLIVE123');
|
||||||
|
assert.equal(getYouTubeVideoId('https://music.youtube.com/watch?v=musicId12'), 'musicId12');
|
||||||
|
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
|
||||||
|
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
|
||||||
|
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
|
||||||
|
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
|
||||||
|
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
|
||||||
|
assert.deepEqual(getVimeoParts('https://vimeo.com/123456789/abc123'), {
|
||||||
|
id: '123456789',
|
||||||
|
hash: 'abc123',
|
||||||
|
});
|
||||||
|
assert.ok(parseMediaEmbed('https://vimeo.com/123456789/abc123', 'h')?.embedUrl.includes('h=abc123'));
|
||||||
|
// channel / group / album share a trailing numeric video id
|
||||||
|
assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871');
|
||||||
|
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
|
||||||
|
assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
|
||||||
|
assert.equal(extractEmbedHeight({ type: 'MEASURE', details: { height: 640 } }), 640);
|
||||||
|
assert.equal(extractEmbedHeight({ type: 'resize.embed', data: 812 }), 812); // Reddit
|
||||||
|
assert.equal(
|
||||||
|
extractEmbedHeight({ 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height: 500 }] }] }),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
assert.equal(extractEmbedHeight({ height: 300 }), 300); // generic fallback
|
||||||
|
assert.equal(extractEmbedHeight({ type: 'other' }), undefined);
|
||||||
|
assert.equal(extractEmbedHeight('not-an-object'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile Shorts (m.youtube.com) → portrait', () => {
|
||||||
|
assert.equal(parseMediaEmbed('https://m.youtube.com/shorts/abc123', 'h')?.kind, 'portrait');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TikTok: canonical /video/<id> only', () => {
|
||||||
|
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user/video/7234567890123456789'), '7234567890123456789');
|
||||||
|
assert.equal(getTikTokVideoId('https://vm.tiktok.com/ZMabc/'), null); // short link → oEmbed
|
||||||
|
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isTikTokLink: canonical + short + vm/vt', () => {
|
||||||
|
assert.equal(isTikTokLink('https://www.tiktok.com/@user/video/123'), true);
|
||||||
|
assert.equal(isTikTokLink('https://www.tiktok.com/t/ZTSHuuXWq/'), true);
|
||||||
|
assert.equal(isTikTokLink('https://vm.tiktok.com/ZMabc/'), true);
|
||||||
|
assert.equal(isTikTokLink('https://www.tiktok.com/@user'), false); // profile, not a video
|
||||||
|
assert.equal(isTikTokLink('https://youtube.com/watch?v=x'), false);
|
||||||
|
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', 'h'), null); // handled by its own card
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tiktokIdFromOembed + player url', () => {
|
||||||
|
assert.equal(tiktokIdFromOembed({ embed_product_id: '7659555276823006478' }), '7659555276823006478');
|
||||||
|
assert.equal(tiktokIdFromOembed({ html: '<blockquote data-video-id="123456">' }), '123456');
|
||||||
|
assert.equal(tiktokIdFromOembed({}), null);
|
||||||
|
assert.equal(tiktokPlayerEmbedUrl('999'), 'https://www.tiktok.com/player/v1/999?autoplay=1&rel=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dailymotion + Streamable', () => {
|
||||||
|
assert.equal(getDailymotionId('https://www.dailymotion.com/video/x8abcde'), 'x8abcde');
|
||||||
|
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
|
||||||
|
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
|
||||||
|
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Twitch: channel / video / clip', () => {
|
||||||
|
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/somestreamer'), {
|
||||||
|
type: 'channel',
|
||||||
|
value: 'somestreamer',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/videos/123456789'), {
|
||||||
|
type: 'video',
|
||||||
|
value: '123456789',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getTwitchTarget('https://clips.twitch.tv/FunnyClipSlug'), {
|
||||||
|
type: 'clip',
|
||||||
|
value: 'FunnyClipSlug',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/streamer/clip/CoolSlug'), {
|
||||||
|
type: 'clip',
|
||||||
|
value: 'CoolSlug',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Spotify target + height', () => {
|
||||||
|
assert.deepEqual(getSpotifyEmbedTarget('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT'), {
|
||||||
|
type: 'track',
|
||||||
|
id: '4cOdK2wGLETKBW3PvgPWqT',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getSpotifyEmbedTarget('https://open.spotify.com/intl-de/album/xyz'), {
|
||||||
|
type: 'album',
|
||||||
|
id: 'xyz',
|
||||||
|
});
|
||||||
|
assert.equal(getSpotifyEmbedTarget('https://open.spotify.com/'), null);
|
||||||
|
assert.equal(spotifyEmbedHeight('track'), 152);
|
||||||
|
assert.equal(spotifyEmbedHeight('album'), 352);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SoundCloud track detection', () => {
|
||||||
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true);
|
||||||
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
|
||||||
|
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
|
||||||
|
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'),
|
||||||
|
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&playsinline=1',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildVideoEmbedUrl('vimeo', '42'),
|
||||||
|
'https://player.vimeo.com/video/42?autoplay=1&dnt=1',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildVideoEmbedUrl('vimeo', '42', 'h4sh'),
|
||||||
|
'https://player.vimeo.com/video/42?autoplay=1&dnt=1&h=h4sh',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
|
||||||
|
assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), {
|
||||||
|
provider: 'youtube',
|
||||||
|
kind: 'portrait',
|
||||||
|
embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0&playsinline=1',
|
||||||
|
});
|
||||||
|
assert.equal(parseMediaEmbed('https://www.youtube.com/watch?v=xyz', HOST)?.kind, 'landscape');
|
||||||
|
assert.equal(parseMediaEmbed('https://streamable.com/abc', HOST)?.provider, 'streamable');
|
||||||
|
const spotify = parseMediaEmbed('https://open.spotify.com/track/abc', HOST);
|
||||||
|
assert.equal(spotify?.kind, 'audio');
|
||||||
|
assert.equal(spotify?.height, 152);
|
||||||
|
assert.equal(parseMediaEmbed('https://soundcloud.com/a/b', HOST)?.provider, 'soundcloud');
|
||||||
|
assert.equal(parseMediaEmbed('https://example.com/x', HOST), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Apple Music: album vs single song height, embed host swap', () => {
|
||||||
|
const album = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456');
|
||||||
|
assert.equal(album?.embedUrl, 'https://embed.music.apple.com/us/album/some-album/123456');
|
||||||
|
assert.equal(album?.height, 450);
|
||||||
|
const song = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456?i=789');
|
||||||
|
assert.equal(song?.height, 175);
|
||||||
|
assert.ok(song?.embedUrl.includes('?i=789'));
|
||||||
|
assert.equal(getAppleMusicEmbed('https://music.apple.com/us/browse'), null);
|
||||||
|
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTweetId', () => {
|
||||||
|
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
|
||||||
|
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
|
||||||
|
assert.equal(getTweetId('https://x.com/user'), null);
|
||||||
|
assert.equal(getTweetId('https://youtube.com/watch?v=x'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseMediaEmbed: Apple Music → audio', () => {
|
||||||
|
const m = parseMediaEmbed('https://music.apple.com/us/album/x/1?i=2', 'chat.lotusguild.org');
|
||||||
|
assert.equal(m?.provider, 'applemusic');
|
||||||
|
assert.equal(m?.kind, 'audio');
|
||||||
|
assert.equal(m?.height, 175);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tidal: track (audio) vs video (landscape)', () => {
|
||||||
|
assert.deepEqual(getTidalEmbed('https://tidal.com/browse/track/12345'), {
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: 'https://embed.tidal.com/tracks/12345',
|
||||||
|
height: 120,
|
||||||
|
});
|
||||||
|
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.embedUrl, 'https://embed.tidal.com/albums/999?layout=gridify');
|
||||||
|
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.height, 275);
|
||||||
|
assert.equal(getTidalEmbed('https://tidal.com/video/555')?.kind, 'landscape');
|
||||||
|
assert.equal(getTidalEmbed('https://tidal.com/browse'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Instagram: p / reel / tv → embed path', () => {
|
||||||
|
assert.equal(getInstagramEmbed('https://www.instagram.com/p/AbC123_-/'), 'https://www.instagram.com/p/AbC123_-/embed/');
|
||||||
|
assert.equal(getInstagramEmbed('https://instagram.com/reel/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/');
|
||||||
|
assert.equal(getInstagramEmbed('https://www.instagram.com/reels/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/');
|
||||||
|
assert.equal(getInstagramEmbed('https://www.instagram.com/someuser/'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reddit post embed → redditmedia', () => {
|
||||||
|
assert.equal(
|
||||||
|
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
|
||||||
|
'https://embed.reddit.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
|
||||||
|
);
|
||||||
|
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://embed.reddit.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
|
||||||
|
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
|
||||||
|
// redd.it short link → comments-only embed (no subreddit in the redirect)
|
||||||
|
assert.equal(getRedditPostEmbed('https://redd.it/1abc23'), 'https://embed.reddit.com/comments/1abc23/?ref_source=embed&ref=share&embed=true&theme=dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
|
||||||
|
assert.equal(parseMediaEmbed('https://www.instagram.com/p/abc/', 'h')?.kind, 'rich');
|
||||||
|
assert.equal(parseMediaEmbed('https://www.reddit.com/r/x/comments/y/z/', 'h')?.provider, 'reddit');
|
||||||
|
assert.equal(parseMediaEmbed('https://tidal.com/browse/track/1', 'h')?.provider, 'tidal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Bluesky / Loom / Kick', () => {
|
||||||
|
assert.equal(
|
||||||
|
getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social/post/3kabc'),
|
||||||
|
'https://embed.bsky.app/embed/alice.bsky.social/app.bsky.feed.post/3kabc',
|
||||||
|
);
|
||||||
|
assert.equal(getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social'), null);
|
||||||
|
assert.equal(parseMediaEmbed('https://bsky.app/profile/a.bsky.social/post/3k', 'h')?.kind, 'rich');
|
||||||
|
|
||||||
|
assert.equal(getLoomId('https://www.loom.com/share/abc123DEF'), 'abc123DEF');
|
||||||
|
assert.equal(getLoomId('https://www.loom.com/embed/abc123DEF'), 'abc123DEF');
|
||||||
|
assert.equal(
|
||||||
|
parseMediaEmbed('https://www.loom.com/share/xyz', 'h')?.embedUrl,
|
||||||
|
'https://www.loom.com/embed/xyz',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
|
||||||
|
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
|
||||||
|
assert.ok(
|
||||||
|
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
|
||||||
|
'player.kick.com/streamer?autoplay=true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseMediaEmbed: Twitch embed carries the parent host', () => {
|
||||||
|
const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST);
|
||||||
|
assert.equal(chan?.provider, 'twitch');
|
||||||
|
assert.ok(chan?.embedUrl.includes('channel=streamer'));
|
||||||
|
assert.ok(chan?.embedUrl.includes(`parent=${HOST}`));
|
||||||
|
const clip = parseMediaEmbed('https://clips.twitch.tv/Slug', HOST);
|
||||||
|
assert.ok(clip?.embedUrl.startsWith('https://clips.twitch.tv/embed?clip=Slug'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
// Pure helpers for detecting embeddable media URLs and building their embed
|
||||||
|
// URLs. No React/DOM/CSS imports so this stays unit-testable in isolation.
|
||||||
|
//
|
||||||
|
// "kind" drives how the card lays the embed out:
|
||||||
|
// landscape → 16:9 video portrait → 9:16 video
|
||||||
|
// audio → short fixed-height player (Spotify/SoundCloud/Tidal/Apple Music)
|
||||||
|
// rich → tall fixed-height post embed (Instagram / Reddit)
|
||||||
|
|
||||||
|
export type EmbedKind = 'landscape' | 'portrait' | 'audio' | 'rich';
|
||||||
|
|
||||||
|
export type MediaEmbed = {
|
||||||
|
provider: string;
|
||||||
|
kind: EmbedKind;
|
||||||
|
embedUrl: string;
|
||||||
|
/** Fixed pixel height for audio/rich embeds (aspect-ratio is used for video). */
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- YouTube --------------------------------------------------------------
|
||||||
|
|
||||||
|
const YOUTUBE_HOSTS = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'music.youtube.com'];
|
||||||
|
|
||||||
|
export function getYouTubeVideoId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname, searchParams } = new URL(url);
|
||||||
|
if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
|
||||||
|
if (YOUTUBE_HOSTS.includes(hostname)) {
|
||||||
|
if (pathname === '/watch') return searchParams.get('v');
|
||||||
|
const m =
|
||||||
|
pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/) ||
|
||||||
|
pathname.match(/^\/live\/([A-Za-z0-9_-]+)/) ||
|
||||||
|
pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYouTubeShorts(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (!YOUTUBE_HOSTS.includes(hostname)) 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 (!YOUTUBE_HOSTS.includes(hostname)) return null;
|
||||||
|
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Vimeo ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Vimeo id + optional private/unlisted hash (vimeo.com/{id}/{hash}). */
|
||||||
|
export function getVimeoParts(url: string): { id: string; hash?: string } | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||||
|
// Canonical /{id} or unlisted /{id}/{hash}
|
||||||
|
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
|
||||||
|
if (m) return { id: m[1], hash: m[2] };
|
||||||
|
// channels/groups/album share a trailing numeric video id
|
||||||
|
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
|
||||||
|
if (m) return { id: m[1] };
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVimeoVideoId(url: string): string | null {
|
||||||
|
return getVimeoParts(url)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TikTok ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Canonical /video/<id> links only; short links resolve via oEmbed (below). */
|
||||||
|
export function getTikTokVideoId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
if (h !== 'tiktok.com') return null;
|
||||||
|
const m = pathname.match(/\/video\/(\d+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any embeddable TikTok video link — canonical, short (/t/, /v/), or vm/vt.tiktok.com. */
|
||||||
|
export function isTikTokLink(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
if (h === 'vm.tiktok.com' || h === 'vt.tiktok.com') return true;
|
||||||
|
if (h === 'tiktok.com') return /^\/(t\/|v\/|embed\/|@[^/]+\/video\/)/.test(pathname);
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tiktokOembedUrl(url: string): string {
|
||||||
|
return `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the numeric video id from a TikTok oEmbed JSON response. */
|
||||||
|
export function tiktokIdFromOembed(data: {
|
||||||
|
embed_product_id?: unknown;
|
||||||
|
html?: unknown;
|
||||||
|
}): string | null {
|
||||||
|
const pid = String(data.embed_product_id ?? '').match(/\d+/)?.[0];
|
||||||
|
if (pid) return pid;
|
||||||
|
if (typeof data.html === 'string') {
|
||||||
|
return data.html.match(/data-video-id="(\d+)"/)?.[1] ?? data.html.match(/\/video\/(\d+)/)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a content height out of the postMessage shapes used by the resizable
|
||||||
|
* embeds. Shapes verified against the providers' live 2026 embed scripts:
|
||||||
|
* Instagram: { type: 'MEASURE', details: { height } }
|
||||||
|
* Reddit: { type: 'resize.embed', data: <height> }
|
||||||
|
* Twitter/X: { 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height }] }] }
|
||||||
|
*/
|
||||||
|
export function extractEmbedHeight(data: unknown): number | undefined {
|
||||||
|
if (!data || typeof data !== 'object') return undefined;
|
||||||
|
const d = data as {
|
||||||
|
type?: string;
|
||||||
|
height?: number;
|
||||||
|
data?: unknown;
|
||||||
|
details?: { height?: number };
|
||||||
|
'twttr.embed'?: unknown;
|
||||||
|
};
|
||||||
|
if (d.type === 'MEASURE' && typeof d.details?.height === 'number') return d.details.height;
|
||||||
|
if (d.type === 'resize.embed' && typeof d.data === 'number') return d.data;
|
||||||
|
const tw = d['twttr.embed'];
|
||||||
|
if (tw) {
|
||||||
|
const calls = (Array.isArray(tw) ? tw : [tw]) as Array<{
|
||||||
|
method?: string;
|
||||||
|
params?: Array<{ height?: number }>;
|
||||||
|
}>;
|
||||||
|
for (let i = 0; i < calls.length; i += 1) {
|
||||||
|
const c = calls[i];
|
||||||
|
if (c?.method === 'twttr.private.resize' && typeof c.params?.[0]?.height === 'number') {
|
||||||
|
return c.params[0].height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof d.height === 'number') return d.height;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tiktokPlayerEmbedUrl(id: string): string {
|
||||||
|
// Pure 9:16 video player. music_info/description default OFF (they'd switch
|
||||||
|
// TikTok to a wide "video + info panel" layout); controls/progress/play/volume/
|
||||||
|
// fullscreen buttons all default ON, so only autoplay + rel are load-bearing.
|
||||||
|
return `https://www.tiktok.com/player/v1/${encodeURIComponent(id)}?autoplay=1&rel=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dailymotion ----------------------------------------------------------
|
||||||
|
|
||||||
|
export function getDailymotionId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
if (h === 'dai.ly') return pathname.slice(1).split('/')[0] || null;
|
||||||
|
if (h === 'dailymotion.com') {
|
||||||
|
const m = pathname.match(/^\/video\/([A-Za-z0-9]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Streamable -----------------------------------------------------------
|
||||||
|
|
||||||
|
export function getStreamableId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
|
||||||
|
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
|
||||||
|
return m && m[1] !== 'e' ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Twitch ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export type TwitchTarget =
|
||||||
|
| { type: 'channel'; value: string }
|
||||||
|
| { type: 'video'; value: string }
|
||||||
|
| { type: 'clip'; value: string };
|
||||||
|
|
||||||
|
export function getTwitchTarget(url: string): TwitchTarget | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/');
|
||||||
|
if (h === 'clips.twitch.tv' && parts[0]) return { type: 'clip', value: parts[0] };
|
||||||
|
if (h === 'twitch.tv' || h === 'm.twitch.tv') {
|
||||||
|
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
|
||||||
|
if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] };
|
||||||
|
if (parts.length === 1 && parts[0]) return { type: 'channel', value: parts[0] };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spotify --------------------------------------------------------------
|
||||||
|
|
||||||
|
const SPOTIFY_TYPES = ['track', 'album', 'playlist', 'episode', 'show', 'artist'] as const;
|
||||||
|
export type SpotifyType = (typeof SPOTIFY_TYPES)[number];
|
||||||
|
|
||||||
|
export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: string } | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'open.spotify.com') return null;
|
||||||
|
// /intl-xx/track/<id> or /track/<id>
|
||||||
|
const parts = pathname.replace(/^\/+/, '').split('/');
|
||||||
|
const idx = parts.findIndex((p) => (SPOTIFY_TYPES as readonly string[]).includes(p));
|
||||||
|
if (idx === -1 || !parts[idx + 1]) return null;
|
||||||
|
return { type: parts[idx] as SpotifyType, id: parts[idx + 1].split('?')[0] };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SoundCloud -----------------------------------------------------------
|
||||||
|
|
||||||
|
export function isSoundCloudTrack(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
// NOTE: on.soundcloud.com short links are NOT handled here — the w.soundcloud
|
||||||
|
// widget resolver doesn't follow the redirect; supporting them needs an oEmbed
|
||||||
|
// round-trip (soundcloud.com/oembed is CORS-enabled) to get the canonical URL.
|
||||||
|
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
|
||||||
|
// /<artist>/<track|sets/set> — at least two segments, not a bare profile
|
||||||
|
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
||||||
|
return parts.length >= 2;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Apple Music ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
|
||||||
|
export function getAppleMusicEmbed(
|
||||||
|
url: string,
|
||||||
|
): { embedUrl: string; height: number; video: boolean } | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.hostname !== 'music.apple.com' && u.hostname !== 'embed.music.apple.com') return null;
|
||||||
|
if (!/\/(album|playlist|song|music-video)\//.test(u.pathname)) return null;
|
||||||
|
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
|
||||||
|
const video = /\/music-video\//.test(u.pathname);
|
||||||
|
// A single song (?i=… on an album, or a /song/ link) is compact; collections are tall.
|
||||||
|
const isSong = u.searchParams.has('i') || /\/song\//.test(u.pathname);
|
||||||
|
return { embedUrl, height: isSong ? 175 : 450, video };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- X / Twitter ----------------------------------------------------------
|
||||||
|
|
||||||
|
export function getTweetId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
const h = hostname.replace(/^www\./, '');
|
||||||
|
if (h !== 'twitter.com' && h !== 'x.com' && h !== 'mobile.twitter.com') return null;
|
||||||
|
const m = pathname.match(/\/status(?:es)?\/(\d+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tidal ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getTidalEmbed(
|
||||||
|
url: string,
|
||||||
|
): { kind: 'audio' | 'landscape'; embedUrl: string; height?: number } | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const h = u.hostname.replace(/^(www|listen|desktop)\./, '');
|
||||||
|
if (h !== 'tidal.com') return null;
|
||||||
|
const p = u.pathname.replace(/^\/browse/, '');
|
||||||
|
let m = p.match(/^\/track\/(\d+)/);
|
||||||
|
if (m) return { kind: 'audio', embedUrl: `https://embed.tidal.com/tracks/${m[1]}`, height: 120 };
|
||||||
|
m = p.match(/^\/album\/(\d+)/);
|
||||||
|
if (m)
|
||||||
|
// layout=gridify → full-width grid that fills the container (fixes the
|
||||||
|
// narrow/centered default); ~275px is Tidal's own album embed height.
|
||||||
|
return {
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: `https://embed.tidal.com/albums/${m[1]}?layout=gridify`,
|
||||||
|
height: 275,
|
||||||
|
};
|
||||||
|
m = p.match(/^\/playlist\/([0-9a-fA-F-]+)/);
|
||||||
|
if (m)
|
||||||
|
return {
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: `https://embed.tidal.com/playlists/${m[1]}?layout=gridify`,
|
||||||
|
height: 275,
|
||||||
|
};
|
||||||
|
m = p.match(/^\/video\/(\d+)/);
|
||||||
|
if (m) return { kind: 'landscape', embedUrl: `https://embed.tidal.com/videos/${m[1]}` };
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Instagram ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getInstagramEmbed(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.hostname !== 'instagram.com' && u.hostname !== 'www.instagram.com') return null;
|
||||||
|
const m = u.pathname.match(/^\/(p|reel|reels|tv)\/([A-Za-z0-9_-]+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
const type = m[1] === 'reels' ? 'reel' : m[1];
|
||||||
|
return `https://www.instagram.com/${type}/${m[2]}/embed/`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reddit (post embed via redditmedia — bypasses the homeserver's blocked
|
||||||
|
// preview fetch, which Reddit serves a bot-check "please wait" page to) ---
|
||||||
|
|
||||||
|
const REDDIT_EMBED_QS = '?ref_source=embed&ref=share&embed=true&theme=dark';
|
||||||
|
|
||||||
|
export function getRedditPostEmbed(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
|
||||||
|
// redd.it/<id> short link → reddit.com/comments/<id> (no subreddit needed).
|
||||||
|
if (h === 'redd.it') {
|
||||||
|
const id = u.pathname.replace(/^\/+|\/+$/g, '').split('/')[0];
|
||||||
|
return id ? `https://embed.reddit.com/comments/${id}/${REDDIT_EMBED_QS}` : null;
|
||||||
|
}
|
||||||
|
if (h !== 'reddit.com') return null;
|
||||||
|
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
// embed.reddit.com is the current host (www.redditmedia.com now 301s here).
|
||||||
|
return `https://embed.reddit.com/r/${m[1]}/comments/${m[2]}/${REDDIT_EMBED_QS}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Loom -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getLoomId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname.replace(/^www\./, '') !== 'loom.com') return null;
|
||||||
|
const m = pathname.match(/^\/(?:share|embed)\/([A-Za-z0-9]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
|
||||||
|
|
||||||
|
export function getKickChannel(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname.replace(/^www\./, '') !== 'kick.com') return null;
|
||||||
|
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
||||||
|
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bluesky --------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getBlueskyEmbed(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'bsky.app' && hostname !== 'www.bsky.app') return null;
|
||||||
|
const m = pathname.match(/^\/profile\/([^/]+)\/post\/([A-Za-z0-9]+)/);
|
||||||
|
// authority is a handle or did; embed.bsky.app resolves either.
|
||||||
|
return m ? `https://embed.bsky.app/embed/${m[1]}/app.bsky.feed.post/${m[2]}` : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Embed-URL builders ---------------------------------------------------
|
||||||
|
|
||||||
|
const enc = encodeURIComponent;
|
||||||
|
|
||||||
|
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string, hash?: string): string {
|
||||||
|
if (provider === 'vimeo') {
|
||||||
|
// dnt=1 = Do Not Track (no non-essential cookies); h={hash} required for unlisted.
|
||||||
|
return `https://player.vimeo.com/video/${enc(id)}?autoplay=1&dnt=1${
|
||||||
|
hash ? `&h=${enc(hash)}` : ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
// playsinline=1 keeps iOS Safari from forcing the native fullscreen player.
|
||||||
|
return `https://www.youtube-nocookie.com/embed/${enc(id)}?autoplay=1&rel=0&playsinline=1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spotify compact players (track/episode) are short; collections are taller. */
|
||||||
|
export function spotifyEmbedHeight(type: SpotifyType): number {
|
||||||
|
return type === 'track' || type === 'episode' ? 152 : 352;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve any supported media URL to an embed spec, or null if none applies.
|
||||||
|
* `host` is the current page hostname — required by Twitch's `parent` param.
|
||||||
|
*/
|
||||||
|
export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
||||||
|
const shortsId = getYoutubeShortsId(url);
|
||||||
|
if (shortsId)
|
||||||
|
return { provider: 'youtube', kind: 'portrait', embedUrl: buildVideoEmbedUrl('youtube', shortsId) };
|
||||||
|
|
||||||
|
const ytId = getYouTubeVideoId(url);
|
||||||
|
if (ytId)
|
||||||
|
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) };
|
||||||
|
|
||||||
|
const vimeo = getVimeoParts(url);
|
||||||
|
if (vimeo)
|
||||||
|
return {
|
||||||
|
provider: 'vimeo',
|
||||||
|
kind: 'landscape',
|
||||||
|
embedUrl: buildVideoEmbedUrl('vimeo', vimeo.id, vimeo.hash),
|
||||||
|
};
|
||||||
|
|
||||||
|
// NOTE: TikTok is handled by its own card (TikTokEmbedCard) — short "copy-link"
|
||||||
|
// URLs (vm.tiktok.com, tiktok.com/t/…) need a client-side oEmbed lookup to
|
||||||
|
// resolve the video id, which a sync parser can't do. See isTikTokLink below.
|
||||||
|
|
||||||
|
const dmId = getDailymotionId(url);
|
||||||
|
if (dmId)
|
||||||
|
return {
|
||||||
|
provider: 'dailymotion',
|
||||||
|
kind: 'landscape',
|
||||||
|
// geo.dailymotion.com is the current player; the old /embed/video path was
|
||||||
|
// deprecated in Sept 2024.
|
||||||
|
embedUrl: `https://geo.dailymotion.com/player.html?video=${enc(dmId)}&autoplay=1`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamableId = getStreamableId(url);
|
||||||
|
if (streamableId)
|
||||||
|
return {
|
||||||
|
provider: 'streamable',
|
||||||
|
kind: 'landscape',
|
||||||
|
embedUrl: `https://streamable.com/e/${enc(streamableId)}?autoplay=1`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const twitch = getTwitchTarget(url);
|
||||||
|
if (twitch) {
|
||||||
|
const parent = enc(host);
|
||||||
|
const embedUrl =
|
||||||
|
twitch.type === 'clip'
|
||||||
|
? `https://clips.twitch.tv/embed?clip=${enc(twitch.value)}&parent=${parent}&autoplay=true`
|
||||||
|
: `https://player.twitch.tv/?${twitch.type}=${enc(twitch.value)}&parent=${parent}&autoplay=true`;
|
||||||
|
return { provider: 'twitch', kind: 'landscape', embedUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotify = getSpotifyEmbedTarget(url);
|
||||||
|
if (spotify)
|
||||||
|
return {
|
||||||
|
provider: 'spotify',
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: `https://open.spotify.com/embed/${spotify.type}/${enc(spotify.id)}`,
|
||||||
|
height: spotifyEmbedHeight(spotify.type),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSoundCloudTrack(url))
|
||||||
|
return {
|
||||||
|
provider: 'soundcloud',
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: `https://w.soundcloud.com/player/?url=${enc(
|
||||||
|
url,
|
||||||
|
)}&color=%23ff5500&auto_play=true&show_comments=false&hide_related=true`,
|
||||||
|
height: 166,
|
||||||
|
};
|
||||||
|
|
||||||
|
const apple = getAppleMusicEmbed(url);
|
||||||
|
if (apple)
|
||||||
|
return {
|
||||||
|
provider: 'applemusic',
|
||||||
|
kind: apple.video ? 'landscape' : 'audio',
|
||||||
|
embedUrl: apple.embedUrl,
|
||||||
|
height: apple.video ? undefined : apple.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tidal = getTidalEmbed(url);
|
||||||
|
if (tidal)
|
||||||
|
return { provider: 'tidal', kind: tidal.kind, embedUrl: tidal.embedUrl, height: tidal.height };
|
||||||
|
|
||||||
|
const insta = getInstagramEmbed(url);
|
||||||
|
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
|
||||||
|
|
||||||
|
const reddit = getRedditPostEmbed(url);
|
||||||
|
if (reddit) return { provider: 'reddit', kind: 'rich', embedUrl: reddit, height: 480 };
|
||||||
|
|
||||||
|
const bsky = getBlueskyEmbed(url);
|
||||||
|
if (bsky) return { provider: 'bluesky', kind: 'rich', embedUrl: bsky, height: 600 };
|
||||||
|
|
||||||
|
const loomId = getLoomId(url);
|
||||||
|
if (loomId)
|
||||||
|
return {
|
||||||
|
provider: 'loom',
|
||||||
|
kind: 'landscape',
|
||||||
|
embedUrl: `https://www.loom.com/embed/${enc(loomId)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const kick = getKickChannel(url);
|
||||||
|
if (kick)
|
||||||
|
return {
|
||||||
|
provider: 'kick',
|
||||||
|
kind: 'landscape',
|
||||||
|
embedUrl: `https://player.kick.com/${enc(kick)}?autoplay=true`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user