(options?: {
enabled?: boolean
/** Skip the "wait for first user message" gate. Used by the freebuff
* waiting room, which has no conversation but still needs ads. */
forceStart?: boolean
/** Ad network to request first. The server owns fallback ordering. */
provider?: AdProvider
/** Product surface requesting the ad. The server maps this to placements. */
surface?: AdSurface
})
| 88 | * Activity is tracked via the global activity-tracker module. |
| 89 | */ |
| 90 | export const useGravityAd = (options?: { |
| 91 | enabled?: boolean |
| 92 | /** Skip the "wait for first user message" gate. Used by the freebuff |
| 93 | * waiting room, which has no conversation but still needs ads. */ |
| 94 | forceStart?: boolean |
| 95 | /** Ad network to request first. The server owns fallback ordering. */ |
| 96 | provider?: AdProvider |
| 97 | /** Product surface requesting the ad. The server maps this to placements. */ |
| 98 | surface?: AdSurface |
| 99 | }): GravityAdState => { |
| 100 | const enabled = options?.enabled ?? true |
| 101 | const forceStart = options?.forceStart ?? false |
| 102 | const provider: AdProvider = options?.provider ?? 'gravity' |
| 103 | const surface = options?.surface |
| 104 | const [ads, setAds] = useState<AdResponse[] | null>(null) |
| 105 | const [isLoading, setIsLoading] = useState(false) |
| 106 | |
| 107 | // Check if terminal height is too small to show ads |
| 108 | const { terminalHeight } = useTerminalLayout() |
| 109 | const isVeryCompactHeight = terminalHeight <= 17 |
| 110 | |
| 111 | // Freebuff always shows ads even on compact screens (ads are mandatory there). |
| 112 | const isFreeMode = IS_FREEBUFF |
| 113 | |
| 114 | // Skip ads on very compact screens unless we're in Freebuff (where ads are mandatory) |
| 115 | // Also skip if explicitly disabled (e.g. user has a subscription) |
| 116 | const shouldHideAds = !enabled || (isVeryCompactHeight && !isFreeMode) |
| 117 | |
| 118 | // Use Zustand selector instead of manual subscription - only rerenders when value changes |
| 119 | const hasUserMessagedStore = useChatStore((s) => |
| 120 | s.messages.some((m) => m.variant === 'user'), |
| 121 | ) |
| 122 | // forceStart lets callers (e.g. the waiting room) opt out of the |
| 123 | // "wait for the first user message" gate. |
| 124 | const shouldStart = forceStart || hasUserMessagedStore |
| 125 | |
| 126 | // Single consolidated controller ref |
| 127 | const ctrlRef = useRef<GravityController>({ |
| 128 | choiceCache: [], |
| 129 | choiceCacheIndex: 0, |
| 130 | impressionsFired: new Set(), |
| 131 | adsShownSinceActivity: 0, |
| 132 | tickInFlight: false, |
| 133 | }) |
| 134 | |
| 135 | // Ref for the tick function (avoids useCallback dependency issues) |
| 136 | const tickRef = useRef<() => void>(() => {}) |
| 137 | |
| 138 | // Ref to track whether ads should be hidden for use in async code |
| 139 | const shouldHideAdsRef = useRef(shouldHideAds) |
| 140 | shouldHideAdsRef.current = shouldHideAds |
| 141 | |
| 142 | // Fire impression and update credits (called when showing an ad) |
| 143 | const recordImpressionOnce = (ad: AdResponse): void => { |
| 144 | // Don't record impressions when ads should be hidden |
| 145 | if (shouldHideAdsRef.current) return |
| 146 | |
| 147 | const ctrl = ctrlRef.current |
no test coverage detected