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

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:
Lotus CI
2026-09-26 11:54:16 -04:00
co-authored by Claude Opus 5.5
parent 1b5e6a37f5
commit ba5b1ffe7d
4 changed files with 277 additions and 52 deletions
@@ -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,
{
+197 -52
View File
@@ -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
+26
View File
@@ -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);
});
+27
View File
@@ -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/