Skip to content

Commit ac7e1cf

Browse files
committed
Ads client: cache ads to cycle through when no more ads available + refactor
1 parent 4478db8 commit ac7e1cf

File tree

1 file changed

+152
-149
lines changed

1 file changed

+152
-149
lines changed

cli/src/hooks/use-gravity-ad.ts

Lines changed: 152 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { Message, WEBSITE_URL } from '@codebuff/sdk'
2-
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { useEffect, useRef, useState } from 'react'
33

44
import { getAdsEnabled } from '../commands/ads'
55
import { useChatStore } from '../state/chat-store'
6-
import { subscribeToActivity } from '../utils/activity-tracker'
6+
import { isUserActive, subscribeToActivity } from '../utils/activity-tracker'
77
import { getAuthToken } from '../utils/auth'
88
import { logger } from '../utils/logger'
99

1010
const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad
11-
const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then stop
11+
const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then pause fetching new ads
12+
const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads
13+
const MAX_AD_CACHE_SIZE = 50 // Maximum number of ads to keep in cache
1214

1315
// Ad response type (matches Gravity API response, credits added after impression)
1416
export type AdResponse = {
@@ -27,82 +29,111 @@ export type GravityAdState = {
2729
isLoading: boolean
2830
}
2931

32+
// Consolidated controller state for the ad rotation logic
33+
type GravityController = {
34+
cache: AdResponse[]
35+
cacheIndex: number
36+
impressionsFired: Set<string>
37+
adsShownSinceActivity: number
38+
tickInFlight: boolean
39+
intervalId: ReturnType<typeof setInterval> | null
40+
}
41+
42+
// Pure helper: add an ad to the cache (if not already present)
43+
function addToCache(ctrl: GravityController, ad: AdResponse): void {
44+
if (ctrl.cache.some((x) => x.impUrl === ad.impUrl)) return
45+
if (ctrl.cache.length >= MAX_AD_CACHE_SIZE) ctrl.cache.shift()
46+
ctrl.cache.push(ad)
47+
}
48+
49+
// Pure helper: get the next cached ad (cycles through the cache)
50+
function nextFromCache(ctrl: GravityController): AdResponse | null {
51+
if (ctrl.cache.length === 0) return null
52+
const ad = ctrl.cache[ctrl.cacheIndex % ctrl.cache.length]!
53+
ctrl.cacheIndex = (ctrl.cacheIndex + 1) % ctrl.cache.length
54+
return ad
55+
}
56+
3057
/**
3158
* Hook for fetching and rotating Gravity ads.
3259
*
3360
* Behavior:
3461
* - Ads only start after the user sends their first message
3562
* - Ads rotate every 60 seconds
36-
* - After 3 ads without user activity, rotation stops
37-
* - Any user activity resets the counter and resumes rotation
63+
* - After 3 ads without user activity, stops fetching new ads but continues cycling cached ads
64+
* - Any user activity resets the counter and resumes fetching new ads
3865
*
3966
* Activity is tracked via the global activity-tracker module.
4067
*/
4168
export const useGravityAd = (): GravityAdState => {
4269
const [ad, setAd] = useState<AdResponse | null>(null)
4370
const [isLoading, setIsLoading] = useState(false)
44-
const [isActive, setIsActive] = useState(false)
45-
const impressionFiredRef = useRef<Set<string>>(new Set())
46-
47-
// Counter: how many ads shown since last user activity
48-
const adsShownRef = useRef<number>(0)
49-
50-
// Is rotation currently paused (shown 3 ads without activity)?
51-
const isPausedRef = useRef<boolean>(false)
5271

53-
// Rotation timer
54-
const rotationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
72+
// Use Zustand selector instead of manual subscription - only rerenders when value changes
73+
const hasUserMessaged = useChatStore((s) =>
74+
s.messages.some((m) => m.variant === 'user'),
75+
)
76+
77+
// Single consolidated controller ref
78+
const ctrlRef = useRef<GravityController>({
79+
cache: [],
80+
cacheIndex: 0,
81+
impressionsFired: new Set(),
82+
adsShownSinceActivity: 0,
83+
tickInFlight: false,
84+
intervalId: null,
85+
})
86+
87+
// Ref for the tick function (avoids useCallback dependency issues)
88+
const tickRef = useRef<() => void>(() => { })
89+
90+
// Fire impression and update credits (called when showing an ad)
91+
const recordImpressionOnce = (impUrl: string): void => {
92+
const ctrl = ctrlRef.current
93+
if (ctrl.impressionsFired.has(impUrl)) return
94+
ctrl.impressionsFired.add(impUrl)
5595

56-
// Fire impression via web API when ad changes (grants credits)
57-
useEffect(() => {
58-
if (isActive && ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) {
59-
const currentImpUrl = ad.impUrl
60-
impressionFiredRef.current.add(currentImpUrl)
61-
const authToken = getAuthToken()
62-
if (!authToken) {
63-
logger.warn('[gravity] No auth token, skipping impression recording')
64-
return
65-
}
96+
const authToken = getAuthToken()
97+
if (!authToken) {
98+
logger.warn('[gravity] No auth token, skipping impression recording')
99+
return
100+
}
66101

67-
fetch(`${WEBSITE_URL}/api/v1/ads/impression`, {
68-
method: 'POST',
69-
headers: {
70-
'Content-Type': 'application/json',
71-
Authorization: `Bearer ${authToken}`,
72-
},
73-
body: JSON.stringify({
74-
impUrl: currentImpUrl,
75-
}),
102+
fetch(`${WEBSITE_URL}/api/v1/ads/impression`, {
103+
method: 'POST',
104+
headers: {
105+
'Content-Type': 'application/json',
106+
Authorization: `Bearer ${authToken}`,
107+
},
108+
body: JSON.stringify({ impUrl }),
109+
})
110+
.then((res) => res.json())
111+
.then((data) => {
112+
if (data.creditsGranted > 0) {
113+
logger.info(
114+
{ creditsGranted: data.creditsGranted },
115+
'[gravity] Ad impression credits granted',
116+
)
117+
setAd((cur) =>
118+
cur?.impUrl === impUrl
119+
? { ...cur, credits: data.creditsGranted }
120+
: cur,
121+
)
122+
}
76123
})
77-
.then((res) => res.json())
78-
.then((data) => {
79-
if (data.creditsGranted > 0) {
80-
logger.info(
81-
{ creditsGranted: data.creditsGranted },
82-
'[gravity] Ad impression credits granted',
83-
)
84-
setAd((currentAd) =>
85-
currentAd?.impUrl === currentImpUrl
86-
? { ...currentAd, credits: data.creditsGranted }
87-
: currentAd,
88-
)
89-
}
90-
})
91-
.catch((err) => {
92-
logger.debug({ err }, '[gravity] Failed to record ad impression')
93-
})
94-
}
95-
}, [ad, isActive])
124+
.catch((err) => {
125+
logger.debug({ err }, '[gravity] Failed to record ad impression')
126+
})
127+
}
96128

97-
const clearTimer = useCallback(() => {
98-
if (rotationTimerRef.current) {
99-
clearTimeout(rotationTimerRef.current)
100-
rotationTimerRef.current = null
101-
}
102-
}, [])
129+
// Show an ad and fire impression
130+
const showAd = (next: AdResponse): void => {
131+
setAd(next)
132+
recordImpressionOnce(next.impUrl)
133+
}
103134

104135
// Fetch an ad via web API
105-
const fetchAd = useCallback(async (): Promise<AdResponse | null> => {
136+
const fetchAd = async (): Promise<AdResponse | null> => {
106137
if (!getAdsEnabled()) return null
107138

108139
const authToken = getAuthToken()
@@ -164,114 +195,86 @@ export const useGravityAd = (): GravityAdState => {
164195
}
165196

166197
const data = await response.json()
167-
const ad = data.ad as AdResponse | null
168-
169-
return ad
198+
return data.ad as AdResponse | null
170199
} catch (err) {
171200
logger.error({ err }, '[gravity] Failed to fetch ad')
172201
return null
173202
}
174-
}, [])
175-
176-
// Schedule ad rotation
177-
const scheduleRotation = useCallback(() => {
178-
clearTimer()
179-
180-
if (!getAdsEnabled() || isPausedRef.current) {
181-
logger.debug(
182-
{ isPaused: isPausedRef.current },
183-
'[gravity] Not scheduling rotation',
184-
)
185-
return
186-
}
187-
188-
rotationTimerRef.current = setTimeout(async () => {
189-
adsShownRef.current += 1
190-
191-
if (adsShownRef.current >= MAX_ADS_AFTER_ACTIVITY) {
192-
isPausedRef.current = true
193-
return
194-
}
203+
}
195204

196-
const newAd = await fetchAd()
197-
if (newAd) {
198-
setAd(newAd)
205+
// Update tick function (uses ref to avoid useCallback dependency issues)
206+
tickRef.current = () => {
207+
void (async () => {
208+
const ctrl = ctrlRef.current
209+
if (ctrl.tickInFlight) return
210+
ctrl.tickInFlight = true
211+
212+
try {
213+
if (!getAdsEnabled()) return
214+
215+
// Derive "can fetch new ads" from counter and activity (no separate paused ref needed)
216+
const canFetchNew =
217+
ctrl.adsShownSinceActivity < MAX_ADS_AFTER_ACTIVITY &&
218+
isUserActive(ACTIVITY_THRESHOLD_MS)
219+
220+
let next: AdResponse | null = null
221+
222+
if (canFetchNew) {
223+
next = await fetchAd()
224+
if (next) addToCache(ctrl, next)
225+
}
226+
227+
// Fall back to cached ads if no new ad
228+
if (!next) {
229+
next = nextFromCache(ctrl)
230+
}
231+
232+
if (next) {
233+
ctrl.adsShownSinceActivity += 1
234+
showAd(next)
235+
}
236+
} finally {
237+
ctrl.tickInFlight = false
199238
}
239+
})()
240+
}
200241

201-
scheduleRotation()
202-
}, AD_ROTATION_INTERVAL_MS)
203-
}, [clearTimer, fetchAd])
204-
205-
// Handle activity from the global activity tracker
206-
const handleActivity = useCallback(() => {
207-
const wasPaused = isPausedRef.current
208-
adsShownRef.current = 0
209-
210-
if (wasPaused) {
211-
isPausedRef.current = false
212-
scheduleRotation()
213-
}
214-
}, [scheduleRotation])
215-
216-
// Subscribe to global activity tracker
242+
// Reset ads shown counter on user activity
217243
useEffect(() => {
218244
if (!getAdsEnabled()) return
219-
220-
const unsubscribe = subscribeToActivity(handleActivity)
221-
return unsubscribe
222-
}, [handleActivity])
223-
224-
// Subscribe to UI messages to detect first user message
225-
// We use UI messages (not runState.messageHistory) because UI messages
226-
// update immediately when the user sends a message, allowing us to fetch
227-
// ads sooner rather than waiting for the assistant to respond
228-
useEffect(() => {
229-
if (isActive || !getAdsEnabled()) {
230-
return
231-
}
232-
233-
// Check initial state
234-
const initialMessages = useChatStore.getState().messages
235-
if (initialMessages.some((msg) => msg.variant === 'user')) {
236-
setIsActive(true)
237-
return
238-
}
239-
240-
const unsubscribe = useChatStore.subscribe((state) => {
241-
const hasUserMessage = state.messages.some(
242-
(msg) => msg.variant === 'user',
243-
)
244-
245-
if (hasUserMessage) {
246-
unsubscribe()
247-
setIsActive(true)
248-
}
245+
return subscribeToActivity(() => {
246+
ctrlRef.current.adsShownSinceActivity = 0
249247
})
248+
}, [])
250249

251-
return unsubscribe
252-
}, [isActive])
253-
254-
// Fetch first ad and start rotation when becoming active
250+
// Start rotation when user sends first message
255251
useEffect(() => {
256-
if (!isActive) return
252+
if (!hasUserMessaged || !getAdsEnabled()) return
257253

258254
setIsLoading(true)
259-
fetchAd().then((firstAd) => {
255+
256+
// Fetch first ad immediately
257+
void (async () => {
258+
const firstAd = await fetchAd()
260259
if (firstAd) {
261-
setAd(firstAd)
260+
addToCache(ctrlRef.current, firstAd)
261+
showAd(firstAd)
262+
ctrlRef.current.adsShownSinceActivity = 1
262263
}
263-
// Always start rotation, even if first fetch returned null
264-
scheduleRotation()
265264
setIsLoading(false)
266-
})
267-
}, [isActive, fetchAd, scheduleRotation])
265+
})()
268266

269-
// Cleanup timer on unmount
270-
useEffect(() => {
271-
return () => clearTimer()
272-
}, [clearTimer])
267+
// Start interval for rotation (consistent 60s intervals)
268+
const id = setInterval(() => tickRef.current(), AD_ROTATION_INTERVAL_MS)
269+
ctrlRef.current.intervalId = id
270+
271+
return () => {
272+
clearInterval(id)
273+
ctrlRef.current.intervalId = null
274+
}
275+
}, [hasUserMessaged])
273276

274-
return { ad: isActive ? ad : null, isLoading }
277+
return { ad: hasUserMessaged ? ad : null, isLoading }
275278
}
276279

277280
type AdMessage = { role: 'user' | 'assistant'; content: string }

0 commit comments

Comments
 (0)