fix(embeds): quality pass — close button, focus, a11y, perf, SoundCloud revert
From the quality-review agents: - Revert on.soundcloud.com support: the w.soundcloud widget doesn't follow the redirect (needs an oEmbed resolve, deferred). - Add a Close button to playing video/TikTok embeds and a Collapse button to the expanded X post — playback was previously one-way (only escapable by scrolling). - focus-visible ring on the embed facade (folds resets outline:none, leaving keyboard users with no indicator). - Only subscribe to resize postMessages while the iframe is mounted (was attaching a global listener per Instagram/Reddit facade before play). - TikTok oEmbed fetch now uses AbortController (abort on unmount) + aria-busy / 'Loading…' label on the resolving spinner. - Decorative facade thumbnails use alt="" (parent already names them); drop the dangling-colon aria-labels when there's no title. - Cap URL previews at 6 per message so a link-dump can't spawn dozens of fetches. Tests 728. No CSP change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ export const EmbedFacade = style([
|
|||||||
':hover': {
|
':hover': {
|
||||||
filter: 'brightness(0.85)',
|
filter: 'brightness(0.85)',
|
||||||
},
|
},
|
||||||
|
selectors: {
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: `2px solid ${color.Primary.Main}`,
|
||||||
|
outlineOffset: '-2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -676,7 +676,27 @@ function TwitterCard({
|
|||||||
if (canEmbed && expanded && tweetId) {
|
if (canEmbed && expanded && tweetId) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{header}
|
<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>
|
<UrlPreviewContent>
|
||||||
<TweetEmbed id={tweetId} />
|
<TweetEmbed id={tweetId} />
|
||||||
</UrlPreviewContent>
|
</UrlPreviewContent>
|
||||||
@@ -1059,8 +1079,9 @@ function MediaEmbedCard({
|
|||||||
: embed.provider === 'instagram'
|
: embed.provider === 'instagram'
|
||||||
? INSTAGRAM_ORIGINS
|
? INSTAGRAM_ORIGINS
|
||||||
: REDDIT_ORIGINS;
|
: REDDIT_ORIGINS;
|
||||||
|
// Only subscribe to resize messages while the iframe is actually mounted.
|
||||||
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
|
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
|
||||||
resizeOrigins,
|
playing ? resizeOrigins : NO_RESIZE_ORIGINS,
|
||||||
embed.height ?? 480,
|
embed.height ?? 480,
|
||||||
);
|
);
|
||||||
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
|
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
|
||||||
@@ -1093,7 +1114,8 @@ function MediaEmbedCard({
|
|||||||
const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
|
const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
|
||||||
|
|
||||||
const thumb = thumbnailUrl ? (
|
const thumb = thumbnailUrl ? (
|
||||||
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
|
// alt="" — the parent button/link already carries the accessible name.
|
||||||
|
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<span className={previewCss.EmbedPlaceholder}>
|
<span className={previewCss.EmbedPlaceholder}>
|
||||||
<Icon size="600" src={Icons.Play} />
|
<Icon size="600" src={Icons.Play} />
|
||||||
@@ -1155,7 +1177,7 @@ function MediaEmbedCard({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={previewCss.EmbedFacade}
|
className={previewCss.EmbedFacade}
|
||||||
aria-label={`Open on ${badge.label}: ${title}`}
|
aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`}
|
||||||
>
|
>
|
||||||
{thumb}
|
{thumb}
|
||||||
{playOverlay}
|
{playOverlay}
|
||||||
@@ -1177,10 +1199,28 @@ function MediaEmbedCard({
|
|||||||
>
|
>
|
||||||
<SiteBadge label={badge.label} colorClass={badge.class} />
|
<SiteBadge label={badge.label} colorClass={badge.class} />
|
||||||
</Text>
|
</Text>
|
||||||
{playing && video && (
|
{playing && (
|
||||||
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen">
|
<Box alignItems="Center" gap="100" shrink="No">
|
||||||
<Text size="T200">⛶ Fullscreen</Text>
|
{video && (
|
||||||
</Chip>
|
<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>
|
</Box>
|
||||||
{/* While a video plays, drop the title/description so the player isn't
|
{/* While a video plays, drop the title/description so the player isn't
|
||||||
@@ -1213,6 +1253,10 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const mediaRef = useRef<HTMLDivElement>(null);
|
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 mxcImage = prev['og:image'] as string | undefined;
|
||||||
const rawTitle = prev['og:title'] ?? '';
|
const rawTitle = prev['og:title'] ?? '';
|
||||||
@@ -1228,9 +1272,12 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
}
|
}
|
||||||
setResolving(true);
|
setResolving(true);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(tiktokOembedUrl(url));
|
const res = await fetch(tiktokOembedUrl(url), { signal: controller.signal });
|
||||||
const id = tiktokIdFromOembed(await res.json());
|
const id = tiktokIdFromOembed(await res.json());
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
if (id) {
|
if (id) {
|
||||||
setVideoId(id);
|
setVideoId(id);
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
@@ -1238,9 +1285,9 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
setFailed(true);
|
setFailed(true);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setFailed(true);
|
if (!controller.signal.aborted) setFailed(true);
|
||||||
} finally {
|
} finally {
|
||||||
setResolving(false);
|
if (!controller.signal.aborted) setResolving(false);
|
||||||
}
|
}
|
||||||
}, [url, videoId]);
|
}, [url, videoId]);
|
||||||
|
|
||||||
@@ -1249,7 +1296,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
const facadeInner = (
|
const facadeInner = (
|
||||||
<>
|
<>
|
||||||
{thumbnailUrl ? (
|
{thumbnailUrl ? (
|
||||||
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
|
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<span className={previewCss.EmbedPlaceholder} />
|
<span className={previewCss.EmbedPlaceholder} />
|
||||||
)}
|
)}
|
||||||
@@ -1280,7 +1327,10 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
className={previewCss.EmbedFacade}
|
className={previewCss.EmbedFacade}
|
||||||
onClick={start}
|
onClick={start}
|
||||||
disabled={resolving}
|
disabled={resolving}
|
||||||
aria-label={`Play TikTok${title ? `: ${title}` : ''}`}
|
aria-busy={resolving}
|
||||||
|
aria-label={
|
||||||
|
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{facadeInner}
|
{facadeInner}
|
||||||
</button>
|
</button>
|
||||||
@@ -1311,9 +1361,25 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
<SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
|
<SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
|
||||||
</Text>
|
</Text>
|
||||||
{playing && videoId && (
|
{playing && videoId && (
|
||||||
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen">
|
<Box alignItems="Center" gap="100" shrink="No">
|
||||||
<Text size="T200">⛶ Fullscreen</Text>
|
<Chip
|
||||||
</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>
|
</Box>
|
||||||
{failed && (
|
{failed && (
|
||||||
|
|||||||
@@ -132,11 +132,11 @@ test('Spotify target + height', () => {
|
|||||||
assert.equal(spotifyEmbedHeight('album'), 352);
|
assert.equal(spotifyEmbedHeight('album'), 352);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SoundCloud track detection (+ on. short link)', () => {
|
test('SoundCloud track detection', () => {
|
||||||
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true);
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true);
|
||||||
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
|
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
|
||||||
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), true); // share short link
|
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
|
||||||
assert.equal(parseMediaEmbed('https://on.soundcloud.com/abc123', 'h')?.provider, 'soundcloud');
|
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
|
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
|
||||||
|
|||||||
@@ -247,10 +247,10 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
|
|||||||
export function isSoundCloudTrack(url: string): boolean {
|
export function isSoundCloudTrack(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
const h = hostname.replace(/^www\./, '');
|
// NOTE: on.soundcloud.com short links are NOT handled here — the w.soundcloud
|
||||||
// on.soundcloud.com/<code> is a share short link — the widget follows it.
|
// widget resolver doesn't follow the redirect; supporting them needs an oEmbed
|
||||||
if (h === 'on.soundcloud.com') return pathname.replace(/^\/+|\/+$/g, '').length > 0;
|
// round-trip (soundcloud.com/oembed is CORS-enabled) to get the canonical URL.
|
||||||
if (h !== 'soundcloud.com') return false;
|
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
|
||||||
// /<artist>/<track|sets/set> — at least two segments, not a bare profile
|
// /<artist>/<track|sets/set> — at least two segments, not a bare profile
|
||||||
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
||||||
return parts.length >= 2;
|
return parts.length >= 2;
|
||||||
|
|||||||
Reference in New Issue
Block a user