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([
|
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).
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user