Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/lib/components/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {inPageContext} from '../utils/snips';
import {isSteamMarketMode, SteamMarketMode} from './market/mode';

export enum ConflictingExtension {
CS2_TRADER,
Expand All @@ -14,6 +15,19 @@ type CSSProperties = JQuery.PlainObject<
string | number | ((this: HTMLElement, index: number, value: string) => string | number | void | undefined)
>;

type DecoratorGuard = (() => boolean) | SteamMarketMode;

function matchesGuard(guard?: DecoratorGuard): boolean {
if (!guard) {
return true;
}
if (typeof guard === 'function') {
return guard();
}

return isSteamMarketMode(guard);
}

/**
* Decorator that applies CSS to DOM elements from conflicting extensions
* @param selector CSS selector for elements to style
Expand All @@ -24,12 +38,16 @@ export function StyleConflictingElement(
extension: ConflictingExtension,
selector: string,
mode: ConflictingMode,
cssProps: CSSProperties
cssProps: CSSProperties,
guard?: DecoratorGuard
): any {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
if (!inPageContext()) {
return;
}
if (typeof $J !== 'function' || !matchesGuard(guard)) {
return;
}

const styleElements = () => {
$J(selector).each(function () {
Expand All @@ -46,6 +64,11 @@ export function StyleConflictingElement(
};

const interval = setInterval(async () => {
if (typeof $J !== 'function' || !matchesGuard(guard)) {
clearInterval(interval);
return;
}

const result = await checkAndStyle();
if (result && mode === ConflictingMode.ONCE) {
clearInterval(interval);
Expand All @@ -57,7 +80,8 @@ export function StyleConflictingElement(
export function HideConflictingElement(
extension: ConflictingExtension,
selector: string,
mode: ConflictingMode = ConflictingMode.ONCE
mode: ConflictingMode = ConflictingMode.ONCE,
guard?: DecoratorGuard
) {
return StyleConflictingElement(extension, selector, mode, {display: 'none'});
return StyleConflictingElement(extension, selector, mode, {display: 'none'}, guard);
}
39 changes: 32 additions & 7 deletions src/lib/components/injectors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {customElement} from 'lit/decorators.js';
import {FloatElement} from './custom';
import {inPageContext} from '../utils/snips';
import {isSteamMarketMode, SteamMarketMode} from './market/mode';

export enum InjectionMode {
// Injects once at page load for elements matching the selector
Expand All @@ -23,6 +24,8 @@ interface InjectionConfig {
op: (ctx: JQuery<HTMLElement>, target: typeof FloatElement) => void;
}

type InjectionGuard = (() => boolean) | SteamMarketMode;

const InjectionConfigs: {[key in InjectionType]: InjectionConfig} = {
[InjectionType.Append]: {
exists: (ctx, selector) => !!ctx.children(selector).length,
Expand Down Expand Up @@ -53,11 +56,29 @@ export function CustomElement(): any {
};
}

function Inject(selector: string, mode: InjectionMode, type: InjectionType): any {
function canInject(guard?: InjectionGuard): boolean {
if (typeof $J !== 'function') {
return false;
}
if (!guard) {
return true;
}
if (typeof guard === 'function') {
return guard();
}

return isSteamMarketMode(guard);
}

function Inject(selector: string, mode: InjectionMode, type: InjectionType, guard?: InjectionGuard): any {
return function (target: typeof FloatElement, propertyKey: string, descriptor: PropertyDescriptor) {
if (!inPageContext()) {
return;
}
if (!canInject(guard)) {
return;
}

switch (mode) {
case InjectionMode.ONCE:
$J(selector).each(function () {
Expand All @@ -66,6 +87,10 @@ function Inject(selector: string, mode: InjectionMode, type: InjectionType): any
break;
case InjectionMode.CONTINUOUS:
setInterval(() => {
if (!canInject(guard)) {
return;
}

$J(selector).each(function () {
// Don't add the item again if we already have
if (InjectionConfigs[type].exists($J(this), target.tag())) return;
Expand All @@ -78,14 +103,14 @@ function Inject(selector: string, mode: InjectionMode, type: InjectionType): any
};
}

export function InjectAppend(selector: string, mode: InjectionMode = InjectionMode.ONCE): any {
return Inject(selector, mode, InjectionType.Append);
export function InjectAppend(selector: string, mode: InjectionMode = InjectionMode.ONCE, guard?: InjectionGuard): any {
return Inject(selector, mode, InjectionType.Append, guard);
}

export function InjectBefore(selector: string, mode: InjectionMode = InjectionMode.ONCE): any {
return Inject(selector, mode, InjectionType.Before);
export function InjectBefore(selector: string, mode: InjectionMode = InjectionMode.ONCE, guard?: InjectionGuard): any {
return Inject(selector, mode, InjectionType.Before, guard);
}

export function InjectAfter(selector: string, mode: InjectionMode = InjectionMode.ONCE): any {
return Inject(selector, mode, InjectionType.After);
export function InjectAfter(selector: string, mode: InjectionMode = InjectionMode.ONCE, guard?: InjectionGuard): any {
return Inject(selector, mode, InjectionType.After, guard);
}
105 changes: 105 additions & 0 deletions src/lib/components/market/beta/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {gBetaListingStore} from './data_store';
import {BetaCardScanner} from './card_scanner';
import {BetaListingEnhancer} from './listing';
import './filter_panel';

const FILTER_PANEL_TAG = 'csfloat-beta-filter-panel';
const LISTINGS_GRID_SELECTOR = '[style*="grid-columns:repeat(auto-fill, minmax(260px"]';
/** Wait for the listings grid to stop churning before mounting (React hydration). */
const MOUNT_SETTLE_MS = 300;

/**
* Boots the Steam Market beta enhancements. Must only be called when {@link SteamMarketMode.BETA}
* is active. Idempotent: repeated calls have no effect.
*/
export function initBetaMarket(): void {
if ((window as any).__csfloatBetaMarketInitialized) return;
(window as any).__csfloatBetaMarketInitialized = true;

gBetaListingStore.init();

BetaCardScanner.start((card, listingId) => {
BetaListingEnhancer.enhance(card, listingId);
});

mountFilterPanelWhenReady();
}

/**
* Watches for the Steam beta listings grid to appear and inserts the filter panel above the
* listings section. The grid only exists once Steam has hydrated, and may be re-rendered on
* filter/pagination, so we keep watching and re-mount if our panel disappears.
*/
function mountFilterPanelWhenReady(): void {
let mountTimer: number | undefined;

const scheduleTryMount = () => {
if (mountTimer !== undefined) {
window.clearTimeout(mountTimer);
}
mountTimer = window.setTimeout(() => {
mountTimer = undefined;
tryMount();
}, MOUNT_SETTLE_MS);
};

scheduleTryMount();

const observer = new MutationObserver(() => scheduleTryMount());
observer.observe(document.body, {childList: true, subtree: true});
}

function tryMount(): void {
const grid = document.querySelector<HTMLElement>(LISTINGS_GRID_SELECTOR);
if (!grid) return;

const mountPoint = findMountPoint(grid);
if (!mountPoint) return;

const {parent, before} = mountPoint;
const existing = parent.querySelector<HTMLElement>(`:scope > ${FILTER_PANEL_TAG}`);
if (existing) {
if (existing.nextElementSibling !== before) {
parent.insertBefore(existing, before);
}
return;
}

const key = decodedMarketHashName();
if (!key) return;

const panel = document.createElement(FILTER_PANEL_TAG);
panel.setAttribute('key', key);
parent.insertBefore(panel, before);
}

/**
* Returns a mount point outside the React-managed listings subtree.
*
* The grid's parent is owned by React; inserting a sibling there causes hydration error #418 and
* React tears down the whole section. Mount before that container instead so React reconciliation
* does not touch our panel.
*/
function findMountPoint(grid: HTMLElement): {parent: HTMLElement; before: HTMLElement} | undefined {
const listingsSection = grid.parentElement;
if (!listingsSection) return;

const parent = listingsSection.parentElement;
if (!parent) return;

return {parent, before: listingsSection};
}

/**
* Returns the market hash name from the current page URL, e.g.
* `/market/listings/730/AK-47%20%7C%20Redline%20%28Field-Tested%29` -> `AK-47 | Redline (Field-Tested)`.
*/
function decodedMarketHashName(): string | undefined {
const match = window.location.pathname.match(/^\/market\/listings\/\d+\/(.+?)\/?$/);
if (!match) return;
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}
94 changes: 94 additions & 0 deletions src/lib/components/market/beta/card_scanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {AppId} from '../../../types/steam_constants';

export const CSFLOAT_LISTING_ID_ATTR = 'data-csfloat-listing-id';

export type CardCallback = (card: HTMLElement, listingId: string) => void;

const LISTING_IMAGE_REGEX = new RegExp(`market_listings/[^/]+/${AppId.CSGO}/(\\d+)/`);

/**
* Watches the Steam Market beta listing grid and tags listing cards with their listing ID.
*
* The new market UI uses generated class names (e.g. `JAg6-ldsh48-`) that we do not want to depend
* on. Instead, every listing card embeds a background image whose URL contains the listing ID
* (`market_listings/.../730/<listingid>/...`). We use that as our anchor: locate any element
* whose inline style references this URL, walk up to the closest grid card root, and tag it.
*
* Each newly-discovered card invokes {@link onCard} so the caller can hydrate it. Callers should
* be idempotent because re-scans are possible after Steam re-renders the grid (filtering,
* pagination).
*/
export class BetaCardScanner {
private observer: MutationObserver | undefined;
private interval: number | undefined;

private constructor(private readonly onCard: CardCallback) {}

static start(onCard: CardCallback): BetaCardScanner {
const scanner = new BetaCardScanner(onCard);
scanner.scan();

scanner.observer = new MutationObserver(() => scanner.scan());
scanner.observer.observe(document.body, {childList: true, subtree: true});

// The observer covers DOM mutations, but the React Router sometimes hydrates async without
// mutating between scans we can hook into. A low-frequency interval acts as a safety net.
scanner.interval = window.setInterval(() => scanner.scan(), 1000);

return scanner;
}

stop(): void {
this.observer?.disconnect();
this.observer = undefined;
if (this.interval) {
window.clearInterval(this.interval);
this.interval = undefined;
}
}

private scan(): void {
const candidates = document.querySelectorAll<HTMLElement>('[style*="market_listings/"]');
for (const candidate of candidates) {
const listingId = extractListingIdFromStyle(candidate.getAttribute('style'));
if (!listingId) continue;

const card = findCardRoot(candidate);
if (!card) continue;

if (card.getAttribute(CSFLOAT_LISTING_ID_ATTR) === listingId) {
continue;
}

card.setAttribute(CSFLOAT_LISTING_ID_ATTR, listingId);
try {
this.onCard(card, listingId);
} catch (e) {
console.error('CSFloat: failed to enhance Steam Market beta card', e);
}
}
}
}

function extractListingIdFromStyle(style: string | null): string | undefined {
if (!style) return;
const match = style.match(LISTING_IMAGE_REGEX);
return match?.[1];
}

/**
* Walks up to the closest ancestor that looks like a market listing card. Steam beta cards
* declare `--grid-row:span 3` on their root; we use that style hint to find the boundary
* without depending on minified class names.
*/
function findCardRoot(element: HTMLElement): HTMLElement | undefined {
let cursor: HTMLElement | null = element;
while (cursor && cursor !== document.body) {
const style = cursor.getAttribute('style') ?? '';
if (style.includes('grid-row:span') || style.includes('grid-row: span')) {
return cursor;
}
cursor = cursor.parentElement;
}
return undefined;
}
Loading
Loading