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([
|
export const EmbedPlaceholder = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ import { onEnterOrSpace } from '../../utils/keyboard';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
|
EMBED_LOAD_TIMEOUT_MS,
|
||||||
extractEmbedHeight,
|
extractEmbedHeight,
|
||||||
getSteamTarget,
|
getSteamTarget,
|
||||||
getTikTokVideoId,
|
getTikTokVideoId,
|
||||||
getTweetId,
|
getTweetId,
|
||||||
isTikTokLink,
|
isTikTokLink,
|
||||||
|
isTwitterNoResults,
|
||||||
MediaEmbed,
|
MediaEmbed,
|
||||||
parseMediaEmbed,
|
parseMediaEmbed,
|
||||||
steamWidgetEmbedUrl,
|
steamWidgetEmbedUrl,
|
||||||
@@ -669,10 +671,84 @@ function useIframeAutoHeight(origins: string[], initial: number) {
|
|||||||
return { ref, height };
|
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.
|
// Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets.
|
||||||
// Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade).
|
// 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 { 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 =
|
const theme =
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
window.matchMedia &&
|
window.matchMedia &&
|
||||||
@@ -680,20 +756,38 @@ function TweetEmbed({ id }: { id: string }) {
|
|||||||
? 'light'
|
? 'light'
|
||||||
: 'dark';
|
: '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 (
|
return (
|
||||||
<iframe
|
<div style={{ position: 'relative' }}>
|
||||||
ref={ref}
|
<iframe
|
||||||
className={previewCss.EmbedIframeStatic}
|
key={attempt}
|
||||||
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
|
ref={ref}
|
||||||
id,
|
className={previewCss.EmbedIframeStatic}
|
||||||
)}&theme=${theme}&dnt=true`}
|
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
|
||||||
title="Post on X"
|
id,
|
||||||
style={{ height: `${height}px` }}
|
)}&theme=${theme}&dnt=true`}
|
||||||
sandbox={EMBED_SANDBOX}
|
title="Post on X"
|
||||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
style={{ height: `${height}px` }}
|
||||||
loading="lazy"
|
sandbox={EMBED_SANDBOX}
|
||||||
scrolling="no"
|
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>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<UrlPreviewContent>
|
<UrlPreviewContent>
|
||||||
<TweetEmbed id={tweetId} />
|
<TweetEmbed id={tweetId} url={url} />
|
||||||
</UrlPreviewContent>
|
</UrlPreviewContent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1151,6 +1245,7 @@ function MediaEmbedCard({
|
|||||||
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const mediaRef = useRef<HTMLDivElement>(null);
|
const mediaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(playing);
|
||||||
|
|
||||||
const rawTitle = prev['og:title'] ?? '';
|
const rawTitle = prev['og:title'] ?? '';
|
||||||
const rawDescription = prev['og:description'] ?? '';
|
const rawDescription = prev['og:description'] ?? '';
|
||||||
@@ -1228,30 +1323,54 @@ function MediaEmbedCard({
|
|||||||
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
|
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
|
||||||
{playing && rich ? (
|
{playing && rich ? (
|
||||||
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
|
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
|
||||||
<iframe
|
<div style={{ position: 'relative' }}>
|
||||||
ref={resizeRef}
|
<iframe
|
||||||
className={previewCss.EmbedIframeStatic}
|
key={attempt}
|
||||||
src={embed.embedUrl}
|
ref={resizeRef}
|
||||||
title={title || badge.label}
|
className={previewCss.EmbedIframeStatic}
|
||||||
style={{ height: `${resizeHeight}px` }}
|
src={embed.embedUrl}
|
||||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
title={title || badge.label}
|
||||||
sandbox={EMBED_SANDBOX}
|
style={{ height: `${resizeHeight}px` }}
|
||||||
allowFullScreen
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
loading="lazy"
|
sandbox={EMBED_SANDBOX}
|
||||||
scrolling="no"
|
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}>
|
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
|
||||||
{playing ? (
|
{playing ? (
|
||||||
<iframe
|
<>
|
||||||
className={previewCss.EmbedIframe}
|
<iframe
|
||||||
src={embed.embedUrl}
|
key={attempt}
|
||||||
title={title || badge.label}
|
className={previewCss.EmbedIframe}
|
||||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
src={embed.embedUrl}
|
||||||
sandbox={EMBED_SANDBOX}
|
title={title || badge.label}
|
||||||
allowFullScreen
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
loading="lazy"
|
sandbox={EMBED_SANDBOX}
|
||||||
/>
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={onLoad}
|
||||||
|
/>
|
||||||
|
{stalled && (
|
||||||
|
<EmbedNotice
|
||||||
|
message={EMBED_STALLED_MESSAGE}
|
||||||
|
url={url}
|
||||||
|
site={badge.label}
|
||||||
|
onRetry={retry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : inlineMediaEmbeds ? (
|
) : inlineMediaEmbeds ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1347,6 +1466,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
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);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const tiktokWatch = useEmbedWatchdog(playing && !!videoId);
|
||||||
|
|
||||||
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
|
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
|
||||||
useEffect(() => () => abortRef.current?.abort(), []);
|
useEffect(() => () => abortRef.current?.abort(), []);
|
||||||
@@ -1407,15 +1527,27 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
|
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
|
||||||
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
|
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
|
||||||
{playing && videoId ? (
|
{playing && videoId ? (
|
||||||
<iframe
|
<>
|
||||||
className={previewCss.EmbedIframe}
|
<iframe
|
||||||
src={tiktokPlayerEmbedUrl(videoId)}
|
key={tiktokWatch.attempt}
|
||||||
title={title || 'TikTok'}
|
className={previewCss.EmbedIframe}
|
||||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
src={tiktokPlayerEmbedUrl(videoId)}
|
||||||
sandbox={EMBED_SANDBOX}
|
title={title || 'TikTok'}
|
||||||
allowFullScreen
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
loading="lazy"
|
sandbox={EMBED_SANDBOX}
|
||||||
/>
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={tiktokWatch.onLoad}
|
||||||
|
/>
|
||||||
|
{tiktokWatch.stalled && (
|
||||||
|
<EmbedNotice
|
||||||
|
message={EMBED_STALLED_MESSAGE}
|
||||||
|
url={url}
|
||||||
|
site="TikTok"
|
||||||
|
onRetry={tiktokWatch.retry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : inlineMediaEmbeds ? (
|
) : inlineMediaEmbeds ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1758,6 +1890,7 @@ function SteamAppCard({
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||||
const [showWidget, setShowWidget] = useState(false);
|
const [showWidget, setShowWidget] = useState(false);
|
||||||
|
const steamWatch = useEmbedWatchdog(showWidget);
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const mxcImage = prev['og:image'] as string | undefined;
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
@@ -1791,13 +1924,25 @@ function SteamAppCard({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{showWidget ? (
|
{showWidget ? (
|
||||||
<iframe
|
<div style={{ position: 'relative' }}>
|
||||||
className={previewCss.SteamWidget}
|
<iframe
|
||||||
src={steamWidgetEmbedUrl(appId)}
|
key={steamWatch.attempt}
|
||||||
title={title ? `Steam store: ${title}` : 'Steam store widget'}
|
className={previewCss.SteamWidget}
|
||||||
sandbox={EMBED_SANDBOX}
|
src={steamWidgetEmbedUrl(appId)}
|
||||||
loading="lazy"
|
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">
|
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
buildVideoEmbedUrl,
|
buildVideoEmbedUrl,
|
||||||
spotifyEmbedHeight,
|
spotifyEmbedHeight,
|
||||||
parseMediaEmbed,
|
parseMediaEmbed,
|
||||||
|
isTwitterNoResults,
|
||||||
} from './videoEmbed';
|
} from './videoEmbed';
|
||||||
|
|
||||||
const HOST = 'chat.lotusguild.org';
|
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);
|
const clip = parseMediaEmbed('https://clips.twitch.tv/Slug', HOST);
|
||||||
assert.ok(clip?.embedUrl.startsWith('https://clips.twitch.tv/embed?clip=Slug'));
|
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;
|
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 {
|
export function tiktokPlayerEmbedUrl(id: string): string {
|
||||||
// Pure 9:16 video player. music_info/description default OFF (they'd switch
|
// 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/
|
// TikTok to a wide "video + info panel" layout); controls/progress/play/volume/
|
||||||
|
|||||||
Reference in New Issue
Block a user