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 ( +