diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 850ec05fd..3f89a021c 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -457,6 +457,21 @@ export const BadgeInstagram = style({ 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({ backgroundColor: '#000000', color: '#ffffff', diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 89abee899..0bc71cb0e 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -558,6 +558,7 @@ function TikTokCard({ const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com']; const INSTAGRAM_ORIGINS = ['https://www.instagram.com']; const REDDIT_ORIGINS = ['https://embed.reddit.com']; +const BLUESKY_ORIGINS = ['https://embed.bsky.app']; const NO_RESIZE_ORIGINS: string[] = []; const EMBED_MAX_HEIGHT = 1400; @@ -1038,6 +1039,9 @@ const EMBED_BADGE: Record = { tidal: { label: 'TIDAL', class: previewCss.BadgeTidal }, instagram: { label: 'Instagram', class: previewCss.BadgeInstagram }, 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 @@ -1078,7 +1082,9 @@ function MediaEmbedCard({ ? NO_RESIZE_ORIGINS : embed.provider === 'instagram' ? INSTAGRAM_ORIGINS - : REDDIT_ORIGINS; + : embed.provider === 'bluesky' + ? BLUESKY_ORIGINS + : REDDIT_ORIGINS; // Only subscribe to resize messages while the iframe is actually mounted. const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight( playing ? resizeOrigins : NO_RESIZE_ORIGINS, diff --git a/src/app/utils/videoEmbed.test.ts b/src/app/utils/videoEmbed.test.ts index 3971da021..42f1ee546 100644 --- a/src/app/utils/videoEmbed.test.ts +++ b/src/app/utils/videoEmbed.test.ts @@ -21,6 +21,9 @@ import { getTidalEmbed, getInstagramEmbed, getRedditPostEmbed, + getBlueskyEmbed, + getLoomId, + getKickChannel, buildVideoEmbedUrl, spotifyEmbedHeight, parseMediaEmbed, @@ -230,6 +233,30 @@ test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => { 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', () => { const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST); assert.equal(chan?.provider, 'twitch'); diff --git a/src/app/utils/videoEmbed.ts b/src/app/utils/videoEmbed.ts index 2de03a79d..a1b021c07 100644 --- a/src/app/utils/videoEmbed.ts +++ b/src/app/utils/videoEmbed.ts @@ -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 --------------------------------------------------- const enc = encodeURIComponent; @@ -479,5 +519,24 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null { const reddit = getRedditPostEmbed(url); 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; }