feat(embeds): interactive X/Twitter post embed + Apple Music player
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:
@@ -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([
|
||||
DefaultReset,
|
||||
{
|
||||
@@ -415,6 +425,11 @@ export const BadgeStreamable = style({
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeAppleMusic = style({
|
||||
backgroundColor: '#fa243c',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Twitch LIVE badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -19,7 +19,7 @@ import { ImageViewer } from '../image-viewer';
|
||||
import { onEnterOrSpace } from '../../utils/keyboard';
|
||||
import { useSetting } from '../../state/hooks/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 };
|
||||
|
||||
@@ -544,6 +544,61 @@ function TikTokCard({
|
||||
// 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({
|
||||
url,
|
||||
prev,
|
||||
@@ -555,12 +610,16 @@ function TwitterCard({
|
||||
mx: ReturnType<typeof useMatrixClient>;
|
||||
useAuthentication: boolean;
|
||||
}) {
|
||||
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const rawTitle = (prev['og:title'] as string | undefined) ?? '';
|
||||
const rawDescription = (prev['og:description'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const imageWidth = prev['og:image:width'] as number | undefined;
|
||||
|
||||
const isTweet = isTwitterTweet(url);
|
||||
const tweetId = getTweetId(url);
|
||||
const { name, handle, text } = parseTweetTitle(rawTitle);
|
||||
|
||||
const tweetText = text ?? (isTweet ? rawDescription : undefined);
|
||||
@@ -571,22 +630,39 @@ function TwitterCard({
|
||||
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
|
||||
: null;
|
||||
|
||||
const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;
|
||||
|
||||
const header = (
|
||||
<div className={previewCss.TwitterHeader}>
|
||||
<XLogoIcon size={18} />
|
||||
<Text priority="400" style={{ fontWeight: 600 }} truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{handle && (
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
|
||||
@{handle}
|
||||
</Text>
|
||||
)}
|
||||
<Box grow="Yes" />
|
||||
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Expanded: the interactive, self-sizing post embed (playable video, gallery…).
|
||||
if (canEmbed && expanded && tweetId) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<UrlPreviewContent>
|
||||
<TweetEmbed id={tweetId} />
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header row */}
|
||||
<div className={previewCss.TwitterHeader}>
|
||||
<XLogoIcon size={18} />
|
||||
<Text priority="400" style={{ fontWeight: 600 }} truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{handle && (
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
|
||||
@{handle}
|
||||
</Text>
|
||||
)}
|
||||
<Box grow="Yes" />
|
||||
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
|
||||
</div>
|
||||
{header}
|
||||
|
||||
<UrlPreviewContent>
|
||||
{isTweet && tweetText ? (
|
||||
@@ -609,17 +685,29 @@ function TwitterCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ opacity: 0.5 }}
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{getDomain(url)}
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ opacity: 0.5 }}
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{getDomain(url)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
@@ -902,6 +990,7 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
|
||||
twitch: { label: 'Twitch', class: previewCss.BadgeTwitchPurple },
|
||||
spotify: { label: 'Spotify', class: previewCss.BadgeSpotify },
|
||||
soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud },
|
||||
applemusic: { label: 'Apple Music', class: previewCss.BadgeAppleMusic },
|
||||
};
|
||||
|
||||
// One media-forward tile for every embeddable provider (video / portrait / audio).
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getTwitchTarget,
|
||||
getSpotifyEmbedTarget,
|
||||
isSoundCloudTrack,
|
||||
getAppleMusicEmbed,
|
||||
getTweetId,
|
||||
buildVideoEmbedUrl,
|
||||
spotifyEmbedHeight,
|
||||
parseMediaEmbed,
|
||||
@@ -109,6 +111,31 @@ test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
|
||||
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', () => {
|
||||
const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST);
|
||||
assert.equal(chan?.provider, 'twitch');
|
||||
|
||||
@@ -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 ---------------------------------------------------
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
@@ -254,5 +285,14 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
||||
height: 166,
|
||||
};
|
||||
|
||||
const apple = getAppleMusicEmbed(url);
|
||||
if (apple)
|
||||
return {
|
||||
provider: 'applemusic',
|
||||
kind: 'audio',
|
||||
embedUrl: apple.embedUrl,
|
||||
height: apple.height,
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user