@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 CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ IMAGE_REEL_SECONDS: () => IMAGE_REEL_SECONDS,
33
34
  NoriFeed: () => NoriFeed,
34
35
  ViewTracker: () => ViewTracker,
35
36
  createViewTracker: () => createViewTracker,
@@ -39,19 +40,60 @@ module.exports = __toCommonJS(index_exports);
39
40
 
40
41
  // src/NoriFeed.tsx
41
42
  var import_react3 = require("react");
42
- var import_react_native2 = require("react-native");
43
+ var import_react_native3 = require("react-native");
43
44
 
44
45
  // src/ReelItem.tsx
45
46
  var import_react = require("react");
46
- var import_react_native = require("react-native");
47
+ var import_react_native2 = require("react-native");
48
+
49
+ // src/ReelVideo.tsx
47
50
  var import_react_native_video = __toESM(require("react-native-video"), 1);
51
+
52
+ // src/videoStyle.ts
53
+ var import_react_native = require("react-native");
54
+ var VIDEO_STYLE = import_react_native.Platform.OS === "web" ? [import_react_native.StyleSheet.absoluteFill, { objectFit: "cover" }] : import_react_native.StyleSheet.absoluteFill;
55
+
56
+ // src/ReelVideo.tsx
48
57
  var import_jsx_runtime = require("react/jsx-runtime");
49
- function ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay }) {
58
+ function ReelVideo({ testID, uri, muted, paused, onLoad, onProgress, onEnd }) {
59
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
60
+ import_react_native_video.default,
61
+ {
62
+ testID,
63
+ source: { uri },
64
+ style: VIDEO_STYLE,
65
+ resizeMode: "cover",
66
+ repeat: true,
67
+ muted,
68
+ paused,
69
+ progressUpdateInterval: 250,
70
+ playInBackground: false,
71
+ onLoad: (data) => onLoad(data.duration),
72
+ onProgress: (data) => onProgress(data.currentTime),
73
+ onEnd
74
+ }
75
+ );
76
+ }
77
+
78
+ // src/ReelItem.tsx
79
+ var import_jsx_runtime2 = require("react/jsx-runtime");
80
+ var IMAGE_REEL_SECONDS = 15;
81
+ function nextFeedIndex(index, count) {
82
+ return index + 1 < count ? index + 1 : null;
83
+ }
84
+ function ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderOverlay, onImageShown }) {
50
85
  const [userPaused, setUserPaused] = (0, import_react.useState)(false);
51
86
  const [positionMs, setPositionMs] = (0, import_react.useState)(0);
52
87
  const durationRef = (0, import_react.useRef)(null);
53
88
  const [durationMs, setDurationMs] = (0, import_react.useState)(null);
54
89
  const isVideo = reel.media_type === "video" && Boolean(reel.media_url);
90
+ const imageShownRef = (0, import_react.useRef)(onImageShown);
91
+ imageShownRef.current = onImageShown;
92
+ (0, import_react.useEffect)(() => {
93
+ if (!active || isVideo || !reel.media_url) return;
94
+ const timer = setTimeout(() => imageShownRef.current?.(), IMAGE_REEL_SECONDS * 1e3);
95
+ return () => clearTimeout(timer);
96
+ }, [active, isVideo, reel.media_url, reel.id]);
55
97
  (0, import_react.useEffect)(() => {
56
98
  if (!active) {
57
99
  setUserPaused(false);
@@ -60,54 +102,49 @@ function ReelItemImpl({ reel, active, height, muted, textColor, tracker, renderO
60
102
  tracker.activate(reel);
61
103
  if (durationRef.current) tracker.loaded(reel.id, durationRef.current);
62
104
  }, [active, reel, tracker]);
63
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native.View, { style: [styles.reel, { height }], testID: `nori-reel-${reel.id}`, children: [
64
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
65
- import_react_native.Pressable,
105
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { style: [styles.reel, { height }], testID: `nori-reel-${reel.id}`, children: [
106
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
107
+ import_react_native2.Pressable,
66
108
  {
67
- style: import_react_native.StyleSheet.absoluteFill,
109
+ style: import_react_native2.StyleSheet.absoluteFill,
68
110
  onPress: () => isVideo && setUserPaused((p) => !p),
69
111
  accessibilityRole: "button",
70
112
  accessibilityLabel: userPaused ? "Play" : "Pause",
71
113
  disabled: !isVideo,
72
- children: isVideo ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
73
- import_react_native_video.default,
114
+ children: isVideo ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
115
+ ReelVideo,
74
116
  {
75
117
  testID: `nori-video-${reel.id}`,
76
- source: { uri: reel.media_url },
77
- style: VIDEO_STYLE,
78
- resizeMode: "cover",
79
- repeat: true,
118
+ uri: reel.media_url,
80
119
  muted,
81
120
  paused: !active || userPaused,
82
- progressUpdateInterval: 250,
83
- playInBackground: false,
84
- onLoad: (data) => {
85
- const ms = data.duration * 1e3;
121
+ onLoad: (seconds) => {
122
+ const ms = seconds * 1e3;
86
123
  durationRef.current = ms;
87
124
  setDurationMs(ms);
88
125
  if (active) tracker.loaded(reel.id, ms);
89
126
  },
90
- onProgress: (data) => {
91
- const ms = data.currentTime * 1e3;
127
+ onProgress: (seconds) => {
128
+ const ms = seconds * 1e3;
92
129
  setPositionMs(ms);
93
130
  if (active) tracker.progress(reel.id, ms);
94
131
  },
95
132
  onEnd: () => active && tracker.ended(reel.id)
96
133
  }
97
- ) : reel.media_url ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Image, { source: { uri: reel.media_url }, style: import_react_native.StyleSheet.absoluteFill, resizeMode: "cover" }) : null
134
+ ) : reel.media_url ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Image, { source: { uri: reel.media_url }, style: import_react_native2.StyleSheet.absoluteFill, resizeMode: "cover" }) : null
98
135
  }
99
136
  ),
100
- userPaused ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.View, { pointerEvents: "none", style: styles.pausedBadge, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: styles.pausedText, children: "\u25B6" }) }) : null,
101
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native.View, { pointerEvents: "box-none", style: styles.caption, children: [
102
- reel.title ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: [styles.title, { color: textColor }], numberOfLines: 2, children: reel.title }) : null,
103
- reel.description ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: [styles.description, { color: textColor }], numberOfLines: 3, children: reel.description }) : null
137
+ userPaused ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.View, { pointerEvents: "none", style: styles.pausedBadge, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: styles.pausedText, children: "\u25B6" }) }) : null,
138
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { pointerEvents: "box-none", style: styles.caption, children: [
139
+ reel.ai_generated ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.View, { style: styles.aiBadge, accessibilityLabel: "AI-generated", testID: `nori-ai-label-${reel.id}`, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: styles.aiText, children: "AI-generated" }) }) : null,
140
+ reel.title ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: [styles.title, { color: textColor }], numberOfLines: 2, children: reel.title }) : null,
141
+ reel.description ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: [styles.description, { color: textColor }], numberOfLines: 3, children: reel.description }) : null
104
142
  ] }),
