diff --git a/src/lib/components/decorators.ts b/src/lib/components/decorators.ts index daa48b6f..cd66c98d 100644 --- a/src/lib/components/decorators.ts +++ b/src/lib/components/decorators.ts @@ -1,4 +1,5 @@ import {inPageContext} from '../utils/snips'; +import {isSteamMarketMode, SteamMarketMode} from './market/mode'; export enum ConflictingExtension { CS2_TRADER, @@ -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 @@ -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 () { @@ -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); @@ -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); } diff --git a/src/lib/components/injectors.ts b/src/lib/components/injectors.ts index ab6cc03b..5d7c3d25 100644 --- a/src/lib/components/injectors.ts +++ b/src/lib/components/injectors.ts @@ -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 @@ -23,6 +24,8 @@ interface InjectionConfig { op: (ctx: JQuery, target: typeof FloatElement) => void; } +type InjectionGuard = (() => boolean) | SteamMarketMode; + const InjectionConfigs: {[key in InjectionType]: InjectionConfig} = { [InjectionType.Append]: { exists: (ctx, selector) => !!ctx.children(selector).length, @@ -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 () { @@ -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; @@ -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); } diff --git a/src/lib/components/market/beta/bootstrap.ts b/src/lib/components/market/beta/bootstrap.ts new file mode 100644 index 00000000..82a549a7 --- /dev/null +++ b/src/lib/components/market/beta/bootstrap.ts @@ -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(LISTINGS_GRID_SELECTOR); + if (!grid) return; + + const mountPoint = findMountPoint(grid); + if (!mountPoint) return; + + const {parent, before} = mountPoint; + const existing = parent.querySelector(`: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]; + } +} diff --git a/src/lib/components/market/beta/card_scanner.ts b/src/lib/components/market/beta/card_scanner.ts new file mode 100644 index 00000000..9923c87b --- /dev/null +++ b/src/lib/components/market/beta/card_scanner.ts @@ -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//...`). 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('[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; +} diff --git a/src/lib/components/market/beta/data_store.ts b/src/lib/components/market/beta/data_store.ts new file mode 100644 index 00000000..170a0e8a --- /dev/null +++ b/src/lib/components/market/beta/data_store.ts @@ -0,0 +1,227 @@ +import {ReplaySubject} from 'rxjs'; + +import {rgAssetProperty} from '../../../types/steam'; + +export type BetaListingAssetProperty = rgAssetProperty; + +export interface BetaListing { + listingid: string; + unPrice?: number; + unFee?: number; + eCurrency?: number; + converted_price?: number; + converted_fee?: number; + converted_currency?: number; + description: { + appid: number; + market_hash_name?: string; + market_actions?: {link: string; name: string}[]; + actions?: {link: string; name: string}[]; + tags?: {category: string; internal_name: string; name?: string}[]; + type?: string; + }; + asset: { + id?: string; + assetid?: string; + asset_properties?: BetaListingAssetProperty[]; + appid?: number; + contextid?: string; + instanceid?: string; + classid?: string; + amount?: number; + }; +} + +/** + * Stores Steam Market beta listing metadata indexed by listing ID. + * + * Steam beta exposes the initial listing metadata via React Query's dehydrated cache at + * {@link window.SSR.renderContext.queryData}. The actual rows live under a query keyed by + * `["market_item_search", ...]` -> `state.data.pages[].listings[]`. + * + * On its own this gives us only the initial set; we additionally tee {@link window.fetch} so any + * later requests that surface listings (filtering, pagination) get folded into the same store. + */ +class BetaListingStore { + private listings = new Map(); + private updates = new ReplaySubject(1); + + onUpdate$ = this.updates.asObservable(); + + private initialized = false; + + init(): void { + if (this.initialized) return; + this.initialized = true; + + try { + this.ingestSSRRenderContext(); + } catch (e) { + console.warn('CSFloat: failed to read initial Steam Market beta listing data', e); + } + + this.installFetchInterceptor(); + } + + get(listingId: string): BetaListing | undefined { + return this.listings.get(listingId); + } + + has(listingId: string): boolean { + return this.listings.has(listingId); + } + + /** + * Returns the constructed inspect link for a listing, replacing any `%propid:6%` placeholder + * with the actual inspect token from the asset properties. + */ + getInspectLink(listingId: string): string | undefined { + const listing = this.listings.get(listingId); + if (!listing) return; + + const link = listing.description.market_actions?.[0]?.link; + if (!link) return; + + if (link.includes('%propid:6%')) { + const token = listing.asset.asset_properties?.find((p) => p.propertyid === 6)?.string_value; + if (!token) return; + return link.replace('%propid:6%', token); + } + + return link; + } + + /** + * Returns the asset ID for the listing. Steam beta exposes this as either `id` or `assetid`. + */ + getAssetId(listingId: string): string | undefined { + const listing = this.listings.get(listingId); + return listing?.asset.id ?? listing?.asset.assetid; + } + + private ingestSSRRenderContext(): void { + const ssr = (window as any).SSR; + const raw = ssr?.renderContext?.queryData; + if (typeof raw !== 'string') return; + + const parsed = JSON.parse(raw); + const queries: any[] = parsed?.queries ?? []; + for (const query of queries) { + this.ingestUnknown(query?.state?.data); + } + } + + private installFetchInterceptor(): void { + const original = window.fetch; + if (!original || (original as any).__csfloatPatched) return; + + const patched: typeof window.fetch = async (...args) => { + const response = await original(...args); + + try { + const url = typeof args[0] === 'string' ? args[0] : (args[0] as Request)?.url ?? ''; + if (url.includes('/market/')) { + // Tee the response so we can read it without disturbing the caller. + response + .clone() + .text() + .then((text) => this.ingestStreamedText(text)) + .catch(() => { + /* not all bodies are readable; ignore */ + }); + } + } catch { + /* ignore */ + } + + return response; + }; + + (patched as any).__csfloatPatched = true; + window.fetch = patched; + } + + /** + * Steam SSR streams responses as one JSON value per line. We try each line independently and + * recursively look for arrays of listing rows to ingest. + */ + private ingestStreamedText(text: string): void { + if (!text) return; + + // Single JSON body fast-path. + const trimmed = text.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + this.ingestUnknown(JSON.parse(trimmed)); + return; + } catch { + /* fall through to line-based parsing */ + } + } + + for (const line of text.split('\n')) { + const candidate = line.trim(); + if (!candidate) continue; + try { + this.ingestUnknown(JSON.parse(candidate)); + } catch { + /* not every line is JSON; ignore */ + } + } + } + + /** + * Walks an arbitrary JSON value looking for arrays of listing-shaped objects. + */ + private ingestUnknown(value: unknown, depth = 0): void { + if (!value || depth > 8) return; + + if (Array.isArray(value)) { + for (const item of value) { + this.ingestUnknown(item, depth + 1); + } + return; + } + + if (typeof value !== 'object') return; + const obj = value as Record; + + if (this.isListing(obj)) { + this.upsert(obj as unknown as BetaListing); + return; + } + + // Walk known container shapes first to keep traversal cheap. + const containers = ['pages', 'listings', 'data', 'state']; + for (const key of containers) { + if (key in obj) this.ingestUnknown(obj[key], depth + 1); + } + + // Fallback general walk, capped by depth. + for (const key in obj) { + if (containers.includes(key)) continue; + const child = obj[key]; + if (child && typeof child === 'object') { + this.ingestUnknown(child, depth + 1); + } + } + } + + private isListing(obj: Record): boolean { + return ( + typeof obj.listingid === 'string' && + typeof obj.description === 'object' && + obj.description !== null && + typeof obj.asset === 'object' && + obj.asset !== null + ); + } + + private upsert(listing: BetaListing): void { + if (!listing.listingid) return; + this.listings.set(listing.listingid, listing); + this.updates.next([listing.listingid]); + } +} + +export const gBetaListingStore = new BetaListingStore(); diff --git a/src/lib/components/market/beta/filter_panel.ts b/src/lib/components/market/beta/filter_panel.ts new file mode 100644 index 00000000..23b140b6 --- /dev/null +++ b/src/lib/components/market/beta/filter_panel.ts @@ -0,0 +1,55 @@ +import {css, html, nothing, HTMLTemplateResult} from 'lit'; + +import {CustomElement} from '../../injectors'; +import {FloatElement} from '../../custom'; + +import '../../filter/filter_container'; + +/** + * Beta-styled wrapper around the existing {@link csfloat-filter-container}. + * + * The filter UI itself is unchanged. We only provide a visual frame that fits the new Steam UI + * (tinted background, rounded corners, system spacing). The element reads its filter key from + * the `key` attribute and forwards it to the inner filter container, which is what the existing + * filter service uses to scope item-specific filters. + */ +@CustomElement() +export class BetaFilterPanel extends FloatElement { + static styles = [ + ...FloatElement.styles, + css` + .panel { + margin-bottom: 16px; + padding: 16px; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + color: #c7d5e0; + } + + .panel-title { + font-family: 'Motiva Sans', sans-serif; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #ebebeb; + margin: 0 0 12px; + } + `, + ]; + + private get key(): string { + return this.getAttribute('key') ?? ''; + } + + protected render(): HTMLTemplateResult | typeof nothing { + if (!this.key) return nothing; + return html` +
+

CSFloat Filters

+ +
+ `; + } +} diff --git a/src/lib/components/market/beta/listing.ts b/src/lib/components/market/beta/listing.ts new file mode 100644 index 00000000..114b6371 --- /dev/null +++ b/src/lib/components/market/beta/listing.ts @@ -0,0 +1,170 @@ +import {render} from 'lit'; +import {Subscription} from 'rxjs'; + +import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; +import {gFilterService} from '../../../services/filter'; +import {gFloatFetcher} from '../../../services/float_fetcher'; +import {renderClickableRank} from '../../../utils/skin'; + +import {BetaListing, gBetaListingStore} from './data_store'; + +const ENHANCED_ATTR = 'data-csfloat-enhanced'; +const RANK_ATTR = 'data-csfloat-rank'; +const HIGHLIGHT_VAR = '--csfloat-highlight'; + +/** + * Per-card enhancer for the Steam Market beta listing UI. + * + * Drives the work to fetch the {@link ItemInfo} for a card, inject a clickable rank link beside + * Steam's own "Wear Rating" value, and apply filter highlighting using {@link gFilterService}. + */ +export class BetaListingEnhancer { + private filterSub: Subscription | undefined; + private storeSub: Subscription | undefined; + private removalObserver: MutationObserver | undefined; + + static enhance(card: HTMLElement, listingId: string): void { + if (card.getAttribute(ENHANCED_ATTR) === listingId) { + return; + } + card.setAttribute(ENHANCED_ATTR, listingId); + new BetaListingEnhancer(card, listingId).start(); + } + + private constructor(private readonly card: HTMLElement, private readonly listingId: string) {} + + private start(): void { + this.observeRemoval(); + + const listing = gBetaListingStore.get(this.listingId); + if (listing) { + void this.processListing(listing); + return; + } + + // We don't yet have metadata for this listing (e.g. arrived from a paginated fetch we + // haven't ingested). Subscribe to the store and retry once it shows up. + this.storeSub = gBetaListingStore.onUpdate$.subscribe((ids) => { + if (!ids.includes(this.listingId)) return; + const updated = gBetaListingStore.get(this.listingId); + if (!updated) return; + this.storeSub?.unsubscribe(); + this.storeSub = undefined; + void this.processListing(updated); + }); + } + + private async processListing(listing: BetaListing): Promise { + const inspectLink = gBetaListingStore.getInspectLink(this.listingId); + const assetId = gBetaListingStore.getAssetId(this.listingId); + if (!inspectLink || !assetId) return; + + let info: ItemInfo; + try { + info = await gFloatFetcher.fetch({link: inspectLink, asset_id: assetId}); + } catch (e) { + return; + } + + if (!document.body.contains(this.card)) return; + + this.injectRank(listing, info); + this.subscribeToFilters(listing, info); + } + + private injectRank(listing: BetaListing, info: ItemInfo): void { + if (this.card.querySelector(`[${RANK_ATTR}]`)) return; + + const wearProp = listing.asset.asset_properties?.find((p) => p.propertyid === 2); + const rawFloat = wearProp?.float_value; + if (rawFloat === undefined || rawFloat === null) return; + const targetFloat = parseFloat(String(rawFloat)); + if (Number.isNaN(targetFloat)) return; + + const wearSpan = this.findWearSpan(targetFloat); + if (!wearSpan) return; + + const rankSpan = document.createElement('span'); + rankSpan.setAttribute(RANK_ATTR, ''); + rankSpan.style.marginLeft = '4px'; + render(renderClickableRank(info), rankSpan); + + // Empty template (item didn't qualify for a rank) results in a span with no anchor. + if (!rankSpan.querySelector('a')) return; + + wearSpan.insertAdjacentElement('afterend', rankSpan); + } + + /** + * Locates the span that displays the wear rating value for this card by matching its text + * to the asset's `propertyid=2` float value. Steam renders the value with high precision so a + * small tolerance is enough to be unambiguous within a single card. + */ + private findWearSpan(targetFloat: number): HTMLElement | undefined { + const spans = this.card.querySelectorAll('span'); + for (const span of spans) { + const text = span.textContent?.trim(); + if (!text) continue; + const value = parseFloat(text); + if (Number.isNaN(value)) continue; + if (Math.abs(value - targetFloat) < 1e-6) { + return span; + } + } + return undefined; + } + + private subscribeToFilters(listing: BetaListing, info: ItemInfo): void { + const price = convertedPrice(listing); + this.filterSub = gFilterService.onUpdate$.subscribe(() => { + const colour = gFilterService.matchColour(info, price); + this.applyHighlight(colour); + }); + } + + /** + * Applies a filter match colour to the card. We use an inset box shadow so the highlight is + * highly visible without overwriting the card's own background, which keeps the new Steam UI + * looking close to default. + */ + private applyHighlight(colour: string | null): void { + const card = this.card; + if (colour) { + card.style.setProperty(HIGHLIGHT_VAR, colour); + card.style.setProperty('box-shadow', `inset 0 0 0 2px ${colour}`); + } else { + card.style.removeProperty(HIGHLIGHT_VAR); + card.style.removeProperty('box-shadow'); + } + } + + private observeRemoval(): void { + if (!this.card.parentElement) return; + this.removalObserver = new MutationObserver(() => { + if (!document.body.contains(this.card)) { + this.dispose(); + } + }); + this.removalObserver.observe(this.card.parentElement, {childList: true}); + } + + private dispose(): void { + this.filterSub?.unsubscribe(); + this.filterSub = undefined; + this.storeSub?.unsubscribe(); + this.storeSub = undefined; + this.removalObserver?.disconnect(); + this.removalObserver = undefined; + } +} + +/** + * Returns the listing's price in the user's wallet currency, in major units (e.g. dollars), or + * undefined if Steam didn't include a converted price for this listing. + */ +function convertedPrice(listing: BetaListing): number | undefined { + if (listing.converted_price === undefined || listing.converted_fee === undefined) { + return undefined; + } + return (listing.converted_price + listing.converted_fee) / 100; +} diff --git a/src/lib/components/market/item_row_wrapper.ts b/src/lib/components/market/item_row_wrapper.ts index da8f6bef..b1e13076 100644 --- a/src/lib/components/market/item_row_wrapper.ts +++ b/src/lib/components/market/item_row_wrapper.ts @@ -8,6 +8,7 @@ import {rgAsset, ListingData} from '../../types/steam'; import {gFloatFetcher} from '../../services/float_fetcher'; import {ItemInfo} from '../../bridge/handlers/fetch_inspect_info'; import {getMarketInspectLink, inlineEasyInspect} from './helpers'; +import {SteamMarketMode} from './mode'; import { formatSeed, getFadePercentage, @@ -30,21 +31,29 @@ import {ClientSend} from '../../bridge/client'; import {ConflictingExtension, ConflictingMode, HideConflictingElement, StyleConflictingElement} from '../decorators'; @CustomElement() -@InjectAppend('#searchResultsRows .market_listing_row .market_listing_item_name_block', InjectionMode.CONTINUOUS) +@InjectAppend( + '#searchResultsRows .market_listing_row .market_listing_item_name_block', + InjectionMode.CONTINUOUS, + SteamMarketMode.LEGACY +) @HideConflictingElement( ConflictingExtension.CS2_TRADER, '#searchResultsRows .market_listing_row .stickerHolderMarket, #searchResultsRows .market_listing_row .stickersTotal, #searchResultsRows .market_listing_row .floatBarMarket', - ConflictingMode.CONTINUOUS + ConflictingMode.CONTINUOUS, + SteamMarketMode.LEGACY ) @HideConflictingElement( ConflictingExtension.SIH, - '#searchResultsRows .market_listing_row .sih-images, #searchResultsRows .market_listing_row .sih-keychains' + '#searchResultsRows .market_listing_row .sih-images, #searchResultsRows .market_listing_row .sih-keychains', + ConflictingMode.ONCE, + SteamMarketMode.LEGACY ) @StyleConflictingElement( ConflictingExtension.SIH, '#searchResultsRows .market_listing_row .market_listing_item_name_block', ConflictingMode.ONCE, - {'max-width': '100%', 'margin-top': '8px'} + {'max-width': '100%', 'margin-top': '8px'}, + SteamMarketMode.LEGACY ) export class ItemRowWrapper extends FloatElement { static styles = [ diff --git a/src/lib/components/market/mode.ts b/src/lib/components/market/mode.ts new file mode 100644 index 00000000..ab358a5c --- /dev/null +++ b/src/lib/components/market/mode.ts @@ -0,0 +1,22 @@ +export enum SteamMarketMode { + BETA = 'beta', + LEGACY = 'legacy', +} + +export function getSteamMarketMode(): SteamMarketMode { + if ( + typeof $J === 'function' && + typeof g_rgListingInfo === 'object' && + g_rgListingInfo !== null && + typeof g_rgAssets === 'object' && + g_rgAssets !== null + ) { + return SteamMarketMode.LEGACY; + } + + return SteamMarketMode.BETA; +} + +export function isSteamMarketMode(mode: SteamMarketMode): boolean { + return getSteamMarketMode() === mode; +} diff --git a/src/lib/components/market/utility_belt.ts b/src/lib/components/market/utility_belt.ts index 4fe38b83..dd9062d6 100644 --- a/src/lib/components/market/utility_belt.ts +++ b/src/lib/components/market/utility_belt.ts @@ -9,9 +9,10 @@ import '../filter/filter_container'; import {Observe} from '../../utils/observers'; import {isBuggedSkin} from '../../utils/skin'; import {AppId, ContextId} from '../../types/steam_constants'; +import {SteamMarketMode} from './mode'; @CustomElement() -@InjectBefore('#searchResultsRows', InjectionMode.ONCE) +@InjectBefore('#searchResultsRows', InjectionMode.ONCE, SteamMarketMode.LEGACY) export class UtilityBelt extends FloatElement { @state() private buggedSkinCount = 0; diff --git a/src/lib/page_scripts/market_listing.ts b/src/lib/page_scripts/market_listing.ts index a7d31f6b..0a4e4d9b 100644 --- a/src/lib/page_scripts/market_listing.ts +++ b/src/lib/page_scripts/market_listing.ts @@ -1,7 +1,13 @@ import {init} from './utils'; +import {getSteamMarketMode, SteamMarketMode} from '../components/market/mode'; import '../components/market/item_row_wrapper'; import '../components/market/utility_belt'; +import {initBetaMarket} from '../components/market/beta/bootstrap'; init('src/lib/page_scripts/market_listing.js', main); -async function main() {} +async function main() { + if (getSteamMarketMode() === SteamMarketMode.BETA) { + initBetaMarket(); + } +} diff --git a/src/lib/page_scripts/utils.ts b/src/lib/page_scripts/utils.ts index 7c9b7630..6557a8e2 100644 --- a/src/lib/page_scripts/utils.ts +++ b/src/lib/page_scripts/utils.ts @@ -65,7 +65,7 @@ export async function init(scriptPath: string, ifPage: () => any) { // Add Roboto font in the page context const fontLink = document.createElement('link'); - fontLink.href = 'https://fonts.googleapis.com/css2?family=Roboto:wght@500;700&display=swap'; + fontLink.href = 'https://fonts.googleapis.com/css2?family=Roboto:wght@500;700&display=swap'; // TODO: Remove this for the new market beta fontLink.rel = 'stylesheet'; document.head.appendChild(fontLink);