diff --git a/src/lib/components/injectors.ts b/src/lib/components/injectors.ts index 80f16320..683af275 100644 --- a/src/lib/components/injectors.ts +++ b/src/lib/components/injectors.ts @@ -12,6 +12,13 @@ export enum InjectionMode { CONTINUOUS, } +export enum InjectionPosition { + Before = 'beforebegin', + Prepend = 'afterbegin', + Append = 'beforeend', + After = 'afterend', +} + enum InjectionType { Append, Before, @@ -24,6 +31,44 @@ interface InjectionConfig { } type InjectionGuard = () => boolean; +type MaybePromise = T | Promise; +type InjectionContextResult = TContext | null | undefined; +type InjectionContextBuilder = (scope: HTMLElement) => MaybePromise>; + +export interface ScopedInjectionArgs { + scope: HTMLElement; + context: TContext; +} + +export interface InjectionScope { + selector: string; + mode: InjectionMode; + guard?: InjectionGuard; + context: InjectionContextBuilder; + state: InjectionScopeState; +} + +interface InjectionScopeState { + contextCache: WeakMap>>; + completed: WeakMap>; + inFlight: WeakMap>; +} + +export interface InjectionScopeConfig { + selector: string; + mode?: InjectionMode; + guard?: InjectionGuard; + context: InjectionContextBuilder; +} + +export interface ScopedInjectionConfig { + anchor: (args: ScopedInjectionArgs) => HTMLElement | null | undefined; + position?: InjectionPosition; +} + +type ScopedElement = FloatElement & { + injectionContext?: TContext; +}; const InjectionConfigs: {[key in InjectionType]: InjectionConfig} = { [InjectionType.Append]: { @@ -69,17 +114,155 @@ export function CustomElement(): any { const canInject = (guard?: InjectionGuard) => (guard ? guard() : true); +function assertNever(value: never): never { + throw new Error(`Unhandled injection mode: ${value}`); +} + +export function defineInjectionScope(config: InjectionScopeConfig): InjectionScope { + return { + ...config, + mode: config.mode ?? InjectionMode.ONCE, + state: { + contextCache: new WeakMap(), + completed: new WeakMap(), + inFlight: new WeakMap(), + }, + }; +} + +function getTagSet(map: WeakMap>, scope: HTMLElement): Set { + let tags = map.get(scope); + if (!tags) { + tags = new Set(); + map.set(scope, tags); + } + return tags; +} + +function hasTag(map: WeakMap>, scope: HTMLElement, tag: string): boolean { + return map.get(scope)?.has(tag) ?? false; +} + +function addTag(map: WeakMap>, scope: HTMLElement, tag: string): void { + getTagSet(map, scope).add(tag); +} + +function deleteTag(map: WeakMap>, scope: HTMLElement, tag: string): void { + map.get(scope)?.delete(tag); +} + +function getCompletedMap( + map: WeakMap>, + scope: HTMLElement +): Map { + let tags = map.get(scope); + if (!tags) { + tags = new Map(); + map.set(scope, tags); + } + return tags; +} + +function hasCompletedInjection( + injectionScope: InjectionScope, + scope: HTMLElement, + tag: string +): boolean { + const element = injectionScope.state.completed.get(scope)?.get(tag); + if (element === undefined) return false; + if (element === null) return true; + if (element.isConnected) return true; + + injectionScope.state.completed.get(scope)?.delete(tag); + return false; +} + +function addCompletedInjection( + injectionScope: InjectionScope, + scope: HTMLElement, + tag: string, + element: Element | null +): void { + getCompletedMap(injectionScope.state.completed, scope).set(tag, element); +} + +async function getScopeContext( + injectionScope: InjectionScope, + scope: HTMLElement +): Promise> { + const cached = injectionScope.state.contextCache.get(scope); + if (cached) return cached; + + const context = Promise.resolve() + .then(() => injectionScope.context(scope)) + .then((result) => { + if (result === undefined) { + injectionScope.state.contextCache.delete(scope); + return result; + } + + injectionScope.state.contextCache.set(scope, Promise.resolve(result)); + return result; + }) + .catch((e) => { + injectionScope.state.contextCache.delete(scope); + throw e; + }); + + injectionScope.state.contextCache.set(scope, context); + return context; +} + +async function injectIntoScope( + scope: HTMLElement, + target: typeof FloatElement, + injectionScope: InjectionScope, + config: ScopedInjectionConfig +): Promise { + const tag = target.tag(); + if (hasCompletedInjection(injectionScope, scope, tag) || hasTag(injectionScope.state.inFlight, scope, tag)) { + return; + } + + addTag(injectionScope.state.inFlight, scope, tag); + + try { + const context = await getScopeContext(injectionScope, scope); + if (context === undefined) return; + if (context === null) { + addCompletedInjection(injectionScope, scope, tag, null); + return; + } + + const anchor = config.anchor({scope, context}); + if (anchor === undefined) return; + if (anchor === null) { + addCompletedInjection(injectionScope, scope, tag, null); + return; + } + + const element = target.elem() as ScopedElement; + element.injectionContext = context; + anchor.insertAdjacentElement(config.position ?? InjectionPosition.Append, element); + addCompletedInjection(injectionScope, scope, tag, element); + } catch (e) { + // Failed context builders are retried on the next scan. + } finally { + deleteTag(injectionScope.state.inFlight, scope, tag); + } +} + 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: + if (!canInject(guard)) { + return; + } document.querySelectorAll(selector).forEach((el) => { InjectionConfigs[type].op(el, target); }); @@ -98,6 +281,8 @@ function Inject(selector: string, mode: InjectionMode, type: InjectionType, guar }); }, 250); break; + default: + assertNever(mode); } }; } @@ -113,3 +298,35 @@ export function InjectBefore(selector: string, mode: InjectionMode = InjectionMo export function InjectAfter(selector: string, mode: InjectionMode = InjectionMode.ONCE, guard?: InjectionGuard): any { return Inject(selector, mode, InjectionType.After, guard); } + +export function InjectIntoScope( + injectionScope: InjectionScope, + config: ScopedInjectionConfig +): any { + return function (target: typeof FloatElement, propertyKey: string, descriptor: PropertyDescriptor) { + if (!inPageContext()) { + return; + } + + const inject = () => { + if (!canInject(injectionScope.guard)) { + return; + } + + document.querySelectorAll(injectionScope.selector).forEach((scope) => { + void injectIntoScope(scope, target, injectionScope, config); + }); + }; + + switch (injectionScope.mode) { + case InjectionMode.ONCE: + inject(); + break; + case InjectionMode.CONTINUOUS: + setInterval(inject, 250); + break; + default: + assertNever(injectionScope.mode); + } + }; +} 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..9955b2ed --- /dev/null +++ b/src/lib/components/market/react/listing.ts @@ -0,0 +1,66 @@ +import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; +import {gFloatFetcher} from '../../../services/float_fetcher'; +import {getFiberProps} from '../../../utils/fiber'; +import {defineInjectionScope, InjectionMode} from '../../injectors'; +import {isReactSteamMarket} from '../mode'; +import type {MarketListing, MarketListingProps} from './types'; + +export interface ReactListingContext { + listing: MarketListing; + inspectLink: string; + assetId: string; + targetFloat: number | null; + itemInfo: ItemInfo; +} + +export const ReactMarketListingScope = defineInjectionScope({ + selector: 'div[style*="--grid-rows"]:has([style*="market_listings/"])', + mode: InjectionMode.CONTINUOUS, + guard: isReactSteamMarket, + context: buildReactListingContext, +}); + +function getInspectLink(listing: MarketListing): string | 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) return null; + return link.replace('%propid:6%', propId); + } + + return link; +} + +function getTargetFloat(listing: MarketListing): number | null { + const wearProp = 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; + + const targetFloat = Number(rawFloat); + return Number.isNaN(targetFloat) ? null : targetFloat; +} + +async function buildReactListingContext(scope: HTMLElement): Promise { + const listing = getFiberProps(scope, (fiber) => typeof fiber.key === 'string')?.listing; + if (!listing) return undefined; + + const inspectLink = getInspectLink(listing); + const assetId = listing.asset.assetid; + if (!inspectLink || !assetId) return null; + + try { + const itemInfo = await gFloatFetcher.fetch({link: inspectLink, asset_id: assetId}); + return { + listing, + inspectLink, + assetId, + targetFloat: getTargetFloat(listing), + itemInfo, + }; + } catch (e) { + return null; + } +} diff --git a/src/lib/components/market/react/placement.ts b/src/lib/components/market/react/placement.ts new file mode 100644 index 00000000..8978828f --- /dev/null +++ b/src/lib/components/market/react/placement.ts @@ -0,0 +1,21 @@ +import type {ScopedInjectionArgs} from '../../injectors'; +import {parseRank} from '../../../utils/skin'; +import type {ReactListingContext} from './listing'; + +export function findWearSpan({ + scope, + context, +}: ScopedInjectionArgs): HTMLSpanElement | null | undefined { + if (!parseRank(context.itemInfo) || context.targetFloat === null) return null; + + const spans = scope.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 - context.targetFloat) < 1e-6) return span; + } + + return undefined; +} diff --git a/src/lib/components/market/react/rank.ts b/src/lib/components/market/react/rank.ts new file mode 100644 index 00000000..8b500d0c --- /dev/null +++ b/src/lib/components/market/react/rank.ts @@ -0,0 +1,33 @@ +import {css, html, nothing} from 'lit'; +import {property} from 'lit/decorators.js'; + +import {CustomElement, InjectIntoScope, InjectionPosition} from '../../injectors'; +import {FloatElement} from '../../custom'; +import {renderClickableRank} from '../../../utils/skin'; +import {ReactMarketListingScope, ReactListingContext} from './listing'; +import {findWearSpan} from './placement'; + +@CustomElement() +@InjectIntoScope(ReactMarketListingScope, { + anchor: findWearSpan, + position: InjectionPosition.After, +}) +export class ReactListingRank extends FloatElement { + @property({attribute: false}) injectionContext?: ReactListingContext; + + static styles = [ + css` + :host:has(a[href]) { + margin-left: 4px; + } + `, + ]; + + protected render() { + if (!this.injectionContext) return nothing; + + return html` e.stopPropagation()}> + ${renderClickableRank(this.injectionContext.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..e8149f06 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/rank'; 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; +}