105
- renderOverlay ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.View, { pointerEvents: "box-none", style: import_react_native.StyleSheet.absoluteFill, children: renderOverlay({ reel, active, positionMs, durationMs }) }) : null
143
+ renderOverlay ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.View, { pointerEvents: "box-none", style: import_react_native2.StyleSheet.absoluteFill, children: renderOverlay({ reel, active, positionMs, durationMs }) }) : null
106
144
  ] });
107
145
  }
108
146
  var ReelItem = (0, import_react.memo)(ReelItemImpl);
109
- var VIDEO_STYLE = import_react_native.Platform.OS === "web" ? [import_react_native.StyleSheet.absoluteFill, { objectFit: "cover" }] : import_react_native.StyleSheet.absoluteFill;
110
- var styles = import_react_native.StyleSheet.create({
147
+ var styles = import_react_native2.StyleSheet.create({
111
148
  reel: { width: "100%", overflow: "hidden", backgroundColor: "#000" },
112
149
  caption: { position: "absolute", left: 16, right: 80, bottom: 120 },
113
150
  title: { fontSize: 17, fontWeight: "700", marginBottom: 4, textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 4 },
@@ -123,7 +160,18 @@ var styles = import_react_native.StyleSheet.create({
123
160
  alignItems: "center",
124
161
  justifyContent: "center"
125
162
  },
126
- pausedText: { color: "#fff", fontSize: 26, marginLeft: 4 }
163
+ pausedText: { color: "#fff", fontSize: 26, marginLeft: 4 },
164
+ aiBadge: {
165
+ alignSelf: "flex-start",
166
+ marginBottom: 6,
167
+ paddingHorizontal: 8,
168
+ paddingVertical: 3,
169
+ borderRadius: 10,
170
+ backgroundColor: "rgba(0,0,0,0.55)",
171
+ borderWidth: 1,
172
+ borderColor: "rgba(255,255,255,0.35)"
173
+ },
174
+ aiText: { color: "#fff", fontSize: 11, fontWeight: "700", letterSpacing: 0.3 }
127
175
  });
128
176
 
129
177
  // src/useFeed.ts
@@ -187,6 +235,7 @@ function useFeed(client, { tag = null, pageSize = 10 } = {}) {
187
235
 
188
236
  // src/viewTracker.ts
189
237
  var PROGRESS_MILESTONES = [25, 50, 75];
238
+ var MAX_TICK_MS = 1500;
190
239
  var ViewTracker = class {
191
240
  constructor(track, options) {
192
241
  this.track = track;
@@ -194,11 +243,20 @@ var ViewTracker = class {
194
243
  this.impressions = /* @__PURE__ */ new Set();
195
244
  this.completed = /* @__PURE__ */ new Set();
196
245
  this.current = null;
246
+ this.now = options.now ?? (() => Date.now());
197
247
  }
198
248
  /** The reel is now the visible one. */
199
249
  activate(reel) {
200
250
  if (this.current?.reelId === reel.id) return;
201
- this.current = { reelId: reel.id, durationMs: null, milestones: /* @__PURE__ */ new Set(), opened: false };
251
+ this.current = {
252
+ reelId: reel.id,
253
+ durationMs: null,
254
+ milestones: /* @__PURE__ */ new Set(),
255
+ opened: false,
256
+ furthestMs: 0,
257
+ watchedMs: 0,
258
+ lastTickAt: null
259
+ };
202
260
  if (!this.impressions.has(reel.id)) {
203
261
  this.impressions.add(reel.id);
204
262
  this.track({ type: "impression", reel_id: reel.id });
@@ -211,30 +269,44 @@ var ViewTracker = class {
211
269
  view.durationMs = Math.round(durationMs);
212
270
  if (!view.opened) {
213
271
  view.opened = true;
272
+ view.lastTickAt = this.now();
214
273
  this.track({ type: "view_progress", reel_id: reelId, position_ms: 0, duration_ms: view.durationMs, immediate: true });
215
274
  }
216
275
  }
217
- /** Playback position of the visible video. */
276
+ /** Playback position of the visible video (called while it plays). */
218
277
  progress(reelId, positionMs) {
219
278
  const view = this.view(reelId);
220
279
  if (!view || !view.durationMs || !view.opened) return;
280
+ const now = this.now();
281
+ if (view.lastTickAt !== null) {
282
+ const gap = now - view.lastTickAt;
283
+ if (gap > 0 && gap <= MAX_TICK_MS) view.watchedMs += gap;
284
+ }
285
+ view.lastTickAt = now;
221
286
  const position = Math.max(0, Math.min(Math.round(positionMs), view.durationMs));
222
- const percent = position / view.durationMs * 100;
287
+ view.furthestMs = Math.max(view.furthestMs, position);
288
+ const percent = view.furthestMs / view.durationMs * 100;
223
289
  if (!this.completed.has(reelId) && percent >= this.options.minWatchPercent) {
224
- this.completed.add(reelId);
225
- this.track({ type: "view_complete", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });
226
- return;
290
+ const required = Math.max(
291
+ view.durationMs * this.options.minWatchPercent / 100,
292
+ (this.options.minViewSeconds ?? 0) * 1e3
293
+ );
294
+ if (view.watchedMs >= required) {
295
+ this.completed.add(reelId);
296
+ this.track({ type: "view_complete", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
297
+ return;
298
+ }
227
299
  }
228
300
  for (const milestone of PROGRESS_MILESTONES) {
229
301
  if (percent >= milestone && !view.milestones.has(milestone)) {
230
302
  view.milestones.add(milestone);
231
303
  if (!this.completed.has(reelId)) {
232
- this.track({ type: "view_progress", reel_id: reelId, position_ms: position, duration_ms: view.durationMs });
304
+ this.track({ type: "view_progress", reel_id: reelId, position_ms: view.furthestMs, duration_ms: view.durationMs });
233
305
  }
234
306
  }
235
307
  }
236
308
  }
237
- /** The video played to the end (also covers a minWatchPercent of 100). */
309
+ /** The video reached its end (it then loops). */
238
310
  ended(reelId) {
239
311
  const view = this.view(reelId);
240
312
  if (view?.durationMs) this.progress(reelId, view.durationMs);
@@ -257,8 +329,9 @@ function createViewTracker(client, options) {
257
329
  }
258
330
 
259
331
  // src/NoriFeed.tsx
260
- var import_jsx_runtime2 = require("react/jsx-runtime");
332
+ var import_jsx_runtime3 = require("react/jsx-runtime");
261
333
  var DEFAULT_MIN_WATCH_PERCENT = 90;
334
+ var DEFAULT_MIN_VIEW_SECONDS = 5;
262
335
  function NoriFeed({
263
336
  client,
264
337
  tag = null,
@@ -289,9 +362,13 @@ function NoriFeed({
289
362
  };
290
363
  }, [client]);
291
364
  const minWatchPercent = config?.rewards.min_watch_percent ?? DEFAULT_MIN_WATCH_PERCENT;
292
- const tracker = (0, import_react3.useMemo)(() => createViewTracker(client, { minWatchPercent }), [client, minWatchPercent]);
365
+ const minViewSeconds = config?.rewards.min_view_seconds ?? DEFAULT_MIN_VIEW_SECONDS;
366
+ const tracker = (0, import_react3.useMemo)(
367
+ () => createViewTracker(client, { minWatchPercent, minViewSeconds }),
368
+ [client, minWatchPercent, minViewSeconds]
369
+ );
293
370
  (0, import_react3.useEffect)(() => {
294
- const sub = import_react_native2.AppState.addEventListener("change", (state) => {
371
+ const sub = import_react_native3.AppState.addEventListener("change", (state) => {
295
372
  if (state !== "active") void client.flush().catch(() => void 0);
296
373
  });
297
374
  return () => sub.remove();
@@ -300,6 +377,11 @@ function NoriFeed({
300
377
  onReelChangeRef.current = onReelChange;
301
378
  const reelsRef = (0, import_react3.useRef)(feed.reels);
302
379
  reelsRef.current = feed.reels;
380
+ const listRef = (0, import_react3.useRef)(null);
381
+ const advanceFrom = (0, import_react3.useCallback)((index) => {
382
+ const next = nextFeedIndex(index, reelsRef.current.length);
383
+ if (next != null) listRef.current?.scrollToIndex({ index: next, animated: true });
384
+ }, []);
303
385
  const onViewableItemsChanged = (0, import_react3.useRef)(({ viewableItems }) => {
304
386
  const first = viewableItems.find((v) => v.isViewable && v.index !== null);
305
387
  if (first?.index == null) return;
@@ -318,23 +400,24 @@ function NoriFeed({
318
400
  const accent = theme.primary_color ?? "#fff";
319
401
  let body;
320
402
  if (feed.loading && feed.reels.length === 0) {
321
- body = /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.ActivityIndicator, { color: accent, style: styles2.center, testID: `${testID}-loading` });
403
+ body = /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.ActivityIndicator, { color: accent, style: styles2.center, testID: `${testID}-loading` });
322
404
  } else if (feed.holdout) {
323
405
  body = renderHoldout ? renderHoldout() : null;
324
406
  } else if (feed.error && feed.reels.length === 0) {
325
- body = /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { style: styles2.center, testID: `${testID}-error`, children: [
326
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: [styles2.message, { color: textColor }], children: "Couldn't load the feed." }),
327
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Pressable, { onPress: () => void feed.refresh(), accessibilityRole: "button", style: [styles2.retry, { borderColor: accent }], children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: [styles2.retryText, { color: accent }], children: "Try again" }) })
407
+ body = /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles2.center, testID: `${testID}-error`, children: [
408
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.Text, { style: [styles2.message, { color: textColor }], children: "Couldn't load the feed." }),
409
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.Pressable, { onPress: () => void feed.refresh(), accessibilityRole: "button", style: [styles2.retry, { borderColor: accent }], children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.Text, { style: [styles2.retryText, { color: accent }], children: "Try again" }) })
328
410
  ] });
329
411
  } else if (feed.reels.length === 0) {
330
- body = renderEmpty ? renderEmpty() : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.View, { style: styles2.center, testID: `${testID}-empty`, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: [styles2.message, { color: textColor }], children: "Nothing here yet." }) });
412
+ body = renderEmpty ? renderEmpty() : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.View, { style: styles2.center, testID: `${testID}-empty`, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.Text, { style: [styles2.message, { color: textColor }], children: "Nothing here yet." }) });
331
413
  } else if (height > 0) {
332
- body = /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
333
- import_react_native2.FlatList,
414
+ body = /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
415
+ import_react_native3.FlatList,
334
416
  {
417
+ ref: listRef,
335
418
  data: feed.reels,
336
419
  keyExtractor: (reel) => String(reel.id),
337
- renderItem: ({ item, index }) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
420
+ renderItem: ({ item, index }) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
338
421
  ReelItem,
339
422
  {
340
423
  reel: item,
@@ -343,7 +426,8 @@ function NoriFeed({
343
426
  muted,
344
427
  textColor,
345
428
  tracker,
346
- renderOverlay
429
+ renderOverlay,
430
+ onImageShown: () => advanceFrom(index)
347
431
  }
348
432
  ),
349
433
  getItemLayout: (_, index) => ({ length: height, offset: height * index, index }),
@@ -362,13 +446,13 @@ function NoriFeed({
362
446
  initialNumToRender: 2,
363
447
  maxToRenderPerBatch: 2,
364
448
  removeClippedSubviews: true,
365
- ListFooterComponent: feed.loadingMore ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.ActivityIndicator, { color: accent, style: styles2.footer }) : void 0
449
+ ListFooterComponent: feed.loadingMore ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.ActivityIndicator, { color: accent, style: styles2.footer }) : void 0
366
450
  }
367
451
  );
368
452
  }
369
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.View, { style: [styles2.container, { backgroundColor: background }, style], onLayout, testID, children: body });
453
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.View, { style: [styles2.container, { backgroundColor: background }, style], onLayout, testID, children: body });
370
454
  }
371
- var styles2 = import_react_native2.StyleSheet.create({
455
+ var styles2 = import_react_native3.StyleSheet.create({
372
456
  container: { flex: 1, overflow: "hidden" },
373
457
  center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
374
458
  message: { fontSize: 16, textAlign: "center", marginBottom: 16 },
@@ -378,6 +462,7 @@ var styles2 = import_react_native2.StyleSheet.create({
378
462
  });
379
463
  // Annotate the CommonJS export names for ESM import in node:
380
464
  0 && (module.exports = {
465
+ IMAGE_REEL_SECONDS,
381
466
  NoriFeed,
382
467
  ViewTracker,
383
468
  createViewTracker,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["export { NoriFeed } from \"./NoriFeed\";\nexport type { NoriFeedProps } from \"./NoriFeed\";\nexport type { ReelOverlayContext } from \"./ReelItem\";\nexport { useFeed } from \"./useFeed\";\nexport type { FeedState, UseFeedOptions } from \"./useFeed\";\nexport { ViewTracker, createViewTracker } from \"./viewTracker\";\nexport type { ViewTrackerOptions } from \"./viewTracker\";\n","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":["import_react","import_react_native","Video","import_react","import_jsx_runtime","styles"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/NoriFeed.tsx","../src/ReelItem.tsx","../src/ReelVideo.tsx","../src/videoStyle.ts","../src/useFeed.ts","../src/viewTracker.ts"],"sourcesContent":["export { NoriFeed } from \"./NoriFeed\";\nexport type { NoriFeedProps } from \"./NoriFeed\";\nexport type { ReelOverlayContext } from \"./ReelItem\";\nexport { IMAGE_REEL_SECONDS } from \"./ReelItem\";\nexport { useFeed } from \"./useFeed\";\nexport type { FeedState, UseFeedOptions } from \"./useFeed\";\nexport { ViewTracker, createViewTracker } from \"./viewTracker\";\nexport type { ViewTrackerOptions } from \"./viewTracker\";\n","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":["import_react","import_react_native","import_react_native","Video","import_jsx_runtime","import_react","import_jsx_runtime","styles"]}
package/dist/index.d.cts 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 (from config.rewards). */
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
- * >= minWatchPercent view_complete (once; the server decides the reward)
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 played to the end (also covers a minWatchPercent of 100). */
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 };