feat(embeds): interactive X/Twitter post embed + Apple Music player
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s

X/Twitter: the static OG card was a snapshot — no video, no galleries/threads.
Keep it as the (reliable) facade and add a 'View post' button that loads the
official platform.twitter.com interactive embed on click: playable video/GIF,
image galleries, quote tweets. The embed self-sizes via a scoped postMessage
resize listener (matched to our iframe + the platform.twitter.com origin). Nothing
loads from X until the user clicks, and the link still works if X blocks the frame.

Apple Music: music.apple.com album/playlist/song links now play inline via the
embed.music.apple.com player (compact for a single song, tall for collections),
through the existing MediaEmbedCard audio path.

Pure parsers/builders unit-tested (13 cases). Desktop CSP frame-src adds
platform.twitter.com + embed.music.apple.com (separate commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:14:53 -04:00
parent 6c903fe3e5
commit 791b1cb5ea
4 changed files with 198 additions and 27 deletions
@@ -257,6 +257,16 @@ export const EmbedIframe = style([
}, },
]); ]);
// Variable-height iframe that sizes itself (X/Twitter post embed — height set inline).
export const EmbedIframeStatic = style([
DefaultReset,
{
width: '100%',
border: 0,
display: 'block',
},
]);
export const EmbedPlaceholder = style([ export const EmbedPlaceholder = style([
DefaultReset, DefaultReset,
{ {
@@ -415,6 +425,11 @@ export const BadgeStreamable = style({
color: '#ffffff', color: '#ffffff',
}); });
export const BadgeAppleMusic = style({
backgroundColor: '#fa243c',
color: '#ffffff',
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Twitch LIVE badge // Twitch LIVE badge
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay'; import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -19,7 +19,7 @@ import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard'; 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 { MediaEmbed, parseMediaEmbed } from '../../utils/videoEmbed'; import { getTweetId, MediaEmbed, parseMediaEmbed } from '../../utils/videoEmbed';
const linkStyles = { color: color.Success.Main }; const linkStyles = { color: color.Success.Main };
@@ -544,6 +544,61 @@ function TikTokCard({
// Card 3: Twitter / X // Card 3: Twitter / X
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Interactive X/Twitter post embed. The official platform.twitter.com iframe
// renders the full post — playable video/GIF, image galleries, quote tweets —
// and reports its height back to us via postMessage, which we apply so the card
// grows to fit. Scoped to messages from OUR iframe + the platform.twitter.com
// origin. Only mounted after the user clicks "View post" (privacy facade).
function TweetEmbed({ id }: { id: string }) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [height, setHeight] = useState(320);
const theme =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.origin !== 'https://platform.twitter.com') return;
if (iframeRef.current && e.source !== iframeRef.current.contentWindow) return;
let payload: unknown = e.data;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
return;
}
}
const embed = (payload as { 'twttr.embed'?: unknown } | null)?.['twttr.embed'];
const calls = Array.isArray(embed) ? embed : embed ? [embed] : [];
calls.forEach((c: { method?: string; params?: Array<{ height?: number }> }) => {
const h = c?.method === 'twttr.private.resize' ? c.params?.[0]?.height : undefined;
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), 1400));
});
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
return (
<iframe
ref={iframeRef}
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="allow-scripts allow-same-origin allow-popups allow-presentation"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy"
scrolling="no"
/>
);
}
function TwitterCard({ function TwitterCard({
url, url,
prev, prev,
@@ -555,12 +610,16 @@ function TwitterCard({
mx: ReturnType<typeof useMatrixClient>; mx: ReturnType<typeof useMatrixClient>;
useAuthentication: boolean; useAuthentication: boolean;
}) { }) {
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [expanded, setExpanded] = useState(false);
const rawTitle = (prev['og:title'] as string | undefined) ?? ''; const rawTitle = (prev['og:title'] as string | undefined) ?? '';
const rawDescription = (prev['og:description'] as string | undefined) ?? ''; const rawDescription = (prev['og:description'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined; const mxcImage = prev['og:image'] as string | undefined;
const imageWidth = prev['og:image:width'] as number | undefined; const imageWidth = prev['og:image:width'] as number | undefined;
const isTweet = isTwitterTweet(url); const isTweet = isTwitterTweet(url);
const tweetId = getTweetId(url);
const { name, handle, text } = parseTweetTitle(rawTitle); const { name, handle, text } = parseTweetTitle(rawTitle);
const tweetText = text ?? (isTweet ? rawDescription : undefined); const tweetText = text ?? (isTweet ? rawDescription : undefined);
@@ -571,9 +630,9 @@ function TwitterCard({
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false) ? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
: null; : null;
return ( const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;
<>
{/* Header row */} const header = (
<div className={previewCss.TwitterHeader}> <div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} /> <XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate> <Text priority="400" style={{ fontWeight: 600 }} truncate>
@@ -587,6 +646,23 @@ function TwitterCard({
<Box grow="Yes" /> <Box grow="Yes" />
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} /> <SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
</div> </div>
);
// Expanded: the interactive, self-sizing post embed (playable video, gallery…).
if (canEmbed && expanded && tweetId) {
return (
<>
{header}
<UrlPreviewContent>
<TweetEmbed id={tweetId} />
</UrlPreviewContent>
</>
);
}
return (
<>
{header}
<UrlPreviewContent> <UrlPreviewContent>
{isTweet && tweetText ? ( {isTweet && tweetText ? (
@@ -609,6 +685,7 @@ function TwitterCard({
/> />
)} )}
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text <Text
size="T200" size="T200"
priority="300" priority="300"
@@ -620,6 +697,17 @@ function TwitterCard({
> >
{getDomain(url)} {getDomain(url)}
</Text> </Text>
{canEmbed && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => setExpanded(true)}
before={<Icon size="50" src={Icons.Play} />}
>
<Text size="T200">View post</Text>
</Chip>
)}
</Box>
</UrlPreviewContent> </UrlPreviewContent>
</> </>
); );
@@ -902,6 +990,7 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
twitch: { label: 'Twitch', class: previewCss.BadgeTwitchPurple }, twitch: { label: 'Twitch', class: previewCss.BadgeTwitchPurple },
spotify: { label: 'Spotify', class: previewCss.BadgeSpotify }, spotify: { label: 'Spotify', class: previewCss.BadgeSpotify },
soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud }, soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud },
applemusic: { label: 'Apple Music', class: previewCss.BadgeAppleMusic },
}; };
// One media-forward tile for every embeddable provider (video / portrait / audio). // One media-forward tile for every embeddable provider (video / portrait / audio).
+27
View File
@@ -11,6 +11,8 @@ import {
getTwitchTarget, getTwitchTarget,
getSpotifyEmbedTarget, getSpotifyEmbedTarget,
isSoundCloudTrack, isSoundCloudTrack,
getAppleMusicEmbed,
getTweetId,
buildVideoEmbedUrl, buildVideoEmbedUrl,
spotifyEmbedHeight, spotifyEmbedHeight,
parseMediaEmbed, parseMediaEmbed,
@@ -109,6 +111,31 @@ test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
assert.equal(parseMediaEmbed('https://example.com/x', HOST), null); assert.equal(parseMediaEmbed('https://example.com/x', HOST), null);
}); });
test('Apple Music: album vs single song height, embed host swap', () => {
const album = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456');
assert.equal(album?.embedUrl, 'https://embed.music.apple.com/us/album/some-album/123456');
assert.equal(album?.height, 450);
const song = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456?i=789');
assert.equal(song?.height, 175);
assert.ok(song?.embedUrl.includes('?i=789'));
assert.equal(getAppleMusicEmbed('https://music.apple.com/us/browse'), null);
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
});
test('getTweetId', () => {
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
assert.equal(getTweetId('https://x.com/user'), null);
assert.equal(getTweetId('https://youtube.com/watch?v=x'), null);
});
test('parseMediaEmbed: Apple Music → audio', () => {
const m = parseMediaEmbed('https://music.apple.com/us/album/x/1?i=2', 'chat.lotusguild.org');
assert.equal(m?.provider, 'applemusic');
assert.equal(m?.kind, 'audio');
assert.equal(m?.height, 175);
});
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');
+40
View File
@@ -170,6 +170,37 @@ export function isSoundCloudTrack(url: string): boolean {
} }
} }
// --- Apple Music ----------------------------------------------------------
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
export function getAppleMusicEmbed(url: string): { embedUrl: string; height: number } | null {
try {
const u = new URL(url);
if (u.hostname !== 'music.apple.com' && u.hostname !== 'embed.music.apple.com') return null;
if (!/\/(album|playlist|song|music-video)\//.test(u.pathname)) return null;
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
// A single song (?i=… on an album, or a /song/ link) is compact; collections are tall.
const isSong = u.searchParams.has('i') || /\/song\//.test(u.pathname);
return { embedUrl, height: isSong ? 175 : 450 };
} catch {
return null;
}
}
// --- X / Twitter ----------------------------------------------------------
export function getTweetId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h !== 'twitter.com' && h !== 'x.com' && h !== 'mobile.twitter.com') return null;
const m = pathname.match(/\/status(?:es)?\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- Embed-URL builders --------------------------------------------------- // --- Embed-URL builders ---------------------------------------------------
const enc = encodeURIComponent; const enc = encodeURIComponent;
@@ -254,5 +285,14 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
height: 166, height: 166,
}; };
const apple = getAppleMusicEmbed(url);
if (apple)
return {
provider: 'applemusic',
kind: 'audio',
embedUrl: apple.embedUrl,
height: apple.height,
};
return null; return null;
} }