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
@@ -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