diff --git a/src/lib/components/injectors.ts b/src/lib/components/injectors.ts index 80f16320..7e150226 100644 --- a/src/lib/components/injectors.ts +++ b/src/lib/components/injectors.ts @@ -74,12 +74,12 @@ function Inject(selector: string, mode: InjectionMode, type: InjectionType, guar if (!inPageContext()) { return; } - if (!canInject(guard)) { - return; - } switch (mode) { case InjectionMode.ONCE: + if (!canInject(guard)) { + return; + } document.querySelectorAll(selector).forEach((el) => { InjectionConfigs[type].op(el, target); }); diff --git a/src/lib/components/market/mode.ts b/src/lib/components/market/mode.ts index 02775183..eadaea94 100644 --- a/src/lib/components/market/mode.ts +++ b/src/lib/components/market/mode.ts @@ -8,7 +8,7 @@ export function isLegacySteamMarket(): boolean { ); } -/** True only if current page is part of the Steam Market AND the beta is being used */ +/** True only if current page is part of the Steam Market AND the React version is being used */ export function isReactSteamMarket(): boolean { return (window as any).SSR?.reactRoot !== undefined; } diff --git a/src/lib/components/market/react/listing.ts b/src/lib/components/market/react/listing.ts new file mode 100644 index 00000000..5677ad6c --- /dev/null +++ b/src/lib/components/market/react/listing.ts @@ -0,0 +1,112 @@ +import {css, nothing} from 'lit'; + +import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; +import {FloatElement} from '../../custom'; +import {CustomElement, InjectAppend, InjectionMode} from '../../injectors'; +import {isReactSteamMarket} from '../mode'; +import {gFloatFetcher} from '../../../services/float_fetcher'; +import {ReactListingRank} from './rank'; + +import {getFiberProps} from '../../../utils/fiber'; +import type {MarketListing, MarketListingProps} from './types'; + +/** + * Simple version of {@link ItemRowWrapper} with reduced functionality, adapted for the React version of the Steam Market. + */ +@CustomElement() +@InjectAppend( + 'div[style*="--grid-rows"]:has([style*="market_listings/"])', + InjectionMode.CONTINUOUS, + isReactSteamMarket +) +export class ReactListingEnhancer extends FloatElement { + static styles = [ + css` + :host { + display: none; + } + `, + ]; + + private get card(): HTMLElement { + const parent = this.parentElement; + if (!parent) throw new Error('Card element not found'); + return parent; + } + + /** Steam implementation detail: the "key" property on the Fiber node is the listing ID. */ + get fiberProps(): MarketListingProps | null { + return getFiberProps(this.card, (fiber) => typeof fiber.key === 'string') ?? null; + } + + get listing(): MarketListing | null { + return this.fiberProps?.listing ?? null; + } + + get inspectLink(): string | null { + const listing = this.listing; + if (!listing) return null; + + const link = listing.description.actions?.[0]?.link; + if (!link) return null; + + if (link.includes('%propid:6%')) { + const propId = listing.asset.asset_properties?.find((p) => p.propertyid === 6)?.string_value; + if (!propId || !link) return null; + return link.replace('%propid:6%', propId); + } + return link; + } + + get assetId(): string | null { + const listing = this.listing; + if (!listing) return null; + return listing.asset.assetid; + } + + get targetFloat(): number | null { + const wearProp = this.listing?.asset.asset_properties?.find((p) => p.propertyid === 2); + // this is a number in the React properties, but a string in the rgAsset properties + const rawFloat = wearProp?.float_value; + if (rawFloat === undefined || rawFloat === null) return null; + return Number(rawFloat); + } + + connectedCallback(): void { + super.connectedCallback(); + + if (this.inspectLink && this.assetId) { + void this.processListing(this.inspectLink, this.assetId); + } + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + protected render(): typeof nothing { + return nothing; + } + + private async processListing(inspectLink: string, assetId: string): Promise { + if (!inspectLink || !assetId || !this.isConnected || !this.card) return; + + let info: ItemInfo; + try { + info = await gFloatFetcher.fetch({link: inspectLink, asset_id: assetId}); + } catch (e) { + return; + } + + this.injectRank(info); + } + + private injectRank(info: ItemInfo): void { + const rank = ReactListingRank.elem() as ReactListingRank; + rank.itemInfo = info; + rank.card = this.card; + rank.targetFloat = this.targetFloat; + // Append into the card; the element repositions itself correctly. + this.card.appendChild(rank); + } +} diff --git a/src/lib/components/market/react/rank.ts b/src/lib/components/market/react/rank.ts new file mode 100644 index 00000000..e756866c --- /dev/null +++ b/src/lib/components/market/react/rank.ts @@ -0,0 +1,60 @@ +import {css, html, nothing} from 'lit'; +import {property} from 'lit/decorators.js'; + +import {CustomElement} from '../../injectors'; +import {FloatElement} from '../../custom'; +import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; +import {parseRank, renderClickableRank} from '../../../utils/skin'; + +@CustomElement() +export class ReactListingRank extends FloatElement { + @property({type: Object}) itemInfo!: ItemInfo; + @property({attribute: false}) card!: HTMLElement; + @property({attribute: false}) targetFloat!: number | null; + + static styles = [ + css` + :host:has(a[href]) { + margin-left: 4px; + } + `, + ]; + + private injected = false; + + connectedCallback(): void { + super.connectedCallback(); + this.placeNextToWear(); + } + + /** Moves this element next to the wear span in the card. */ + private placeNextToWear(): void { + if (this.injected || !this.itemInfo || !this.card) return; + if (!parseRank(this.itemInfo)) return; + + const wearSpan = this.findWearSpan(); + if (!wearSpan) return; + + this.injected = true; + wearSpan.insertAdjacentElement('afterend', this); + } + + private findWearSpan(): HTMLSpanElement | null { + if (!this.targetFloat) return null; + + const spans = this.card.querySelectorAll('span[style*="pre-wrap"]'); + for (const span of spans) { + const text = span.textContent?.trim(); + if (!text) continue; + const value = parseFloat(text); + if (!Number.isNaN(value) && Math.abs(value - this.targetFloat) < 1e-6) return span; + } + return null; + } + + protected render() { + if (!this.itemInfo || !this.targetFloat || !this.card) return nothing; + + return html` e.stopPropagation()}>${renderClickableRank(this.itemInfo)}`; + } +} diff --git a/src/lib/components/market/react/types.ts b/src/lib/components/market/react/types.ts new file mode 100644 index 00000000..83c1753e --- /dev/null +++ b/src/lib/components/market/react/types.ts @@ -0,0 +1,72 @@ +import type {Action, rgAssetProperty, rgDescription, rgInternalDescription} from '../../../types/steam'; + +/** + * Shapes of the props Steam's React version of the Steam Market renders into its listing components. We read these + * off the React Fiber node's `memoizedProps` (see {@link getFiberProps}), but the types describe the + * market listing domain data, not React internals. + */ + +export interface EnhancedAppearance { + mime_type: string; + url: string; +} + +export interface MarketDescriptionLine extends rgInternalDescription { + color?: string; + name: string; +} + +export interface MarketListingDescription + extends Omit { + commodity: boolean; + currency: boolean; + descriptions: MarketDescriptionLine[]; + fraudwarnings: string[]; + tradable: boolean; + market_marketable_restriction: number; + market_bucket_group_name: string; + market_bucket_group_id: string; + market_name_inside_group: string; + marketable: boolean; + owner_actions: Action[]; + owner_descriptions: rgInternalDescription[]; + sealed: boolean; + sealed_type: number; + tags: unknown[]; +} + +export interface MarketListingAsset { + asset_properties: rgAssetProperty[]; + amount: number; + appid: number; + accessory_properties: rgAssetProperty[]; + assetid: string; + classid: string; + contextid: string; + id: string; + instanceid: string; +} + +export interface MarketListing { + asset: MarketListingAsset; + description: MarketListingDescription; + eCurrency: number; + enhanced_appearances: EnhancedAppearance[]; + listingid: string; + publisherFeeApp: number; + publisherFeePct: number; + strSubtotal: string; + unFee: number; + unFeePerUnit: number; + unPrice: number; + unPricePerUnit: number; + unPublisherFee: number; + unPublisherFeePerUnit: number; + unSteamFee: number; + unSteamFeePerUnit: number; +} + +export interface MarketListingProps { + expectEnhancedAppearance: boolean; + listing: MarketListing; +} diff --git a/src/lib/page_scripts/market_listing.ts b/src/lib/page_scripts/market_listing.ts index a7d31f6b..55b425b8 100644 --- a/src/lib/page_scripts/market_listing.ts +++ b/src/lib/page_scripts/market_listing.ts @@ -1,6 +1,7 @@ import {init} from './utils'; import '../components/market/item_row_wrapper'; import '../components/market/utility_belt'; +import '../components/market/react/listing'; init('src/lib/page_scripts/market_listing.js', main); diff --git a/src/lib/utils/fiber.ts b/src/lib/utils/fiber.ts new file mode 100644 index 00000000..4f8ec9a0 --- /dev/null +++ b/src/lib/utils/fiber.ts @@ -0,0 +1,47 @@ +/** + * Subset of React's internal Fiber instance shape. These are internal implementation details of React, + * so they may change between React versions. + * + * See React's source for the authoritative definition for Steam's React version (v19.1.1): + * https://github.com/facebook/react/blob/v19.1.1/packages/react-reconciler/src/ReactInternalTypes.js + */ +export interface Fiber { + alternate: Fiber | null; + child: Fiber | null; + deletions: Fiber[] | null; + elementType: unknown; + flags: number; + key: string | null; + memoizedProps: unknown; + pendingProps: unknown; + return: Fiber | null; + sibling: Fiber | null; + stateNode: HTMLElement | null; +} + +/** + * Returns the Fiber instance React attached to `element`, or null if the element was not rendered + * by React (no `__reactFiber` property present). + */ +function getCurrentFiber(element: HTMLElement): Fiber | null { + const key = Object.keys(element).find((k) => k.startsWith('__reactFiber$')); + if (!key) return null; + return (element[key as keyof HTMLElement] as Fiber | undefined) ?? null; +} + +/** + * Walks up the Fiber tree from `element` until `predicate` returns true for an ancestor Fiber, then + * returns that Fiber's `memoizedProps`. + * + * Returns undefined if the element wasn't rendered by React or no ancestor satisfies `predicate`. + */ +export function getFiberProps(element: HTMLElement, predicate: (fiber: Fiber) => boolean): T | undefined { + let fiber = getCurrentFiber(element); + while (fiber) { + if (predicate(fiber)) { + return fiber.memoizedProps as T; + } + fiber = fiber.return; + } + return undefined; +}