11import { Message , WEBSITE_URL } from '@codebuff/sdk'
2- import { useCallback , useEffect , useRef , useState } from 'react'
2+ import { useEffect , useRef , useState } from 'react'
33
44import { getAdsEnabled } from '../commands/ads'
55import { useChatStore } from '../state/chat-store'
6- import { subscribeToActivity } from '../utils/activity-tracker'
6+ import { isUserActive , subscribeToActivity } from '../utils/activity-tracker'
77import { getAuthToken } from '../utils/auth'
88import { logger } from '../utils/logger'
99
1010const 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)
1416export 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 */
4168export 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
277280type AdMessage = { role : 'user' | 'assistant' ; content : string }
0 commit comments