From ef82650cf7f51899ce37c50b177c901af0c94525 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 21 Jul 2026 21:41:06 -0400 Subject: [PATCH] feat(embeds): detailed Steam store / news / app-widget embeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize store.steampowered.com content URLs and render each richly, within the existing privacy-first facade. 2-agent reviewed (both SHIP). - getSteamTarget / steamWidgetEmbedUrl (videoEmbed.ts, +tests): classify /app/{id}, /news/app/{id}/view/{gid}, /(bundle|sub|dlc)/{id}; non-content pages (home/search/wishlist) and other hosts fall through to the generic card. - SteamCard now dispatches: - app → OG capsule header + click-to-play facade → Steam's OFFICIAL store widget iframe (store.steampowered.com/widget/{id}): live region-aware price, discount %, Buy on Steam. Nothing loads from Steam until "Show price & store" is pressed; gated by the inlineMediaEmbeds setting. App pages use the wide card so the ~646px widget has room. - news → rich announcement card (banner + headline + body preview + link) — your example URL previously fell through to the plain generic card. - bundle/sub/dlc → the OG store card. Grounded in our CSP: the widget works via frame-src https: (no infra change), images route through the homeserver (img-src excludes Steam), and there is NO client-side Steam API call (connect-src + Steam CORS both block it) — which is also the honest ceiling: no review scores/genres client-side, price/buy come from the official widget. Runtime QA still needed: the live widget iframe rendering (height/fit) can't be verified headlessly. Gates: tsc 0, eslint 0, prettier clean, 912 tests, build ok. Co-Authored-By: Claude Opus 4.8 --- .../components/url-preview/UrlPreview.css.tsx | 50 +++++ .../components/url-preview/UrlPreviewCard.tsx | 190 ++++++++++++++++-- src/app/utils/videoEmbed.test.ts | 32 +++ src/app/utils/videoEmbed.ts | 36 ++++ 4 files changed, 293 insertions(+), 15 deletions(-) diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 240ce8f85..89e5660c1 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -419,6 +419,56 @@ export const BadgeSteam = style({ color: '#c7d5e0', }); +// --------------------------------------------------------------------------- +// Steam card — full-width header/banner image + official store widget iframe +// --------------------------------------------------------------------------- + +export const SteamBannerWrapper = style([ + DefaultReset, + { + position: 'relative', + display: 'block', + width: '100%', + // Steam header capsules are 460×215 (~2.14:1); news banners vary but crop + // fine to the same ratio. + aspectRatio: '460 / 215', + overflow: 'hidden', + flexShrink: 0, + backgroundColor: '#0e1520', + cursor: 'pointer', + + ':hover': { + filter: 'brightness(0.9)', + }, + }, +]); + +export const SteamBannerImg = style([ + DefaultReset, + { + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'center', + display: 'block', + }, +]); + +// The official Steam store widget is a compact banner (~646×190). Full-width, +// fixed height so the iframe doesn't collapse to its intrinsic size. +export const SteamWidget = style([ + DefaultReset, + { + width: '100%', + height: toRem(190), + border: 0, + display: 'block', + borderRadius: config.radii.R300, + backgroundColor: '#1b2838', + marginTop: config.space.S100, + }, +]); + export const BadgeWikipedia = style({ backgroundColor: color.SurfaceVariant.ContainerLine, color: color.SurfaceVariant.OnContainer, diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ef3f10721..754668da6 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -34,11 +34,13 @@ import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { extractEmbedHeight, + getSteamTarget, getTikTokVideoId, getTweetId, isTikTokLink, MediaEmbed, parseMediaEmbed, + steamWidgetEmbedUrl, tiktokIdFromOembed, tiktokOembedUrl, tiktokPlayerEmbedUrl, @@ -223,17 +225,6 @@ function isTwitch(url: string): boolean { } } -function isSteamApp(url: string): boolean { - try { - const { hostname, pathname } = new URL(url); - const h = hostname.replace(/^www\./, ''); - if (h !== 'store.steampowered.com') return false; - return pathname.startsWith('/app/'); - } catch { - return false; - } -} - function isWikipedia(url: string): boolean { try { const { hostname, pathname } = new URL(url); @@ -324,7 +315,7 @@ function getCardVariant(url: string): CardVariant { if (getRedditSubreddit(url) !== null) return 'reddit'; if (getSpotifyType(url) !== null) return 'spotify'; if (isTwitch(url)) return 'twitch'; - if (isSteamApp(url)) return 'steam'; + if (getSteamTarget(url)) return 'steam'; if (isWikipedia(url)) return 'wikipedia'; if (isDiscordInvite(url)) return 'discord'; if (isNpm(url)) return 'npm'; @@ -1562,7 +1553,8 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) ); } -function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { +// Steam bundle/sub/dlc or other store page — OG card (no per-app widget). +function SteamStoreCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const title = prev['og:title'] ?? ''; @@ -1631,6 +1623,167 @@ function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { ); } +// Steam news / announcement post — rich OG card (Steam has no official embed +// widget for announcements): banner + headline + body preview. +function SteamNewsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const title = prev['og:title'] ?? ''; + const description = prev['og:description'] ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + const bannerUrl = mxcImage + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false) + : null; + + return ( + + {bannerUrl && ( + + {title} + + )} + + + + + Announcement + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + View on Steam + + + + ); +} + +// Steam store app page — OG header + a click-to-play facade that loads Steam's +// official store-widget iframe (live, region-aware price / discount / Buy on +// Steam). Nothing loads from Steam until the user presses "Show price & store". +function SteamAppCard({ + url, + prev, + appId, +}: { + url: string; + prev: IPreviewUrlResponse; + appId: string; +}) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds'); + const [showWidget, setShowWidget] = useState(false); + const title = prev['og:title'] ?? ''; + const description = prev['og:description'] ?? ''; + const mxcImage = prev['og:image'] as string | undefined; + const capsuleUrl = mxcImage + ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false) + : null; + + return ( + + {capsuleUrl && ( + + {title} + + )} + + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + {showWidget ? ( +