Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions api/ApiHelper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,76 @@ export function initAPI(returnSSRResponse: boolean = false): API {
})
}

/**
* Subscribes to live updates for a single auction. Whenever a new bid is placed on it, the backend
* resends the full auction state and `onUpdate` is called with the freshly parsed details.
* Only one live-update subscription (auction or sold) can be active per connection; this replaces any previous one.
*/
let subscribeAuctionUpdates = (auctionUUID: string, onUpdate: (auctionDetails: AuctionDetails) => void, onErrorCallback?: Function): void => {
websocketHelper.removeOldSubscriptionByType(RequestType.SUBSCRIBE_UPDATES)
websocketHelper.subscribe({
type: RequestType.SUBSCRIBE_UPDATES,
data: { topic: `auction/${auctionUUID}` },
callback: function (response) {
if (response.type === 'auctionUpdate') {
onUpdate(parseAuctionDetails(response.data))
}
},
resubscribe: function () {
subscribeAuctionUpdates(auctionUUID, onUpdate, onErrorCallback)
},
onError: function (message) {
if (onErrorCallback) {
onErrorCallback(message)
}
}
})
}

/**
* Subscribes to sold auctions of a given item tag. Whenever an auction of that tag is sold and matches the
* given filter, `onSold` is called with the parsed recent auction.
* Only one live-update subscription (auction or sold) can be active per connection; this replaces any previous one.
*/
let subscribeSoldAuctions = (
itemTag: string,
itemFilter: ItemFilter,
onSold: (auction: RecentAuction) => void,
onErrorCallback?: Function
): void => {
websocketHelper.removeOldSubscriptionByType(RequestType.SUBSCRIBE_UPDATES)
// strip UI-only keys before sending (mirrors the notification subscribe)
let filter = { ...(itemFilter || {}) } as { [key: string]: any }
delete filter._hide
delete filter._sellerName
websocketHelper.subscribe({
type: RequestType.SUBSCRIBE_UPDATES,
data: { topic: `sold/${itemTag}`, filter },
callback: function (response) {
if (response.type === 'soldAuction') {
onSold(parseRecentAuction(response.data))
}
},
resubscribe: function () {
subscribeSoldAuctions(itemTag, itemFilter, onSold, onErrorCallback)
},
onError: function (message) {
if (onErrorCallback) {
onErrorCallback(message)
}
}
})
}

// Stops receiving live updates. This is a local-only cleanup: it drops the subscription so its
// callback no longer fires and it isn't restored on reconnect. There is no backend round-trip -
// the backend keeps a single slot per connection that is replaced by the next subscribe and freed
// when the connection closes, so an explicit unsubscribe message (which could race a newer
// subscribe, since the backend dispatches each socket message on its own task) isn't needed.
let unsubscribeUpdates = (): void => {
websocketHelper.removeOldSubscriptionByType(RequestType.SUBSCRIBE_UPDATES)
}

