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:
@@ -676,7 +676,27 @@ function TwitterCard({
|
||||
if (canEmbed && expanded && tweetId) {
|
||||
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>
|
||||
<TweetEmbed id={tweetId} />
|
||||
</UrlPreviewContent>
|
||||
@@ -1059,8 +1079,9 @@ function MediaEmbedCard({
|
||||
: embed.provider === 'instagram'
|
||||
? INSTAGRAM_ORIGINS
|
||||
: REDDIT_ORIGINS;
|
||||
// Only subscribe to resize messages while the iframe is actually mounted.
|
||||
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
|
||||
resizeOrigins,
|
||||
playing ? resizeOrigins : NO_RESIZE_ORIGINS,
|
||||
embed.height ?? 480,
|
||||
);
|
||||
// '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 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}>
|
||||
<Icon size="600" src={Icons.Play} />
|
||||
@@ -1155,7 +1177,7 @@ function MediaEmbedCard({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={previewCss.EmbedFacade}
|
||||
aria-label={`Open on ${badge.label}: ${title}`}
|
||||
aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`}
|
||||
>
|
||||
{thumb}
|
||||
{playOverlay}
|
||||
@@ -1177,10 +1199,28 @@ function MediaEmbedCard({
|
||||
>
|
||||
<SiteBadge label={badge.label} colorClass={badge.class} />
|
||||
</Text>
|
||||
{playing && video && (
|
||||
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen">
|
||||
<Text size="T200">⛶ Fullscreen</Text>
|
||||
</Chip>
|
||||
{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
|
||||
@@ -1213,6 +1253,10 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
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'] ?? '';
|
||||
@@ -1228,9 +1272,12 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
}
|
||||
setResolving(true);
|
||||
setFailed(false);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const res = await fetch(tiktokOembedUrl(url));
|
||||
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);
|
||||
@@ -1238,9 +1285,9 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
setFailed(true);
|
||||
}
|
||||
} catch {
|
||||
setFailed(true);
|
||||
if (!controller.signal.aborted) setFailed(true);
|
||||
} finally {
|
||||
setResolving(false);
|
||||
if (!controller.signal.aborted) setResolving(false);
|
||||
}
|
||||
}, [url, videoId]);
|
||||
|
||||
@@ -1249,7 +1296,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
const facadeInner = (
|
||||
<>
|
||||
{thumbnailUrl ? (
|
||||
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
|
||||
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className={previewCss.EmbedPlaceholder} />
|
||||
)}
|
||||
@@ -1280,7 +1327,10 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
className={previewCss.EmbedFacade}
|
||||
onClick={start}
|
||||
disabled={resolving}
|
||||
aria-label={`Play TikTok${title ? `: ${title}` : ''}`}
|
||||
aria-busy={resolving}
|
||||
aria-label={
|
||||
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
|
||||
}
|
||||
>
|
||||
{facadeInner}
|
||||
</button>
|
||||
@@ -1311,9 +1361,25 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
<SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
|
||||
</Text>
|
||||
{playing && videoId && (
|
||||
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen">
|
||||
<Text size="T200">⛶ Fullscreen</Text>
|
||||
</Chip>
|
||||
<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 && (
|
||||
|
||||
Reference in New Issue
Block a user