Skip to content
Merged
223 changes: 220 additions & 3 deletions src/lib/components/injectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export enum InjectionMode {
CONTINUOUS,
}

export enum InjectionPosition {
Before = 'beforebegin',
Prepend = 'afterbegin',
Append = 'beforeend',
After = 'afterend',
}

enum InjectionType {
Append,
Before,
Expand All @@ -24,6 +31,44 @@ interface InjectionConfig {
}

type InjectionGuard = () => boolean;
type MaybePromise<T> = T | Promise<T>;
type InjectionContextResult<TContext> = TContext | null | undefined;
type InjectionContextBuilder<TContext> = (scope: HTMLElement) => MaybePromise<InjectionContextResult<TContext>>;

export interface ScopedInjectionArgs<TContext> {
scope: HTMLElement;
context: TContext;
}

export interface InjectionScope<TContext> {
selector: string;
mode: InjectionMode;
guard?: InjectionGuard;
context: InjectionContextBuilder<TContext>;
state: InjectionScopeState<TContext>;
}

interface InjectionScopeState<TContext> {
contextCache: WeakMap<HTMLElement, Promise<InjectionContextResult<TContext>>>;
completed: WeakMap<HTMLElement, Map<string, Element | null>>;
inFlight: WeakMap<HTMLElement, Set<string>>;
}

export interface InjectionScopeConfig<TContext> {
selector: string;
mode?: InjectionMode;
guard?: InjectionGuard;
context: InjectionContextBuilder<TContext>;
}

export interface ScopedInjectionConfig<TContext> {
anchor: (args: ScopedInjectionArgs<TContext>) => HTMLElement | null | undefined;
position?: InjectionPosition;
}

type ScopedElement<TContext> = FloatElement & {
injectionContext?: TContext;
};

const InjectionConfigs: {[key in InjectionType]: InjectionConfig} = {
[InjectionType.Append]: {
Expand Down Expand Up @@ -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<TContext>(config: InjectionScopeConfig<TContext>): InjectionScope<TContext> {
return {
...config,
mode: config.mode ?? InjectionMode.ONCE,
state: {
contextCache: new WeakMap(),
completed: new WeakMap(),
inFlight: new WeakMap(),
},
};
}

function getTagSet(map: WeakMap<HTMLElement, Set<string>>, scope: HTMLElement): Set<string> {
let tags = map.get(scope);
if (!tags) {
tags = new Set();
map.set(scope, tags);
}
return tags;
}

function hasTag(map: WeakMap<HTMLElement, Set<string>>, scope: HTMLElement, tag: string): boolean {
return map.get(scope)?.has(tag) ?? false;
}

function addTag(map: WeakMap<HTMLElement, Set<string>>, scope: HTMLElement, tag: string): void {
getTagSet(map, scope).add(tag);
}

function deleteTag(map: WeakMap<HTMLElement, Set<string>>, scope: HTMLElement, tag: string): void {
map.get(scope)?.delete(tag);
}

function getCompletedMap(
map: WeakMap<HTMLElement, Map<string, Element | null>>,
scope: HTMLElement
): Map<string, Element | null> {
let tags = map.get(scope);
if (!tags) {
tags = new Map();
map.set(scope, tags);
}
return tags;
}

function hasCompletedInjection<TContext>(
injectionScope: InjectionScope<TContext>,
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<TContext>(
injectionScope: InjectionScope<TContext>,
scope: HTMLElement,
tag: string,
element: Element | null
): void {
getCompletedMap(injectionScope.state.completed, scope).set(tag, element);
}

async function getScopeContext<TContext>(
injectionScope: InjectionScope<TContext>,
scope: HTMLElement
): Promise<InjectionContextResult<TContext>> {
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<TContext>(
scope: HTMLElement,
target: typeof FloatElement,
injectionScope: InjectionScope<TContext>,
config: ScopedInjectionConfig<TContext>
): Promise<void> {
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<TContext>;
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<HTMLElement>(selector).forEach((el) => {
InjectionConfigs[type].op(el, target);
});
Expand All @@ -98,6 +281,8 @@ function Inject(selector: string, mode: InjectionMode, type: InjectionType, guar
});
}, 250);
break;
default:
assertNever(mode);
}
};
}
Expand All @@ -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<TContext>(
injectionScope: InjectionScope<TContext>,
config: ScopedInjectionConfig<TContext>
): any {
return function (target: typeof FloatElement, propertyKey: string, descriptor: PropertyDescriptor) {
if (!inPageContext()) {
return;
}

const inject = () => {
if (!canInject(injectionScope.guard)) {
return;
}

document.querySelectorAll<HTMLElement>(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);
}
};
}
2 changes: 1 addition & 1 deletion src/lib/components/market/mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
66 changes: 66 additions & 0 deletions src/lib/components/market/react/listing.ts
Original file line number Diff line number Diff line change
@@ -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<ReactListingContext>({
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<ReactListingContext | null | undefined> {
const listing = getFiberProps<MarketListingProps>(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;
}
}
21 changes: 21 additions & 0 deletions src/lib/components/market/react/placement.ts
Original file line number Diff line number Diff line change
@@ -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<ReactListingContext>): HTMLSpanElement | null | undefined {
if (!parseRank(context.itemInfo) || context.targetFloat === null) return null;

const spans = scope.querySelectorAll<HTMLSpanElement>('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;
}
33 changes: 33 additions & 0 deletions src/lib/components/market/react/rank.ts
Original file line number Diff line number Diff line change
@@ -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`<span @click=${(e: Event) => e.stopPropagation()}>
${renderClickableRank(this.injectionContext.itemInfo)}
</span>`;
}
}
Loading