feat(embeds): fallback for hung embeds and deleted X posts (#200)
CI / Build & Quality Checks (push) Successful in 3m24s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 9m57s
CI / Build & Quality Checks (push) Successful in 3m24s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 9m57s
Probed how real providers fail before picking signals: - X renders an EMPTY frame for a deleted/private/suspended post and says so only via postMessage `twttr.private.no_results`. The post embed now swaps to "This post isn't available…" with an "Open on X" link. - A hung frame never fires `load`. After 20 s every player (media, rich posts, TikTok, Steam widget, X) overlays "This embed is taking too long to load" with Retry (remounts the iframe) and "Open on <site>". A late `load` clears it. - Instagram and Bluesky show their own "removed / not found" page, and a refused request still fires `load` (browser error page), so neither needs or can use a guess. A missing height message is not treated as failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1b5e6a37f5
commit
ba5b1ffe7d
@@ -315,6 +315,33 @@ export const EmbedIframeStatic = style([
|
||||
},
|
||||
]);
|
||||
|
||||
// Shown over (or instead of) an embed that hung or that the provider says is gone.
|
||||
export const EmbedNotice = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: config.space.S200,
|
||||
padding: config.space.S300,
|
||||
textAlign: 'center',
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
},
|
||||
]);
|
||||
|
||||
export const EmbedNoticeStatic = style([
|
||||
EmbedNotice,
|
||||
{
|
||||
position: 'static',
|
||||
minHeight: toRem(120),
|
||||
borderRadius: config.radii.R300,
|
||||
},
|
||||
]);
|
||||
|
||||
export const EmbedPlaceholder = style([
|
||||
DefaultReset,
|
||||
{
|
||||
|
||||
@@ -34,11 +34,13 @@ import { onEnterOrSpace } from '../../utils/keyboard';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import {
|
||||
EMBED_LOAD_TIMEOUT_MS,
|
||||
extractEmbedHeight,
|
||||
getSteamTarget,
|
||||
getTikTokVideoId,
|
||||
getTweetId,
|
||||
isTikTokLink,
|
||||
isTwitterNoResults,
|
||||
MediaEmbed,
|
||||
parseMediaEmbed,
|
||||
steamWidgetEmbedUrl,
|
||||
@@ -669,10 +671,84 @@ function useIframeAutoHeight(origins: string[], initial: number) {
|
||||
return { ref, height };
|
||||
}
|
||||
|
||||
// Embed iframes that never fire `load` (provider down, network stall) would
|
||||
// otherwise sit as an empty box forever. After EMBED_LOAD_TIMEOUT_MS the card
|
||||
// offers Retry / "Open on …"; a late `load` clears the notice on its own.
|
||||
// `attempt` is the iframe's `key`, so Retry remounts it.
|
||||
function useEmbedWatchdog(active: boolean) {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setTimedOut(false);
|
||||
if (!active) return undefined;
|
||||
const t = window.setTimeout(() => setTimedOut(true), EMBED_LOAD_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [active, attempt]);
|
||||
const onLoad = useCallback(() => setLoaded(true), []);
|
||||
const retry = useCallback(() => setAttempt((a) => a + 1), []);
|
||||
return { attempt, onLoad, stalled: active && timedOut && !loaded, retry };
|
||||
}
|
||||
|
||||
function EmbedNotice({
|
||||
message,
|
||||
url,
|
||||
site,
|
||||
onRetry,
|
||||
inFlow,
|
||||
}: {
|
||||
message: string;
|
||||
url: string;
|
||||
site: string;
|
||||
onRetry?: () => void;
|
||||
/** Render in the layout flow instead of as an overlay on the player box. */
|
||||
inFlow?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={inFlow ? previewCss.EmbedNoticeStatic : previewCss.EmbedNotice} role="status">
|
||||
<Text size="T300">{message}</Text>
|
||||
<Box gap="200" wrap="Wrap" justifyContent="Center">
|
||||
{onRetry && (
|
||||
<Chip variant="Secondary" radii="Pill" className={MobileTouchTarget} onClick={onRetry}>
|
||||
<Text size="T200">Retry</Text>
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
<Text size="T200">Open on {site}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EMBED_STALLED_MESSAGE = 'This embed is taking too long to load.';
|
||||
|
||||
// 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 }) {
|
||||
function TweetEmbed({ id, url }: { id: string; url: string }) {
|
||||
const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320);
|
||||
const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(true);
|
||||
// A deleted/private/suspended post renders an EMPTY frame; X only says so
|
||||
// via `twttr.private.no_results`.
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (!TWITTER_ORIGINS.includes(e.origin)) return;
|
||||
if (!ref.current || e.source !== ref.current.contentWindow) return;
|
||||
if (isTwitterNoResults(e.data)) setUnavailable(true);
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [ref]);
|
||||
const theme =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
@@ -680,20 +756,38 @@ function TweetEmbed({ id }: { id: string }) {
|
||||
? 'light'
|
||||
: 'dark';
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<EmbedNotice
|
||||
inFlow
|
||||
message="This post isn't available. It may have been deleted, or the account is private or suspended."
|
||||
url={url}
|
||||
site="X"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<iframe
|
||||
key={attempt}
|
||||
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"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
{stalled && (
|
||||
<EmbedNotice message={EMBED_STALLED_MESSAGE} url={url} site="X" onRetry={retry} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -773,7 +867,7 @@ function TwitterCard({
|
||||
</IconButton>
|
||||
</div>
|
||||
<UrlPreviewContent>
|
||||
<TweetEmbed id={tweetId} />
|
||||
<TweetEmbed id={tweetId} url={url} />
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
);
|
||||
@@ -1151,6 +1245,7 @@ function MediaEmbedCard({
|
||||
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const mediaRef = useRef<HTMLDivElement>(null);
|
||||
const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(playing);
|
||||
|
||||
const rawTitle = prev['og:title'] ?? '';
|
||||
const rawDescription = prev['og:description'] ?? '';
|
||||
@@ -1228,30 +1323,54 @@ function MediaEmbedCard({
|
||||
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
|
||||
{playing && rich ? (
|
||||
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
|
||||
<iframe
|
||||
ref={resizeRef}
|
||||
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 style={{ position: 'relative' }}>
|
||||
<iframe
|
||||
key={attempt}
|
||||
ref={resizeRef}
|
||||
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"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
{stalled && (
|
||||
<EmbedNotice
|
||||
message={EMBED_STALLED_MESSAGE}
|
||||
url={url}
|
||||
site={badge.label}
|
||||
onRetry={retry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
<>
|
||||
<iframe
|
||||
key={attempt}
|
||||
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"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
{stalled && (
|
||||
<EmbedNotice
|
||||
message={EMBED_STALLED_MESSAGE}
|
||||
url={url}
|
||||
site={badge.label}
|
||||
onRetry={retry}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : inlineMediaEmbeds ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1347,6 +1466,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
const [failed, setFailed] = useState(false);
|
||||
const mediaRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const tiktokWatch = useEmbedWatchdog(playing && !!videoId);
|
||||
|
||||
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
@@ -1407,15 +1527,27 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
|
||||
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
|
||||
{playing && videoId ? (
|
||||
<iframe
|
||||
className={previewCss.EmbedIframe}
|
||||
src={tiktokPlayerEmbedUrl(videoId)}
|
||||
title={title || 'TikTok'}
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||
sandbox={EMBED_SANDBOX}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
<>
|
||||
<iframe
|
||||
key={tiktokWatch.attempt}
|
||||
className={previewCss.EmbedIframe}
|
||||
src={tiktokPlayerEmbedUrl(videoId)}
|
||||
title={title || 'TikTok'}
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||
sandbox={EMBED_SANDBOX}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
onLoad={tiktokWatch.onLoad}
|
||||
/>
|
||||
{tiktokWatch.stalled && (
|
||||
<EmbedNotice
|
||||
message={EMBED_STALLED_MESSAGE}
|
||||
url={url}
|
||||
site="TikTok"
|
||||
onRetry={tiktokWatch.retry}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : inlineMediaEmbeds ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1758,6 +1890,7 @@ function SteamAppCard({
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||
const [showWidget, setShowWidget] = useState(false);
|
||||
const steamWatch = useEmbedWatchdog(showWidget);
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
@@ -1791,13 +1924,25 @@ function SteamAppCard({
|
||||
</Text>
|
||||
)}
|
||||
{showWidget ? (
|
||||
<iframe
|
||||
className={previewCss.SteamWidget}
|
||||
src={steamWidgetEmbedUrl(appId)}
|
||||
title={title ? `Steam store: ${title}` : 'Steam store widget'}
|
||||
sandbox={EMBED_SANDBOX}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<iframe
|
||||
key={steamWatch.attempt}
|
||||
className={previewCss.SteamWidget}
|
||||
src={steamWidgetEmbedUrl(appId)}
|
||||
title={title ? `Steam store: ${title}` : 'Steam store widget'}
|
||||
sandbox={EMBED_SANDBOX}
|
||||
loading="lazy"
|
||||
onLoad={steamWatch.onLoad}
|
||||
/>
|
||||
{steamWatch.stalled && (
|
||||
<EmbedNotice
|
||||
message={EMBED_STALLED_MESSAGE}
|
||||
url={url}
|
||||
site="Steam"
|
||||
onRetry={steamWatch.retry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||
<Text
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
buildVideoEmbedUrl,
|
||||
spotifyEmbedHeight,
|
||||
parseMediaEmbed,
|
||||
isTwitterNoResults,
|
||||
} from './videoEmbed';
|
||||
|
||||
const HOST = 'chat.lotusguild.org';
|
||||
@@ -403,3 +404,28 @@ test('parseMediaEmbed: Twitch embed carries the parent host', () => {
|
||||
const clip = parseMediaEmbed('https://clips.twitch.tv/Slug', HOST);
|
||||
assert.ok(clip?.embedUrl.startsWith('https://clips.twitch.tv/embed?clip=Slug'));
|
||||
});
|
||||
|
||||
test('isTwitterNoResults: detects X reporting a missing post (string or parsed)', () => {
|
||||
// Captured from platform.twitter.com/embed/Tweet.html?id=1 (no such post).
|
||||
const msg = {
|
||||
'twttr.embed': {
|
||||
jsonrpc: '2.0',
|
||||
method: 'twttr.private.no_results',
|
||||
id: 'embed-0',
|
||||
params: [{ data: { tweet_id: '1' } }],
|
||||
},
|
||||
};
|
||||
assert.equal(isTwitterNoResults(msg), true);
|
||||
assert.equal(isTwitterNoResults(JSON.stringify(msg)), true);
|
||||
assert.equal(isTwitterNoResults({ 'twttr.embed': [msg['twttr.embed']] }), true);
|
||||
});
|
||||
|
||||
test('isTwitterNoResults: ignores normal embed traffic and junk', () => {
|
||||
const call = (method: string) => ({ 'twttr.embed': { jsonrpc: '2.0', method, params: [] } });
|
||||
assert.equal(isTwitterNoResults(call('twttr.private.initialized')), false);
|
||||
assert.equal(isTwitterNoResults(call('twttr.private.resize')), false);
|
||||
assert.equal(isTwitterNoResults(call('twttr.private.rendered')), false);
|
||||
assert.equal(isTwitterNoResults('not json'), false);
|
||||
assert.equal(isTwitterNoResults(null), false);
|
||||
assert.equal(isTwitterNoResults({ type: 'MEASURE' }), false);
|
||||
});
|
||||
|
||||
@@ -168,6 +168,33 @@ export function extractEmbedHeight(data: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// How long an embed iframe may take to fire `load` before the card offers
|
||||
// Retry / "Open on …". Only a hang trips it: a frame whose request was refused,
|
||||
// or whose provider shows its own error page, still fires `load`.
|
||||
export const EMBED_LOAD_TIMEOUT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* X's post embed (platform.twitter.com/embed/Tweet.html) renders an empty frame
|
||||
* for a deleted, private or suspended post and reports it only via this
|
||||
* postMessage: `{"twttr.embed": {"method": "twttr.private.no_results"}}`.
|
||||
* `data` may be the raw string or the parsed object.
|
||||
*/
|
||||
export function isTwitterNoResults(data: unknown): boolean {
|
||||
let d = data;
|
||||
if (typeof d === 'string') {
|
||||
try {
|
||||
d = JSON.parse(d);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!d || typeof d !== 'object') return false;
|
||||
const tw = (d as { 'twttr.embed'?: unknown })['twttr.embed'];
|
||||
if (!tw) return false;
|
||||
const calls = (Array.isArray(tw) ? tw : [tw]) as Array<{ method?: string }>;
|
||||
return calls.some((c) => c?.method === 'twttr.private.no_results');
|
||||
}
|
||||
|
||||
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/
|
||||
|
||||
Reference in New Issue
Block a user