feat(embeds): detailed Steam store / news / app-widget embeds
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 <noreply@anthropic.com>
This commit is contained in:
@@ -419,6 +419,56 @@ export const BadgeSteam = style({
|
|||||||
color: '#c7d5e0',
|
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({
|
export const BadgeWikipedia = style({
|
||||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ import { useSetting } from '../../state/hooks/settings';
|
|||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
extractEmbedHeight,
|
extractEmbedHeight,
|
||||||
|
getSteamTarget,
|
||||||
getTikTokVideoId,
|
getTikTokVideoId,
|
||||||
getTweetId,
|
getTweetId,
|
||||||
isTikTokLink,
|
isTikTokLink,
|
||||||
MediaEmbed,
|
MediaEmbed,
|
||||||
parseMediaEmbed,
|
parseMediaEmbed,
|
||||||
|
steamWidgetEmbedUrl,
|
||||||
tiktokIdFromOembed,
|
tiktokIdFromOembed,
|
||||||
tiktokOembedUrl,
|
tiktokOembedUrl,
|
||||||
tiktokPlayerEmbedUrl,
|
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 {
|
function isWikipedia(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
@@ -324,7 +315,7 @@ function getCardVariant(url: string): CardVariant {
|
|||||||
if (getRedditSubreddit(url) !== null) return 'reddit';
|
if (getRedditSubreddit(url) !== null) return 'reddit';
|
||||||
if (getSpotifyType(url) !== null) return 'spotify';
|
if (getSpotifyType(url) !== null) return 'spotify';
|
||||||
if (isTwitch(url)) return 'twitch';
|
if (isTwitch(url)) return 'twitch';
|
||||||
if (isSteamApp(url)) return 'steam';
|
if (getSteamTarget(url)) return 'steam';
|
||||||
if (isWikipedia(url)) return 'wikipedia';
|
if (isWikipedia(url)) return 'wikipedia';
|
||||||
if (isDiscordInvite(url)) return 'discord';
|
if (isDiscordInvite(url)) return 'discord';
|
||||||
if (isNpm(url)) return 'npm';
|
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 mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
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 (
|
||||||
|
<Box direction="Column" style={{ width: '100%' }}>
|
||||||
|
{bannerUrl && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.SteamBannerWrapper}
|
||||||
|
aria-label={`View on Steam: ${title}`}
|
||||||
|
>
|
||||||
|
<img className={previewCss.SteamBannerImg} src={bannerUrl} alt={title} loading="lazy" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||||
|
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
|
||||||
|
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
||||||
|
Announcement
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{title && (
|
||||||
|
<Text
|
||||||
|
priority="400"
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
View on Steam
|
||||||
|
</Text>
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Box direction="Column" style={{ width: '100%' }}>
|
||||||
|
{capsuleUrl && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.SteamBannerWrapper}
|
||||||
|
aria-label={`View on Steam: ${title}`}
|
||||||
|
>
|
||||||
|
<img className={previewCss.SteamBannerImg} src={capsuleUrl} alt={title} loading="lazy" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{showWidget ? (
|
||||||
|
<iframe
|
||||||
|
className={previewCss.SteamWidget}
|
||||||
|
src={steamWidgetEmbedUrl(appId)}
|
||||||
|
title={title ? `Steam store: ${title}` : 'Steam store widget'}
|
||||||
|
sandbox={EMBED_SANDBOX}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
store.steampowered.com
|
||||||
|
</Text>
|
||||||
|
{inlineMediaEmbeds && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={() => setShowWidget(true)}
|
||||||
|
before={<Icon size="50" src={Icons.Setting} />}
|
||||||
|
>
|
||||||
|
<Text size="T200">Show price & store</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const target = getSteamTarget(url);
|
||||||
|
if (target?.kind === 'news') return <SteamNewsCard url={url} prev={prev} />;
|
||||||
|
if (target?.kind === 'app') return <SteamAppCard url={url} prev={prev} appId={target.appId} />;
|
||||||
|
return <SteamStoreCard url={url} prev={prev} />;
|
||||||
|
}
|
||||||
|
|
||||||
function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const rawDescription = prev['og:description'] ?? '';
|
const rawDescription = prev['og:description'] ?? '';
|
||||||
@@ -2112,6 +2265,9 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||||
: null;
|
: 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
|
// 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
|
// in the card flex row; stack them on phones (no-op for the single-column
|
||||||
// embed cards). Desktop keeps the row layout.
|
// 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;
|
if (content === null) return null;
|
||||||
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
|
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
|
||||||
// link that resolves to a player still gets the wide layout.
|
// link that resolves to a player still gets the wide layout.
|
||||||
const wide = !!resolvedEmbed || isTwitterTweet(url);
|
const wide = !!resolvedEmbed || isTwitterTweet(url) || steamAppWide;
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
|
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
|
||||||
{content}
|
{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.
|
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref} className={buildCardClass(!!embed || isTwitterTweet(url))}>
|
<UrlPreview
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
className={buildCardClass(!!embed || isTwitterTweet(url) || steamAppWide)}
|
||||||
|
>
|
||||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" size="400" />
|
<Spinner variant="Secondary" size="400" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
getBlueskyEmbed,
|
getBlueskyEmbed,
|
||||||
getLoomId,
|
getLoomId,
|
||||||
getKickChannel,
|
getKickChannel,
|
||||||
|
getSteamTarget,
|
||||||
|
steamWidgetEmbedUrl,
|
||||||
buildVideoEmbedUrl,
|
buildVideoEmbedUrl,
|
||||||
spotifyEmbedHeight,
|
spotifyEmbedHeight,
|
||||||
parseMediaEmbed,
|
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);
|
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', () => {
|
test('getTweetId', () => {
|
||||||
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
|
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
|
||||||
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
|
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
|
||||||
|
|||||||
@@ -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 ---------------------------------------------------------------
|
// --- Twitch ---------------------------------------------------------------
|
||||||
|
|
||||||
export type TwitchTarget =
|
export type TwitchTarget =
|
||||||
|
|||||||
Reference in New Issue
Block a user