From 791b1cb5eaf0add98799d6f5d33f524e203bfccc Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 6 Jul 2026 20:14:53 -0400 Subject: [PATCH] feat(embeds): interactive X/Twitter post embed + Apple Music player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/url-preview/UrlPreview.css.tsx | 15 ++ .../components/url-preview/UrlPreviewCard.tsx | 143 ++++++++++++++---- src/app/utils/videoEmbed.test.ts | 27 ++++ src/app/utils/videoEmbed.ts | 40 +++++ 4 files changed, 198 insertions(+), 27 deletions(-) diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 52d6d8b82..5c2bea788 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -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 // --------------------------------------------------------------------------- diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index d1882fa4d..e122f9c09 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -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(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 ( +