feat(embeds): add Bluesky, Loom, Kick
From the coverage-review agent's ranked recommendations (clean iframes, ids in
the URL, one CSP host each):
- Bluesky: bsky.app/profile/{authority}/post/{rkey} → embed.bsky.app (rich, self-
resizing like the other post embeds).
- Loom: loom.com/share|embed/{id} → www.loom.com/embed/{id} (16:9).
- Kick: kick.com/{channel} → player.kick.com/{channel} (live channels only; VODs/
clips have no clean embed and fall back to a link).
Parsers unit-tested. CSP frame-src gains embed.bsky.app / www.loom.com /
player.kick.com (desktop + live web, both updated). Needs live verification once
deployed since the embeds themselves can't be exercised from here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -457,6 +457,21 @@ export const BadgeInstagram = style({
|
|||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BadgeBluesky = style({
|
||||||
|
backgroundColor: '#0085ff',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeLoom = style({
|
||||||
|
backgroundColor: '#625df5',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeKick = style({
|
||||||
|
backgroundColor: '#53fc18',
|
||||||
|
color: '#000000',
|
||||||
|
});
|
||||||
|
|
||||||
export const BadgeTidal = style({
|
export const BadgeTidal = style({
|
||||||
backgroundColor: '#000000',
|
backgroundColor: '#000000',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
|
|||||||
@@ -558,6 +558,7 @@ function TikTokCard({
|
|||||||
const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com'];
|
const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com'];
|
||||||
const INSTAGRAM_ORIGINS = ['https://www.instagram.com'];
|
const INSTAGRAM_ORIGINS = ['https://www.instagram.com'];
|
||||||
const REDDIT_ORIGINS = ['https://embed.reddit.com'];
|
const REDDIT_ORIGINS = ['https://embed.reddit.com'];
|
||||||
|
const BLUESKY_ORIGINS = ['https://embed.bsky.app'];
|
||||||
const NO_RESIZE_ORIGINS: string[] = [];
|
const NO_RESIZE_ORIGINS: string[] = [];
|
||||||
const EMBED_MAX_HEIGHT = 1400;
|
const EMBED_MAX_HEIGHT = 1400;
|
||||||
|
|
||||||
@@ -1038,6 +1039,9 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
|
|||||||
tidal: { label: 'TIDAL', class: previewCss.BadgeTidal },
|
tidal: { label: 'TIDAL', class: previewCss.BadgeTidal },
|
||||||
instagram: { label: 'Instagram', class: previewCss.BadgeInstagram },
|
instagram: { label: 'Instagram', class: previewCss.BadgeInstagram },
|
||||||
reddit: { label: 'Reddit', class: previewCss.BadgeReddit },
|
reddit: { label: 'Reddit', class: previewCss.BadgeReddit },
|
||||||
|
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
||||||
|
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
||||||
|
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
||||||
@@ -1078,6 +1082,8 @@ function MediaEmbedCard({
|
|||||||
? NO_RESIZE_ORIGINS
|
? NO_RESIZE_ORIGINS
|
||||||
: embed.provider === 'instagram'
|
: embed.provider === 'instagram'
|
||||||
? INSTAGRAM_ORIGINS
|
? INSTAGRAM_ORIGINS
|
||||||
|
: embed.provider === 'bluesky'
|
||||||
|
? BLUESKY_ORIGINS
|
||||||
: REDDIT_ORIGINS;
|
: REDDIT_ORIGINS;
|
||||||
// Only subscribe to resize messages while the iframe is actually mounted.
|
// Only subscribe to resize messages while the iframe is actually mounted.
|
||||||
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
|
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
getTidalEmbed,
|
getTidalEmbed,
|
||||||
getInstagramEmbed,
|
getInstagramEmbed,
|
||||||
getRedditPostEmbed,
|
getRedditPostEmbed,
|
||||||
|
getBlueskyEmbed,
|
||||||
|
getLoomId,
|
||||||
|
getKickChannel,
|
||||||
buildVideoEmbedUrl,
|
buildVideoEmbedUrl,
|
||||||
spotifyEmbedHeight,
|
spotifyEmbedHeight,
|
||||||
parseMediaEmbed,
|
parseMediaEmbed,
|
||||||
@@ -230,6 +233,30 @@ test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
|
|||||||
assert.equal(parseMediaEmbed('https://tidal.com/browse/track/1', 'h')?.provider, 'tidal');
|
assert.equal(parseMediaEmbed('https://tidal.com/browse/track/1', 'h')?.provider, 'tidal');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Bluesky / Loom / Kick', () => {
|
||||||
|
assert.equal(
|
||||||
|
getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social/post/3kabc'),
|
||||||
|
'https://embed.bsky.app/embed/alice.bsky.social/app.bsky.feed.post/3kabc',
|
||||||
|
);
|
||||||
|
assert.equal(getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social'), null);
|
||||||
|
assert.equal(parseMediaEmbed('https://bsky.app/profile/a.bsky.social/post/3k', 'h')?.kind, 'rich');
|
||||||
|
|
||||||
|
assert.equal(getLoomId('https://www.loom.com/share/abc123DEF'), 'abc123DEF');
|
||||||
|
assert.equal(getLoomId('https://www.loom.com/embed/abc123DEF'), 'abc123DEF');
|
||||||
|
assert.equal(
|
||||||
|
parseMediaEmbed('https://www.loom.com/share/xyz', 'h')?.embedUrl,
|
||||||
|
'https://www.loom.com/embed/xyz',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
|
||||||
|
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
|
||||||
|
assert.ok(
|
||||||
|
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
|
||||||
|
'player.kick.com/streamer?autoplay=true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseMediaEmbed: Twitch embed carries the parent host', () => {
|
test('parseMediaEmbed: Twitch embed carries the parent host', () => {
|
||||||
const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST);
|
const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST);
|
||||||
assert.equal(chan?.provider, 'twitch');
|
assert.equal(chan?.provider, 'twitch');
|
||||||
|
|||||||
@@ -368,6 +368,46 @@ export function getRedditPostEmbed(url: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Loom -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getLoomId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname.replace(/^www\./, '') !== 'loom.com') return null;
|
||||||
|
const m = pathname.match(/^\/(?:share|embed)\/([A-Za-z0-9]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
|
||||||
|
|
||||||
|
export function getKickChannel(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname.replace(/^www\./, '') !== 'kick.com') return null;
|
||||||
|
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
||||||
|
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bluesky --------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getBlueskyEmbed(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'bsky.app' && hostname !== 'www.bsky.app') return null;
|
||||||
|
const m = pathname.match(/^\/profile\/([^/]+)\/post\/([A-Za-z0-9]+)/);
|
||||||
|
// authority is a handle or did; embed.bsky.app resolves either.
|
||||||
|
return m ? `https://embed.bsky.app/embed/${m[1]}/app.bsky.feed.post/${m[2]}` : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Embed-URL builders ---------------------------------------------------
|
// --- Embed-URL builders ---------------------------------------------------
|
||||||
|
|
||||||
const enc = encodeURIComponent;
|
const enc = encodeURIComponent;
|
||||||
@@ -479,5 +519,24 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
|||||||
const reddit = getRedditPostEmbed(url);
|
const reddit = getRedditPostEmbed(url);
|
||||||
if (reddit) return { provider: 'reddit', kind: 'rich', embedUrl: reddit, height: 480 };
|
if (reddit) return { provider: 'reddit', kind: 'rich', embedUrl: reddit, height: 480 };
|
||||||
|
|
||||||
|
const bsky = getBlueskyEmbed(url);
|
||||||
|
if (bsky) return { provider: 'bluesky', kind: 'rich', embedUrl: bsky, height: 600 };
|
||||||
|
|
||||||
|
const loomId = getLoomId(url);
|
||||||
|
if (loomId)
|
||||||
|
return {
|
||||||
|
provider: 'loom',
|
||||||
|
kind: 'landscape',
|
||||||
|
embedUrl: `https://www.loom.com/embed/${enc(loomId)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const kick = getKickChannel(url);
|
||||||
|
if (kick)
|
||||||
|
return {
|
||||||
|
provider: 'kick',
|
||||||
|
kind: 'landscape',
|
||||||
|
embedUrl: `https://player.kick.com/${enc(kick)}?autoplay=true`,
|
||||||
|
};
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user