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 && (
+
+
+
+ )}
+
+
+
+
+ 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}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+ {showWidget ? (
+
+ ) : (
+
+
+ store.steampowered.com
+
+ {inlineMediaEmbeds && (
+ setShowWidget(true)}
+ before={}
+ >
+ Show price & store
+
+ )}
+
+ )}
+
+
+ );
+}
+
+function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
+ const target = getSteamTarget(url);
+ if (target?.kind === 'news') return ;
+ if (target?.kind === 'app') return ;
+ return ;
+}
+
function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const title = prev['og:title'] ?? '';
const rawDescription = prev['og:description'] ?? '';
@@ -2112,6 +2265,9 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
? parseMediaEmbed(ogUrl, window.location.hostname)
: null;
};
+ // A Steam app page renders the official ~646px store-widget iframe, so it
+ // needs the wide card too.
+ const steamAppWide = getSteamTarget(url)?.kind === 'app';
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
// in the card flex row; stack them on phones (no-op for the single-column
// embed cards). Desktop keeps the row layout.
@@ -2227,7 +2383,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (content === null) return null;
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
// link that resolves to a player still gets the wide layout.
- const wide = !!resolvedEmbed || isTwitterTweet(url);
+ const wide = !!resolvedEmbed || isTwitterTweet(url) || steamAppWide;
return (
{content}
@@ -2237,7 +2393,11 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
return (
-
+
diff --git a/src/app/utils/videoEmbed.test.ts b/src/app/utils/videoEmbed.test.ts
index 3aeb0cb0d..e6b36b319 100644
--- a/src/app/utils/videoEmbed.test.ts
+++ b/src/app/utils/videoEmbed.test.ts
@@ -24,6 +24,8 @@ import {
getBlueskyEmbed,
getLoomId,
getKickChannel,
+ getSteamTarget,
+ steamWidgetEmbedUrl,
buildVideoEmbedUrl,
spotifyEmbedHeight,
parseMediaEmbed,
@@ -208,6 +210,36 @@ test('Apple Music: album vs single song height, embed host swap', () => {
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
});
+test('getSteamTarget: app / news / bundle / non-content', () => {
+ assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
+ kind: 'app',
+ appId: '739630',
+ });
+ assert.deepEqual(
+ getSteamTarget(
+ 'https://store.steampowered.com/news/app/739630/view/668371152183232404?l=english',
+ ),
+ { kind: 'news', appId: '739630', gid: '668371152183232404' },
+ );
+ assert.deepEqual(getSteamTarget('https://store.steampowered.com/bundle/232/'), {
+ kind: 'store',
+ label: 'bundle',
+ });
+ assert.deepEqual(getSteamTarget('https://store.steampowered.com/sub/12345/'), {
+ kind: 'store',
+ label: 'sub',
+ });
+ // non-content store pages and other hosts are not embedded
+ assert.equal(getSteamTarget('https://store.steampowered.com/'), null);
+ assert.equal(getSteamTarget('https://store.steampowered.com/search/?term=horror'), null);
+ assert.equal(getSteamTarget('https://steamcommunity.com/app/739630'), null);
+ assert.equal(getSteamTarget('not a url'), null);
+});
+
+test('steamWidgetEmbedUrl', () => {
+ assert.equal(steamWidgetEmbedUrl('739630'), 'https://store.steampowered.com/widget/739630/');
+});
+
test('getTweetId', () => {
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
diff --git a/src/app/utils/videoEmbed.ts b/src/app/utils/videoEmbed.ts
index 748fa440a..287920cca 100644
--- a/src/app/utils/videoEmbed.ts
+++ b/src/app/utils/videoEmbed.ts
@@ -219,6 +219,42 @@ export function getStreamableId(url: string): string | null {
}
}
+// --- Steam ----------------------------------------------------------------
+
+export type SteamTarget =
+ // a game/app store page → gets the official click-to-play store widget
+ | { kind: 'app'; appId: string }
+ // a news/announcement post → a rich card (no official widget for these)
+ | { kind: 'news'; appId: string; gid: string }
+ // bundle / sub / dlc pages → an OG store card (no per-app widget)
+ | { kind: 'store'; label: string };
+
+/**
+ * Classify a store.steampowered.com content URL. Only content pages (app / news /
+ * bundle / sub / dlc) match; the homepage, search, wishlist, cart etc. return
+ * null and fall through to the generic preview card.
+ */
+export function getSteamTarget(url: string): SteamTarget | null {
+ try {
+ const { hostname, pathname } = new URL(url);
+ if (hostname.replace(/^www\./, '') !== 'store.steampowered.com') return null;
+ let m = pathname.match(/^\/news\/app\/(\d+)\/view\/(\d+)/);
+ if (m) return { kind: 'news', appId: m[1], gid: m[2] };
+ m = pathname.match(/^\/app\/(\d+)/);
+ if (m) return { kind: 'app', appId: m[1] };
+ m = pathname.match(/^\/(bundle|sub|dlc)\/\d+/);
+ if (m) return { kind: 'store', label: m[1] };
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/** Steam's official embeddable store widget (live price / discount / Buy). */
+export function steamWidgetEmbedUrl(appId: string): string {
+ return `https://store.steampowered.com/widget/${encodeURIComponent(appId)}/`;
+}
+
// --- Twitch ---------------------------------------------------------------
export type TwitchTarget =