Repository navigation
Expand file tree
/
Copy pathuseFirstLookBanner.ts
More file actions
264 lines (245 loc) · 9.46 KB
/
Copy pathuseFirstLookBanner.ts
File metadata and controls
264 lines (245 loc) · 9.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
/**
* First Look banner/MREC refresh cycle.
*
* Reference implementation of the pattern documented at
* https://docs.cloudx.io/en/react-native/integrations/first-look
* The only addition is the optional `observer`, which reports what the cycle is
* doing.
*
* THE CYCLE
* 1. Load a CloudX banner off-screen while the previous ad stays visible.
* Mounting the view is what starts the load, so "off-screen" means
* "mounted hidden" — there is a fill ready when the refresh moment comes.
* 2. On fill, swap it in; the previous ad unmounts and its native view is
* destroyed.
* 3. On CloudX no-fill, load GAM for this cycle only.
* 4. If both miss, back off (1,2,4,8...s) and retry from CloudX.
* 5. REFRESH_DELAY_MS after the swap, start again at CloudX.
*
* Step 5 is what makes this first look on EVERY opportunity. A one-way fallback
* that lets GAM keep the slot after one miss is easier to build and permanently
* surrenders the placement.
*
* Both SDKs' own refresh must be off — CloudX dashboard rate 0, GAM disabled in
* the Ad Manager UI — or two timers race over one slot.
*
* The cycle restarts on the fill, not on an impression, because neither view
* reports one. The revenue callback is the only proxy and it is a bad one: it
* has been measured firing 71s after the load, and an Ad Manager unit without
* impression-level revenue reporting never emits it at all, which would strand
* the slot on one ad forever.
*
* An attempt never starts while the app is backgrounded; a cycle that comes due
* then runs when the app returns.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState } from 'react-native';
import type { AppStateStatus } from 'react-native';
import {
ATTEMPT_TIMEOUT_MS,
MAX_BACKOFF_SECONDS,
REFRESH_DELAY_MS,
} from './firstLookTiming';
import type { FirstLookSource } from './FirstLookSource';
/* Re-exported so a caller importing this hook does not need a second import. */
export type { FirstLookSource };
export type FirstLookAttempt = {
source: FirstLookSource;
/** Changes on every attempt so React remounts the ad view, triggering a new load. */
key: number;
};
/**
* Optional. Reports what the cycle is doing; every callback carries the source
* that served the ad. The cycle runs whether or not you pass one.
*/
export type FirstLookBannerObserver = {
/** A fill, which is also the swap: it goes on screen in the same handler. */
onAdLoaded?: (source: FirstLookSource) => void;
/**
* Both sources missed and the opportunity is over. Not raised for the CloudX
* miss alone — that one starts the GAM attempt, so the cycle is still
* running. Only `'gam'` is emitted today.
*/
onAdLoadFailed?: (source: FirstLookSource, error: string) => void;
/**
* The user tapped the ad. Reporting only.
*
* CloudX banners only: react-native-google-mobile-ads wires no banner click
* event on iOS or Android, so a GAM banner click cannot be reported. The
* interstitial is unaffected and reports both sources.
* https://github.com/invertase/react-native-google-mobile-ads
*/
onAdClicked?: (source: FirstLookSource) => void;
};
export function useFirstLookBanner(observer?: FirstLookBannerObserver) {
// The ad on screen. It stays mounted until the next fill is ready.
const [displayed, setDisplayed] = useState<FirstLookAttempt | null>(null);
// The ad loading off-screen. Null while waiting out a delay.
const [loading, setLoading] = useState<FirstLookAttempt | null>(null);
const loadingRef = useRef(loading);
loadingRef.current = loading;
const displayedRef = useRef(displayed);
displayedRef.current = displayed;
// In a ref so a new observer identity never tears down a running cycle.
const observerRef = useRef(observer);
observerRef.current = observer;
const nextKey = useRef(0);
const consecutiveNoFills = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const attemptTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Lets startAttempt arm the timeout before onAdLoadFailed is defined.
const failAttemptRef = useRef<(key: number, message: string) => void>(
() => {},
);
// Tells a real no-fill from a source that never called back.
const timedOut = useRef(false);
// A cycle came due while the app was backgrounded.
const pendingCycle = useRef(false);
// Every attempt is timed: a source that answers neither way counts as failed,
// so the slot cannot hang on it.
const startAttempt = useCallback((source: FirstLookSource) => {
/*
* Gated here so every entry point — the post-fill timer, the fallback, the
* backoff retry — is covered. 'unknown' does not count as away: React
* Native reports it before resolving, and can leave it without a 'change'
* event, stranding the cycle with nothing to restart it.
*/
const appState = AppState.currentState;
if (appState === 'background' || appState === 'inactive') {
pendingCycle.current = true;
// Cancel the attempt outright: the hidden view and its timeout.
if (attemptTimer.current) {
clearTimeout(attemptTimer.current);
attemptTimer.current = null;
}
setLoading(null);
/*
* No callback reports this, so it looks like no demand. Worth knowing
* because 'inactive' covers the iOS ATT prompt, Control Centre and the
* app switcher. Watch AppState yourself if you need to see it.
*/
return;
}
if (attemptTimer.current) {
clearTimeout(attemptTimer.current);
}
const key = nextKey.current++;
attemptTimer.current = setTimeout(() => {
timedOut.current = true;
failAttemptRef.current(key, 'no answer');
}, ATTEMPT_TIMEOUT_MS);
setLoading({ source, key });
}, []);
// Through startAttempt, so attempt zero is timed like the rest — it is the
// one most likely to race CloudX.initialize().
useEffect(() => {
startAttempt('cloudx');
}, [startAttempt]);
// Resume a cycle that came due while the app was backgrounded.
useEffect(() => {
const subscription = AppState.addEventListener(
'change',
(state: AppStateStatus) => {
if (state !== 'active' || !pendingCycle.current) {
return;
}
pendingCycle.current = false;
startAttempt('cloudx');
},
);
return () => subscription.remove();
}, [startAttempt]);
// No refresh may fire into a torn-down slot.
useEffect(
() => () => {
if (timer.current) {
clearTimeout(timer.current);
}
if (attemptTimer.current) {
clearTimeout(attemptTimer.current);
}
},
[],
);
// The off-screen ad filled: swap it in. That unmounts the previous ad view
// and destroys its native ad.
const onAdLoaded = useCallback((key: number) => {
const filled = loadingRef.current;
/*
* Only the attempt loading off-screen. The displayed ad shares these
* handlers and can emit its own load — an auto-refresh left enabled — which
* would promote the hidden attempt before it filled and blank the slot.
*/
if (!filled || filled.key !== key) {
return;
}
if (attemptTimer.current) {
clearTimeout(attemptTimer.current);
}
timedOut.current = false;
consecutiveNoFills.current = 0;
observerRef.current?.onAdLoaded?.(filled.source);
setDisplayed(filled);
// Nothing else is in flight until the refresh delay elapses.
setLoading(null);
// Whoever just served, the next cycle begins at CloudX.
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => startAttempt('cloudx'), REFRESH_DELAY_MS);
}, [startAttempt]);
// CloudX no-fill falls back to GAM for this cycle. A GAM no-fill means both
// sources missed, so retry from CloudX with an exponential delay.
const onAdLoadFailed = useCallback((key: number, message: string) => {
const failed = loadingRef.current;
// Same guard: a failure from the displayed ad must not abort the attempt
// in flight behind it.
if (!failed || failed.key !== key) {
return;
}
if (attemptTimer.current) {
clearTimeout(attemptTimer.current);
}
const wasSilent = timedOut.current;
timedOut.current = false;
// The CloudX miss is not reported: it starts the GAM attempt below.
if (failed.source === 'cloudx') {
startAttempt('gam');
return;
}
// Both missed. A silent attempt is still a miss; it only changes the text.
observerRef.current?.onAdLoadFailed?.(
'gam',
wasSilent
? `GAM did not answer within ${ATTEMPT_TIMEOUT_MS}ms`
: message,
);
const delaySeconds = Math.min(
2 ** consecutiveNoFills.current,
MAX_BACKOFF_SECONDS,
);
consecutiveNoFills.current += 1;
setLoading(null);
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(
() => startAttempt('cloudx'),
delaySeconds * 1000,
);
}, [startAttempt]);
failAttemptRef.current = onAdLoadFailed;
/*
* Matched against `displayed`, not `loading` — the opposite of the callbacks
* above. Only the on-screen ad can be tapped; the loading slot is rendered
* with pointerEvents="none".
*/
const onAdClicked = useCallback((key: number) => {
const shown = displayedRef.current;
if (!shown || shown.key !== key) {
return;
}
observerRef.current?.onAdClicked?.(shown.source);
}, []);
return { displayed, loading, onAdLoaded, onAdLoadFailed, onAdClicked };
}