let getFilters = (tag: string): Promise<FilterOptions[]> => {
return new Promise((resolve, reject) => {
httpApi.sendApiRequest({
Expand Down Expand Up @@ -2875,6 +2945,9 @@ export function initAPI(returnSSRResponse: boolean = false): API {
getRecentAuctions,
getFlips,
subscribeFlips,
subscribeAuctionUpdates,
subscribeSoldAuctions,
unsubscribeUpdates,
getFilters,
getNewPlayers,
getNewItems,
Expand Down
1 change: 1 addition & 0 deletions api/ApiTypes.d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export enum RequestType {
RECENT_AUCTIONS = 'recentAuctions',
SUBSCRIBE_FLIPS = 'subFlip',
UNSUBSCRIBE_FLIPS = 'unsubFlip',
SUBSCRIBE_UPDATES = 'subUpdates',
GET_FLIPS = 'getFlips',
GET_FILTER = 'getFilter',
NEW_AUCTIONS = 'newAuctions',
Expand Down
27 changes: 27 additions & 0 deletions components/AuctionDetails/AuctionDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getStyleForTier
} from '../../utils/Formatter'
import { useForceUpdate } from '../../utils/Hooks'
import { useLiveAuctionSubscription } from '../../hooks/useLiveAuctionSubscription'
import { getLoadingElement } from '../../utils/LoadingUtils'
import { isClientSideRendering } from '../../utils/SSRUtils'
import { CopyButton } from '../CopyButton/CopyButton'
Expand Down Expand Up @@ -56,6 +57,32 @@ function AuctionDetails(props: Props) {
loadAuctionDetails(props.auctionUUID!)
}, [props.auctionUUID])

// subscribe to live updates so new bids show up without a page refresh
useLiveAuctionSubscription(() => api.subscribeAuctionUpdates(props.auctionUUID, applyAuctionUpdate), [props.auctionUUID], !!props.auctionUUID)

function applyAuctionUpdate(newAuctionDetails: AuctionDetails) {
newAuctionDetails.bids.sort((a, b) => b.amount - a.amount)
newAuctionDetails.auction.item.iconUrl = api.getItemImageUrl(newAuctionDetails.auction.item)
setAuctionDetails(newAuctionDetails)

let namePromises: Promise<void>[] = []
newAuctionDetails.bids.forEach(bid => {
namePromises.push(
api.getPlayerName(bid.bidder.uuid).then(name => {
bid.bidder.name = name
})
)
})
namePromises.push(
api.getPlayerName(newAuctionDetails.auctioneer.uuid).then(name => {
newAuctionDetails.auctioneer.name = name
})
)
Promise.all(namePromises).then(() => {
forceUpdate()
})
}

let tryNumber = 1
function loadAuctionDetails(auctionUUID: string) {
// if auction details are already available, don't show loading animation to prevent flickering
Expand Down
40 changes: 40 additions & 0 deletions components/RecentAuctions/RecentAuctions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Button, Card, Form } from 'react-bootstrap'
import InfiniteScroll from 'react-infinite-scroll-component'
import api from '../../api/ApiHelper'
import { useStateWithRef, useWasAlreadyLoggedIn } from '../../utils/Hooks'
import { useLiveAuctionSubscription } from '../../hooks/useLiveAuctionSubscription'
import { getMoreAuctionsElement } from '../../utils/ListUtils'
import { getLoadingElement } from '../../utils/LoadingUtils'
import { getHighestPriorityPremiumProduct, getPremiumType, PREMIUM_RANK } from '../../utils/PremiumTypeUtils'
Expand Down Expand Up @@ -69,6 +70,43 @@ function RecentAuctions(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.item.tag, JSON.stringify(props.itemFilter), props.yearRecentSamples, props.isYearView])

// subscribe to live sold auctions so newly sold auctions show up without a page refresh
function onSoldAuction(auction: RecentAuction) {
if (!mounted) {
return
}
// skip duplicates and keep the newest auction on top
if (recentAuctionsRef.current.some(a => a.uuid === auction.uuid)) {
return
}
setRecentAuctions([auction, ...recentAuctionsRef.current])
}
let resubscribeSoldAuctions = useLiveAuctionSubscription(
() => api.subscribeSoldAuctions(props.item.tag, getEffectiveItemFilter(), onSoldAuction),
[props.item.tag, JSON.stringify(props.itemFilter)],
!props.isYearView
)

// builds the filter actually used to fetch/subscribe, applying the Sold/Expired/All toggle as a HighestBid filter
function getEffectiveItemFilter(): ItemFilter {
let itemFilter = { ...itemFilterRef.current } as ItemFilter
if (!props.itemFilter || props.itemFilter['HighestBid'] === undefined) {
let fetchType = localStorage.getItem(RECENT_AUCTIONS_FETCH_TYPE_KEY)
switch (fetchType) {
case RECENT_AUCTIONS_FETCH_TYPE.UNSOLD:
itemFilter['HighestBid'] = '0'
break
case RECENT_AUCTIONS_FETCH_TYPE.ALL:
break
case RECENT_AUCTIONS_FETCH_TYPE.SOLD:
default:
itemFilter['HighestBid'] = '>0'
break
}
}
return itemFilter
}

function loadRecentAuctions(reset: boolean = false) {
let recentAuctions = reset ? [] : recentAuctionsRef.current
if (reset) {
Expand Down Expand Up @@ -156,6 +194,8 @@ function RecentAuctions(props: Props) {
}

localStorage.setItem(RECENT_AUCTIONS_FETCH_TYPE_KEY, e.target.value)
// re-subscribe to live sold auctions with the newly selected fetch type
resubscribeSoldAuctions()
loadRecentAuctions(true)
}

Expand Down
8 changes: 8 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,14 @@ interface API {
onSubscribeSuccessCallback?: Function
): void
unsubscribeFlips(): Promise<void>
subscribeAuctionUpdates(auctionUUID: string, onUpdate: (auctionDetails: AuctionDetails) => void, onErrorCallback?: Function): void
subscribeSoldAuctions(
itemTag: string,
itemFilter: ItemFilter,
onSold: (auction: RecentAuction) => void,
onErrorCallback?: Function
): void
unsubscribeUpdates(): void
getFilters(tag: string): Promise<FilterOptions[]>
getNewAuctions(): Promise<Auction[]>
getEndedAuctions(): Promise<Auction[]>
Expand Down
37 changes: 37 additions & 0 deletions hooks/useLiveAuctionSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef, type DependencyList } from 'react'
import api from '../api/ApiHelper'

/**
* Manages a single live-update subscription (auction bids or sold auctions).
*
* `subscribe` is invoked to (re)establish the subscription. The hook subscribes when `enabled` is
* true, re-subscribes whenever `deps` change and cleans up on unmount. The returned `resubscribe`
* lets callers re-establish the subscription imperatively (e.g. after a filter toggle) without
* bumping a state counter just to re-trigger an effect.
*
* Only one live-update subscription can be active per connection (see `api.subscribeSoldAuctions`),
* so re-subscribing implicitly replaces the previous one.
*/
export function useLiveAuctionSubscription(subscribe: () => void, deps: DependencyList, enabled: boolean = true): () => void {
// keep the latest `subscribe` closure so re-subscribing always uses current props/state
// without the effect depending on its (per-render) identity
let subscribeRef = useRef(subscribe)
subscribeRef.current = subscribe

let resubscribe = useCallback(() => {
subscribeRef.current()
}, [])

useEffect(() => {
if (!enabled) {
return
}
subscribeRef.current()
return () => {
api.unsubscribeUpdates()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, enabled])

return resubscribe
}
Loading