diff --git a/src/lib/components/common/skincraft_viewer_modal.ts b/src/lib/components/common/skincraft_viewer_modal.ts index 0756cf99..ff4084e1 100644 --- a/src/lib/components/common/skincraft_viewer_modal.ts +++ b/src/lib/components/common/skincraft_viewer_modal.ts @@ -4,7 +4,8 @@ import {classMap} from 'lit/directives/class-map.js'; import {guard} from 'lit/directives/guard.js'; import {createRef, ref} from 'lit/directives/ref.js'; import {styleMap} from 'lit/directives/style-map.js'; -import type {SkinCraftViewerTarget} from '../../services/skincraft_viewer_protocol'; +import type {SkinCraftListingDetails, SkinCraftViewerTarget} from '../../services/skincraft_viewer_protocol'; +import {FLOAT_CONDITION_BANDS} from '../../utils/skin'; import {MODAL_TRANSITION_MS, skinCraftViewerModalStyles} from './skincraft_viewer_modal_styles'; type LoadPhase = 'loading' | 'revealed' | 'error'; @@ -14,11 +15,50 @@ export type SkinCraftViewerModalOptions = { embedSrc: string; /** Heading for the item strip, e.g. "Inventory" — the modal itself is surface-agnostic. */ itemsTitle: string; + /** `grid` shows the item strip; `details` swaps it for a listing-details panel with prev/next. */ + layout?: 'grid' | 'details'; onClose: () => void; onRetry: () => void; onSelect: (target: SkinCraftViewerTarget) => void; + /** Fired when the user nears the end of the loaded items — strip scroll in `grid`, selection proximity in `details`. */ + onItemsNearEnd?: () => void; + /** Enables the details panel's Buy button; the host hands off to the surface's purchase flow. */ + onBuy?: (listingId: string) => void; }; +/** About two card rows — asking for more this early keeps strip scrolling seamless. */ +const ITEMS_NEAR_END_PX = 240; + +/** In details mode, prefetch the next page once selection comes within this many items of the end. */ +const ITEMS_NEAR_END_COUNT = 5; + +/** Full 0–1 wear range in the same bands the float bar uses. */ +const WEAR_SEGMENTS = FLOAT_CONDITION_BANDS.map((band) => ({width: band.max - band.min, color: band.color})); + +function renderChevron(direction: 'left' | 'right'): TemplateResult { + return html` + + + + `; +} + +function formatRestrictionDays(days: number): string { + if (days === 7) return 'one week'; + if (days === 1) return 'one day'; + return `${days} days`; +} + function mixHexColors(base: string, tint: string, tintAmount: number): string { const mixChannel = (offset: number): number => { const baseChannel = Number.parseInt(base.slice(offset, offset + 2), 16); @@ -57,12 +97,14 @@ export class SkinCraftViewerModal { private readonly root: ShadowRoot; private readonly dialogRef = createRef(); private readonly frameRef = createRef(); + private readonly itemGridRef = createRef(); private target?: SkinCraftViewerTarget; private items: SkinCraftViewerTarget[] = []; private phase: LoadPhase = 'loading'; private progress: number | null = null; private errorMessage = ''; + private buyNotice = ''; private entering = false; private closing = false; private iconReady = false; @@ -102,6 +144,7 @@ export class SkinCraftViewerModal { show(target: SkinCraftViewerTarget): void { this.target = target; this.iconReady = false; + this.buyNotice = ''; const request = ++this.iconRequest; this.cancelClose(); @@ -111,6 +154,7 @@ export class SkinCraftViewerModal { void this.revealIconWhenDecoded(target.iconUrl, request); this.scrollSelectedIntoView(); + this.maybeRequestMore(this.selectedIndex); if (opening) this.openDialog(); } @@ -142,66 +186,45 @@ export class SkinCraftViewerModal { this.update(); } + /** Surfaces a failed buy hand-off next to the Buy button; cleared on the next selection. */ + setBuyNotice(message: string): void { + this.buyNotice = message; + this.update(); + } + // `host` binds `this` inside the template's @event handlers, the way LitElement does it. private update(): void { render(this.template(), this.root, {host: this}); } + private get layout(): 'grid' | 'details' { + return this.options.layout ?? 'grid'; + } + private template(): TemplateResult { const revealed = this.phase === 'revealed'; + const details = this.layout === 'details'; return html` - - ${this.target?.name ?? ''} - - - - skincraft.gg - - - × - - - + ${details ? nothing : this.renderHeader()} - + ${details ? nothing : this.renderItemPanel()} + ${details ? this.renderDetailsPanel() : nothing} `; } + private renderHeader(): TemplateResult { + return html` + + ${this.target?.name ?? ''} + + ${this.renderAttribution()} + + × + + + + `; + } + + private renderAttribution(): TemplateResult { + return html` + + + skincraft.gg + + `; + } + + private renderItemPanel(): TemplateResult { + return html` + + `; + } + + private renderDetailsPanel(): TemplateResult { + const target = this.target; + const details = target?.details; + const index = this.selectedIndex; + + return html` + + `; + } + + private renderWearBar(details?: SkinCraftListingDetails): TemplateResult | typeof nothing { + const wear = Number(details?.wearRating); + if (!Number.isFinite(wear)) return nothing; + + const percent = (Math.min(Math.max(wear, 0), 1) * 100).toFixed(3); + return html` + + + ${WEAR_SEGMENTS.map( + (segment) => + html`` + )} + + + + `; + } + + private renderDetailProps(details?: SkinCraftListingDetails): TemplateResult | typeof nothing { + if (!details?.nameTag && !details?.patternTemplate && !details?.wearRating) return nothing; + + return html` + + ${details.nameTag ? html`Name Tag: ${details.nameTag}` : nothing} + ${details.patternTemplate ? html`Pattern Template: ${details.patternTemplate}` : nothing} + ${details.wearRating ? html`Wear Rating: ${details.wearRating}` : nothing} + + `; + } + + private renderDetailActions( + target?: SkinCraftViewerTarget, + details?: SkinCraftListingDetails + ): TemplateResult | typeof nothing { + const inspectUrl = target?.inspectUrl; + const showBuy = !!details?.price && !!details.listingId && !!this.options.onBuy; + if (!inspectUrl && !showBuy) return nothing; + + return html` + + ${inspectUrl ? html`Inspect in Game...` : nothing} + ${showBuy + ? html` + ${details.price} + Buy + ` + : nothing} + + ${this.buyNotice ? html`${this.buyNotice}` : nothing} + `; + } + + private renderAccessories(details?: SkinCraftListingDetails): TemplateResult | typeof nothing { + if (!details?.accessories?.length) return nothing; + + return html` + + Accessories + ${details.accessories.map( + (accessory) => html` + + ${accessory.iconUrl + ? html`` + : nothing} + + ${accessory.name} + ${accessory.detail + ? html`${accessory.detail}` + : nothing} + + + ` + )} + + `; + } + + private renderRestrictions(details?: SkinCraftListingDetails): TemplateResult | typeof nothing { + const restrictions: string[] = []; + if (details?.tradeRestrictionDays) { + restrictions.push(`will not be tradable for ${formatRestrictionDays(details.tradeRestrictionDays)}`); + } + if (details?.marketRestrictionDays) { + restrictions.push( + `cannot be listed on the Steam Community Market for ${formatRestrictionDays( + details.marketRestrictionDays + )}` + ); + } + if (!restrictions.length) return nothing; + + return html` + + After purchase, this item: + + ${restrictions.map((line) => html`${line}`)} + + + `; + } + + private renderDetailLines(details?: SkinCraftListingDetails): TemplateResult | typeof nothing { + if (!details?.lines?.length) return nothing; + + return html` + + ${details.lines.map( + (line) => + html` + ${line.text} + ` + )} + + `; + } + private get selectedKey(): string | undefined { return this.target ? targetKey(this.target) : undefined; } + private get selectedIndex(): number { + const key = this.selectedKey; + if (!key) return -1; + return this.items.findIndex((item) => targetKey(item) === key); + } + + private handlePrevious(): void { + this.selectNeighbor(-1); + this.dropNavFocus(); + } + + private handleNext(): void { + this.selectNeighbor(1); + this.dropNavFocus(); + } + + // A nav button must not keep focus: keys would re-trigger it, and once it disables at the + // list's end, focus falls to and the dialog stops hearing the arrow hotkeys. + private dropNavFocus(): void { + this.dialogRef.value?.focus({preventScroll: true}); + } + + private selectNeighbor(step: number): void { + const index = this.selectedIndex; + const neighbor = index >= 0 ? this.items[index + step] : undefined; + if (!neighbor) return; + + // `onSelect` leads back into `show()`, which runs `maybeRequestMore` for the new index. + this.options.onSelect(neighbor); + } + + private maybeRequestMore(index: number): void { + if (this.layout !== 'details' || index < 0) return; + if (this.items.length - index <= ITEMS_NEAR_END_COUNT) this.options.onItemsNearEnd?.(); + } + + private handleKeydown(event: KeyboardEvent): void { + if (this.layout !== 'details') return; + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.selectNeighbor(-1); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + this.selectNeighbor(1); + } + } + + private handleBuy(): void { + const listingId = this.target?.details?.listingId; + if (listingId) this.options.onBuy?.(listingId); + } + // Both status blocks stay mounted and toggle `hidden` so the item icon survives an error → // retry cycle; its fade-in is driven by a decode() that needs the to already exist. private renderLoading(): TemplateResult { @@ -383,6 +698,22 @@ export class SkinCraftViewerModal { if (event.target === this.dialogRef.value && event.propertyName === 'transform') this.finishClose(); } + /** Whether the user sits near the end of the loaded items — strip scroll in `grid`, selection proximity in `details`. */ + itemsNearEnd(): boolean { + if (this.layout === 'details') { + const index = this.selectedIndex; + return index >= 0 && this.items.length - index <= ITEMS_NEAR_END_COUNT; + } + + const grid = this.itemGridRef.value; + if (!grid) return false; + return grid.scrollTop + grid.clientHeight >= grid.scrollHeight - ITEMS_NEAR_END_PX; + } + + private handleItemsScroll(): void { + if (this.itemsNearEnd()) this.options.onItemsNearEnd?.(); + } + private handleItemsClick(event: MouseEvent): void { const node = event.target; if (!(node instanceof Element)) return; diff --git a/src/lib/components/common/skincraft_viewer_modal_styles.ts b/src/lib/components/common/skincraft_viewer_modal_styles.ts index 83194bda..6b56679f 100644 --- a/src/lib/components/common/skincraft_viewer_modal_styles.ts +++ b/src/lib/components/common/skincraft_viewer_modal_styles.ts @@ -18,6 +18,7 @@ export const skinCraftViewerModalStyles = ` border: 1px solid rgba(193, 206, 255, 0.12); border-radius: 12px; overflow: hidden; + outline: none; color: inherit; background: #15171c; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.68); @@ -423,6 +424,305 @@ export const skinCraftViewerModalStyles = ` background: rgba(255, 255, 255, 0.1); } + .details-panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: #181b21; + } + + .details-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 10px; + height: 48px; + padding: 0 10px 0 16px; + border-bottom: 1px solid rgba(193, 206, 255, 0.08); + } + + .details-nav-btn { + display: inline-flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + padding: 0 12px; + color: rgba(245, 248, 255, 0.85); + font: inherit; + font-size: 13px; + font-weight: 600; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(193, 206, 255, 0.12); + border-radius: 8px; + cursor: pointer; + transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; + } + + .details-nav-btn svg { + flex: 0 0 auto; + opacity: 0.65; + transition: opacity 150ms ease; + } + + .details-nav-btn:hover:not(:disabled) { + color: #fff; + background: rgba(255, 255, 255, 0.1); + border-color: rgba(193, 206, 255, 0.24); + } + + .details-nav-btn:hover:not(:disabled) svg { + opacity: 1; + } + + .details-nav-btn:active:not(:disabled) { + transform: translateY(1px); + } + + .details-nav-btn:disabled { + opacity: 0.35; + cursor: default; + } + + .details-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-top: 1px solid rgba(193, 206, 255, 0.08); + } + + .details-scroll { + flex: 1; + min-height: 0; + padding: 16px; + overflow-y: auto; + scrollbar-color: rgba(193, 206, 255, 0.2) transparent; + scrollbar-width: thin; + } + + .details-scroll::-webkit-scrollbar { + width: 8px; + } + + .details-scroll::-webkit-scrollbar-thumb { + background: rgba(193, 206, 255, 0.2); + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; + } + + .details-name { + font-size: 18px; + font-weight: 700; + line-height: 1.25; + overflow-wrap: break-word; + } + + .details-type { + margin-top: 4px; + color: rgba(245, 248, 255, 0.55); + font-size: 13px; + } + + .details-wear-bar { + position: relative; + margin: 16px 0 6px; + } + + .details-wear-track { + display: flex; + height: 8px; + overflow: hidden; + border-radius: 4px; + opacity: 0.85; + } + + .details-wear-track div { + height: 100%; + } + + .details-wear-marker { + position: absolute; + top: -3px; + width: 3px; + height: 14px; + background: #d9d9d9; + border-radius: 4px; + transform: translateX(-50%); + } + + .details-props { + margin-top: 14px; + font-size: 13.5px; + line-height: 1.65; + } + + .details-actions { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0; + padding: 14px 0; + border-top: 1px solid rgba(193, 206, 255, 0.1); + border-bottom: 1px solid rgba(193, 206, 255, 0.1); + } + + .details-inspect { + flex: 0 0 auto; + padding: 8px 12px; + color: inherit; + font-size: 13px; + text-decoration: none; + background: rgba(255, 255, 255, 0.1); + border-radius: 6px; + transition: background-color 150ms ease; + } + + .details-inspect:hover { + background: rgba(255, 255, 255, 0.16); + } + + .details-price { + margin-left: auto; + font-size: 15px; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + .details-buy { + padding: 8px 18px; + color: #fff; + font: inherit; + font-size: 13px; + font-weight: 600; + background: #6fa720; + border: 0; + border-radius: 6px; + cursor: pointer; + transition: background-color 150ms ease; + } + + .details-buy:hover { + background: #83bd2c; + } + + .details-buy-notice { + margin: -8px 0 16px; + color: #ff8585; + font-size: 12.5px; + line-height: 1.4; + } + + .details-accessories { + margin-bottom: 16px; + } + + .details-section-title { + margin-bottom: 8px; + color: rgba(245, 248, 255, 0.82); + font-size: 13px; + font-weight: 600; + } + + .details-accessory { + display: flex; + align-items: center; + gap: 12px; + margin-top: 6px; + padding: 9px 10px; + font-size: 13px; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + } + + .details-accessory img { + width: 48px; + height: 36px; + object-fit: contain; + flex: 0 0 auto; + } + + .details-accessory-text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + line-height: 1.35; + } + + .details-accessory-detail { + color: rgba(245, 248, 255, 0.45); + font-size: 12px; + } + + .details-restrictions { + color: rgba(245, 248, 255, 0.6); + font-size: 13px; + line-height: 1.5; + } + + .details-restrictions ul { + margin: 6px 0 0; + padding-left: 22px; + } + + .details-lines { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid rgba(193, 206, 255, 0.1); + } + + .details-lines p { + margin: 10px 0; + color: rgba(245, 248, 255, 0.72); + font-size: 13px; + line-height: 1.5; + } + + .details-lines p.italic { + font-style: italic; + } + + dialog.has-details { + width: min(94vw, calc(80vh * 16 / 9 + 340px), 2260px); + width: min(94vw, calc(80dvh * 16 / 9 + 340px), 2260px); + } + + dialog.has-details .modal-body { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + } + + dialog.has-details .details-panel { + /* The stage's height (its column width at 16:9), restated because the panel's own content + must not be what sizes the shared grid row. Tracks the same 2260px ceiling as the dialog. */ + height: min(calc((min(94vw, 2260px) - 340px) * 9 / 16), 80vh); + height: min(calc((min(94vw, 2260px) - 340px) * 9 / 16), 80dvh); + border-left: 1px solid rgba(193, 206, 255, 0.1); + } + + @media (max-width: 1167px) { + dialog.has-details .modal-body { + display: block; + max-height: calc(100vh - 80px); + max-height: calc(100dvh - 80px); + overflow-y: auto; + } + + dialog.has-details .details-panel { + height: auto; + max-height: 38vh; + border-top: 1px solid rgba(193, 206, 255, 0.1); + border-left: 0; + } + } + .hidden { display: none !important; } diff --git a/src/lib/components/common/ui/floatbar.ts b/src/lib/components/common/ui/floatbar.ts index cb354dc5..cc64d330 100644 --- a/src/lib/components/common/ui/floatbar.ts +++ b/src/lib/components/common/ui/floatbar.ts @@ -1,5 +1,6 @@ import {html, css} from 'lit'; import {property} from 'lit/decorators.js'; +import {FLOAT_CONDITION_BANDS} from '../../../utils/skin'; import {FloatElement} from '../../custom'; import {CustomElement} from '../../injectors'; @@ -44,13 +45,7 @@ export class FloatBar extends FloatElement { `, ]; - private readonly floatConditions = [ - {min: 0, max: 7, color: 'green'}, - {min: 7, max: 15, color: '#18a518'}, - {min: 15, max: 38, color: '#9acd32'}, - {min: 38, max: 45, color: '#cd5c5c'}, - {min: 45, max: 100, color: '#f92424'}, - ]; + private readonly floatConditions = FLOAT_CONDITION_BANDS; get minFloatPercentage(): number { return this.minFloat * 100; diff --git a/src/lib/components/market/react/listing.ts b/src/lib/components/market/react/listing.ts index 9955b2ed..7262336c 100644 --- a/src/lib/components/market/react/listing.ts +++ b/src/lib/components/market/react/listing.ts @@ -1,5 +1,10 @@ import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; import {gFloatFetcher} from '../../../services/float_fetcher'; +import { + getFiberListing, + MARKET_LISTING_CARD_SELECTOR, + toSkinCraftListingItem, +} from '../../../services/skincraft_market_targets'; import {getFiberProps} from '../../../utils/fiber'; import {defineInjectionScope, InjectionMode} from '../../injectors'; import {isReactSteamMarket} from '../mode'; @@ -13,6 +18,10 @@ export interface ReactListingContext { itemInfo: ItemInfo; } +export interface ReactListingCardContext { + listing: MarketListing; +} + export const ReactMarketListingScope = defineInjectionScope({ selector: 'div[style*="--grid-rows"]:has([style*="market_listings/"])', mode: InjectionMode.CONTINUOUS, @@ -20,6 +29,46 @@ export const ReactMarketListingScope = defineInjectionScope context: buildReactListingContext, }); +/** + * Unlike {@link ReactMarketListingScope}, covers every listing card — no rendered screenshot or + * float fetch required — for injections that only need the listing itself. + */ +export const ReactMarketListingCardScope = defineInjectionScope({ + selector: MARKET_LISTING_CARD_SELECTOR, + mode: InjectionMode.CONTINUOUS, + guard: isReactSteamMarket, + context: buildReactListingCardContext, +}); + +function buildReactListingCardContext(scope: HTMLElement): ReactListingCardContext | null | undefined { + const listing = getFiberListing(scope); + if (!listing) return undefined; + + return toSkinCraftListingItem(listing) ? {listing} : null; +} + +/** + * The main "Inspect in Game..." link inside the item dialog, one per swipeable listing pane — + * dialog injections anchor beside it. + */ +export const ReactMarketDialogInspectScope = defineInjectionScope({ + selector: 'dialog a[href*="csgo_econ_action_preview"]', + mode: InjectionMode.CONTINUOUS, + guard: isReactSteamMarket, + context: buildReactDialogInspectContext, +}); + +function buildReactDialogInspectContext(scope: HTMLElement): ReactListingCardContext | null | undefined { + const listing = getFiberListing(scope); + if (!listing) return undefined; + + const item = toSkinCraftListingItem(listing); + if (!item) return null; + // Accessory rows carry their own inspect links; only the pane's own launch link qualifies. + if (!(scope instanceof HTMLAnchorElement) || !scope.href.includes(item.inspect)) return null; + return {listing}; +} + function getInspectLink(listing: MarketListing): string | null { const link = listing.description.actions?.[0]?.link; if (!link) return null; diff --git a/src/lib/components/market/react/placement.ts b/src/lib/components/market/react/placement.ts index d07f148d..8ba288b3 100644 --- a/src/lib/components/market/react/placement.ts +++ b/src/lib/components/market/react/placement.ts @@ -1,7 +1,8 @@ import type {ScopedInjectionArgs} from '../../injectors'; +import {MARKET_PRICE_ROW_SELECTOR} from '../../../services/skincraft_market_targets'; import {getFadePercentage, isBlueSkin, parseRank} from '../../../utils/skin'; import {hasDopplerPhase} from '../../../utils/dopplers'; -import type {ReactListingContext} from './listing'; +import type {ReactListingCardContext, ReactListingContext} from './listing'; function hasSeedDetail(context: ReactListingContext): boolean { return ( @@ -29,6 +30,15 @@ export function findWearSpan({ return undefined; } +export function findPriceRow({scope}: ScopedInjectionArgs): HTMLElement | null | undefined { + return scope.querySelector(MARKET_PRICE_ROW_SELECTOR) ?? undefined; +} + +/** The dialog scope is the inspect link itself; the launcher lands right beside it. */ +export function findDialogInspectLink({scope}: ScopedInjectionArgs): HTMLElement { + return scope; +} + export function findSeedSpan({ scope, context, diff --git a/src/lib/components/market/react/types.ts b/src/lib/components/market/react/types.ts index 83c1753e..8f61ad4f 100644 --- a/src/lib/components/market/react/types.ts +++ b/src/lib/components/market/react/types.ts @@ -1,4 +1,4 @@ -import type {Action, rgAssetProperty, rgDescription, rgInternalDescription} from '../../../types/steam'; +import type {Action, 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 @@ -16,11 +16,21 @@ export interface MarketDescriptionLine extends rgInternalDescription { name: string; } +/** An asset property as the React market hydrates it: `float_value` arrives as a number here, not the string the rg-asset form carries. */ +export interface MarketAssetProperty { + propertyid: number; + int_value?: string; + float_value?: number; + string_value?: string; +} + +// Fields the React fiber has been seen to omit are optional — nothing validates this payload +// before we read it, so consumers must handle their absence. export interface MarketListingDescription - extends Omit { + extends Omit { commodity: boolean; currency: boolean; - descriptions: MarketDescriptionLine[]; + descriptions?: MarketDescriptionLine[]; fraudwarnings: string[]; tradable: boolean; market_marketable_restriction: number; @@ -32,14 +42,25 @@ export interface MarketListingDescription owner_descriptions: rgInternalDescription[]; sealed: boolean; sealed_type: number; - tags: unknown[]; + tags?: rgDescription['tags']; +} + +/** A sticker/charm applied to a listed item, with its own description and slot properties. */ +export interface MarketListingAccessory { + classid: string; + standalone_properties?: MarketAssetProperty[]; + /** Properties tying the accessory to its host item, e.g. propertyid 4 = sticker scrape level. */ + parent_relationship_properties?: MarketAssetProperty[]; + nested_accessories?: MarketListingAccessory[]; + description?: MarketListingDescription; } export interface MarketListingAsset { - asset_properties: rgAssetProperty[]; + asset_properties?: MarketAssetProperty[]; amount: number; appid: number; - accessory_properties: rgAssetProperty[]; + accessory_properties?: MarketAssetProperty[]; + asset_accessories?: MarketListingAccessory[]; assetid: string; classid: string; contextid: string; diff --git a/src/lib/components/market/react/view_3d.ts b/src/lib/components/market/react/view_3d.ts new file mode 100644 index 00000000..3782e663 --- /dev/null +++ b/src/lib/components/market/react/view_3d.ts @@ -0,0 +1,182 @@ +import {css, html, nothing} from 'lit'; +import type {TemplateResult} from 'lit'; +import {property, state} from 'lit/decorators.js'; + +import {gSkinCraftEmbed} from '../../../services/skincraft_embed'; +import {getLoadedListingTargets, toSkinCraftListingItem} from '../../../services/skincraft_market_targets'; +import {MAX_SKINCRAFT_INVENTORY_TARGETS} from '../../../services/skincraft_viewer_protocol'; +import type {SkinCraftItem} from '../../../services/skincraft_viewer_protocol'; +import {gWebGpuAvailability, type WebGpuAvailability} from '../../../services/webgpu_availability'; +import {webGpuGuidance} from '../../../utils/webgpu_guidance'; +import {FloatElement} from '../../custom'; +import {CustomElement, InjectIntoScope, InjectionPosition} from '../../injectors'; +import {ReactMarketDialogInspectScope, ReactMarketListingCardScope, type ReactListingCardContext} from './listing'; +import {findDialogInspectLink, findPriceRow} from './placement'; + +const VIEW_3D_BUTTON_BASE_STYLES = css` + .view-3d-btn { + display: inline-flex; + align-items: center; + height: 24px; + color: #fff; + font-family: inherit; + font-size: 12px; + border: 0; + border-radius: 2px; + cursor: pointer; + transition: background-color 150ms ease; + } + + .view-3d-btn.unavailable { + cursor: default; + opacity: 0.5; + } +`; + +/** + * Shared behaviour for the market 3D launchers, mirroring the inventory "View in 3D" button: they + * render for anything SkinCraft can show, and WebGPU capability only decides enabled vs + * disabled-with-guidance. They stay hidden while the probe settles so they never appear and then + * change. Subclasses own placement and presentation. + */ +abstract class MarketView3DButton extends FloatElement { + @property({attribute: false}) injectionContext?: ReactListingCardContext; + + @state() protected webGpuStatus: WebGpuAvailability = 'checking'; + + protected get skinCraftItem(): SkinCraftItem | undefined { + const listing = this.injectionContext?.listing; + return listing && toSkinCraftListingItem(listing); + } + + connectedCallback(): void { + super.connectedCallback(); + void gWebGpuAvailability.settled().then((status) => { + this.webGpuStatus = status; + }); + } + + /** Runs just before the viewer opens, e.g. to dismiss the surface the button sits on. */ + protected beforeOpen(): void {} + + // The elements behind these buttons are themselves clickable, so always swallow the click. + protected handleClick(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + const item = this.skinCraftItem; + if (this.webGpuStatus !== 'available' || !item) return; + + // A dialog deep-link can show a listing whose card isn't mounted; keep it navigable. + const targets = getLoadedListingTargets(); + if (!targets.some((target) => target.assetId === item.assetId)) { + if (targets.length === MAX_SKINCRAFT_INVENTORY_TARGETS) targets.pop(); + targets.unshift(item); + } + this.beforeOpen(); + gSkinCraftEmbed.open(item, targets); + } + + protected renderUnavailableButton(label: string): TemplateResult { + const reason = gWebGpuAvailability.unavailableReason ?? 'no-webgpu'; + return html` + + ${this.tooltip(webGpuGuidance(reason), 'hint--large')} + + ${label} + + + `; + } +} + +/** Adds a 3D launcher to each listing card's price row, sized to Steam's own Buy button there. */ +@CustomElement() +@InjectIntoScope(ReactMarketListingCardScope, { + anchor: findPriceRow, + position: InjectionPosition.Prepend, +}) +export class ReactListingView3D extends MarketView3DButton { + static styles = [ + ...FloatElement.styles, + VIEW_3D_BUTTON_BASE_STYLES, + css` + :host { + margin-right: auto; + } + + .view-3d-btn { + padding: 0 10px; + background: rgba(255, 255, 255, 0.12); + } + + .view-3d-btn:hover:not(.unavailable) { + background: rgba(255, 255, 255, 0.2); + } + `, + ]; + + protected render() { + if (this.webGpuStatus === 'checking' || !this.skinCraftItem) return nothing; + + if (this.webGpuStatus !== 'available') return this.renderUnavailableButton('3D'); + + return html` + + ${this.tooltip('View in 3D')} + 3D + + `; + } +} + +/** + * Adds a viewer launcher beside the item dialog's "Inspect in Game..." action, matching its native + * styling. Opening the viewer first dismisses Steam's dialog the way pressing Escape would. + */ +@CustomElement() +@InjectIntoScope(ReactMarketDialogInspectScope, { + anchor: findDialogInspectLink, + position: InjectionPosition.After, +}) +export class ReactDialogView3D extends MarketView3DButton { + static styles = [ + ...FloatElement.styles, + VIEW_3D_BUTTON_BASE_STYLES, + css` + .view-3d-btn { + padding: 0 12px; + font-weight: 300; + background: #3d4450; + } + + .view-3d-btn:hover:not(.unavailable) { + background: #464d5c; + } + `, + ]; + + protected beforeOpen(): void { + // The dialog owns the ?detail=… routing, and Escape is Steam's own dismissal path. + const dialog = this.closest('dialog'); + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', code: 'Escape', bubbles: true})); + // A dialog still open after Steam's async handling ignored the synthetic key. + window.setTimeout(() => { + if (dialog?.open) console.warn("CSFloat: Steam's item dialog ignored the dismissal before the 3D viewer."); + }, 500); + } + + protected render() { + if (this.webGpuStatus === 'checking' || !this.skinCraftItem) return nothing; + + if (this.webGpuStatus !== 'available') return this.renderUnavailableButton('View in 3D'); + + return html`View in 3D`; + } +} diff --git a/src/lib/page_scripts/market_listing.ts b/src/lib/page_scripts/market_listing.ts index 19010b53..b6362f1a 100644 --- a/src/lib/page_scripts/market_listing.ts +++ b/src/lib/page_scripts/market_listing.ts @@ -5,7 +5,13 @@ import '../components/market/react/filter_panel'; import '../components/market/react/rank'; import '../components/market/react/seed_info'; import '../components/market/react/highlight'; +import '../components/market/react/view_3d'; +import {gSkinCraftEmbed} from '../services/skincraft_embed'; +import {buyListing, loadMoreListingTargets} from '../services/skincraft_market_targets'; init('src/lib/page_scripts/market_listing.js', main); -async function main() {} +async function main() { + gSkinCraftEmbed.registerItemsProvider(loadMoreListingTargets); + gSkinCraftEmbed.registerBuyListingHandler(buyListing); +} diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 86371d95..6ed5e571 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -10,13 +10,28 @@ import { import type {SkinCraftEmbedCommand} from './skincraft_embed_protocol'; import {getLoadedInventoryTargets} from './skincraft_inventory_targets'; import { + isBuySkinCraftListingMessage, + isMalformedSkinCraftViewerMessage, isOpenSkinCraftViewerMessage, + isRequestSkinCraftViewerItemsMessage, + isSkinCraftBuyListingResultMessage, + isSkinCraftViewerItemsMessage, SKINCRAFT_VIEWER_MESSAGE_SOURCE, STEAM_INSPECT_URL_PATTERN, } from './skincraft_viewer_protocol'; -import type {OpenSkinCraftViewerMessage, SkinCraftItem, SkinCraftViewerTarget} from './skincraft_viewer_protocol'; +import type { + BuySkinCraftListingMessage, + OpenSkinCraftViewerMessage, + RequestSkinCraftViewerItemsMessage, + SkinCraftBuyListingResultMessage, + SkinCraftItem, + SkinCraftViewerItemsMessage, + SkinCraftViewerTarget, +} from './skincraft_viewer_protocol'; const LOAD_TIMEOUT_MS = 20_000; +/** The page answers synchronously; a miss means the page script died or was never injected. */ +const BUY_RESULT_TIMEOUT_MS = 2_000; type LoadPhase = 'idle' | 'loading' | 'loaded' | 'error'; class SkinCraftEmbedService { @@ -37,12 +52,35 @@ class SkinCraftEmbedService { private showLoadingCover = false; private frameHasContent = false; private needsFrameReload = false; + private itemsProvider?: () => Promise; + private buyHandler?: (listingId: string) => boolean; + private providingItems = false; + private latestItemsRequestId = 0; + private itemsRequestPending = false; + private itemsRequestId = 0; + private itemCount = 0; + private pendingBuyListingId?: string; + private buyResultTimer?: number; constructor() { - if (!this.runsInPage) window.addEventListener('message', this.handleOpenRequest); + if (this.runsInPage) { + window.addEventListener('message', this.handlePageRequest); + } else { + window.addEventListener('message', this.handleViewerMessage); + } + } + + /** Page-context surfaces with paginated items (the Market beta) register how to load more. */ + registerItemsProvider(provider: () => Promise): void { + this.itemsProvider = provider; + } + + /** Page-context surfaces with purchasable items register how to hand off to their buy flow. */ + registerBuyListingHandler(handler: (listingId: string) => boolean): void { + this.buyHandler = handler; } - open(target: SkinCraftItem): void { + open(target: SkinCraftItem, items?: SkinCraftItem[]): void { if (!target.inspect) return; if (this.runsInPage) { @@ -51,17 +89,86 @@ class SkinCraftEmbedService { source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, type: 'open', target, - inventory: - typeof g_ActiveInventory === 'undefined' || !g_ActiveInventory - ? [] - : getLoadedInventoryTargets(g_ActiveInventory), + inventory: items ?? this.loadedInventoryTargets(), } satisfies OpenSkinCraftViewerMessage, window.location.origin ); return; } - this.openEmbeddedViewer(target, []); + this.openEmbeddedViewer(target, items ?? []); + } + + private loadedInventoryTargets(): SkinCraftItem[] { + return typeof g_ActiveInventory === 'undefined' || !g_ActiveInventory + ? [] + : getLoadedInventoryTargets(g_ActiveInventory); + } + + private handlePageRequest = (event: MessageEvent): void => { + if (event.source !== window || event.origin !== window.location.origin) return; + if (isRequestSkinCraftViewerItemsMessage(event.data)) { + void this.provideItems(event.data.requestId); + } else if (isBuySkinCraftListingMessage(event.data)) { + this.answerBuyRequest(event.data.listingId); + } else if (isMalformedSkinCraftViewerMessage(event.data, ['buy-listing'])) { + console.error('CSFloat: dropped a malformed SkinCraft viewer message in the page context.', event.data); + } + }; + + /** + * Requests that arrive mid-load coalesce into it: the harvest reads the grid once the load + * settles, so it answers the newest request, and every request gets an answer. + */ + private async provideItems(requestId: number): Promise { + this.latestItemsRequestId = requestId; + if (this.providingItems) return; + + this.providingItems = true; + let inventory: SkinCraftItem[] = []; + try { + if (this.itemsProvider) { + inventory = await this.itemsProvider(); + } else { + console.error('CSFloat: SkinCraft viewer items were requested before a provider was registered.'); + } + } catch (e) { + // An empty answer reads as "nothing more" — the content script keeps its current strip. + console.error('CSFloat: failed to load more items for the SkinCraft viewer.', e); + } finally { + this.providingItems = false; + } + + // Always answered, even empty: the content script's pending flag latches until a response. + window.postMessage( + { + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'items', + requestId: this.latestItemsRequestId, + inventory, + } satisfies SkinCraftViewerItemsMessage, + window.location.origin + ); + } + + private answerBuyRequest(listingId: string): void { + let success = false; + try { + success = this.buyHandler?.(listingId) ?? false; + } catch (e) { + console.error('CSFloat: the buy hand-off threw.', e); + } + if (!success) console.error(`CSFloat: found no native Buy button for listing ${listingId}.`); + + window.postMessage( + { + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'buy-result', + listingId, + success, + } satisfies SkinCraftBuyListingResultMessage, + window.location.origin + ); } close(): void { @@ -70,6 +177,9 @@ class SkinCraftEmbedService { this.active = false; this.pendingInspect = undefined; this.latestLoadId = undefined; + this.itemsRequestPending = false; + this.clearBuyRequest(); + this.itemCount = 0; this.loadProgress = null; this.loadPhase = 'idle'; this.showLoadingCover = false; @@ -80,18 +190,116 @@ class SkinCraftEmbedService { document.removeEventListener('visibilitychange', this.handleVisibilityChange); } - private handleOpenRequest = (event: MessageEvent): void => { + private handleViewerMessage = (event: MessageEvent): void => { if (event.source !== window || event.origin !== window.location.origin) return; - if (!isOpenSkinCraftViewerMessage(event.data)) return; - this.openEmbeddedViewer(event.data.target, event.data.inventory); + if (isOpenSkinCraftViewerMessage(event.data)) { + this.openEmbeddedViewer(event.data.target, event.data.inventory); + } else if (isSkinCraftViewerItemsMessage(event.data)) { + this.applyItemsUpdate(event.data); + } else if (isSkinCraftBuyListingResultMessage(event.data)) { + this.handleBuyResult(event.data); + } else if (isMalformedSkinCraftViewerMessage(event.data, ['open', 'items', 'buy-result'])) { + console.error('CSFloat: dropped a malformed SkinCraft viewer message.', event.data); + } }; private openEmbeddedViewer(target: SkinCraftItem, inventory: SkinCraftItem[]): void { const modal = this.ensureModal(); + this.itemCount = inventory.length; + this.itemsRequestPending = false; modal.setItems(inventory.map((item) => this.toViewerTarget(item))); this.selectEmbeddedTarget(this.toViewerTarget(target)); } + private get isMarketPage(): boolean { + return window.location.pathname.startsWith('/market/'); + } + + private requestMoreItems(): void { + if (this.itemsRequestPending || !this.active || !this.isMarketPage) return; + this.itemsRequestPending = true; + window.postMessage( + { + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'request-items', + requestId: ++this.itemsRequestId, + } satisfies RequestSkinCraftViewerItemsMessage, + window.location.origin + ); + } + + /** + * Hands the purchase to Steam's own flow. The viewer only closes once the page confirms the + * hand-off — a fire-and-forget close would leave a failure with no dialog and no explanation. + */ + private handleBuyRequest(listingId: string): void { + if (this.pendingBuyListingId) return; + + this.pendingBuyListingId = listingId; + this.buyResultTimer = window.setTimeout(() => this.finishBuyRequest(false), BUY_RESULT_TIMEOUT_MS); + window.postMessage( + { + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'buy-listing', + listingId, + } satisfies BuySkinCraftListingMessage, + window.location.origin + ); + } + + private handleBuyResult(message: SkinCraftBuyListingResultMessage): void { + if (message.listingId !== this.pendingBuyListingId) return; + this.finishBuyRequest(message.success); + } + + private finishBuyRequest(success: boolean): void { + if (!this.pendingBuyListingId) return; + + const listingId = this.pendingBuyListingId; + this.clearBuyRequest(); + if (success) { + this.close(); + return; + } + + console.error(`CSFloat: the buy hand-off for listing ${listingId} failed.`); + this.modal?.setBuyNotice("Steam's purchase flow couldn't be opened — the listing may no longer be available."); + } + + private clearBuyRequest(): void { + this.pendingBuyListingId = undefined; + if (this.buyResultTimer === undefined) return; + window.clearTimeout(this.buyResultTimer); + this.buyResultTimer = undefined; + } + + private applyItemsUpdate({requestId, inventory}: SkinCraftViewerItemsMessage): void { + // Only the outstanding request counts: `close()` can't cancel the page's in-flight load, so + // its answer may land in a later session, after that session's own request or none at all. + if (!this.itemsRequestPending || requestId !== this.itemsRequestId) return; + + this.itemsRequestPending = false; + if (!this.active || !this.modal) return; + + const items = inventory.map((item) => this.toViewerTarget(item)); + // A harvest only covers mounted cards; the current item may have none (a dialog deep-link) + // and must stay in the strip or Previous/Next both disable under it. + const current = this.activeTarget; + if (current && !items.some((item) => this.isSameTarget(item, current))) items.unshift(current); + // A shrunk harvest (a failed load, or the grid re-filtered underneath) never truncates the + // items the user is navigating — the snapshot only ever grows within a session. + if (items.length <= this.itemCount) return; + + this.itemCount = items.length; + this.modal.setItems(items); + // Keep filling while the user is still near the end of the loaded items. + if (this.modal.itemsNearEnd()) this.requestMoreItems(); + } + + private isSameTarget(a: SkinCraftViewerTarget, b: SkinCraftViewerTarget): boolean { + return (a.assetId || a.inspect) === (b.assetId || b.inspect); + } + private selectEmbeddedTarget(target: SkinCraftViewerTarget): void { const modal = this.ensureModal(); // Switch in place only when the frame is showing a model; otherwise dropping the cover @@ -119,12 +327,15 @@ class SkinCraftEmbedService { this.frameHasContent = false; const modal = new SkinCraftViewerModal({ embedSrc: this.embedSrc, - itemsTitle: 'Inventory', + itemsTitle: this.isMarketPage ? 'Listings' : 'Inventory', + layout: this.isMarketPage ? 'details' : 'grid', onClose: () => this.close(), onRetry: () => { if (this.activeTarget) this.requestLoad(this.activeTarget.inspect, true); }, onSelect: (target) => this.selectEmbeddedTarget(target), + onItemsNearEnd: () => this.requestMoreItems(), + onBuy: this.isMarketPage ? (listingId) => this.handleBuyRequest(listingId) : undefined, }); document.body.appendChild(modal.element); window.addEventListener('message', this.handleEmbedMessage); @@ -225,7 +436,7 @@ class SkinCraftEmbedService { if (this.loadPhase === 'loaded' && url && STEAM_INSPECT_URL_PATTERN.test(url)) { window.location.href = url; } else { - console.warn('SkinCraft: no launchable inspect link for the item on screen.'); + console.warn('CSFloat: no launchable inspect link for the item on screen.'); } break; } diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index be4ecab2..e18f9c54 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -1,4 +1,4 @@ -import type {CAppwideInventory, CInventory, InventoryAsset, rgAsset, rgAssetProperty} from '../types/steam'; +import type {CAppwideInventory, CInventory, rgAsset, rgAssetProperty} from '../types/steam'; import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; import {ContextId} from '../types/steam_constants'; import {isCAppwideInventory} from '../utils/checkers'; @@ -7,24 +7,45 @@ import {steamEconomyImageUrl} from '../utils/steam_images'; import {gFloatFetcher} from './float_fetcher'; import { HEX_COLOR_PATTERN, + MAX_SKINCRAFT_ICON_URL, MAX_SKINCRAFT_INVENTORY_TARGETS, + MAX_SKINCRAFT_ITEM_NAME, SKINCRAFT_INSPECT_PATTERN, STEAM_INSPECT_URL_PATTERN, } from './skincraft_viewer_protocol'; import type {SkinCraftItem} from './skincraft_viewer_protocol'; -type CachedItemInfoLookup = (assetId: string) => ItemInfo | undefined; +export type CachedItemInfoLookup = (assetId: string) => ItemInfo | undefined; + +/** An asset property loose enough to cover both the rg-asset and React-market value typings. */ +export type SkinCraftSourceProperty = {propertyid: number; string_value?: string}; + +/** The description slice {@link toSkinCraftItem} reads; inventory `rgAsset`s and market fiber descriptions both qualify. */ +export type SkinCraftSourceDescription = Pick< + rgAsset, + 'market_hash_name' | 'type' | 'tags' | 'actions' | 'icon_url' | 'icon_url_large' | 'background_color' +> & {asset_properties?: SkinCraftSourceProperty[]}; + +/** The asset slice {@link toSkinCraftItem} reads, so market listings satisfy it without casts. */ +export type SkinCraftSourceAsset = { + assetid: string; + asset_properties?: SkinCraftSourceProperty[]; + description: SkinCraftSourceDescription; +}; const cachedItemInfo: CachedItemInfoLookup = (assetId) => gFloatFetcher.getCached(assetId); -function getAssetProperties(asset: InventoryAsset, fallbackProperties: rgAssetProperty[]): rgAssetProperty[] { +function getAssetProperties( + asset: SkinCraftSourceAsset, + fallbackProperties: SkinCraftSourceProperty[] +): SkinCraftSourceProperty[] { if (asset.asset_properties?.length) return asset.asset_properties; if (asset.description.asset_properties?.length) return asset.description.asset_properties; return fallbackProperties; } /** Item types SkinCraft renders (gloves fall under `isSkin`). */ -function isSkinCraftRenderable(description: rgAsset): boolean { +function isSkinCraftRenderable(description: SkinCraftSourceDescription): boolean { return ( isSkin(description) || isSticker(description) || @@ -39,7 +60,9 @@ const MASKED_ACTION_PATTERN = /^steam:\/\/(?:run|rungame)\/730\/\d{0,20}\/\+csgo_econ_action_preview%20(%propid:6%|[0-9a-f]{40,8192})$/i; /** Split at the hex slot, which Steam either fills from asset property 6 or embeds inline. */ -function getMaskedInspectAction(description: rgAsset): {prefix: string; embeddedHex?: string} | undefined { +function getMaskedInspectAction( + description: SkinCraftSourceDescription +): {prefix: string; embeddedHex?: string} | undefined { for (const action of description.actions ?? []) { const slot = MASKED_ACTION_PATTERN.exec(action.link)?.[1]; if (slot) { @@ -53,8 +76,8 @@ function getMaskedInspectAction(description: rgAsset): {prefix: string; embedded } function getSkinCraftInspect( - asset: InventoryAsset, - fallbackProperties: rgAssetProperty[] + asset: SkinCraftSourceAsset, + fallbackProperties: SkinCraftSourceProperty[] ): Pick | undefined { if (!isSkinCraftRenderable(asset.description)) return; @@ -69,9 +92,15 @@ function getSkinCraftInspect( return {inspect, inspectUrl: inspectUrl && STEAM_INSPECT_URL_PATTERN.test(inspectUrl) ? inspectUrl : undefined}; } +/** Bounded per the viewer protocol; an over-long URL drops the icon, not the item. */ +export function toBoundedIconUrl(icon: string | undefined): string | undefined { + const iconUrl = icon ? steamEconomyImageUrl(icon) : undefined; + return iconUrl && iconUrl.length <= MAX_SKINCRAFT_ICON_URL ? iconUrl : undefined; +} + export function toSkinCraftItem( - asset: InventoryAsset | undefined, - fallbackProperties: rgAssetProperty[] = [], + asset: SkinCraftSourceAsset | undefined, + fallbackProperties: SkinCraftSourceProperty[] = [], getCachedItemInfo: CachedItemInfoLookup = cachedItemInfo ): SkinCraftItem | undefined { if (!asset?.description || typeof asset.description.market_hash_name !== 'string') return; @@ -79,14 +108,13 @@ export function toSkinCraftItem( const inspectFields = getSkinCraftInspect(asset, fallbackProperties); if (!inspectFields) return; - const icon = asset.description.icon_url_large || asset.description.icon_url; const itemInfo = getCachedItemInfo(asset.assetid); const rarityColor = asset.description.tags?.find((tag) => tag.category === 'Rarity')?.color; const backgroundColor = asset.description.background_color; return { ...inspectFields, - name: asset.description.market_hash_name, - iconUrl: icon ? steamEconomyImageUrl(icon) : undefined, + name: asset.description.market_hash_name.slice(0, MAX_SKINCRAFT_ITEM_NAME), + iconUrl: toBoundedIconUrl(asset.description.icon_url_large || asset.description.icon_url), assetId: asset.assetid, seed: itemInfo ? formatSeed(itemInfo) : undefined, float: itemInfo ? formatFloatWithRank(itemInfo, 6) : undefined, diff --git a/src/lib/services/skincraft_market_targets.test.ts b/src/lib/services/skincraft_market_targets.test.ts new file mode 100644 index 00000000..cb98e2ee --- /dev/null +++ b/src/lib/services/skincraft_market_targets.test.ts @@ -0,0 +1,308 @@ +import {describe, expect, it} from 'vitest'; +import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; +import type {MarketListing} from '../components/market/react/types'; +import {formatFloatWithRank, formatSeed} from '../utils/skin'; +import {STEAM_ECONOMY_IMAGE_PREFIX} from '../utils/steam_images'; +import {formatBuyerPrice, toSkinCraftListingItem} from './skincraft_market_targets'; +import { + isOpenSkinCraftViewerMessage, + MAX_SKINCRAFT_ACCESSORIES, + MAX_SKINCRAFT_ACCESSORY_NAME, + MAX_SKINCRAFT_DETAIL_FIELD, + MAX_SKINCRAFT_DETAIL_LINES, + MAX_SKINCRAFT_DETAIL_TEXT, + MAX_SKINCRAFT_ITEM_NAME, + MAX_SKINCRAFT_PATTERN_TEMPLATE, + SKINCRAFT_VIEWER_MESSAGE_SOURCE, +} from './skincraft_viewer_protocol'; + +const MASKED_LINK = 'steam://run/730//+csgo_econ_action_preview%20%propid:6%'; + +function createListing(overrides: {description?: object; asset?: object} = {}): MarketListing { + return { + listingid: '556910233323745386', + asset: { + assetid: '53323033442', + asset_properties: [ + {propertyid: 2, float_value: 0.529}, + {propertyid: 6, string_value: 'a'.repeat(80)}, + ], + ...overrides.asset, + }, + description: { + appid: 730, + market_hash_name: 'AK-47 | Redline (Battle-Scarred)', + type: 'Classified Rifle', + name_color: 'd32ce6', + background_color: '3d293f', + icon_url: 'icon-path', + tags: [], + actions: [{name: 'Inspect in Game...', link: MASKED_LINK}], + ...overrides.description, + }, + } as unknown as MarketListing; +} + +describe('SkinCraft market targets', () => { + it('maps a beta listing, treating its empty tags as absent when deciding renderability', () => { + expect(toSkinCraftListingItem(createListing())).toEqual({ + inspect: 'a'.repeat(80), + inspectUrl: `steam://run/730//+csgo_econ_action_preview%20${'a'.repeat(80)}`, + name: 'AK-47 | Redline (Battle-Scarred)', + iconUrl: `${STEAM_ECONOMY_IMAGE_PREFIX}icon-path/330x192`, + assetId: '53323033442', + seed: undefined, + float: undefined, + rarityColor: 'd32ce6', + backgroundColor: '3d293f', + details: { + listingId: '556910233323745386', + game: 'Counter-Strike 2', + type: 'Classified Rifle', + wearRating: '0.52900000', + }, + }); + }); + + it("mirrors Steam's item dialog details for the panel", () => { + const listing = { + ...createListing({ + asset: { + assetid: '53323033442', + asset_properties: [ + {propertyid: 1, int_value: '515'}, + {propertyid: 2, float_value: 0.5291814804077148}, + {propertyid: 5, string_value: 'AK-47| Nerfed'}, + {propertyid: 6, string_value: 'a'.repeat(80)}, + ], + }, + description: { + market_tradable_restriction: 7, + market_marketable_restriction: 7, + descriptions: [ + {type: 'html', value: 'Exterior: Battle-Scarred', name: 'exterior_wear'}, + {type: 'html', value: ' ', name: 'blank'}, + {type: 'html', value: 'Powerful and reliable.\n\nNever be afraid', name: 'description'}, + {type: 'html', value: 'The Phoenix Collection', color: '9da1a9', name: 'itemset_name'}, + ], + }, + }), + strSubtotal: '$35.20', + } as MarketListing; + + expect(toSkinCraftListingItem(listing)?.details).toEqual({ + listingId: '556910233323745386', + game: 'Counter-Strike 2', + type: 'Classified Rifle', + nameTag: 'AK-47| Nerfed', + patternTemplate: '515', + wearRating: '0.52918148', + price: '$35.20', + tradeRestrictionDays: 7, + marketRestrictionDays: 7, + lines: [ + {text: 'Exterior: Battle-Scarred', italic: undefined, color: undefined}, + {text: 'Powerful and reliable.', italic: undefined, color: undefined}, + {text: 'Never be afraid', italic: true, color: undefined}, + {text: 'The Phoenix Collection', italic: undefined, color: '9da1a9'}, + ], + }); + }); + + it("maps applied accessories with Steam's own attribute lines, keeping their markup out of the text lines", () => { + const accessory = (description: object, parentProperties: object[] = []) => ({ + classid: '7104637288', + standalone_properties: [], + parent_relationship_properties: parentProperties, + nested_accessories: [], + description, + }); + const listing = createListing({ + asset: { + assetid: '53323033442', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], + asset_accessories: [ + accessory( + {market_hash_name: 'Sticker | NRG | Austin 2025', type: 'High Grade Sticker', icon_url: 'nrg'}, + [{propertyid: 4, float_value: 0.6800000071525574}] + ), + accessory({ + market_hash_name: 'Sticker | OG | Austin 2025', + type: 'High Grade Sticker', + icon_url: 'og', + }), + accessory( + {market_hash_name: 'Charm | Baby Karat T', type: 'Extraordinary Charm', icon_url: 'charm'}, + [{propertyid: 3, int_value: '88143'}] + ), + ], + }, + description: { + descriptions: [ + {type: 'html', value: '', name: 'sticker_info'}, + {type: 'html', value: 'The Phoenix Collection', name: 'itemset_name'}, + ], + }, + }); + const details = toSkinCraftListingItem(listing)?.details; + + expect(details?.accessories).toEqual([ + { + name: 'Sticker | NRG | Austin 2025', + iconUrl: `${STEAM_ECONOMY_IMAGE_PREFIX}nrg/330x192`, + detail: 'Sticker Scrape Level: 0.680000007', + }, + { + name: 'Sticker | OG | Austin 2025', + iconUrl: `${STEAM_ECONOMY_IMAGE_PREFIX}og/330x192`, + detail: 'Sticker Scrape Level: 0', + }, + { + name: 'Charm | Baby Karat T', + iconUrl: `${STEAM_ECONOMY_IMAGE_PREFIX}charm/330x192`, + detail: 'Charm Template: 88143', + }, + ]); + expect(details?.lines).toEqual([{text: 'The Phoenix Collection', italic: undefined, color: undefined}]); + }); + + it('accepts non-skin renderable types by their type string', () => { + const listing = createListing({ + description: {market_hash_name: 'Sticker | Crown (Foil)', type: 'High Grade Sticker'}, + }); + + expect(toSkinCraftListingItem(listing)?.inspect).toBe('a'.repeat(80)); + }); + + it('rejects item types SkinCraft cannot render', () => { + const listing = createListing({ + description: {market_hash_name: 'Dreams & Nightmares Case', type: 'Base Grade Container'}, + }); + + expect(toSkinCraftListingItem(listing)).toBeUndefined(); + }); + + it('requires the masked inspect hex', () => { + const listing = createListing({asset: {asset_properties: [{propertyid: 2, float_value: 0.529}]}}); + + expect(toSkinCraftListingItem(listing)).toBeUndefined(); + }); + + it('prefers a rarity colour resolved from tags over the name colour', () => { + const listing = createListing({ + description: { + tags: [ + {category: 'Weapon', internal_name: 'weapon_ak47'}, + {category: 'Rarity', internal_name: 'Rarity_Ancient_Weapon', color: 'eb4b4b'}, + ], + }, + }); + + expect(toSkinCraftListingItem(listing)?.rarityColor).toBe('eb4b4b'); + }); + + it('formats float and seed from cached item info', () => { + const itemInfo = {floatvalue: 0.5291814804077148, paintseed: 515, paintindex: 316} as unknown as ItemInfo; + const target = toSkinCraftListingItem(createListing(), () => itemInfo); + + expect(target?.float).toBe(formatFloatWithRank(itemInfo, 6)); + expect(target?.seed).toBe(formatSeed(itemInfo)); + }); + + it('drops non-hex text colours instead of forwarding them', () => { + const listing = createListing({ + description: { + name_color: 'red', + descriptions: [{type: 'html', value: 'The Phoenix Collection', color: '#9da1a9', name: 'itemset_name'}], + }, + }); + const target = toSkinCraftListingItem(listing); + + expect(target?.rarityColor).toBeUndefined(); + expect(target?.details?.lines).toEqual([{text: 'The Phoenix Collection', italic: undefined, color: undefined}]); + }); + + it('produces targets the viewer protocol accepts', () => { + const target = toSkinCraftListingItem(createListing()); + + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [target], + }) + ).toBe(true); + }); + + it('clamps oversized Steam data to the protocol bounds so the message still validates', () => { + const accessory = (name: string, template?: string) => ({ + classid: '1', + parent_relationship_properties: template ? [{propertyid: 3, int_value: template}] : [], + description: {market_hash_name: name, type: 'Extraordinary Charm', icon_url: 'charm'}, + }); + const listing = createListing({ + asset: { + assetid: '53323033442', + asset_properties: [ + {propertyid: 1, int_value: '9'.repeat(40)}, + {propertyid: 5, string_value: 'N'.repeat(400)}, + {propertyid: 6, string_value: 'a'.repeat(80)}, + ], + asset_accessories: Array.from({length: MAX_SKINCRAFT_ACCESSORIES + 2}, () => + accessory('C'.repeat(400), '9'.repeat(200)) + ), + }, + description: { + market_hash_name: `${'M'.repeat(600)} (Factory New)`, + type: 'T'.repeat(400), + market_tradable_restriction: 9000, + descriptions: Array.from({length: MAX_SKINCRAFT_DETAIL_LINES + 6}, (_, index) => ({ + type: 'html', + value: index ? `line ${index}` : 'x'.repeat(MAX_SKINCRAFT_DETAIL_TEXT + 100), + name: `line-${index}`, + })), + }, + }); + const target = toSkinCraftListingItem(listing); + const details = target?.details; + + expect(target?.name).toHaveLength(MAX_SKINCRAFT_ITEM_NAME); + expect(details?.type).toHaveLength(MAX_SKINCRAFT_DETAIL_FIELD); + expect(details?.nameTag).toHaveLength(MAX_SKINCRAFT_DETAIL_FIELD); + expect(details?.patternTemplate).toHaveLength(MAX_SKINCRAFT_PATTERN_TEMPLATE); + expect(details?.tradeRestrictionDays).toBeUndefined(); + expect(details?.accessories).toHaveLength(MAX_SKINCRAFT_ACCESSORIES); + expect(details?.accessories?.[0].name).toHaveLength(MAX_SKINCRAFT_ACCESSORY_NAME); + expect(details?.lines).toHaveLength(MAX_SKINCRAFT_DETAIL_LINES); + expect(details?.lines?.[0].text).toHaveLength(MAX_SKINCRAFT_DETAIL_TEXT); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [target], + }) + ).toBe(true); + }); +}); + +describe('formatBuyerPrice', () => { + const listing = (strSubtotal: string, unPrice?: number, unFee?: number) => + ({strSubtotal, unPrice, unFee}) as MarketListing; + + it('adds the buyer fee inside the localized subtotal format', () => { + expect(formatBuyerPrice(listing('$35.20', 3520, 528))).toBe('$40.48'); + expect(formatBuyerPrice(listing('1 234,56 zł', 123456, 17283))).toBe('1 407,39 zł'); + expect(formatBuyerPrice(listing('R$ 1.234,56', 123456, 876544))).toBe('R$ 10.000,00'); + expect(formatBuyerPrice(listing('0,99€', 99, 15))).toBe('1,14€'); + }); + + it('leaves formats it cannot verify against the raw subtotal untouched', () => { + expect(formatBuyerPrice(listing('¥ 3,520', 352000, 52800))).toBe('¥ 3,520'); + expect(formatBuyerPrice(listing('$35.20', 9999, 528))).toBe('$35.20'); + expect(formatBuyerPrice(listing('$35.20', 3520, 0))).toBe('$35.20'); + expect(formatBuyerPrice(listing('$35.20'))).toBe('$35.20'); + expect(formatBuyerPrice(listing(''))).toBeUndefined(); + }); +}); diff --git a/src/lib/services/skincraft_market_targets.ts b/src/lib/services/skincraft_market_targets.ts new file mode 100644 index 00000000..ea04f371 --- /dev/null +++ b/src/lib/services/skincraft_market_targets.ts @@ -0,0 +1,267 @@ +import type { + MarketAssetProperty, + MarketListing, + MarketListingAccessory, + MarketListingProps, +} from '../components/market/react/types'; +import {getFiberProps} from '../utils/fiber'; +import {toBoundedIconUrl, toSkinCraftItem} from './skincraft_inventory_targets'; +import type {CachedItemInfoLookup, SkinCraftSourceAsset} from './skincraft_inventory_targets'; +import { + ASSET_ID_PATTERN, + HEX_COLOR_PATTERN, + isRestrictionDays, + MAX_SKINCRAFT_ACCESSORIES, + MAX_SKINCRAFT_ACCESSORY_DETAIL, + MAX_SKINCRAFT_ACCESSORY_NAME, + MAX_SKINCRAFT_DETAIL_FIELD, + MAX_SKINCRAFT_DETAIL_LINES, + MAX_SKINCRAFT_DETAIL_TEXT, + MAX_SKINCRAFT_INVENTORY_TARGETS, + MAX_SKINCRAFT_PATTERN_TEMPLATE, + MAX_SKINCRAFT_PRICE, + MAX_SKINCRAFT_WEAR_RATING, +} from './skincraft_viewer_protocol'; +import type { + SkinCraftAccessory, + SkinCraftDetailLine, + SkinCraftItem, + SkinCraftListingDetails, +} from './skincraft_viewer_protocol'; + +/** Every listing card in the Market beta results grid, with or without a rendered screenshot. */ +export const MARKET_LISTING_CARD_SELECTOR = 'div[style*="--grid-rows"]'; + +/** The action row at the bottom of a listing card, holding Steam's own Buy button. */ +export const MARKET_PRICE_ROW_SELECTOR = 'div[style*="--justify:end"][style*="--align:center"]:has(button)'; + +const LOAD_MORE_TIMEOUT_MS = 5_000; +const LOAD_MORE_POLL_MS = 250; + +/** Steam's asset property ids, as they appear in both the rg-asset and React-market payloads. */ +const AssetPropertyId = { + PatternTemplate: 1, + Wear: 2, + CharmTemplate: 3, + ScrapeLevel: 4, + NameTag: 5, +} as const; + +/** A market listing reshaped as the inventory-style source {@link toSkinCraftItem} consumes. */ +function toListingAsset(listing: MarketListing): SkinCraftSourceAsset { + // Beta market descriptions ship empty `tags`; dropping them lets the type/name fallbacks + // decide renderability instead. + const {tags, ...description} = listing.description; + return { + assetid: listing.asset.assetid, + asset_properties: listing.asset.asset_properties, + description: tags?.length ? listing.description : description, + }; +} + +/** The description entries that duplicate applied sticker/charm data as markup rather than prose. */ +const ACCESSORY_INFO_ENTRIES = new Set(['sticker_info', 'keychain_info']); + +/** Matches how Steam prints scrape levels: the float32 value at 9 significant digits. */ +function formatScrapeLevel(value: number): string { + return String(Number(value.toPrecision(9))); +} + +/** The per-accessory attribute line; property 4 is the sticker scrape level, 3 the charm template. */ +function toAccessoryDetail(accessory: MarketListingAccessory): string | undefined { + const properties = [ + ...(accessory.parent_relationship_properties ?? []), + ...(accessory.standalone_properties ?? []), + ]; + const property = (id: number): MarketAssetProperty | undefined => properties.find((p) => p.propertyid === id); + + const scrape = Number(property(AssetPropertyId.ScrapeLevel)?.float_value); + if (Number.isFinite(scrape)) return `Sticker Scrape Level: ${formatScrapeLevel(scrape)}`; + + const template = property(AssetPropertyId.CharmTemplate)?.int_value; + if (template) return `Charm Template: ${template}`.slice(0, MAX_SKINCRAFT_ACCESSORY_DETAIL); + + // Steam spells out the zero on unscraped stickers rather than dropping the line. + return accessory.description?.type?.endsWith('Sticker') ? 'Sticker Scrape Level: 0' : undefined; +} + +function toAccessories(listing: MarketListing): SkinCraftAccessory[] | undefined { + const accessories: SkinCraftAccessory[] = []; + for (const accessory of listing.asset.asset_accessories ?? []) { + const description = accessory.description; + if (typeof description?.market_hash_name !== 'string') continue; + + accessories.push({ + name: description.market_hash_name.slice(0, MAX_SKINCRAFT_ACCESSORY_NAME), + iconUrl: toBoundedIconUrl(description.icon_url_large || description.icon_url), + detail: toAccessoryDetail(accessory), + }); + if (accessories.length === MAX_SKINCRAFT_ACCESSORIES) break; + } + return accessories.length ? accessories : undefined; +} + +/** Flattens Steam's HTML description entries into sanitized plain-text lines. */ +function toDetailLines(listing: MarketListing): SkinCraftDetailLine[] | undefined { + const lines: SkinCraftDetailLine[] = []; + for (const entry of listing.description.descriptions ?? []) { + if ((entry.type && entry.type !== 'html') || typeof entry.value !== 'string') continue; + if (ACCESSORY_INFO_ENTRIES.has(entry.name)) continue; + + for (const raw of entry.value.split(/\n+/)) { + const italic = /^\s*/i.test(raw); + const text = raw.replace(/<[^>]*>/g, '').trim(); + if (!text) continue; + + const color = entry.color && HEX_COLOR_PATTERN.test(entry.color) ? entry.color : undefined; + lines.push({text: text.slice(0, MAX_SKINCRAFT_DETAIL_TEXT), italic: italic || undefined, color}); + if (lines.length === MAX_SKINCRAFT_DETAIL_LINES) return lines; + } + } + return lines.length ? lines : undefined; +} + +/** + * The buyer-facing price (subtotal + fee, what Steam's cards show), formatted by rewriting the + * numeric part of the localized `strSubtotal`; falls back to it whenever it doesn't parse as `unPrice`. + */ +export function formatBuyerPrice(listing: MarketListing): string | undefined { + const subtotal = listing.strSubtotal; + if (!subtotal) return undefined; + + const {unPrice, unFee} = listing; + if (!Number.isInteger(unPrice) || !Number.isInteger(unFee) || unFee <= 0) return subtotal; + + const centsDigits = (cents: number): string => (cents / 100).toFixed(2).replace('.', ''); + const numeric = /\d[\d.,'\s]*\d|\d/.exec(subtotal)?.[0]; + // Two-decimal formats only: without that separator this could be a zero-decimal currency. + const decimalSeparator = numeric && numeric.length >= 4 ? numeric.charAt(numeric.length - 3) : ''; + if (!numeric || !decimalSeparator || /\d/.test(decimalSeparator)) return subtotal; + if (numeric.replace(/\D/g, '') !== centsDigits(unPrice)) return subtotal; + + const totalDigits = centsDigits(unPrice + unFee); + const groupSeparator = /\D/.exec(numeric.slice(0, -3))?.[0]; + const integer = totalDigits.slice(0, -2); + const grouped = groupSeparator ? integer.replace(/\B(?=(\d{3})+$)/g, groupSeparator) : integer; + return subtotal.replace(numeric, `${grouped}${decimalSeparator}${totalDigits.slice(-2)}`); +} + +function toRestrictionDays(value: number | undefined): number | undefined { + return value && isRestrictionDays(value) ? value : undefined; +} + +function toListingDetails(listing: MarketListing): SkinCraftListingDetails { + const description = listing.description; + const property = (id: number): MarketAssetProperty | undefined => + listing.asset.asset_properties?.find((p) => p.propertyid === id); + const wear = Number(property(AssetPropertyId.Wear)?.float_value); + const nameTag = property(AssetPropertyId.NameTag)?.string_value; + const template = property(AssetPropertyId.PatternTemplate)?.int_value; + + // Clamped to the validator's bounds — one over-limit value would drop the whole message. + return { + listingId: ASSET_ID_PATTERN.test(listing.listingid) ? listing.listingid : undefined, + game: description.appid === 730 ? 'Counter-Strike 2' : undefined, + type: description.type ? description.type.slice(0, MAX_SKINCRAFT_DETAIL_FIELD) : undefined, + nameTag: typeof nameTag === 'string' ? nameTag.slice(0, MAX_SKINCRAFT_DETAIL_FIELD) : undefined, + patternTemplate: template ? String(template).slice(0, MAX_SKINCRAFT_PATTERN_TEMPLATE) : undefined, + wearRating: Number.isFinite(wear) ? wear.toFixed(8).slice(0, MAX_SKINCRAFT_WEAR_RATING) : undefined, + price: formatBuyerPrice(listing)?.slice(0, MAX_SKINCRAFT_PRICE), + tradeRestrictionDays: toRestrictionDays(description.market_tradable_restriction), + marketRestrictionDays: toRestrictionDays(description.market_marketable_restriction), + accessories: toAccessories(listing), + lines: toDetailLines(listing), + }; +} + +export function toSkinCraftListingItem( + listing: MarketListing, + getCachedItemInfo?: CachedItemInfoLookup +): SkinCraftItem | undefined { + const target = toSkinCraftItem(toListingAsset(listing), [], getCachedItemInfo); + if (!target) return; + + // Without tags, rarity falls back to the colour Steam itself renders the name in. + const nameColor = listing.description.name_color; + const rarityColor = target.rarityColor ?? (nameColor && HEX_COLOR_PATTERN.test(nameColor) ? nameColor : undefined); + return {...target, rarityColor, details: toListingDetails(listing)}; +} + +/** The listing owning `element`, from whichever ancestor fiber carries it in its props. */ +export function getFiberListing(element: HTMLElement): MarketListing | undefined { + return getFiberProps(element, (fiber) => { + const props = fiber.memoizedProps; + return !!props && typeof props === 'object' && !!(props as Partial).listing; + })?.listing; +} + +/** SkinCraft targets for the market listings currently mounted on the page, in page order. */ +export function getLoadedListingTargets(getCachedItemInfo?: CachedItemInfoLookup): SkinCraftItem[] { + const targets: SkinCraftItem[] = []; + const seenAssets = new Set(); + + for (const scope of document.querySelectorAll(MARKET_LISTING_CARD_SELECTOR)) { + const listing = getFiberListing(scope); + const target = listing && toSkinCraftListingItem(listing, getCachedItemInfo); + if (!target?.assetId || seenAssets.has(target.assetId)) continue; + + seenAssets.add(target.assetId); + targets.push(target); + if (targets.length === MAX_SKINCRAFT_INVENTORY_TARGETS) break; + } + + return targets; +} + +/** + * Loads the grid's next page by revealing its infinite-scroll sentinel — Steam's own loader keeps + * pagination consistent with whatever filters the user has applied — then re-harvests every mounted + * card. The page's scroll position is restored either way (the results sit behind the viewer modal + * while this runs). No growth — end of results, or a timeout — reads as "nothing more right now". + */ +export async function loadMoreListingTargets(): Promise { + const cards = document.querySelectorAll(MARKET_LISTING_CARD_SELECTOR); + const lastCard = cards[cards.length - 1]; + if (!lastCard) return getLoadedListingTargets(); + + const {scrollX, scrollY} = window; + lastCard.scrollIntoView({block: 'end'}); + try { + await waitForCardGrowth(cards.length); + } finally { + window.scrollTo(scrollX, scrollY); + } + + return getLoadedListingTargets(); +} + +async function waitForCardGrowth(previousCount: number): Promise { + const deadline = Date.now() + LOAD_MORE_TIMEOUT_MS; + while (Date.now() < deadline) { + await new Promise((resolve) => window.setTimeout(resolve, LOAD_MORE_POLL_MS)); + if (document.querySelectorAll(MARKET_LISTING_CARD_SELECTOR).length > previousCount) return; + } + console.debug('CSFloat: the market grid did not grow before the timeout; treating as end of results.'); +} + +/** Steam's purchase button in a card's price row: by English label, else the row's only button. */ +function findBuyButton(scope: HTMLElement): HTMLButtonElement | undefined { + const row = scope.querySelector(MARKET_PRICE_ROW_SELECTOR); + const buttons = [...(row ?? scope).querySelectorAll('button')]; + return ( + buttons.find((button) => button.textContent?.trim() === 'Buy') ?? + (row && buttons.length === 1 ? buttons[0] : undefined) + ); +} + +/** Hands off to Steam's own purchase flow by clicking the listing card's native Buy button. */ +export function buyListing(listingId: string): boolean { + for (const scope of document.querySelectorAll(MARKET_LISTING_CARD_SELECTOR)) { + if (getFiberListing(scope)?.listingid !== listingId) continue; + + const buyButton = findBuyButton(scope); + buyButton?.click(); + return !!buyButton; + } + return false; +} diff --git a/src/lib/services/skincraft_viewer_protocol.test.ts b/src/lib/services/skincraft_viewer_protocol.test.ts index 51ee4be1..595000dc 100644 --- a/src/lib/services/skincraft_viewer_protocol.test.ts +++ b/src/lib/services/skincraft_viewer_protocol.test.ts @@ -1,6 +1,13 @@ import {describe, expect, it} from 'vitest'; import { + isBuySkinCraftListingMessage, isOpenSkinCraftViewerMessage, + isRequestSkinCraftViewerItemsMessage, + isSkinCraftBuyListingResultMessage, + isSkinCraftViewerItemsMessage, + MAX_SKINCRAFT_ACCESSORIES, + MAX_SKINCRAFT_DETAIL_LINES, + MAX_SKINCRAFT_DETAIL_TEXT, MAX_SKINCRAFT_INVENTORY_TARGETS, SKINCRAFT_VIEWER_MESSAGE_SOURCE, } from './skincraft_viewer_protocol'; @@ -92,3 +99,139 @@ describe('SkinCraft viewer open messages', () => { ).toBe(false); }); }); + +describe('SkinCraft viewer listing details', () => { + const details = { + listingId: '556910233323745386', + game: 'Counter-Strike 2', + type: 'Classified Rifle', + nameTag: 'AK-47| Nerfed', + patternTemplate: '515', + wearRating: '0.52918148', + price: '$35.20', + tradeRestrictionDays: 7, + marketRestrictionDays: 7, + accessories: [ + { + name: 'Sticker | dupreeh | Katowice 2019', + iconUrl: 'https://community.akamai.steamstatic.com/economy/image/dupreeh/330x192', + detail: 'Sticker Scrape Level: 0.680000007', + }, + ], + lines: [{text: 'Exterior: Battle-Scarred'}, {text: 'Never be afraid', italic: true, color: '9da1a9'}], + }; + + it('accepts a target carrying well-formed details', () => { + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target: {...target, details}, + inventory: [], + }) + ).toBe(true); + }); + + it.each([ + ['a non-numeric listing id', {...details, listingId: 'not-a-listing'}], + ['an oversized line', {...details, lines: [{text: 'a'.repeat(MAX_SKINCRAFT_DETAIL_TEXT + 1)}]}], + ['a malformed line colour', {...details, lines: [{text: 'x', color: 'red'}]}], + ['out-of-range restriction days', {...details, tradeRestrictionDays: 9000}], + [ + 'an accessory icon from an untrusted origin', + {...details, accessories: [{name: 'Sticker | X', iconUrl: 'https://evil.example/x.png'}]}, + ], + [ + 'too many accessories', + {...details, accessories: Array.from({length: MAX_SKINCRAFT_ACCESSORIES + 1}, () => ({name: 'Sticker'}))}, + ], + [ + 'too many description lines', + {...details, lines: Array.from({length: MAX_SKINCRAFT_DETAIL_LINES + 1}, () => ({text: 'x'}))}, + ], + ])('rejects details with %s', (_label, malformed) => { + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target: {...target, details: malformed}, + inventory: [], + }) + ).toBe(false); + }); +}); + +describe('SkinCraft viewer buy messages', () => { + it('accepts only well-formed listing ids from this protocol', () => { + expect( + isBuySkinCraftListingMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'buy-listing', + listingId: '556910233323745386', + }) + ).toBe(true); + expect( + isBuySkinCraftListingMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'buy-listing', + listingId: 'javascript:alert(1)', + }) + ).toBe(false); + expect(isBuySkinCraftListingMessage({source: 'other', type: 'buy-listing', listingId: '1'})).toBe(false); + }); + + it('holds buy results to the same listing id and source rules', () => { + expect( + isSkinCraftBuyListingResultMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'buy-result', + listingId: '556910233323745386', + success: false, + }) + ).toBe(true); + expect( + isSkinCraftBuyListingResultMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'buy-result', + listingId: 'javascript:alert(1)', + success: true, + }) + ).toBe(false); + expect( + isSkinCraftBuyListingResultMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'buy-result', + listingId: '1', + success: 'yes', + }) + ).toBe(false); + }); +}); + +describe('SkinCraft viewer items messages', () => { + it('accepts a request for more items only from this protocol, carrying a request id', () => { + const request = {source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, type: 'request-items', requestId: 1}; + expect(isRequestSkinCraftViewerItemsMessage(request)).toBe(true); + expect(isRequestSkinCraftViewerItemsMessage({...request, source: 'other'})).toBe(false); + expect(isRequestSkinCraftViewerItemsMessage({...request, type: 'items'})).toBe(false); + expect(isRequestSkinCraftViewerItemsMessage({...request, requestId: undefined})).toBe(false); + expect(isRequestSkinCraftViewerItemsMessage({...request, requestId: '1'})).toBe(false); + expect(isRequestSkinCraftViewerItemsMessage({...request, requestId: -1})).toBe(false); + expect(isRequestSkinCraftViewerItemsMessage({...request, requestId: 1.5})).toBe(false); + }); + + it('holds item updates to the same target validation as open messages', () => { + const update = {source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, type: 'items', requestId: 1, inventory: [target]}; + expect(isSkinCraftViewerItemsMessage(update)).toBe(true); + expect(isSkinCraftViewerItemsMessage({...update, requestId: undefined})).toBe(false); + expect(isSkinCraftViewerItemsMessage({...update, inventory: [{...target, inspect: 'not-an-inspect'}]})).toBe( + false + ); + expect( + isSkinCraftViewerItemsMessage({ + ...update, + inventory: Array.from({length: MAX_SKINCRAFT_INVENTORY_TARGETS + 1}, () => target), + }) + ).toBe(false); + }); +}); diff --git a/src/lib/services/skincraft_viewer_protocol.ts b/src/lib/services/skincraft_viewer_protocol.ts index 79a9f44f..084ffc2b 100644 --- a/src/lib/services/skincraft_viewer_protocol.ts +++ b/src/lib/services/skincraft_viewer_protocol.ts @@ -7,7 +7,51 @@ export const SKINCRAFT_INSPECT_PATTERN = /^[0-9a-f]{40,8192}$/i; export const HEX_COLOR_PATTERN = /^[0-9a-f]{6}$/i; export const STEAM_INSPECT_URL_PATTERN = /^steam:\/\/(?:run|rungame)\/730\/\d{0,20}\/\+csgo_econ_action_preview%20[0-9a-f]{40,8192}$/i; -const ASSET_ID_PATTERN = /^\d{1,32}$/; +/** Also fits listing ids, which share the numeric-id format. */ +export const ASSET_ID_PATTERN = /^\d{1,32}$/; +export const MAX_SKINCRAFT_DETAIL_LINES = 24; +export const MAX_SKINCRAFT_DETAIL_TEXT = 2048; +export const MAX_SKINCRAFT_ACCESSORIES = 8; + +// Producers must clamp to these same bounds: one over-limit field drops its whole message. +export const MAX_SKINCRAFT_ITEM_NAME = 512; +export const MAX_SKINCRAFT_ICON_URL = 4096; +export const MAX_SKINCRAFT_ACCESSORY_NAME = 256; +export const MAX_SKINCRAFT_ACCESSORY_DETAIL = 128; +export const MAX_SKINCRAFT_DETAIL_FIELD = 256; +export const MAX_SKINCRAFT_PATTERN_TEMPLATE = 16; +export const MAX_SKINCRAFT_WEAR_RATING = 32; +export const MAX_SKINCRAFT_PRICE = 64; + +/** One sanitized line of Steam's item description block (exterior, flavour text, collection, …). */ +export type SkinCraftDetailLine = { + text: string; + italic?: boolean; + color?: string; +}; + +/** A sticker or charm applied to the listed item. */ +export type SkinCraftAccessory = { + name: string; + iconUrl?: string; + /** Steam's per-accessory attribute line, e.g. "Sticker Scrape Level: 0.680000007". */ + detail?: string; +}; + +/** The listing facts the viewer's details panel mirrors from Steam's own item dialog. */ +export type SkinCraftListingDetails = { + listingId?: string; + game?: string; + type?: string; + nameTag?: string; + patternTemplate?: string; + wearRating?: string; + price?: string; + tradeRestrictionDays?: number; + marketRestrictionDays?: number; + accessories?: SkinCraftAccessory[]; + lines?: SkinCraftDetailLine[]; +}; /** An item as it crosses the page → content-script boundary. */ export type SkinCraftItem = { @@ -21,6 +65,7 @@ export type SkinCraftItem = { float?: string; rarityColor?: string; backgroundColor?: string; + details?: SkinCraftListingDetails; }; /** A {@link SkinCraftItem} the modal can render, with its SkinCraft permalink resolved. */ @@ -33,6 +78,120 @@ export type OpenSkinCraftViewerMessage = { inventory: SkinCraftItem[]; }; +/** + * Content script → page: load more items for the viewer's strip (paginated surfaces only). The + * page echoes `requestId` so an answer to a request from an earlier viewer session is recognizable. + */ +export type RequestSkinCraftViewerItemsMessage = { + source: typeof SKINCRAFT_VIEWER_MESSAGE_SOURCE; + type: 'request-items'; + requestId: number; +}; + +/** Content script → page: hand the user off to Steam's own purchase flow for a listing. */ +export type BuySkinCraftListingMessage = { + source: typeof SKINCRAFT_VIEWER_MESSAGE_SOURCE; + type: 'buy-listing'; + listingId: string; +}; + +/** Page → content script: the refreshed item strip, answering the `request-items` with `requestId`. */ +export type SkinCraftViewerItemsMessage = { + source: typeof SKINCRAFT_VIEWER_MESSAGE_SOURCE; + type: 'items'; + requestId: number; + inventory: SkinCraftItem[]; +}; + +/** Page → content script: whether the `buy-listing` hand-off reached Steam's purchase flow. */ +export type SkinCraftBuyListingResultMessage = { + source: typeof SKINCRAFT_VIEWER_MESSAGE_SOURCE; + type: 'buy-result'; + listingId: string; + success: boolean; +}; + +function isBoundedString(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && value.length <= maxLength; +} + +function isOptional(value: unknown, check: (value: unknown) => value is T): boolean { + return value === undefined || check(value); +} + +export function isRestrictionDays(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 365; +} + +function isRequestId(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +/** + * A message claiming our source and an expected type, yet failing its guard — producer/validator + * drift that receivers log rather than drop silently. + */ +export function isMalformedSkinCraftViewerMessage(data: unknown, expectedTypes: readonly string[]): boolean { + if (!data || typeof data !== 'object') return false; + + const message = data as {source?: unknown; type?: unknown}; + return ( + message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && + typeof message.type === 'string' && + expectedTypes.includes(message.type) + ); +} + +function isSkinCraftDetailLine(data: unknown): data is SkinCraftDetailLine { + if (!data || typeof data !== 'object') return false; + + const line = data as Partial; + return ( + isBoundedString(line.text, MAX_SKINCRAFT_DETAIL_TEXT) && + (line.italic === undefined || typeof line.italic === 'boolean') && + (line.color === undefined || (typeof line.color === 'string' && HEX_COLOR_PATTERN.test(line.color))) + ); +} + +function isSkinCraftAccessory(data: unknown): data is SkinCraftAccessory { + if (!data || typeof data !== 'object') return false; + + const accessory = data as Partial; + return ( + isBoundedString(accessory.name, MAX_SKINCRAFT_ACCESSORY_NAME) && + isOptional(accessory.detail, (v): v is string => isBoundedString(v, MAX_SKINCRAFT_ACCESSORY_DETAIL)) && + (accessory.iconUrl === undefined || + (isBoundedString(accessory.iconUrl, MAX_SKINCRAFT_ICON_URL) && + accessory.iconUrl.startsWith(STEAM_ECONOMY_IMAGE_PREFIX))) + ); +} + +function isSkinCraftListingDetails(data: unknown): data is SkinCraftListingDetails { + if (!data || typeof data !== 'object') return false; + + const details = data as Partial; + return ( + (details.listingId === undefined || + (typeof details.listingId === 'string' && ASSET_ID_PATTERN.test(details.listingId))) && + (details.accessories === undefined || + (Array.isArray(details.accessories) && + details.accessories.length <= MAX_SKINCRAFT_ACCESSORIES && + details.accessories.every(isSkinCraftAccessory))) && + isOptional(details.game, (v): v is string => isBoundedString(v, 128)) && + isOptional(details.type, (v): v is string => isBoundedString(v, MAX_SKINCRAFT_DETAIL_FIELD)) && + isOptional(details.nameTag, (v): v is string => isBoundedString(v, MAX_SKINCRAFT_DETAIL_FIELD)) && + isOptional(details.patternTemplate, (v): v is string => isBoundedString(v, MAX_SKINCRAFT_PATTERN_TEMPLATE)) && + isOptional(details.wearRating, (v): v is string => isBoundedString(v, MAX_SKINCRAFT_WEAR_RATING)) && + isOptional(details.price, (v): v is string => isBoundedString(v, MAX_SKINCRAFT_PRICE)) && + isOptional(details.tradeRestrictionDays, isRestrictionDays) && + isOptional(details.marketRestrictionDays, isRestrictionDays) && + (details.lines === undefined || + (Array.isArray(details.lines) && + details.lines.length <= MAX_SKINCRAFT_DETAIL_LINES && + details.lines.every(isSkinCraftDetailLine))) + ); +} + /** Structural check only: does the untyped message data have the shape of a {@link SkinCraftItem}? */ function isSkinCraftItemShape(data: unknown): data is SkinCraftItem { if (!data || typeof data !== 'object') return false; @@ -47,7 +206,8 @@ function isSkinCraftItemShape(data: unknown): data is SkinCraftItem { (item.float === undefined || typeof item.float === 'string') && (item.rarityColor === undefined || typeof item.rarityColor === 'string') && (item.backgroundColor === undefined || typeof item.backgroundColor === 'string') && - (item.iconUrl === undefined || typeof item.iconUrl === 'string') + (item.iconUrl === undefined || typeof item.iconUrl === 'string') && + (item.details === undefined || isSkinCraftListingDetails(item.details)) ); } @@ -56,14 +216,14 @@ function isValidSkinCraftItem(item: SkinCraftItem): boolean { return ( SKINCRAFT_INSPECT_PATTERN.test(item.inspect) && (item.inspectUrl === undefined || STEAM_INSPECT_URL_PATTERN.test(item.inspectUrl)) && - item.name.length <= 512 && + item.name.length <= MAX_SKINCRAFT_ITEM_NAME && (item.assetId === undefined || ASSET_ID_PATTERN.test(item.assetId)) && (item.seed === undefined || item.seed.length <= 64) && (item.float === undefined || item.float.length <= 64) && (item.rarityColor === undefined || HEX_COLOR_PATTERN.test(item.rarityColor)) && (item.backgroundColor === undefined || HEX_COLOR_PATTERN.test(item.backgroundColor)) && (item.iconUrl === undefined || - (item.iconUrl.length <= 4096 && item.iconUrl.startsWith(STEAM_ECONOMY_IMAGE_PREFIX))) + (item.iconUrl.length <= MAX_SKINCRAFT_ICON_URL && item.iconUrl.startsWith(STEAM_ECONOMY_IMAGE_PREFIX))) ); } @@ -71,6 +231,10 @@ function isSkinCraftItem(data: unknown): data is SkinCraftItem { return isSkinCraftItemShape(data) && isValidSkinCraftItem(data); } +function isSkinCraftItemList(data: unknown): data is SkinCraftItem[] { + return Array.isArray(data) && data.length <= MAX_SKINCRAFT_INVENTORY_TARGETS && data.every(isSkinCraftItem); +} + export function isOpenSkinCraftViewerMessage(data: unknown): data is OpenSkinCraftViewerMessage { if (!data || typeof data !== 'object') return false; @@ -79,8 +243,54 @@ export function isOpenSkinCraftViewerMessage(data: unknown): data is OpenSkinCra message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && message.type === 'open' && isSkinCraftItem(message.target) && - Array.isArray(message.inventory) && - message.inventory.length <= MAX_SKINCRAFT_INVENTORY_TARGETS && - message.inventory.every(isSkinCraftItem) + isSkinCraftItemList(message.inventory) + ); +} + +export function isRequestSkinCraftViewerItemsMessage(data: unknown): data is RequestSkinCraftViewerItemsMessage { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial; + return ( + message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && + message.type === 'request-items' && + isRequestId(message.requestId) + ); +} + +export function isBuySkinCraftListingMessage(data: unknown): data is BuySkinCraftListingMessage { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial; + return ( + message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && + message.type === 'buy-listing' && + typeof message.listingId === 'string' && + ASSET_ID_PATTERN.test(message.listingId) + ); +} + +export function isSkinCraftViewerItemsMessage(data: unknown): data is SkinCraftViewerItemsMessage { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial; + return ( + message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && + message.type === 'items' && + isRequestId(message.requestId) && + isSkinCraftItemList(message.inventory) + ); +} + +export function isSkinCraftBuyListingResultMessage(data: unknown): data is SkinCraftBuyListingResultMessage { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial; + return ( + message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && + message.type === 'buy-result' && + typeof message.listingId === 'string' && + ASSET_ID_PATTERN.test(message.listingId) && + typeof message.success === 'boolean' ); } diff --git a/src/lib/utils/skin.ts b/src/lib/utils/skin.ts index 2a2fbf64..2acd26a5 100644 --- a/src/lib/utils/skin.ts +++ b/src/lib/utils/skin.ts @@ -22,6 +22,15 @@ export function rangeFromWear(wear: number): [number, number] | null { return null; } +/** The float condition bands and their bar colours, as percentages of the full 0–1 wear range. */ +export const FLOAT_CONDITION_BANDS = [ + {min: 0, max: 7, color: 'green'}, + {min: 7, max: 15, color: '#18a518'}, + {min: 15, max: 38, color: '#9acd32'}, + {min: 38, max: 45, color: '#cd5c5c'}, + {min: 45, max: 100, color: '#f92424'}, +] as const; + export function getLowestRank(info: ItemInfo): number | undefined { if (!info.low_rank && !info.high_rank) { // Item has no rank to return @@ -103,6 +112,9 @@ export function renderClickableRank(info: ItemInfo): TemplateResult<1> { `; } +/** The description fields the item-type guards read; full `rgAsset`s and market fiber descriptions both qualify. */ +export type AssetTypeSource = Pick; + export function isSellableOnCSFloat(asset: rgAsset): boolean { return ( isSkin(asset) || @@ -115,7 +127,7 @@ export function isSellableOnCSFloat(asset: rgAsset): boolean { isPin(asset) ); } -export function isSkin(asset: rgAsset): boolean { +export function isSkin(asset: AssetTypeSource): boolean { return asset.tags ? asset.tags.some((a) => a.category === 'Weapon' || (a.category === 'Type' && a.internal_name === 'Type_Hands')) : ['★', 'Factory New', 'Minimal Wear', 'Field-Tested', 'Well-Worn', 'Battle-Scarred'].some((keyword) => @@ -123,39 +135,39 @@ export function isSkin(asset: rgAsset): boolean { ); } -export function isCharm(asset: rgAsset): boolean { +export function isCharm(asset: AssetTypeSource): boolean { return isAbstractType(asset, 'Charm', 'CSGO_Tool_Keychain'); } -export function isHighlightCharm(asset: rgAsset): boolean { +export function isHighlightCharm(asset: AssetTypeSource): boolean { return isCharm(asset) && !!asset.tags && asset.tags.some((a) => a.internal_name === 'highlight'); } -export function isAgent(asset: rgAsset): boolean { +export function isAgent(asset: AssetTypeSource): boolean { return isAbstractType(asset, 'Agent', 'Type_CustomPlayer'); } -export function isSticker(asset: rgAsset): boolean { +export function isSticker(asset: AssetTypeSource): boolean { return isAbstractType(asset, 'Sticker', 'CSGO_Tool_Sticker'); } -export function isPatch(asset: rgAsset): boolean { +export function isPatch(asset: AssetTypeSource): boolean { return isAbstractType(asset, 'Patch', 'CSGO_Type_Patch'); } -export function isCase(asset: rgAsset): boolean { +export function isCase(asset: AssetTypeSource): boolean { return isAbstractType(asset, 'Container', 'CSGO_Type_WeaponCase'); } -export function isMusicKit(asset: rgAsset): boolean { +export function isMusicKit(asset: AssetTypeSource): boolean { return isAbstractType(asset, 'Music Kit', 'CSGO_Type_MusicKit'); } -export function isPin(asset: rgAsset): boolean { +export function isPin(asset: AssetTypeSource): boolean { return isAbstractType(asset, 'Pin', 'CSGO_Type_Collectible'); } -function isAbstractType(asset: rgAsset, type: string, internalName: string): boolean { +function isAbstractType(asset: AssetTypeSource, type: string, internalName: string): boolean { // Half-hydrated descriptions can arrive without `type`, despite the declared shape. if (typeof asset.type === 'string' && asset.type.endsWith(type)) { return true;
+ ${line.text} +