Skip to content

Commit 0cdbe01

Browse files
authored
Simplify ad response shape (#562)
1 parent 3388ffe commit 0cdbe01

7 files changed

Lines changed: 51 additions & 188 deletions

File tree

cli/src/chat.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export const Chat = ({
174174
})
175175
const hasSubscription = subscriptionData?.hasSubscription ?? false
176176

177-
const { adData, recordImpression } = useGravityAd({
177+
const { ads, recordImpression } = useGravityAd({
178178
enabled: IS_FREEBUFF || !hasSubscription,
179179
provider: 'gravity',
180180
fallbackProvider: 'carbon',
@@ -1463,11 +1463,8 @@ export const Chat = ({
14631463
/>
14641464
)}
14651465

1466-
{adData && (IS_FREEBUFF || getAdsEnabled()) && (
1467-
<ChoiceAdBanner
1468-
ads={adData.variant === 'choice' ? adData.ads : [adData.ad]}
1469-
onImpression={recordImpression}
1470-
/>
1466+
{ads && (IS_FREEBUFF || getAdsEnabled()) && (
1467+
<ChoiceAdBanner ads={ads} onImpression={recordImpression} />
14711468
)}
14721469

14731470
{reviewMode ? (

cli/src/components/waiting-room-screen.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
115115
// forceStart bypasses the "wait for first user message" gate inside the hook,
116116
// which would otherwise block ads here since no conversation exists yet.
117117
// Try Gravity first, then fall back to Carbon when Gravity doesn't fill.
118-
const { adData, recordImpression } = useGravityAd({
118+
const { ads, recordImpression } = useGravityAd({
119119
enabled: true,
120120
forceStart: true,
121121
provider: 'gravity',
@@ -369,17 +369,14 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
369369
</box>
370370

371371
{/* Ad banner pinned to the bottom, same look-and-feel as in chat. */}
372-
{adData && (
372+
{ads && (
373373
<box style={{ flexShrink: 0 }}>
374-
<ChoiceAdBanner
375-
ads={adData.variant === 'choice' ? adData.ads : [adData.ad]}
376-
onImpression={recordImpression}
377-
/>
374+
<ChoiceAdBanner ads={ads} onImpression={recordImpression} />
378375
</box>
379376
)}
380377

381378
{/* Horizontal separator (mirrors chat input divider style) */}
382-
{!adData && (
379+
{!ads && (
383380
<text style={{ fg: theme.muted, flexShrink: 0 }}>
384381
{'─'.repeat(terminalWidth)}
385382
</text>

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

Lines changed: 27 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getAuthToken } from '../utils/auth'
99
import { IS_FREEBUFF } from '../utils/constants'
1010
import { logger } from '../utils/logger'
1111

12-
import type { Message} from '@codebuff/sdk';
12+
import type { Message } from '@codebuff/sdk'
1313

1414
const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad
1515
const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then pause fetching new ads
@@ -28,52 +28,26 @@ export type AdResponse = {
2828
credits?: number // Set after impression is recorded (in cents)
2929
}
3030

31-
export type AdVariant = 'banner' | 'choice'
32-
3331
/**
3432
* Which upstream ad network to query. The server maps each provider onto the
3533
* same normalized response shape, so the rest of the hook is provider-agnostic.
3634
*/
3735
export type AdProvider = 'gravity' | 'carbon'
3836
export type AdSurface = 'waiting_room'
3937

40-
export type AdData =
41-
| { variant: 'banner'; ad: AdResponse }
42-
| { variant: 'choice'; ads: AdResponse[] }
43-
4438
export type GravityAdState = {
45-
ad: AdResponse | null
46-
adData: AdData | null
39+
ads: AdResponse[] | null
4740
isLoading: boolean
4841
recordImpression: (impUrl: string) => void
4942
}
5043

5144
// Consolidated controller state for the ad rotation logic
5245
type GravityController = {
53-
cache: AdResponse[]
54-
cacheIndex: number
5546
choiceCache: AdResponse[][] // Cache of choice ad sets (each entry is 4 ads)
5647
choiceCacheIndex: number
57-
variant: AdVariant | null // Assigned variant from backend
5848
impressionsFired: Set<string>
5949
adsShownSinceActivity: number
6050
tickInFlight: boolean
61-
intervalId: ReturnType<typeof setInterval> | null
62-
}
63-
64-
// Pure helper: add an ad to the cache (if not already present)
65-
function addToCache(ctrl: GravityController, ad: AdResponse): void {
66-
if (ctrl.cache.some((x) => x.impUrl === ad.impUrl)) return
67-
if (ctrl.cache.length >= MAX_AD_CACHE_SIZE) ctrl.cache.shift()
68-
ctrl.cache.push(ad)
69-
}
70-
71-
// Pure helper: get the next cached ad (cycles through the cache)
72-
function nextFromCache(ctrl: GravityController): AdResponse | null {
73-
if (ctrl.cache.length === 0) return null
74-
const ad = ctrl.cache[ctrl.cacheIndex % ctrl.cache.length]!
75-
ctrl.cacheIndex = (ctrl.cacheIndex + 1) % ctrl.cache.length
76-
return ad
7751
}
7852

7953
// Pure helper: add a choice ad set to the choice cache
@@ -121,8 +95,7 @@ export const useGravityAd = (options?: {
12195
const provider: AdProvider = options?.provider ?? 'gravity'
12296
const fallbackProvider = options?.fallbackProvider
12397
const surface = options?.surface
124-
const [ad, setAd] = useState<AdResponse | null>(null)
125-
const [adData, setAdData] = useState<AdData | null>(null)
98+
const [ads, setAds] = useState<AdResponse[] | null>(null)
12699
const [isLoading, setIsLoading] = useState(false)
127100

128101
// Check if terminal height is too small to show ads
@@ -146,19 +119,15 @@ export const useGravityAd = (options?: {
146119

147120
// Single consolidated controller ref
148121
const ctrlRef = useRef<GravityController>({
149-
cache: [],
150-
cacheIndex: 0,
151122
choiceCache: [],
152123
choiceCacheIndex: 0,
153-
variant: null,
154124
impressionsFired: new Set(),
155125
adsShownSinceActivity: 0,
156126
tickInFlight: false,
157-
intervalId: null,
158127
})
159128

160129
// Ref for the tick function (avoids useCallback dependency issues)
161-
const tickRef = useRef<() => void>(() => { })
130+
const tickRef = useRef<() => void>(() => {})
162131

163132
// Ref to track whether ads should be hidden for use in async code
164133
const shouldHideAdsRef = useRef(shouldHideAds)
@@ -197,26 +166,12 @@ export const useGravityAd = (options?: {
197166
{ creditsGranted: data.creditsGranted },
198167
'[ads] Ad impression credits granted',
199168
)
200-
setAd((cur) =>
201-
cur?.impUrl === impUrl
202-
? { ...cur, credits: data.creditsGranted }
203-
: cur,
204-
)
205-
// Also update credits in adData for choice ads
206-
setAdData((cur) => {
169+
// Also update credits in visible ads
170+
setAds((cur) => {
207171
if (!cur) return cur
208-
if (cur.variant === 'choice') {
209-
return {
210-
...cur,
211-
ads: cur.ads.map((a) =>
212-
a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a,
213-
),
214-
}
215-
}
216-
if (cur.variant === 'banner' && cur.ad.impUrl === impUrl) {
217-
return { ...cur, ad: { ...cur.ad, credits: data.creditsGranted } }
218-
}
219-
return cur
172+
return cur.map((a) =>
173+
a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a,
174+
)
220175
})
221176
}
222177
})
@@ -225,23 +180,7 @@ export const useGravityAd = (options?: {
225180
})
226181
}
227182

228-
// Show a single banner ad and fire impression
229-
const showAd = (next: AdResponse): void => {
230-
setAd(next)
231-
setAdData({ variant: 'banner', ad: next })
232-
recordImpressionOnce(next.impUrl)
233-
}
234-
235-
// Show a choice ad set (impressions are fired by the component for visible ads only)
236-
const showChoiceAds = (ads: AdResponse[]): void => {
237-
setAd(ads[0] ?? null) // Keep backwards compat for ad field
238-
setAdData({ variant: 'choice', ads })
239-
}
240-
241-
type FetchAdResult =
242-
| { variant: 'banner'; ad: AdResponse }
243-
| { variant: 'choice'; ads: AdResponse[] }
244-
| null
183+
type FetchAdResult = { ads: AdResponse[] } | null
245184

246185
// Fetch an ad via web API
247186
const fetchAd = async (): Promise<FetchAdResult> => {
@@ -324,21 +263,15 @@ export const useGravityAd = (options?: {
324263
}
325264

326265
const data = await response.json()
327-
const variant = data.variant ?? 'banner'
328-
329-
if (
330-
variant === 'choice' &&
331-
Array.isArray(data.ads) &&
332-
data.ads.length > 0
333-
) {
334-
return { variant: 'choice', ads: data.ads as AdResponse[] }
335-
}
336266

337-
if (data.ad) {
338-
return { variant: 'banner', ad: data.ad as AdResponse }
267+
if (Array.isArray(data.ads) && data.ads.length > 0) {
268+
return { ads: data.ads as AdResponse[] }
339269
}
340270
} catch (err) {
341-
logger.error({ err, provider: providerToTry }, '[ads] Failed to fetch ad')
271+
logger.error(
272+
{ err, provider: providerToTry },
273+
'[ads] Failed to fetch ad',
274+
)
342275
}
343276
}
344277

@@ -363,30 +296,15 @@ export const useGravityAd = (options?: {
363296
const result = canFetchNew ? await fetchAd() : null
364297

365298
if (result) {
366-
ctrl.variant = result.variant
367-
if (result.variant === 'choice') {
368-
addToChoiceCache(ctrl, result.ads)
369-
ctrl.adsShownSinceActivity += 1
370-
showChoiceAds(result.ads)
371-
} else {
372-
addToCache(ctrl, result.ad)
373-
ctrl.adsShownSinceActivity += 1
374-
showAd(result.ad)
375-
}
299+
addToChoiceCache(ctrl, result.ads)
300+
ctrl.adsShownSinceActivity += 1
301+
setAds(result.ads)
376302
} else {
377303
// Fall back to cached ads
378-
if (ctrl.variant === 'choice') {
379-
const cachedSet = nextFromChoiceCache(ctrl)
380-
if (cachedSet) {
381-
ctrl.adsShownSinceActivity += 1
382-
showChoiceAds(cachedSet)
383-
}
384-
} else {
385-
const next = nextFromCache(ctrl)
386-
if (next) {
387-
ctrl.adsShownSinceActivity += 1
388-
showAd(next)
389-
}
304+
const cachedSet = nextFromChoiceCache(ctrl)
305+
if (cachedSet) {
306+
ctrl.adsShownSinceActivity += 1
307+
setAds(cachedSet)
390308
}
391309
}
392310
} finally {
@@ -414,34 +332,25 @@ export const useGravityAd = (options?: {
414332
const result = await fetchAd()
415333
if (result) {
416334
const ctrl = ctrlRef.current
417-
ctrl.variant = result.variant
418-
if (result.variant === 'choice') {
419-
addToChoiceCache(ctrl, result.ads)
420-
showChoiceAds(result.ads)
421-
} else {
422-
addToCache(ctrl, result.ad)
423-
showAd(result.ad)
424-
}
335+
addToChoiceCache(ctrl, result.ads)
336+
setAds(result.ads)
425337
ctrl.adsShownSinceActivity = 1
426338
}
427339
setIsLoading(false)
428340
})()
429341

430342
// Start interval for rotation (consistent 60s intervals)
431343
const id = setInterval(() => tickRef.current(), AD_ROTATION_INTERVAL_MS)
432-
ctrlRef.current.intervalId = id
433344

434345
return () => {
435346
clearInterval(id)
436-
ctrlRef.current.intervalId = null
437347
}
438348
}, [shouldStart, shouldHideAds, provider, fallbackProvider, surface])
439349

440-
// Don't return ad when ads should be hidden
350+
// Don't return ads when ads should be hidden
441351
const visible = shouldStart && !shouldHideAds
442352
return {
443-
ad: visible ? ad : null,
444-
adData: visible ? adData : null,
353+
ads: visible ? ads : null,
445354
isLoading,
446355
recordImpression: recordImpressionOnce,
447356
}

0 commit comments

Comments
 (0)