@@ -8,6 +8,8 @@ import {getMarketInspectLink} from './helpers';
88import { ItemInfo } from '../../bridge/handlers/fetch_inspect_info' ;
99import { getFadeParams , getFadePercentage } from '../../utils/skin' ;
1010import { AppId , ContextId } from '../../types/steam_constants' ;
11+ import { debounce } from 'lodash-decorators' ;
12+ import { DebouncedFunc } from 'lodash' ;
1113
1214enum SortType {
1315 FLOAT = 'Float' ,
@@ -20,13 +22,31 @@ enum SortDirection {
2022 DESC ,
2123}
2224
25+ // Union type for fetched item info: successful and failed.
26+ type SortableItem =
27+ | {
28+ failed : false ;
29+ listingId : string ;
30+ info : ItemInfo ;
31+ converted_price : number ;
32+ fadePercentage : number ;
33+ }
34+ | {
35+ failed : true ;
36+ listingId : string ;
37+ } ;
38+
39+ type SuccessfulSortableItem = Extract < SortableItem , { failed : false } > ;
40+
2341@CustomElement ( )
2442export class SortListings extends FloatElement {
2543 @state ( )
2644 private type : SortType = SortType . FLOAT ;
2745 @state ( )
2846 private direction : SortDirection = SortDirection . NONE ;
2947
48+ private observer : MutationObserver | null = null ;
49+
3050 @state ( )
3151 get isFadeSkin ( ) {
3252 const firstRow = document . querySelector ( '#searchResultsRows .market_listing_row.market_recent_listing_row' ) ;
@@ -42,6 +62,113 @@ export class SortListings extends FloatElement {
4262 return getFadeParams ( asset ) !== undefined ;
4363 }
4464
65+ connectedCallback ( ) {
66+ super . connectedCallback ( ) ;
67+
68+ // Find the container of listings that we need to watch.
69+ const targetNode = document . getElementById ( 'searchResultsRows' ) ;
70+ if ( ! targetNode ) return ;
71+
72+ // Create a MutationObserver to detect when the page's items are dynamically replaced.
73+ this . observer = new MutationObserver ( ( ) => this . onMutation ( ) ) ;
74+
75+ // Start observing the target node for additions or removals of child elements.
76+ this . observer . observe ( targetNode , { childList : true } ) ;
77+ }
78+
79+ disconnectedCallback ( ) {
80+ super . disconnectedCallback ( ) ;
81+ if ( this . observer ) {
82+ this . observer . disconnect ( ) ;
83+ }
84+
85+ // Workaround to avoid using @ts -ignore:
86+ // type assertion to inform ts about the .cancel() added from the lodash debounce
87+ ( this . onMutation as DebouncedFunc < ( ) => void > ) . cancel ( ) ;
88+ }
89+
90+ /**
91+ * This decorated method is called when the item list changes.
92+ * The @debounce decorator ensures it only runs once after a series of rapid changes.
93+ */
94+ @debounce ( 500 )
95+ private onMutation ( ) {
96+ // Only re-sort if a sort is currently active.
97+ if ( this . direction === SortDirection . NONE ) return ;
98+
99+ const targetNode = document . getElementById ( 'searchResultsRows' ) ;
100+
101+ // Disconnect the observer temporarily to prevent sortListings() from causing this mutation
102+ // handler to re-trigger, causing a loop.
103+ this . observer ?. disconnect ( ) ;
104+
105+ this . sortListings ( this . type , this . direction )
106+ . catch ( ( err ) => console . error ( 'CSFloat: Failed to re-sort list' , err ) )
107+ . finally ( ( ) => {
108+ // Reconnect the observer to watch for the next page change.
109+ if ( targetNode ) {
110+ this . observer ?. observe ( targetNode , { childList : true } ) ;
111+ }
112+ } ) ;
113+ }
114+
115+ private async sortListings ( sortType : SortType , direction : SortDirection ) {
116+ const rows = document . querySelectorAll ( '#searchResultsRows .market_listing_row.market_recent_listing_row' ) ;
117+ if ( rows . length === 0 ) return ;
118+
119+ const infoPromises = [ ...rows ]
120+ . map ( ( e ) => e . id . replace ( 'listing_' , '' ) )
121+ . map ( async ( listingId ) : Promise < SortableItem > => {
122+ // Catch error to prevent one failure from stopping the Promise.all() later
123+ try {
124+ const link = getMarketInspectLink ( listingId ) ;
125+ const info = await gFloatFetcher . fetch ( { link : link ! } ) ;
126+ const listingInfo = g_rgListingInfo [ listingId ] ;
127+ const asset = g_rgAssets [ AppId . CSGO ] [ ContextId . PRIMARY ] [ listingInfo . asset . id ] ;
128+ return {
129+ failed : false ,
130+ info,
131+ listingId : listingId ! ,
132+ converted_price : listingInfo ?. converted_price || 0 ,
133+ fadePercentage : ( asset && getFadePercentage ( asset , info ) ?. percentage ) || 0 ,
134+ } ;
135+ } catch ( error ) {
136+ console . error ( `CSFloat: Failed to fetch float for listing ${ listingId } :` , error ) ;
137+ return { failed : true , listingId : listingId ! } ;
138+ }
139+ } ) ;
140+
141+ const infos = await Promise . all ( infoPromises ) ;
142+
143+ // Type Guard that checks if an item was successfully fetched.
144+ function isSuccessfulItem ( item : SortableItem ) : item is SuccessfulSortableItem {
145+ return ! item . failed ;
146+ }
147+
148+ const successfulItems = infos . filter ( isSuccessfulItem ) ;
149+ const failedItems = infos . filter ( ( r ) => r . failed ) ;
150+ const sortedInfos = [ ...SortListings . sort ( successfulItems , sortType , direction ) , ...failedItems ] ;
151+
152+ let lastItem = document . querySelector ( '#searchResultsRows .market_listing_table_header' ) ;
153+
154+ for ( const info of sortedInfos ) {
155+ const itemElement = document . querySelector ( `#listing_${ info . listingId } ` ) ;
156+ if ( itemElement && itemElement . parentNode && lastItem ) {
157+ lastItem = itemElement . parentNode . insertBefore ( itemElement , lastItem . nextSibling ) ;
158+ }
159+ }
160+ }
161+
162+ async onClick ( sortType : SortType ) {
163+ const newDirection =
164+ sortType === this . type ? SortListings . getNextSortDirection ( this . direction ) : SortDirection . ASC ;
165+
166+ await this . sortListings ( sortType , newDirection ) ;
167+
168+ this . type = sortType ;
169+ this . direction = newDirection ;
170+ }
171+
45172 computeButtonText ( sortType : SortType ) : string {
46173 let txt = `Sort by ${ sortType } ` ;
47174
@@ -107,43 +234,4 @@ export class SortListings extends FloatElement {
107234 ) ;
108235 }
109236 }
110-
111- async onClick ( sortType : SortType ) {
112- const newDirection =
113- sortType == this . type ? SortListings . getNextSortDirection ( this . direction ) : SortDirection . ASC ;
114-
115- const rows = document . querySelectorAll ( '#searchResultsRows .market_listing_row.market_recent_listing_row' ) ;
116-
117- const infoPromises = [ ...rows ]
118- . map ( ( e ) => e . id . replace ( 'listing_' , '' ) )
119- . map ( async ( listingId ) => {
120- const link = getMarketInspectLink ( listingId ) ;
121-
122- const info = await gFloatFetcher . fetch ( { link : link ! } ) ;
123-
124- const listingInfo = g_rgListingInfo [ listingId ] ;
125-
126- const asset = g_rgAssets [ AppId . CSGO ] [ ContextId . PRIMARY ] [ listingInfo . asset . id ] ;
127-
128- return {
129- info,
130- listingId : listingId ! ,
131- converted_price : listingInfo ?. converted_price || 0 ,
132- fadePercentage : ( asset && getFadePercentage ( asset , info ) ?. percentage ) || 0 ,
133- } ;
134- } ) ;
135-
136- const infos = await Promise . all ( infoPromises ) ;
137- const sortedInfos = SortListings . sort ( infos , sortType , newDirection ) ;
138-
139- let lastItem = document . querySelector ( '#searchResultsRows .market_listing_table_header' ) ;
140-
141- for ( const info of sortedInfos ) {
142- const itemElement = document . querySelector ( `#listing_${ info . listingId } ` ) ;
143- lastItem = itemElement ! . parentNode ! . insertBefore ( itemElement ! , lastItem ! . nextSibling ) ;
144- }
145-
146- this . type = sortType ;
147- this . direction = newDirection ;
148- }
149237}
0 commit comments