@getnori/feed 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +136 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -6
- package/dist/index.d.ts +19 -6
- package/dist/index.js +130 -46
- package/dist/index.js.map +1 -1
- package/dist/web/web.js +452 -0
- package/dist/web/web.js.map +1 -0
- package/package.json +9 -3
package/dist/index.d.ts
CHANGED
|
@@ -3,16 +3,26 @@ import { StyleProp, ViewStyle } from 'react-native';
|
|
|
3
3
|
import { TrackInput, Reel, NoriClient, NoriError } from '@getnori/core';
|
|
4
4
|
|
|
5
5
|
interface ViewTrackerOptions {
|
|
6
|
-
/** Report view_complete once playback reaches this share of the reel (
|
|
6
|
+
/** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */
|
|
7
7
|
minWatchPercent: number;
|
|
8
|
+
/** ...and the viewer has watched at least this long, counting loops (config.rewards.min_view_seconds). */
|
|
9
|
+
minViewSeconds?: number;
|
|
10
|
+
/** Clock in ms (tests). Default Date.now. */
|
|
11
|
+
now?: () => number;
|
|
8
12
|
}
|
|
9
13
|
/**
|
|
10
14
|
* Turns playback callbacks into the events the API expects for one reel.
|
|
11
15
|
*
|
|
12
16
|
* becomes visible impression (once per reel per feed)
|
|
13
|
-
* video loaded view_progress @0 (opens the view on the server's clock)
|
|
17
|
+
* video loaded view_progress @0 (sent at once: opens the view on the server's clock)
|
|
14
18
|
* 25 / 50 / 75 % view_progress
|
|
15
|
-
*
|
|
19
|
+
* complete view_complete (once; the server decides the reward)
|
|
20
|
+
*
|
|
21
|
+
* "Complete" needs both conditions the server checks: the furthest position
|
|
22
|
+
* reaches minWatchPercent of the reel, and the viewer has watched for at
|
|
23
|
+
* least max(that share of the reel, minViewSeconds), counting loops. So a
|
|
24
|
+
* 5-second reel with a 5-second minimum completes after one full play, not
|
|
25
|
+
* at 80 % (which the server would refuse as "watched too fast").
|
|
16
26
|
*
|
|
17
27
|
* Images get an impression only: a reward needs a watched video.
|
|
18
28
|
*/
|
|
@@ -22,14 +32,15 @@ declare class ViewTracker {
|
|
|
22
32
|
private impressions;
|
|
23
33
|
private completed;
|
|
24
34
|
private current;
|
|
35
|
+
private readonly now;
|
|
25
36
|
constructor(track: (input: TrackInput) => void, options: ViewTrackerOptions);
|
|
26
37
|
/** The reel is now the visible one. */
|
|
27
38
|
activate(reel: Pick<Reel, "id" | "media_type">): void;
|
|
28
39
|
/** The visible video loaded and knows its duration. */
|
|
29
40
|
loaded(reelId: number, durationMs: number): void;
|
|
30
|
-
/** Playback position of the visible video. */
|
|
41
|
+
/** Playback position of the visible video (called while it plays). */
|
|
31
42
|
progress(reelId: number, positionMs: number): void;
|
|
32
|
-
/** The video
|
|
43
|
+
/** The video reached its end (it then loops). */
|
|
33
44
|
ended(reelId: number): void;
|
|
34
45
|
/** The viewer scrolled away; a return opens a new view. */
|
|
35
46
|
deactivate(): void;
|
|
@@ -37,6 +48,8 @@ declare class ViewTracker {
|
|
|
37
48
|
}
|
|
38
49
|
declare function createViewTracker(client: Pick<NoriClient, "track">, options: ViewTrackerOptions): ViewTracker;
|
|
39
50
|
|
|
51
|
+
/** How long an image reel stays on screen before the feed moves on (see #15). */
|
|
52
|
+
declare const IMAGE_REEL_SECONDS = 15;
|
|
40
53
|
interface ReelOverlayContext {
|
|
41
54
|
reel: Reel;
|
|
42
55
|
/** This reel is the one on screen. */
|
|
@@ -98,4 +111,4 @@ interface FeedState {
|
|
|
98
111
|
/** Pages through the viewer's feed. Reels already shown are never duplicated. */
|
|
99
112
|
declare function useFeed(client: NoriClient, { tag, pageSize }?: UseFeedOptions): FeedState;
|
|
100
113
|
|
|
101
|
-
export { type FeedState, NoriFeed, type NoriFeedProps, type ReelOverlayContext, type UseFeedOptions, ViewTracker, type ViewTrackerOptions, createViewTracker, useFeed };
|
|
114
|
+
export { type FeedState, IMAGE_REEL_SECONDS, NoriFeed, type NoriFeedProps, type ReelOverlayContext, type UseFeedOptions, ViewTracker, type ViewTrackerOptions, createViewTracker, useFeed };
|
package/dist/index.js
CHANGED
|
@@ -5,22 +5,63 @@ import {
|
|
|
5
5
|
AppState,
|
|
6
6
|
FlatList,
|
|
7
7
|
Pressable as Pressable2,
|
|
8
|
-
StyleSheet as
|
|
8
|
+
StyleSheet as StyleSheet3,
|
|
9
9
|
Text as Text2,
|
|
10
10
|
View as View2
|
|
11
11
|
} from "react-native";
|
|
12
12
|
|
|
13
13
|
// src/ReelItem.tsx
|
|
14
14
|
import { memo, useEffect, useRef, useState } from "react";
|
|
15
|
-
import { Image,
|
|
15
|
+
import { Image, Pressable, StyleSheet as StyleSheet2, Text, View } from "react-native";
|
|
16
|
+
|
|
17
|
+
// src/ReelVideo.tsx
|
|
16
18
|
import Video from "react-native-video";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
// src/videoStyle.ts
|
|
21
|
+
import { Platform, StyleSheet } from "react-native";
|
|
22
|
+
var VIDEO_STYLE = Platform.OS === "web" ? [StyleSheet.absoluteFill, { objectFit: "cover" }] : StyleSheet.absoluteFill;
|
|
23
|
+
|
|
24
|
+
// src/ReelVideo.tsx
|
|
25
|
+
import { jsx } from "react/jsx-runtime";
|
|
26
|
+
function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }) {
|
|
27
|
+
return /* @__PURE__ */ jsx(
|
|
28
|
+
Video,
|
|
29
|
+
{
|
|
30
|
+
testID,
|
|
31
|
+
source: { uri },
|
|
32
|
+
style: VIDEO_STYLE,
|
|
33
|
+
resizeMode: "cover",
|
|
34
|
+
repeat: true,
|
|
35
|
+
muted,
|
|
36
|
+
paused,
|
|
37
|
+
progressUpdateInterval: 250,
|
|
38
|
+
playInBackground: false,
|
|
39
|
+
onLoad: (data) => onLoad(data.duration),
|
|
40
|
+
onProgress: (data) => onProgress(data.currentTime),
|
|
41
|
+
onEnd
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/ReelItem.tsx
|
|
47
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
48
|
+
var IMAGE_REEL_SECONDS = 15;
|
|
49
|
+
function nextFeedIndex(index, count) {
|
|
50
|
+
return index + 1 < count ? index + 1 : null;
|
|
51
|
+
}
|
|
52
|
+
function ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }) {
|
|
19
53
|
const [userPaused, setUserPaused] = useState(false);
|
|
20
54
|
const [positionMs, setPositionMs] = useState(0);
|
|
21
55
|
const durationRef = useRef(null);
|
|
22
56
|
const [durationMs, setDurationMs] = useState(null);
|
|
23
57
|
const isVideo = reel.media_type === "video" && Boolean(reel.media_url);
|
|
58
|
+
const imageShownRef = useRef(onImageShown);
|
|
59
|
+
imageShownRef.current = onImageShown;
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!active || isVideo || !reel.media_url) return;
|
|
62
|
+
const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1e3);
|
|
63
|
+
return () => clearTimeout(timer);
|
|
64
|
+
}, [active, isVideo, reel.media_url, reel.id]);
|
|
24
65
|
useEffect(() => {
|
|
25
66
|
if (!active) {
|
|
26
67
|
setUserPaused(false);
|
|
@@ -30,53 +71,48 @@ function ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderO
|
|
|
30
71
|
if (durationRef.current) tracker.loaded(reel.id, durationRef.current);
|
|
31
72
|
}, [active, reel, tracker]);
|
|
32
73
|
return /* @__PURE__ */ jsxs(View, { style: [styles.reel, { height }], testID: `nori-reel-${reel.id}`, children: [
|
|
33
|
-
/* @__PURE__ */
|
|
74
|
+
/* @__PURE__ */ jsx2(
|
|
34
75
|
Pressable,
|
|
35
76
|
{
|
|
36
|
-
style:
|
|
77
|
+
style: StyleSheet2.absoluteFill,
|
|
37
78
|
onPress: () => isVideo && setUserPaused((p) => !p),
|
|
38
79
|
accessibilityRole: "button",
|
|
39
80
|
accessibilityLabel: userPaused ? "Play" : "Pause",
|
|
40
81
|
disabled: !isVideo,
|
|
41
|
-
children: isVideo ? /* @__PURE__ */
|
|
42
|
-
|
|
82
|
+
children: isVideo ? /* @__PURE__ */ jsx2(
|
|
83
|
+
ReelVideo,
|
|
43
84
|
{
|
|
44
85
|
testID: `nori-video-${reel.id}`,
|
|
45
|
-
|
|
46
|
-
style: VIDEO_STYLE,
|
|
47
|
-
resizeMode: "cover",
|
|
48
|
-
repeat: true,
|
|
86
|
+
uri: reel.media_url,
|
|
49
87
|
muted,
|
|
50
88
|
paused: !active || userPaused,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
onLoad: (data) => {
|
|
54
|
-
const ms = data.duration * 1e3;
|
|
89
|
+
onLoad: (seconds) => {
|
|
90
|
+
const ms = seconds * 1e3;
|
|
55
91
|
durationRef.current = ms;
|
|
56
92
|
setDurationMs(ms);
|
|
57
93
|
if (active) tracker.loaded(reel.id, ms);
|
|
58
94
|
},
|
|
59
|
-
onProgress: (
|
|
60
|
-
const ms =
|
|
95
|
+
onProgress: (seconds) => {
|
|
96
|
+
const ms = seconds * 1e3;
|
|
61
97
|
setPositionMs(ms);
|
|
62
98
|
if (active) tracker.progress(reel.id, ms);
|
|
63
99
|
},
|
|
64
100
|
onEnd: () => active && tracker.ended(reel.id)
|
|
65
101
|
}
|
|
66
|
-
) : reel.media_url ? /* @__PURE__ */
|
|
102
|
+
) : reel.media_url ? /* @__PURE__ */ jsx2(Image, { source: { uri: reel.media_url }, style: StyleSheet2.absoluteFill, resizeMode: "cover" }) : null
|
|
67
103
|
}
|
|
68
104
|
),
|
|
69
|
-
userPaused ? /* @__PURE__ */
|
|
105
|
+
userPaused ? /* @__PURE__ */ jsx2(View, { pointerEvents: "none", style: styles.pausedBadge, children: /* @__PURE__ */ jsx2(Text, { style: styles.pausedText, children: "\u25B6" }) }) : null,
|
|
70
106
|
/* @__PURE__ */ jsxs(View, { pointerEvents: "box-none", style: styles.caption, children: [
|
|
71
|
-
reel.
|
|
72
|
-
reel.
|
|
107
|
+
reel.ai_generated ? /* @__PURE__ */ jsx2(View, { style: styles.aiBadge, accessibilityLabel: "AI-generated", testID: `nori-ai-label-${reel.id}`, children: /* @__PURE__ */ jsx2(Text, { style: styles.aiText, children: "AI-generated" }) }) : null,
|
|
108
|
+
reel.title ? /* @__PURE__ */ jsx2(Text, { style: [styles.title, { color: textColor }], numberOfLines: 2, children: reel.title }) : null,
|
|
109
|
+
reel.description ? /* @__PURE__ */ jsx2(Text, { style: [styles.description, { color: textColor }], numberOfLines: 3, children: reel.description }) : null
|
|
73
110
|
] }),
|
|
74
|
-
renderOverlay ? /* @__PURE__ */
|
|
111
|
+
renderOverlay ? /* @__PURE__ */ jsx2(View, { pointerEvents: "box-none", style: StyleSheet2.absoluteFill, children: renderOverlay({ reel, active, positionMs, durationMs }) }) : null
|
|
75
112
|
] });
|
|
76
113
|
}
|
|
77
114
|
var ReelItem = memo(ReelItemImpl);
|
|
78
|
-
var
|
|
79
|
-
var styles = StyleSheet.create({
|
|
115
|
+
var styles = StyleSheet2.create({
|
|
80
116
|
reel: { width: "100%", overflow: "hidden", backgroundColor: "#000" },
|
|
81
117
|
caption: { position: "absolute", left: 16, right: 80, bottom: 120 },
|
|
82
118
|
title: { fontSize: 17, fontWeight: "700", marginBottom: 4, textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 4 },
|
|
@@ -92,7 +128,18 @@ var styles = StyleSheet.create({
|
|
|
92
128
|
alignItems: "center",
|
|
93
129
|
justifyContent: "center"
|
|
94
130
|
},
|
|
95
|
-
pausedText: { color: "#fff", fontSize: 26, marginLeft: 4 }
|
|
131
|
+
pausedText: { color: "#fff", fontSize: 26, marginLeft: 4 },
|
|
132
|
+
aiBadge: {
|
|
133
|
+
alignSelf: "flex-start",
|
|
134
|
+
marginBottom: 6,
|
|
135
|
+
paddingHorizontal: 8,
|
|
136
|
+
paddingVertical: 3,
|
|
137
|
+
borderRadius: 10,
|
|
138
|
+
backgroundColor: "rgba(0,0,0,0.55)",
|
|
139
|
+
borderWidth: 1,
|
|
140
|
+
borderColor: "rgba(255,255,255,0.35)"
|
|
141
|
+
},
|
|
142
|
+
aiText: { color: "#fff", fontSize: 11, fontWeight: "700", letterSpacing: 0.3 }
|
|
96
143
|
});
|
|
97
144
|
|
|
98
145
|
// src/useFeed.ts
|
|
@@ -156,6 +203,7 @@ function useFeed(client, { tag = null, pageSize = 10 } = {}) {
|
|
|
156
203
|
|
|
157
204
|
// src/viewTracker.ts
|
|
158
205
|
var PROGRESS_MILESTONES = [25, 50, 75];
|
|
206
|
+
var MAX_TICK_MS = 1500;
|
|
159
207
|
var ViewTracker = class {
|
|
160
208
|
constructor(track, options) {
|
|
161
209
|
this.track = track;
|
|
@@ -163,11 +211,20 @@ var ViewTracker = class {
|
|
|
163
211
|
this.impressions = /* @__PURE__ */ new Set();
|
|
164
212
|
this.completed = /* @__PURE__ */ new Set();
|
|
165
213
|
this.current = null;
|
|
214
|
+
this.now = options.now ?? (() => Date.now());
|
|
166
215
|
}
|
|
167
216
|
/** The reel is now the visible one. */
|
|
168
217
|
activate(reel) {
|
|
169
218
|
if (this.current?.reelId === reel.id) return;
|
|
170
|
-
this.current = {
|
|
219
|
+
this.current = {
|
|
220
|
+
reelId: reel.id,
|
|
221
|
+
durationMs: null,
|
|
222
|
+
milestones: /* @__PURE__ */ new Set(),
|
|
223
|
+
opened: false,
|
|
224
|
+
furthestMs: 0,
|
|
225
|
+
watchedMs: 0,
|
|
226
|
+
lastTickAt: null
|
|
227
|
+
};
|
|
171
228
|
if (!this.impressions.has(reel.id)) {
|
|
172
229
|
this.impressions.add(reel.id);
|
|
173
230
|
this.track({ type: "impression", reel_id: reel.id });
|
|
@@ -180,30 +237,44 @@ var ViewTracker = class {
|
|
|
180
237
|
view.durationMs = Math.round(durationMs);
|
|
181
238
|
if (!view.opened) {
|
|
182
239
|
view.opened = true;
|
|
240
|
+
view.lastTickAt = this.now();
|
|
183
241
|
this.track({ type: "view_progress", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });
|
|
184
242
|
}
|
|
185
243
|
}
|
|
186
|
-
/** Playback position of the visible video. */
|
|
244
|
+
/** Playback position of the visible video (called while it plays). */
|
|
187
245
|
progress(reelId, positionMs) {
|
|
188
246
|
const view = this.view(reelId);
|
|
189
247
|
if (!view || !view.durationMs || !view.opened) return;
|
|
248
|
+
const now = this.now();
|
|
249
|
+
if (view.lastTickAt !== null) {
|
|
250
|
+
const gap = now - view.lastTickAt;
|
|
251
|
+
if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;
|
|
252
|
+
}
|
|
253
|
+
view.lastTickAt = now;
|
|
190
254
|
const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));
|
|
191
|
-
|
|
255
|
+
view.furthestMs = Math.max(view.furthestMs, position);
|
|
256
|
+
const percent = view.furthestMs / view.durationMs * 100;
|
|
192
257
|
if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
258
|
+
const required = Math.max(
|
|
259
|
+
view.durationMs * this.options.minWatchPercent / 100,
|
|
260
|
+
(this.options.minViewSeconds ?? 0) * 1e3
|
|
261
|
+
);
|
|
262
|
+
if (view.watchedMs >= required) {
|
|
263
|
+
this.completed.add(reelId);
|
|
264
|
+
this.track({ type: "view_complete", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
196
267
|
}
|
|
197
268
|
for (const milestone of PROGRESS_MILESTONES) {
|
|
198
269
|
if (percent >= milestone && !view.milestones.has(milestone)) {
|
|
199
270
|
view.milestones.add(milestone);
|
|
200
271
|
if (!this.completed.has(reelId)) {
|
|
201
|
-
this.track({ type: "view_progress", reel_id: reelId, position_ms:
|
|
272
|
+
this.track({ type: "view_progress", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
|
|
202
273
|
}
|
|
203
274
|
}
|
|
204
275
|
}
|
|
205
276
|
}
|
|
206
|
-
/** The video
|
|
277
|
+
/** The video reached its end (it then loops). */
|
|
207
278
|
ended(reelId) {
|
|
208
279
|
const view = this.view(reelId);
|
|
209
280
|
if (view?.durationMs) this.progress(reelId, view.durationMs);
|
|
@@ -226,8 +297,9 @@ function createViewTracker(client, options) {
|
|
|
226
297
|
}
|
|
227
298
|
|
|
228
299
|
// src/NoriFeed.tsx
|
|
229
|
-
import { jsx as
|
|
300
|
+
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
230
301
|
var DEFAULT_MIN_WATCH_PERCENT = 90;
|
|
302
|
+
var DEFAULT_MIN_VIEW_SECONDS = 5;
|
|
231
303
|
function NoriFeed({
|
|
232
304
|
client,
|
|
233
305
|
tag = null,
|
|
@@ -258,7 +330,11 @@ function NoriFeed({
|
|
|
258
330
|
};
|
|
259
331
|
}, [client]);
|
|
260
332
|
const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;
|
|
261
|
-
const
|
|
333
|
+
const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;
|
|
334
|
+
const tracker = useMemo(
|
|
335
|
+
() => createViewTracker(client, { minWatchPercent, minViewSeconds }),
|
|
336
|
+
[client, minWatchPercent, minViewSeconds]
|
|
337
|
+
);
|
|
262
338
|
useEffect3(() => {
|
|
263
339
|
const sub = AppState.addEventListener("change", (state) => {
|
|
264
340
|
if (state !== "active") void client.flush().catch(() => void 0);
|
|
@@ -269,6 +345,11 @@ function NoriFeed({
|
|
|
269
345
|
onReelChangeRef.current = onReelChange;
|
|
270
346
|
const reelsRef = useRef3(feed.reels);
|
|
271
347
|
reelsRef.current = feed.reels;
|
|
348
|
+
const listRef = useRef3(null);
|
|
349
|
+
const advanceFrom = useCallback2((index) => {
|
|
350
|
+
const next = nextFeedIndex(index, reelsRef.current.length);
|
|
351
|
+
if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });
|
|
352
|
+
}, []);
|
|
272
353
|
const onViewableItemsChanged = useRef3(({ viewableItems }) => {
|
|
273
354
|
const first = viewableItems.find((v) => v.isViewable && v.index !== null);
|
|
274
355
|
if (first?.index == null) return;
|
|
@@ -287,23 +368,24 @@ function NoriFeed({
|
|
|
287
368
|
const accent = theme.primary_color ?? "#fff";
|
|
288
369
|
let body;
|
|
289
370
|
if (feed.loading && feed.reels.length === 0) {
|
|
290
|
-
body = /* @__PURE__ */
|
|
371
|
+
body = /* @__PURE__ */ jsx3(ActivityIndicator, { color: accent, style: styles2.center, testID: `${testID}-loading` });
|
|
291
372
|
} else if (feed.holdout) {
|
|
292
373
|
body = renderHoldout ? renderHoldout() : null;
|
|
293
374
|
} else if (feed.error && feed.reels.length === 0) {
|
|
294
375
|
body = /* @__PURE__ */ jsxs2(View2, { style: styles2.center, testID: `${testID}-error`, children: [
|
|
295
|
-
/* @__PURE__ */
|
|
296
|
-
/* @__PURE__ */
|
|
376
|
+
/* @__PURE__ */ jsx3(Text2, { style: [styles2.message, { color: textColor }], children: "Couldn't load the feed." }),
|
|
377
|
+
/* @__PURE__ */ jsx3(Pressable2, { onPress: () => void feed.refresh(), accessibilityRole: "button", style: [styles2.retry, { borderColor: accent }], children: /* @__PURE__ */ jsx3(Text2, { style: [styles2.retryText, { color: accent }], children: "Try again" }) })
|
|
297
378
|
] });
|
|
298
379
|
} else if (feed.reels.length === 0) {
|
|
299
|
-
body = renderEmpty ? renderEmpty() : /* @__PURE__ */
|
|
380
|
+
body = renderEmpty ? renderEmpty() : /* @__PURE__ */ jsx3(View2, { style: styles2.center, testID: `${testID}-empty`, children: /* @__PURE__ */ jsx3(Text2, { style: [styles2.message, { color: textColor }], children: "Nothing here yet." }) });
|
|
300
381
|
} else if (height > 0) {
|
|
301
|
-
body = /* @__PURE__ */
|
|
382
|
+
body = /* @__PURE__ */ jsx3(
|
|
302
383
|
FlatList,
|
|
303
384
|
{
|
|
385
|
+
ref: listRef,
|
|
304
386
|
data: feed.reels,
|
|
305
387
|
keyExtractor: (reel) => String(reel.id),
|
|
306
|
-
renderItem: ({ item, index }) => /* @__PURE__ */
|
|
388
|
+
renderItem: ({ item, index }) => /* @__PURE__ */ jsx3(
|
|
307
389
|
ReelItem,
|
|
308
390
|
{
|
|
309
391
|
reel: item,
|
|
@@ -312,7 +394,8 @@ function NoriFeed({
|
|
|
312
394
|
muted,
|
|
313
395
|
textColor,
|
|
314
396
|
tracker,
|
|
315
|
-
renderOverlay
|
|
397
|
+
renderOverlay,
|
|
398
|
+
onImageShown: () => advanceFrom(index)
|
|
316
399
|
}
|
|
317
400
|
),
|
|
318
401
|
getItemLayout: (_, index) => ({ length: height, offset: height * index, index }),
|
|
@@ -331,13 +414,13 @@ function NoriFeed({
|
|
|
331
414
|
initialNumToRender: 2,
|
|
332
415
|
maxToRenderPerBatch: 2,
|
|
333
416
|
removeClippedSubviews: true,
|
|
334
|
-
ListFooterComponent: feed.loadingMore ? /* @__PURE__ */
|
|
417
|
+
ListFooterComponent: feed.loadingMore ? /* @__PURE__ */ jsx3(ActivityIndicator, { color: accent, style: styles2.footer }) : void 0
|
|
335
418
|
}
|
|
336
419
|
);
|
|
337
420
|
}
|
|
338
|
-
return /* @__PURE__ */
|
|
421
|
+
return /* @__PURE__ */ jsx3(View2, { style: [styles2.container, { backgroundColor: background }, style], onLayout, testID, children: body });
|
|
339
422
|
}
|
|
340
|
-
var styles2 =
|
|
423
|
+
var styles2 = StyleSheet3.create({
|
|
341
424
|
container: { flex: 1, overflow: "hidden" },
|
|
342
425
|
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
|
343
426
|
message: { fontSize: 16, textAlign: "center", marginBottom: 16 },
|
|
@@ -346,6 +429,7 @@ var styles2 = StyleSheet2.create({
|
|
|
346
429
|
footer: { paddingVertical: 24 }
|
|
347
430
|
});
|
|
348
431
|
export {
|
|
432
|
+
IMAGE_REEL_SECONDS,
|
|
349
433
|
NoriFeed,
|
|
350
434
|
ViewTracker,
|
|
351
435
|
createViewTracker,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /** Fixed reel height. Default: the height of the feed's own container. */\n height?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_MIN_WATCH_PERCENT = 90;\n\n/** A full-height, vertically paged reel feed. Fills its parent. */\nexport function NoriFeed({\n client,\n tag = null,\n pageSize = 10,\n muted = false,\n renderOverlay,\n onReelChange,\n renderEmpty,\n renderHoldout,\n height: fixedHeight,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const tracker = useMemo(() => createViewTracker(client, { minWatchPercent }), [client, minWatchPercent]);\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Platform, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport Video from \"react-native-video\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <Video\n testID={`nori-video-${reel.id}`}\n source={{ uri: reel.media_url as string }}\n style={VIDEO_STYLE}\n resizeMode=\"cover\"\n repeat\n muted={muted}\n paused={!active || userPaused}\n progressUpdateInterval={250}\n playInBackground={false}\n onLoad={(data: { duration: number }) => {\n const ms = data.duration * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(data: { currentTime: number }) => {\n const ms = data.currentTime * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\nconst VIDEO_STYLE = Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n});\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (from config.rewards). */\n minWatchPercent: number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * >= minWatchPercent view_complete (once; the server decides the reward)\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: { reelId: number; durationMs: number | null; milestones: Set<number>; opened: boolean } | null = null;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {}\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = { reelId: reel.id, durationMs: null, milestones: new Set(), opened: false };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video. */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n const percent = (position / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });\n return;\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video played to the end (also covers a minWatchPercent of 100). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":"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","names":["useCallback","useEffect","useRef","useState","Pressable","StyleSheet","Text","View","useEffect","useRef","useState","jsx","jsxs","useState","useEffect","useRef","useCallback","styles","View","Text","Pressable","StyleSheet"]}
|
|
1
|
+
{"version":3,"sources":["../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/ReelVideo.tsx","../src/videoStyle.ts","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n ActivityIndicator,\n AppState,\n FlatList,\n Pressable,\n StyleSheet,\n Text,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type FlatListProps,\n type ViewStyle,\n} from \"react-native\";\nimport type { Config, NoriClient, Reel } from \"@getnori/core\";\nimport { ReelItem, nextFeedIndex, type ReelOverlayContext } from \"./ReelItem\";\nimport { useFeed } from \"./useFeed\";\nimport { createViewTracker } from \"./viewTracker\";\n\nexport interface NoriFeedProps {\n client: NoriClient;\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page. Default 10. */\n pageSize?: number;\n /** Start muted. Default false. */\n muted?: boolean;\n /**\n * Drawn over each reel, e.g. Commerce product cards or a Rewards badge:\n * `renderOverlay={(ctx) => <ProductStrip client={nori} reel={ctx.reel} positionMs={ctx.positionMs} ... />}`\n */\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** The visible reel changed. */\n onReelChange?: (reel: Reel, index: number) => void;\n /** Shown when there are no reels. */\n renderEmpty?: () => React.ReactNode;\n /**\n * Shown to holdout viewers (the experiment's control group, who see no\n * feed). Default: nothing. Show your app's usual content here.\n */\n renderHoldout?: () => React.ReactNode;\n /** Fixed reel height. Default: the height of the feed's own container. */\n height?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_MIN_WATCH_PERCENT = 90;\nconst DEFAULT_MIN_VIEW_SECONDS = 5;\n\n/** A full-height, vertically paged reel feed. Fills its parent. */\nexport function NoriFeed({\n client,\n tag = null,\n pageSize = 10,\n muted = false,\n renderOverlay,\n onReelChange,\n renderEmpty,\n renderHoldout,\n height: fixedHeight,\n style,\n testID = \"nori-feed\",\n}: NoriFeedProps) {\n const feed = useFeed(client, { tag, pageSize });\n const [config, setConfig] = useState<Config | null>(null);\n const [measuredHeight, setHeight] = useState(0);\n const height = fixedHeight ?? measuredHeight;\n const [activeIndex, setActiveIndex] = useState(0);\n\n useEffect(() => {\n let live = true;\n client.config().then(\n (c) => live && setConfig(c),\n () => undefined, // theme and reward rules have defaults\n );\n return () => {\n live = false;\n };\n }, [client]);\n\n const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;\n const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;\n const tracker = useMemo(\n () => createViewTracker(client, { minWatchPercent, minViewSeconds }),\n [client, minWatchPercent, minViewSeconds],\n );\n\n // Send queued events when the app goes to the background.\n useEffect(() => {\n const sub = AppState.addEventListener(\"change\", (state) => {\n if (state !== \"active\") void client.flush().catch(() => undefined);\n });\n return () => sub.remove();\n }, [client]);\n\n const onReelChangeRef = useRef(onReelChange);\n onReelChangeRef.current = onReelChange;\n const reelsRef = useRef(feed.reels);\n reelsRef.current = feed.reels;\n\n const listRef = useRef<FlatList<Reel>>(null);\n const advanceFrom = useCallback((index: number) => {\n const next = nextFeedIndex(index, reelsRef.current.length);\n if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });\n }, []);\n\n const onViewableItemsChanged = useRef<NonNullable<FlatListProps<Reel>[\"onViewableItemsChanged\"]>>(({ viewableItems }) => {\n const first = viewableItems.find((v) => v.isViewable && v.index !== null);\n if (first?.index == null) return;\n setActiveIndex(first.index);\n const reel = reelsRef.current[first.index];\n if (reel) onReelChangeRef.current?.(reel, first.index);\n }).current;\n const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 80 }).current;\n\n const onLayout = useCallback((e: LayoutChangeEvent) => {\n const h = Math.round(e.nativeEvent.layout.height);\n if (h > 0) setHeight(h);\n }, []);\n\n const theme = config?.theme ?? {};\n const background = theme.background_color ?? \"#000\";\n const textColor = theme.text_color ?? \"#fff\";\n const accent = theme.primary_color ?? \"#fff\";\n\n let body: React.ReactNode;\n if (feed.loading && feed.reels.length === 0) {\n body = <ActivityIndicator color={accent} style={styles.center} testID={`${testID}-loading`} />;\n } else if (feed.holdout) {\n body = renderHoldout ? renderHoldout() : null;\n } else if (feed.error && feed.reels.length === 0) {\n body = (\n <View style={styles.center} testID={`${testID}-error`}>\n <Text style={[styles.message, { color: textColor }]}>Couldn't load the feed.</Text>\n <Pressable onPress={() => void feed.refresh()} accessibilityRole=\"button\" style={[styles.retry, { borderColor: accent }]}>\n <Text style={[styles.retryText, { color: accent }]}>Try again</Text>\n </Pressable>\n </View>\n );\n } else if (feed.reels.length === 0) {\n body = renderEmpty ? renderEmpty() : (\n <View style={styles.center} testID={`${testID}-empty`}>\n <Text style={[styles.message, { color: textColor }]}>Nothing here yet.</Text>\n </View>\n );\n } else if (height > 0) {\n body = (\n <FlatList\n ref={listRef}\n data={feed.reels}\n keyExtractor={(reel) => String(reel.id)}\n renderItem={({ item, index }) => (\n <ReelItem\n reel={item}\n active={index === activeIndex}\n height={height}\n muted={muted}\n textColor={textColor}\n tracker={tracker}\n renderOverlay={renderOverlay}\n onImageShown={() => advanceFrom(index)}\n />\n )}\n getItemLayout={(_, index) => ({ length: height, offset: height * index, index })}\n pagingEnabled\n snapToInterval={height}\n snapToAlignment=\"start\"\n decelerationRate=\"fast\"\n showsVerticalScrollIndicator={false}\n onViewableItemsChanged={onViewableItemsChanged}\n viewabilityConfig={viewabilityConfig}\n onEndReached={() => void feed.loadMore()}\n onEndReachedThreshold={2}\n refreshing={feed.refreshing}\n onRefresh={() => void feed.refresh()}\n windowSize={3}\n initialNumToRender={2}\n maxToRenderPerBatch={2}\n removeClippedSubviews\n ListFooterComponent={feed.loadingMore ? <ActivityIndicator color={accent} style={styles.footer} /> : undefined}\n />\n );\n }\n\n return (\n <View style={[styles.container, { backgroundColor: background }, style]} onLayout={onLayout} testID={testID}>\n {body}\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n container: { flex: 1, overflow: \"hidden\" },\n center: { flex: 1, alignItems: \"center\", justifyContent: \"center\", padding: 24 },\n message: { fontSize: 16, textAlign: \"center\", marginBottom: 16 },\n retry: { borderWidth: 1, borderRadius: 20, paddingHorizontal: 20, paddingVertical: 10 },\n retryText: { fontSize: 15, fontWeight: \"600\" },\n footer: { paddingVertical: 24 },\n});\n","import React, { memo, useEffect, useRef, useState } from \"react\";\nimport { Image, Pressable, StyleSheet, Text, View } from \"react-native\";\nimport { ReelVideo } from \"./ReelVideo\";\nimport type { Reel } from \"@getnori/core\";\nimport type { ViewTracker } from \"./viewTracker\";\n\n/** How long an image reel stays on screen before the feed moves on (see #15). */\nexport const IMAGE_REEL_SECONDS = 15;\n\n/** The index to scroll to after `index`, or null at the end of the loaded reels. */\nexport function nextFeedIndex(index: number, count: number): number | null {\n return index + 1 < count ? index + 1 : null;\n}\n\nexport interface ReelOverlayContext {\n reel: Reel;\n /** This reel is the one on screen. */\n active: boolean;\n /** Playback position (0 for images). */\n positionMs: number;\n durationMs: number | null;\n}\n\nexport interface ReelItemProps {\n reel: Reel;\n active: boolean;\n height: number;\n muted: boolean;\n textColor: string;\n tracker: ViewTracker;\n renderOverlay?: (ctx: ReelOverlayContext) => React.ReactNode;\n /** Called when an image reel has been on screen for IMAGE_REEL_SECONDS. */\n onImageShown?: () => void;\n}\n\nfunction ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }: ReelItemProps) {\n const [userPaused, setUserPaused] = useState(false);\n const [positionMs, setPositionMs] = useState(0);\n const durationRef = useRef<number | null>(null);\n const [durationMs, setDurationMs] = useState<number | null>(null);\n const isVideo = reel.media_type === \"video\" && Boolean(reel.media_url);\n // Keep the latest callback in a ref so a parent re-render doesn't restart the image timer.\n const imageShownRef = useRef(onImageShown);\n imageShownRef.current = onImageShown;\n\n // An image reel stays on screen for IMAGE_REEL_SECONDS, then the feed moves on.\n useEffect(() => {\n if (!active || isVideo || !reel.media_url) return;\n const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1000);\n return () => clearTimeout(timer);\n }, [active, isVideo, reel.media_url, reel.id]);\n\n // Becoming visible opens a view; returning to a loaded video re-opens it.\n useEffect(() => {\n if (!active) {\n setUserPaused(false);\n return;\n }\n tracker.activate(reel);\n if (durationRef.current) tracker.loaded(reel.id, durationRef.current);\n }, [active, reel, tracker]);\n\n return (\n <View style={[styles.reel, { height }]} testID={`nori-reel-${reel.id}`}>\n <Pressable\n style={StyleSheet.absoluteFill}\n onPress={() => isVideo && setUserPaused((p) => !p)}\n accessibilityRole=\"button\"\n accessibilityLabel={userPaused ? \"Play\" : \"Pause\"}\n disabled={!isVideo}\n >\n {isVideo ? (\n <ReelVideo\n testID={`nori-video-${reel.id}`}\n uri={reel.media_url as string}\n muted={muted}\n paused={!active || userPaused}\n onLoad={(seconds) => {\n const ms = seconds * 1000;\n durationRef.current = ms;\n setDurationMs(ms);\n if (active) tracker.loaded(reel.id, ms);\n }}\n onProgress={(seconds) => {\n const ms = seconds * 1000;\n setPositionMs(ms);\n if (active) tracker.progress(reel.id, ms);\n }}\n onEnd={() => active && tracker.ended(reel.id)}\n />\n ) : reel.media_url ? (\n <Image source={{ uri: reel.media_url }} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n ) : null}\n </Pressable>\n\n {userPaused ? (\n <View pointerEvents=\"none\" style={styles.pausedBadge}>\n <Text style={styles.pausedText}>▶</Text>\n </View>\n ) : null}\n\n <View pointerEvents=\"box-none\" style={styles.caption}>\n {reel.ai_generated ? (\n <View style={styles.aiBadge} accessibilityLabel=\"AI-generated\" testID={`nori-ai-label-${reel.id}`}>\n <Text style={styles.aiText}>AI-generated</Text>\n </View>\n ) : null}\n {reel.title ? (\n <Text style={[styles.title, { color: textColor }]} numberOfLines={2}>\n {reel.title}\n </Text>\n ) : null}\n {reel.description ? (\n <Text style={[styles.description, { color: textColor }]} numberOfLines={3}>\n {reel.description}\n </Text>\n ) : null}\n </View>\n\n {renderOverlay ? (\n <View pointerEvents=\"box-none\" style={StyleSheet.absoluteFill}>\n {renderOverlay({ reel, active, positionMs, durationMs })}\n </View>\n ) : null}\n </View>\n );\n}\n\nexport const ReelItem = memo(ReelItemImpl);\n\n// react-native-video's web player ignores resizeMode and letterboxes; cover via CSS there.\n\nconst styles = StyleSheet.create({\n reel: { width: \"100%\", overflow: \"hidden\", backgroundColor: \"#000\" },\n caption: { position: \"absolute\", left: 16, right: 80, bottom: 120 },\n title: { fontSize: 17, fontWeight: \"700\", marginBottom: 4, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n description: { fontSize: 14, lineHeight: 19, textShadowColor: \"rgba(0,0,0,0.6)\", textShadowRadius: 4 },\n pausedBadge: {\n position: \"absolute\", alignSelf: \"center\", top: \"45%\", width: 64, height: 64, borderRadius: 32,\n backgroundColor: \"rgba(0,0,0,0.45)\", alignItems: \"center\", justifyContent: \"center\",\n },\n pausedText: { color: \"#fff\", fontSize: 26, marginLeft: 4 },\n aiBadge: {\n alignSelf: \"flex-start\", marginBottom: 6, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,\n backgroundColor: \"rgba(0,0,0,0.55)\", borderWidth: 1, borderColor: \"rgba(255,255,255,0.35)\",\n },\n aiText: { color: \"#fff\", fontSize: 11, fontWeight: \"700\", letterSpacing: 0.3 },\n});\n","/** Native reel video (iOS, Android): react-native-video. The web build uses ReelVideo.web.tsx. */\nimport React from \"react\";\nimport Video from \"react-native-video\";\nimport { VIDEO_STYLE } from \"./videoStyle\";\n\nexport interface ReelVideoProps {\n testID?: string;\n uri: string;\n muted: boolean;\n paused: boolean;\n /** Duration in seconds, once known. */\n onLoad: (durationSeconds: number) => void;\n /** Playback position in seconds. */\n onProgress: (positionSeconds: number) => void;\n onEnd: () => void;\n}\n\nexport function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }: ReelVideoProps) {\n return (\n <Video\n testID={testID}\n source={{ uri }}\n style={VIDEO_STYLE}\n resizeMode=\"cover\"\n repeat\n muted={muted}\n paused={paused}\n progressUpdateInterval={250}\n playInBackground={false}\n onLoad={(data: { duration: number }) => onLoad(data.duration)}\n onProgress={(data: { currentTime: number }) => onProgress(data.currentTime)}\n onEnd={onEnd}\n />\n );\n}\n","import { Platform, StyleSheet } from \"react-native\";\n\n/** Full-bleed video. On the web the browser's object-fit does the cover fit. */\nexport const VIDEO_STYLE =\n Platform.OS === \"web\" ? [StyleSheet.absoluteFill, { objectFit: \"cover\" } as object] : StyleSheet.absoluteFill;\n","import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { NoriClient, NoriError, Reel } from \"@getnori/core\";\n\nexport interface UseFeedOptions {\n /** Only reels with this tag. */\n tag?: string | null;\n /** Reels per page (1-50). Default 10. */\n pageSize?: number;\n}\n\nexport interface FeedState {\n reels: Reel[];\n /** First page loading. */\n loading: boolean;\n loadingMore: boolean;\n refreshing: boolean;\n error: NoriError | Error | null;\n /** This viewer is in the holdout group: the feed is intentionally empty. */\n holdout: boolean;\n hasMore: boolean;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n}\n\n/** Pages through the viewer's feed. Reels already shown are never duplicated. */\nexport function useFeed(client: NoriClient, { tag = null, pageSize = 10 }: UseFeedOptions = {}): FeedState {\n const [reels, setReels] = useState<Reel[]>([]);\n const [loading, setLoading] = useState(true);\n const [loadingMore, setLoadingMore] = useState(false);\n const [refreshing, setRefreshing] = useState(false);\n const [error, setError] = useState<NoriError | Error | null>(null);\n const [holdout, setHoldout] = useState(false);\n const [cursor, setCursor] = useState<string | null>(null);\n const [hasMore, setHasMore] = useState(true);\n\n const busy = useRef(false);\n const generation = useRef(0); // ignores responses from before a refresh or a tag change\n const limit = Math.min(Math.max(1, Math.round(pageSize)), 50);\n\n const load = useCallback(\n async (mode: \"first\" | \"more\" | \"refresh\", fromCursor: string | null) => {\n if (busy.current && mode === \"more\") return;\n busy.current = true;\n const gen = mode === \"more\" ? generation.current : ++generation.current;\n if (mode === \"more\") setLoadingMore(true);\n if (mode === \"refresh\") setRefreshing(true);\n try {\n const page = await client.feed({ limit, cursor: fromCursor, tag });\n if (gen !== generation.current) return;\n setError(null);\n setHoldout(Boolean(page.holdout));\n setCursor(page.next_cursor ?? null);\n setHasMore(Boolean(page.next_cursor));\n setReels((prev) => {\n const base = mode === \"more\" ? prev : [];\n const seen = new Set(base.map((r) => r.id));\n return [...base, ...page.reels.filter((r) => !seen.has(r.id))];\n });\n } catch (e) {\n if (gen === generation.current) setError(e as Error);\n } finally {\n if (gen === generation.current) {\n busy.current = false;\n setLoading(false);\n setLoadingMore(false);\n setRefreshing(false);\n }\n }\n },\n [client, limit, tag],\n );\n\n useEffect(() => {\n setLoading(true);\n setReels([]);\n void load(\"first\", null);\n }, [load]);\n\n const loadMore = useCallback(async () => {\n if (!hasMore || !cursor || loading || holdout) return;\n await load(\"more\", cursor);\n }, [cursor, hasMore, holdout, load, loading]);\n\n const refresh = useCallback(() => load(\"refresh\", null), [load]);\n\n return { reels, loading, loadingMore, refreshing, error, holdout, hasMore, loadMore, refresh };\n}\n","import type { NoriClient, Reel, TrackInput } from \"@getnori/core\";\n\nexport interface ViewTrackerOptions {\n /** Report view_complete once playback reaches this share of the reel (config.rewards.min_watch_percent). */\n minWatchPercent: number;\n /** ...and the viewer has watched at least this long, counting loops (config.rewards.min_view_seconds). */\n minViewSeconds?: number;\n /** Clock in ms (tests). Default Date.now. */\n now?: () => number;\n}\n\nconst PROGRESS_MILESTONES = [25, 50, 75];\n/** A gap between progress callbacks longer than this (paused, backgrounded) doesn't count as watching. */\nconst MAX_TICK_MS = 1500;\n\ninterface View {\n reelId: number;\n durationMs: number | null;\n milestones: Set<number>;\n opened: boolean;\n furthestMs: number;\n watchedMs: number;\n lastTickAt: number | null;\n}\n\n/**\n * Turns playback callbacks into the events the API expects for one reel.\n *\n * becomes visible impression (once per reel per feed)\n * video loaded view_progress @0 (sent at once: opens the view on the server's clock)\n * 25 / 50 / 75 % view_progress\n * complete view_complete (once; the server decides the reward)\n *\n * \"Complete\" needs both conditions the server checks: the furthest position\n * reaches minWatchPercent of the reel, and the viewer has watched for at\n * least max(that share of the reel, minViewSeconds), counting loops. So a\n * 5-second reel with a 5-second minimum completes after one full play, not\n * at 80 % (which the server would refuse as \"watched too fast\").\n *\n * Images get an impression only: a reward needs a watched video.\n */\nexport class ViewTracker {\n private impressions = new Set<number>();\n private completed = new Set<number>();\n private current: View | null = null;\n private readonly now: () => number;\n\n constructor(\n private readonly track: (input: TrackInput) => void,\n private readonly options: ViewTrackerOptions,\n ) {\n this.now = options.now ?? (() => Date.now());\n }\n\n /** The reel is now the visible one. */\n activate(reel: Pick<Reel, \"id\" | \"media_type\">): void {\n if (this.current?.reelId === reel.id) return;\n this.current = {\n reelId: reel.id, durationMs: null, milestones: new Set(), opened: false,\n furthestMs: 0, watchedMs: 0, lastTickAt: null,\n };\n if (!this.impressions.has(reel.id)) {\n this.impressions.add(reel.id);\n this.track({ type: \"impression\", reel_id: reel.id });\n }\n }\n\n /** The visible video loaded and knows its duration. */\n loaded(reelId: number, durationMs: number): void {\n const view = this.view(reelId);\n if (!view || !(durationMs > 0)) return;\n view.durationMs = Math.round(durationMs);\n if (!view.opened) {\n view.opened = true;\n view.lastTickAt = this.now();\n // Sent at once: the server times the view from when this arrives.\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });\n }\n }\n\n /** Playback position of the visible video (called while it plays). */\n progress(reelId: number, positionMs: number): void {\n const view = this.view(reelId);\n if (!view || !view.durationMs || !view.opened) return;\n const now = this.now();\n if (view.lastTickAt !== null) {\n const gap = now - view.lastTickAt;\n if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;\n }\n view.lastTickAt = now;\n const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));\n view.furthestMs = Math.max(view.furthestMs, position);\n const percent = (view.furthestMs / view.durationMs) * 100;\n\n if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {\n const required = Math.max(\n (view.durationMs * this.options.minWatchPercent) / 100,\n (this.options.minViewSeconds ?? 0) * 1000,\n );\n if (view.watchedMs >= required) {\n this.completed.add(reelId);\n this.track({ type: \"view_complete\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n return;\n }\n }\n for (const milestone of PROGRESS_MILESTONES) {\n if (percent >= milestone && !view.milestones.has(milestone)) {\n view.milestones.add(milestone);\n if (!this.completed.has(reelId)) {\n this.track({ type: \"view_progress\", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });\n }\n }\n }\n }\n\n /** The video reached its end (it then loops). */\n ended(reelId: number): void {\n const view = this.view(reelId);\n if (view?.durationMs) this.progress(reelId, view.durationMs);\n }\n\n /** The viewer scrolled away; a return opens a new view. */\n deactivate(): void {\n this.current = null;\n }\n\n private view(reelId: number) {\n return this.current && this.current.reelId === reelId ? this.current : null;\n }\n}\n\nexport function createViewTracker(client: Pick<NoriClient, \"track\">, options: ViewTrackerOptions): ViewTracker {\n return new ViewTracker((input) => {\n try {\n client.track(input);\n } catch {\n // A disposed client must not crash playback.\n }\n }, options);\n}\n"],"mappings":"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","names":["useCallback","useEffect","useRef","useState","Pressable","StyleSheet","Text","View","StyleSheet","jsx","StyleSheet","useEffect","useRef","useState","jsx","jsxs","useState","useEffect","useRef","useCallback","styles","View","Text","Pressable","StyleSheet"]}
|